fabro/lib/crates/fabro-cli/src/main.rs
Bryan Helmkamp 6e07f688ab
refactor(cli): collapse duplicate server-settings resolvers through local_server
Route install/uninstall through local_server::storage_dir instead of hand-
rolled copies, drop dead connect_api_client and run_dir plumbing, eliminate
double-resolve in prepare_server_bootstrap, and tighten the boundary
allowlist now that uninstall no longer needs the exemption.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:16:49 -04:00

1124 lines
37 KiB
Rust

#![allow(
clippy::exit,
reason = "The CLI exits explicitly with the computed process status."
)]
mod args;
mod command_context;
mod commands;
mod gh;
mod landing;
mod local_server;
mod logging;
mod manifest_builder;
mod server_client;
mod server_runs;
mod shared;
#[cfg(feature = "sleep_inhibitor")]
mod sleep_inhibitor;
mod user_config;
#[cfg(test)]
use std::ffi::OsString;
use anyhow::Result;
use args::{
Commands, GlobalArgs, LONG_VERSION, RunCommands, ServerCommand, ServerNamespace,
global_args_cli_layer, printer_from_verbosity, require_no_json_override,
};
use clap::{CommandFactory, Parser};
use fabro_config::merge::combine_files;
use fabro_telemetry::{git, panic as tel_panic, sanitize, sender};
use fabro_types::settings::SettingsLayer;
use fabro_types::settings::cli::OutputVerbosity;
use fabro_util::printer::Printer;
use fabro_util::terminal::Styles;
use rustls::crypto::ring::default_provider;
use tracing::debug;
#[derive(Parser)]
#[command(name = "fabro", version, long_version = LONG_VERSION)]
struct Cli {
#[command(flatten)]
globals: GlobalArgs,
#[command(subcommand)]
command: Option<Box<Commands>>,
}
impl Cli {
fn parse() -> Self {
<Self as Parser>::parse()
}
#[cfg(test)]
fn try_parse_from<I, T>(args: I) -> Result<Self, clap::Error>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
<Self as Parser>::try_parse_from(args)
}
}
#[expect(clippy::print_stderr, reason = "fatal error reporting before exit")]
#[tokio::main]
async fn main() {
let raw_args: Vec<String> = std::env::args().collect();
let subcommand = raw_args.get(1).map(String::as_str);
let subcommand_arg = raw_args.get(2).map(String::as_str);
if subcommand == Some("__render-graph") && !matches!(subcommand_arg, Some("--help" | "-h")) {
std::process::exit(commands::render_graph::execute());
}
tel_panic::install_panic_hook();
fabro_telemetry::init_cli();
let start = std::time::Instant::now();
let (command_name, result) = Box::pin(main_inner()).await;
let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
let is_error = result.is_err();
// An empty command_name means no subcommand was invoked (landing was shown);
// don't emit a tracking event for that case.
if !command_name.is_empty() {
let command = sanitize::sanitize_command(&raw_args, &command_name);
let repository = git::repository_identifier();
let ci = std::env::var("CI").is_ok();
if is_error {
fabro_telemetry::track!("CLI Errored", {
"subcommand": command_name,
"command": command,
"durationMs": duration_ms,
"repository": repository,
"ci": ci,
"success": false,
"exitCode": 1,
}, error);
} else {
fabro_telemetry::track!("CLI Executed", {
"subcommand": command_name,
"command": command,
"durationMs": duration_ms,
"repository": repository,
"ci": ci,
"success": true,
"exitCode": 0,
});
}
}
fabro_telemetry::shutdown();
if let Err(err) = result {
let style = console::Style::new().red().bold();
for (i, cause) in err.chain().enumerate() {
let text = cause.to_string();
if i == 0 {
for (j, line) in text.lines().enumerate() {
if j == 0 {
eprintln!("{} {line}", style.apply_to("error:"));
} else {
eprintln!(" {line}");
}
}
} else {
for line in text.lines() {
eprintln!(" > {line}");
}
}
}
std::process::exit(1);
}
}
async fn main_inner() -> (String, Result<()>) {
let _ = default_provider().install_default();
let cli = Cli::parse();
let Cli { globals, command } = cli;
let Some(command) = command else {
landing::print();
return (String::new(), Ok(()));
};
let bootstrap_printer = Printer::from_flags(globals.quiet, globals.verbose);
let cli_layer = global_args_cli_layer(&globals);
let process_local_json = globals.json;
let command_name = command.name().to_string();
let pre_tracing_bootstrap = match pre_tracing_bootstrap(command.as_ref()).await {
Ok(bootstrap) => bootstrap,
Err(err) => return (command_name, Err(err)),
};
let user_settings = match user_config::load_settings() {
Ok(settings) => settings,
Err(err) => return (command_name, Err(err)),
};
let combined_settings = combine_files(user_settings, SettingsLayer {
cli: Some(cli_layer.clone()),
..SettingsLayer::default()
});
let cli_settings = match user_config::resolve_cli_settings(&combined_settings) {
Ok(cli_settings) => cli_settings,
Err(err) => return (command_name, Err(err)),
};
let printer = printer_from_verbosity(cli_settings.output.verbosity);
let config_log_level = match &pre_tracing_bootstrap.sink {
logging::InternalLogSink::Cli => cli_settings.logging.level.clone(),
logging::InternalLogSink::Server { .. } => pre_tracing_bootstrap.config_log_level.clone(),
};
if let Err(err) = logging::init_tracing(
globals.debug,
config_log_level.as_deref(),
&pre_tracing_bootstrap.sink,
) {
fabro_util::printerr!(
bootstrap_printer,
"Warning: failed to initialize logging: {err:#}"
);
}
debug!(command = %command_name, "CLI command started");
let foreground_server_log_bootstrap = pre_tracing_bootstrap.foreground_server_log_bootstrap;
let upgrade_handle = if matches!(
command.as_ref(),
Commands::RunCmd(RunCommands::Run(_) | RunCommands::Create(_))
| Commands::Exec(_)
| Commands::Repo(_)
| Commands::Install { .. }
) {
commands::upgrade::spawn_upgrade_check(cli_settings.updates.check, printer)
} else {
None
};
let result = Box::pin(async move {
match *command {
Commands::Exec(args) => commands::exec::execute(args, &cli_settings, printer).await?,
Commands::RunCmd(cmd) => {
Box::pin(commands::run::dispatch(
cmd,
&cli_settings,
&cli_layer,
process_local_json,
printer,
))
.await?;
}
Commands::Preflight(args) => {
commands::preflight::execute(args, &cli_settings, &cli_layer, printer).await?;
}
Commands::Validate(args) => {
let styles = Styles::detect_stderr();
commands::validate::run(&args, &styles, &cli_settings, &cli_layer, printer).await?;
}
Commands::Graph(args) => {
let styles = Styles::detect_stderr();
commands::graph::run(
&args,
&styles,
&cli_settings,
&cli_layer,
process_local_json,
printer,
)
.await?;
}
Commands::Parse(args) => {
commands::parse::run(&args, &cli_settings, printer)?;
}
Commands::Artifact(ns) => {
commands::artifact::dispatch(ns, &cli_settings, &cli_layer, printer).await?;
}
Commands::Store(ns) => {
commands::store::dispatch(ns, &cli_settings, &cli_layer, printer).await?;
}
Commands::RunsCmd(cmd) => {
commands::runs::dispatch(cmd, &cli_settings, &cli_layer, printer).await?;
}
Commands::Model { command } => {
commands::model::execute(command, &cli_settings, &cli_layer, printer).await?;
}
Commands::Server(ns) => {
Box::pin(commands::server::dispatch(
ns.command,
&globals,
foreground_server_log_bootstrap,
printer,
))
.await?;
}
Commands::Doctor(args) => {
let verbose =
args.verbose || cli_settings.output.verbosity == OutputVerbosity::Verbose;
let exit_code = Box::pin(commands::doctor::run_doctor(
&args,
verbose,
&cli_settings,
&cli_layer,
printer,
))
.await?;
std::process::exit(exit_code);
}
Commands::Version(args) => {
commands::version::version_command(&args, &cli_settings, &cli_layer, printer)
.await?;
}
Commands::Discord => {
if process_local_json {
shared::print_json_pretty(&serde_json::json!({
"url": "https://fabro.sh/discord",
}))?;
} else {
open::that("https://fabro.sh/discord")?;
}
}
Commands::Docs => {
if process_local_json {
shared::print_json_pretty(&serde_json::json!({
"url": "https://docs.fabro.sh/",
}))?;
} else {
open::that("https://docs.fabro.sh/")?;
}
}
Commands::Repo(ns) => {
commands::repo::dispatch(ns, &cli_settings, &cli_layer, printer).await?;
}
Commands::Install { args, command } => {
Box::pin(commands::install::execute(
&args,
command,
&cli_settings,
&cli_layer,
process_local_json,
printer,
))
.await?;
}
Commands::Uninstall(args) => {
commands::uninstall::run_uninstall(&args, &cli_settings, printer).await?;
}
Commands::Auth(ns) => {
commands::auth::dispatch(
ns,
&cli_settings,
&cli_layer,
process_local_json,
printer,
)
.await?;
}
Commands::Pr(ns) => {
Box::pin(commands::pr::dispatch(
ns,
&cli_settings,
&cli_layer,
printer,
))
.await?;
}
Commands::Secret(ns) => {
commands::secret::dispatch(ns, &cli_settings, &cli_layer, printer).await?;
}
Commands::Settings(args) => {
Box::pin(commands::config::execute(
&args,
&cli_settings,
&cli_layer,
printer,
))
.await?;
}
Commands::Workflow(ns) => commands::workflow::dispatch(ns, &cli_settings, printer)?,
Commands::Upgrade(args) => {
commands::upgrade::run_upgrade(args, &cli_settings, printer).await?;
}
Commands::Provider(ns) => {
commands::provider::dispatch(
ns,
&cli_settings,
&cli_layer,
process_local_json,
printer,
)
.await?;
}
Commands::Sandbox { command } => {
commands::sandbox::dispatch(
command,
&cli_settings,
&cli_layer,
process_local_json,
printer,
)
.await?;
}
Commands::System(ns) => {
commands::system::dispatch(ns, &cli_settings, &cli_layer, printer).await?;
}
Commands::Completion(args) => {
require_no_json_override(process_local_json)?;
let mut cmd = Cli::command();
let shell = args.shell;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut buf = Vec::new();
clap_complete::generate(shell, &mut cmd, "fabro", &mut buf);
buf
}));
match result {
Ok(buf) => {
#[expect(
clippy::disallowed_types,
clippy::disallowed_methods,
reason = "sync CLI: stream shell completions to stdout"
)]
{
use std::io::Write;
std::io::stdout().write_all(&buf)?;
}
}
Err(_) => {
anyhow::bail!(
"Failed to generate completions for {shell}. \
Try zsh, fish, elvish, or powershell instead."
);
}
}
}
Commands::SendAnalytics { path } => {
let result = sender::upload(&path).await;
let _ = std::fs::remove_file(&path);
result?;
}
Commands::SendPanic { path } => {
let result = tel_panic::capture(&path);
let _ = std::fs::remove_file(&path);
result?;
}
Commands::RenderGraph => unreachable!("__render-graph handled before CLI bootstrap"),
#[cfg(debug_assertions)]
Commands::TestPanic { message } => {
let event = tel_panic::build_event(&message);
let json = serde_json::to_string_pretty(&event)?;
fabro_util::printout!(printer, "{json}");
}
}
Ok(())
})
.await;
// Print upgrade notice after command completes (non-blocking during execution)
if let Some(handle) = upgrade_handle {
let _ = handle.await;
}
(command_name, result)
}
struct PreTracingBootstrap {
sink: logging::InternalLogSink,
config_log_level: Option<String>,
foreground_server_log_bootstrap: Option<commands::server::start::ForegroundServerLogBootstrap>,
}
impl PreTracingBootstrap {
fn cli() -> Self {
Self {
sink: logging::InternalLogSink::Cli,
config_log_level: None,
foreground_server_log_bootstrap: None,
}
}
}
async fn pre_tracing_bootstrap(command: &Commands) -> Result<PreTracingBootstrap> {
match command {
Commands::Server(ServerNamespace {
command: ServerCommand::Start(args),
}) if args.foreground => {
prepare_server_bootstrap(
args.serve_args.config.as_deref(),
args.storage_dir.as_deref(),
true,
)
.await
}
Commands::Server(ServerNamespace {
command: ServerCommand::Restart(args),
}) if args.foreground => {
prepare_server_bootstrap(
args.serve_args.config.as_deref(),
args.storage_dir.as_deref(),
true,
)
.await
}
Commands::Server(ServerNamespace {
command: ServerCommand::Serve(args),
}) => {
prepare_server_bootstrap(
args.serve_args.config.as_deref(),
args.storage_dir.as_deref(),
false,
)
.await
}
_ => Ok(PreTracingBootstrap::cli()),
}
}
async fn prepare_server_bootstrap(
config_path: Option<&std::path::Path>,
storage_dir: Option<&std::path::Path>,
foreground: bool,
) -> Result<PreTracingBootstrap> {
let settings =
user_config::load_settings_with_config_and_storage_dir(config_path, storage_dir)?;
let storage_dir = local_server::storage_dir(&settings)?;
let runtime_state = fabro_config::ServerRuntimeState::new(storage_dir.clone());
let foreground_server_log_bootstrap = if foreground {
Some(commands::server::start::prepare_foreground_server_log(&storage_dir).await?)
} else {
None
};
Ok(PreTracingBootstrap {
sink: logging::InternalLogSink::Server {
path: runtime_state.log_path(),
},
config_log_level: local_server::config_log_level(&settings),
foreground_server_log_bootstrap,
})
}
#[cfg(test)]
#[expect(
clippy::disallowed_methods,
reason = "main.rs tests stage CLI settings fixtures with sync std::fs::write"
)]
mod tests {
use args::{
AuthCommand, AuthNamespace, Commands, InstallGitHubStrategyArg, ModelsCommand,
ProviderCommand, ProviderNamespace, StoreCommand, StoreNamespace,
};
use tokio::runtime::Runtime;
use super::*;
fn runtime() -> Runtime {
Runtime::new().expect("runtime should build")
}
fn write_test_settings(path: &std::path::Path) {
std::fs::write(
path,
r#"
_version = 1
[server.logging]
level = "warn"
"#,
)
.unwrap();
}
#[test]
fn pre_tracing_bootstrap_uses_cli_sink_for_normal_cli_command() {
let cli = Cli::try_parse_from(["fabro", "uninstall"]).expect("should parse");
let command = cli.command.as_deref().unwrap();
let bootstrap = runtime()
.block_on(pre_tracing_bootstrap(command))
.expect("bootstrap should resolve");
assert_eq!(bootstrap.sink, logging::InternalLogSink::Cli);
assert!(bootstrap.config_log_level.is_none());
assert!(bootstrap.foreground_server_log_bootstrap.is_none());
}
#[test]
fn parse_provider_login_openai() {
let cli = Cli::try_parse_from(["fabro", "provider", "login", "--provider", "openai"])
.expect("should parse");
match *cli.command.unwrap() {
Commands::Provider(ProviderNamespace {
command: ProviderCommand::Login(args),
}) => {
assert_eq!(args.provider, fabro_model::Provider::OpenAi);
}
_ => panic!("unexpected command variant"),
}
}
#[test]
fn parse_provider_login_anthropic() {
let cli = Cli::try_parse_from(["fabro", "provider", "login", "--provider", "anthropic"])
.expect("should parse");
match *cli.command.unwrap() {
Commands::Provider(ProviderNamespace {
command: ProviderCommand::Login(args),
}) => {
assert_eq!(args.provider, fabro_model::Provider::Anthropic);
}
_ => panic!("unexpected command variant"),
}
}
#[test]
fn parse_provider_login_api_key_stdin() {
let cli = Cli::try_parse_from([
"fabro",
"provider",
"login",
"--provider",
"anthropic",
"--api-key-stdin",
])
.expect("should parse");
match *cli.command.unwrap() {
Commands::Provider(ProviderNamespace {
command: ProviderCommand::Login(args),
}) => {
assert_eq!(args.provider, fabro_model::Provider::Anthropic);
assert!(args.api_key_stdin);
}
_ => panic!("unexpected command variant"),
}
}
#[test]
fn parse_auth_logout_all() {
let cli = Cli::try_parse_from(["fabro", "auth", "logout", "--all"]).expect("should parse");
match *cli.command.unwrap() {
Commands::Auth(AuthNamespace {
command: AuthCommand::Logout(args),
}) => {
assert!(args.all);
assert!(args.server.server.is_none());
}
_ => panic!("unexpected command variant"),
}
}
#[test]
fn parse_install_non_interactive_accepts_token_strategy() {
let cli = Cli::try_parse_from([
"fabro",
"install",
"--non-interactive",
"--llm-provider",
"anthropic",
"--llm-api-key-env",
"ANTHROPIC_API_KEY",
"--github-strategy",
"token",
"--github-username",
"brynary",
])
.expect("should parse");
match *cli.command.unwrap() {
Commands::Install {
args,
command: None,
} => {
assert!(args.non_interactive);
assert_eq!(
args.scripted.github_strategy,
Some(InstallGitHubStrategyArg::Token)
);
}
_ => panic!("unexpected command variant"),
}
}
#[test]
fn parse_install_github_non_interactive_accepts_token_strategy() {
let cli = Cli::try_parse_from([
"fabro",
"install",
"github",
"--non-interactive",
"--strategy",
"token",
])
.expect("should parse");
match *cli.command.unwrap() {
Commands::Install {
args,
command: Some(args::InstallCommand::Github(github_args)),
} => {
assert!(args.non_interactive);
assert_eq!(github_args.strategy, Some(InstallGitHubStrategyArg::Token));
assert_eq!(github_args.owner, None);
}
_ => panic!("unexpected command variant"),
}
}
#[test]
fn pre_tracing_bootstrap_uses_server_sink_for_server_start_foreground() {
let storage_dir = tempfile::tempdir().unwrap();
let config_dir = tempfile::tempdir().unwrap();
let config_path = config_dir.path().join("settings.toml");
write_test_settings(&config_path);
let cli = Cli::try_parse_from([
"fabro",
"server",
"start",
"--foreground",
"--storage-dir",
storage_dir.path().to_str().unwrap(),
"--config",
config_path.to_str().unwrap(),
])
.expect("should parse");
let command = cli.command.as_deref().unwrap();
let bootstrap = runtime()
.block_on(pre_tracing_bootstrap(command))
.expect("bootstrap should resolve");
assert_eq!(bootstrap.sink, logging::InternalLogSink::Server {
path: storage_dir.path().join("logs").join("server.log"),
});
assert_eq!(bootstrap.config_log_level.as_deref(), Some("warn"));
assert!(bootstrap.foreground_server_log_bootstrap.is_some());
}
#[test]
fn pre_tracing_bootstrap_uses_server_sink_for_server_restart_foreground() {
let storage_dir = tempfile::tempdir().unwrap();
let config_dir = tempfile::tempdir().unwrap();
let config_path = config_dir.path().join("settings.toml");
write_test_settings(&config_path);
let cli = Cli::try_parse_from([
"fabro",
"server",
"restart",
"--foreground",
"--storage-dir",
storage_dir.path().to_str().unwrap(),
"--config",
config_path.to_str().unwrap(),
])
.expect("should parse");
let command = cli.command.as_deref().unwrap();
let bootstrap = runtime()
.block_on(pre_tracing_bootstrap(command))
.expect("bootstrap should resolve");
assert_eq!(bootstrap.sink, logging::InternalLogSink::Server {
path: storage_dir.path().join("logs").join("server.log"),
});
assert_eq!(bootstrap.config_log_level.as_deref(), Some("warn"));
assert!(bootstrap.foreground_server_log_bootstrap.is_some());
}
#[test]
fn pre_tracing_bootstrap_uses_server_sink_for_server_serve() {
let storage_dir = tempfile::tempdir().unwrap();
let config_dir = tempfile::tempdir().unwrap();
let config_path = config_dir.path().join("settings.toml");
write_test_settings(&config_path);
let record_path = storage_dir.path().join("server.json");
let cli = Cli::try_parse_from([
"fabro",
"server",
"__serve",
"--storage-dir",
storage_dir.path().to_str().unwrap(),
"--record-path",
record_path.to_str().unwrap(),
"--config",
config_path.to_str().unwrap(),
])
.expect("should parse");
let command = cli.command.as_deref().unwrap();
let bootstrap = runtime()
.block_on(pre_tracing_bootstrap(command))
.expect("bootstrap should resolve");
assert_eq!(bootstrap.sink, logging::InternalLogSink::Server {
path: storage_dir.path().join("logs").join("server.log"),
});
assert_eq!(bootstrap.config_log_level.as_deref(), Some("warn"));
assert!(bootstrap.foreground_server_log_bootstrap.is_none());
}
#[test]
fn pre_tracing_bootstrap_uses_cli_sink_for_server_start_daemon_wrapper() {
let storage_dir = tempfile::tempdir().unwrap();
let config_dir = tempfile::tempdir().unwrap();
let config_path = config_dir.path().join("settings.toml");
write_test_settings(&config_path);
let cli = Cli::try_parse_from([
"fabro",
"server",
"start",
"--storage-dir",
storage_dir.path().to_str().unwrap(),
"--config",
config_path.to_str().unwrap(),
])
.expect("should parse");
let command = cli.command.as_deref().unwrap();
let bootstrap = runtime()
.block_on(pre_tracing_bootstrap(command))
.expect("bootstrap should resolve");
assert_eq!(bootstrap.sink, logging::InternalLogSink::Cli);
assert!(bootstrap.config_log_level.is_none());
assert!(bootstrap.foreground_server_log_bootstrap.is_none());
}
#[test]
fn pre_tracing_bootstrap_uses_cli_sink_for_server_restart_daemon_wrapper() {
let storage_dir = tempfile::tempdir().unwrap();
let config_dir = tempfile::tempdir().unwrap();
let config_path = config_dir.path().join("settings.toml");
write_test_settings(&config_path);
let cli = Cli::try_parse_from([
"fabro",
"server",
"restart",
"--storage-dir",
storage_dir.path().to_str().unwrap(),
"--config",
config_path.to_str().unwrap(),
])
.expect("should parse");
let command = cli.command.as_deref().unwrap();
let bootstrap = runtime()
.block_on(pre_tracing_bootstrap(command))
.expect("bootstrap should resolve");
assert_eq!(bootstrap.sink, logging::InternalLogSink::Cli);
assert!(bootstrap.config_log_level.is_none());
assert!(bootstrap.foreground_server_log_bootstrap.is_none());
}
#[test]
fn parse_provider_login_missing_provider_flag() {
let result = Cli::try_parse_from(["fabro", "provider", "login"]);
assert!(result.is_err(), "should fail without --provider");
}
#[test]
fn parse_provider_login_bogus_provider() {
let result = Cli::try_parse_from(["fabro", "provider", "login", "--provider", "bogus"]);
assert!(result.is_err(), "should fail with unknown provider");
}
#[test]
fn parse_create_command() {
let cli = Cli::try_parse_from(["fabro", "create", "my-workflow.toml", "--goal", "test"])
.expect("should parse");
match *cli.command.unwrap() {
Commands::RunCmd(RunCommands::Create(args)) => {
assert_eq!(
args.workflow.as_deref(),
Some(std::path::Path::new("my-workflow.toml"))
);
assert_eq!(args.goal.as_deref(), Some("test"));
}
_ => panic!("unexpected command variant"),
}
}
#[test]
fn parse_run_storage_dir_after_subcommand_is_rejected() {
let result = Cli::try_parse_from([
"fabro",
"run",
"test/simple.fabro",
"--storage-dir",
"/tmp/fabro",
]);
assert!(result.is_err(), "should reject run --storage-dir");
}
#[test]
fn parse_model_list_server_target_after_subcommand() {
let cli = Cli::try_parse_from([
"fabro",
"model",
"list",
"--server",
"http://localhost:3000/api/v1",
])
.expect("should parse");
match *cli.command.unwrap() {
Commands::Model {
command: Some(ModelsCommand::List(args)),
} => assert_eq!(args.target.as_deref(), Some("http://localhost:3000/api/v1")),
_ => panic!("unexpected command variant"),
}
}
#[test]
fn parse_exec_server_target_after_subcommand() {
let cli = Cli::try_parse_from([
"fabro",
"exec",
"--server",
"http://localhost:3000/api/v1",
"fix the bug",
])
.expect("should parse");
match *cli.command.unwrap() {
Commands::Exec(args) => {
assert_eq!(args.server.as_deref(), Some("http://localhost:3000/api/v1"));
}
_ => panic!("unexpected command variant"),
}
}
#[test]
fn parse_model_server_target_conflicts_with_storage_dir() {
let result = Cli::try_parse_from([
"fabro",
"model",
"list",
"--storage-dir",
"/tmp/fabro",
"--server",
"http://localhost:3000",
]);
assert!(
result.is_err(),
"should fail with conflicting model target flags"
);
}
#[test]
fn parse_global_server_target_before_subcommand_is_rejected() {
let result = Cli::try_parse_from([
"fabro",
"--server",
"http://localhost:3000/api/v1",
"model",
"list",
]);
assert!(result.is_err(), "should reject top-level --server");
}
#[test]
fn parse_global_storage_dir_before_subcommand_is_rejected() {
let result = Cli::try_parse_from([
"fabro",
"--storage-dir",
"/tmp/fabro",
"run",
"test/simple.fabro",
]);
assert!(result.is_err(), "should reject top-level --storage-dir");
}
#[test]
fn parse_upgrade_prerelease_conflicts_with_version() {
use clap::error::ErrorKind;
let err = Cli::try_parse_from(["fabro", "upgrade", "--prerelease", "--version", "0.1.0"])
.err()
.expect("should reject --prerelease combined with --version");
assert_eq!(
err.kind(),
ErrorKind::ArgumentConflict,
"expected ArgumentConflict, got {:?}: {err}",
err.kind()
);
}
#[test]
fn parse_store_dump_command() {
let cli = Cli::try_parse_from(["fabro", "store", "dump", "ABC123", "-o", "./out"])
.expect("should parse");
match *cli.command.unwrap() {
Commands::Store(StoreNamespace {
command: StoreCommand::Dump(args),
}) => {
assert_eq!(args.run, "ABC123");
assert_eq!(args.output, std::path::PathBuf::from("./out"));
}
_ => panic!("unexpected command variant"),
}
}
#[test]
fn parse_start_command() {
let cli = Cli::try_parse_from(["fabro", "start", "ABC123"]).expect("should parse");
match *cli.command.unwrap() {
Commands::RunCmd(RunCommands::Start(args)) => {
assert_eq!(args.run, "ABC123");
}
_ => panic!("unexpected command variant"),
}
}
#[test]
fn parse_attach_command() {
let cli = Cli::try_parse_from(["fabro", "attach", "ABC123"]).expect("should parse");
match *cli.command.unwrap() {
Commands::RunCmd(RunCommands::Attach(args)) => {
assert_eq!(args.run, "ABC123");
}
_ => panic!("unexpected command variant"),
}
}
#[test]
fn parse_sandbox_cp_command() {
let cli = Cli::try_parse_from(["fabro", "sandbox", "cp", "ABC123:/tmp/file", "./file"])
.expect("should parse");
match *cli.command.unwrap() {
Commands::Sandbox {
command: args::SandboxCommand::Cp(args),
} => {
assert_eq!(args.src, "ABC123:/tmp/file");
assert_eq!(args.dst, "./file");
assert!(!args.recursive);
}
_ => panic!("unexpected command variant"),
}
}
#[test]
fn parse_run_worker_command() {
let cli = Cli::try_parse_from([
"fabro",
"__run-worker",
"--server",
"/tmp/fabro.sock",
"--artifact-upload-token",
"token-123",
"--run-dir",
"/tmp/run",
"--run-id",
"01ARZ3NDEKTSV4RRFFQ69G5FAV",
"--mode",
"start",
])
.expect("should parse");
match *cli.command.unwrap() {
Commands::RunCmd(RunCommands::RunWorker(args)) => {
assert_eq!(args.server, "/tmp/fabro.sock");
assert_eq!(args.artifact_upload_token.as_deref(), Some("token-123"));
assert_eq!(args.run_dir, std::path::PathBuf::from("/tmp/run"));
assert_eq!(args.run_id, "01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap());
assert!(matches!(args.mode, args::RunWorkerMode::Start));
}
_ => panic!("unexpected command variant"),
}
}
#[test]
fn parse_run_worker_with_resume_mode() {
let cli = Cli::try_parse_from([
"fabro",
"__run-worker",
"--server",
"http://127.0.0.1:3000",
"--run-dir",
"/tmp/run",
"--run-id",
"01ARZ3NDEKTSV4RRFFQ69G5FAV",
"--mode",
"resume",
])
.expect("should parse");
match *cli.command.unwrap() {
Commands::RunCmd(RunCommands::RunWorker(args)) => {
assert_eq!(args.server, "http://127.0.0.1:3000");
assert!(args.artifact_upload_token.is_none());
assert_eq!(args.run_dir, std::path::PathBuf::from("/tmp/run"));
assert_eq!(args.run_id, "01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap());
assert!(matches!(args.mode, args::RunWorkerMode::Resume));
}
_ => panic!("unexpected command variant"),
}
}
#[test]
fn parse_render_graph_command() {
let cli = Cli::try_parse_from(["fabro", "__render-graph"]).expect("should parse");
match *cli.command.unwrap() {
Commands::RenderGraph => {}
_ => panic!("unexpected command variant"),
}
}
#[test]
fn parse_settings_command() {
let cli = Cli::try_parse_from(["fabro", "settings"]).expect("should parse");
assert_eq!(cli.command.as_ref().unwrap().name(), "settings");
match *cli.command.unwrap() {
Commands::Settings(args) => {
assert!(!args.local);
assert!(args.target.server.is_none());
assert!(args.workflow.is_none());
}
_ => panic!("unexpected command variant"),
}
}
#[test]
fn parse_settings_with_workflow() {
let cli = Cli::try_parse_from(["fabro", "settings", "demo"]).expect("should parse");
match *cli.command.unwrap() {
Commands::Settings(args) => {
assert_eq!(args.workflow, Some(std::path::PathBuf::from("demo")));
}
_ => panic!("unexpected command variant"),
}
}
#[test]
fn parse_settings_local_mode() {
let cli =
Cli::try_parse_from(["fabro", "settings", "--local", "demo"]).expect("should parse");
match *cli.command.unwrap() {
Commands::Settings(args) => {
assert!(args.local);
assert_eq!(args.workflow, Some(std::path::PathBuf::from("demo")));
}
_ => panic!("unexpected command variant"),
}
}
#[test]
fn parse_quiet_flag() {
let cli = Cli::try_parse_from(["fabro", "--quiet", "settings"]).expect("should parse");
assert!(cli.globals.quiet);
assert!(!cli.globals.verbose);
}
#[test]
fn parse_verbose_flag() {
let cli = Cli::try_parse_from(["fabro", "--verbose", "settings"]).expect("should parse");
assert!(!cli.globals.quiet);
assert!(cli.globals.verbose);
}
#[test]
fn quiet_and_verbose_conflict() {
let result = Cli::try_parse_from(["fabro", "--quiet", "--verbose", "settings"]);
assert!(
result.is_err(),
"should fail when both --quiet and --verbose"
);
}
}