diff --git a/lib/apps/fabro-cli/src/commands/run/create.rs b/lib/apps/fabro-cli/src/commands/run/create.rs index 71e236c65..184c19816 100644 --- a/lib/apps/fabro-cli/src/commands/run/create.rs +++ b/lib/apps/fabro-cli/src/commands/run/create.rs @@ -42,7 +42,7 @@ pub(crate) async fn create_run( &canonical_cwd, Some(&user_workflows_root), )?; - let prepared = prepare_intent_overrides(args, &canonical_cwd)?; + let prepared = prepare_intent_overrides(args, &canonical_cwd).await?; warn_untransmitted_settings( ctx, @@ -56,7 +56,7 @@ pub(crate) async fn create_run( ctx, styles, path, - read_project_run_settings_key_presence(path)?, + read_project_run_settings_key_presence(path).await?, ); } diff --git a/lib/apps/fabro-cli/src/commands/run/overrides.rs b/lib/apps/fabro-cli/src/commands/run/overrides.rs index ec98736d7..3c0ffca45 100644 --- a/lib/apps/fabro-cli/src/commands/run/overrides.rs +++ b/lib/apps/fabro-cli/src/commands/run/overrides.rs @@ -9,6 +9,7 @@ use fabro_manifest::{RunOverrideInput, build_run_overrides}; use fabro_types::RunIntentArgs; use fabro_types::settings::cli::OutputVerbosity; use fabro_types::settings::interp::InterpString; +use tokio::fs; use crate::args::{PreflightArgs, RunArgs}; @@ -74,25 +75,35 @@ fn current_dir_or_dot() -> PathBuf { std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) } -#[expect( - clippy::disallowed_methods, - reason = "CLI argument preparation synchronously reads one local goal file before submission" -)] -pub(super) fn prepare_intent_overrides( +async fn intent_goal_from_args( + goal: Option<&str>, + goal_file: Option<&Path>, + cwd: &Path, +) -> Result> { + match (goal, goal_file) { + (Some(_), Some(_)) => Err(anyhow!( + "--goal and --goal-file are mutually exclusive; use exactly one" + )), + (Some(text), None) => Ok(Some(text.to_owned())), + (None, Some(path)) => { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + }; + Ok(Some(fs::read_to_string(&absolute).await.with_context( + || format!("failed to read goal file {}", absolute.display()), + )?)) + } + (None, None) => Ok(None), + } +} + +pub(super) async fn prepare_intent_overrides( args: &RunArgs, cwd: &Path, ) -> Result { - let goal = match goal_layer_from_args(args.goal.as_deref(), args.goal_file.as_deref(), cwd)? { - None => None, - Some(RunGoalLayer::Inline(goal)) => Some(goal.as_source()), - Some(RunGoalLayer::File { file }) => { - let path = PathBuf::from(file.as_source()); - Some( - std::fs::read_to_string(&path) - .with_context(|| format!("failed to read goal file {}", path.display()))?, - ) - } - }; + let goal = intent_goal_from_args(args.goal.as_deref(), args.goal_file.as_deref(), cwd).await?; let input_overrides = parse_input_overrides(&args.inputs.values)?; let inputs = input_overrides .iter() @@ -172,8 +183,8 @@ mod tests { } } - #[test] - fn intent_overrides_preserve_typed_values_and_sparse_flags() { + #[tokio::test] + async fn intent_overrides_preserve_typed_values_and_sparse_flags() { let mut args = run_args(); args.inputs.values = vec![ "string=hello".to_string(), @@ -192,7 +203,9 @@ mod tests { args.verbose = true; let PreparedIntentOverrides { intent_args, goal } = - prepare_intent_overrides(&args, Path::new("/caller")).unwrap(); + prepare_intent_overrides(&args, Path::new("/caller")) + .await + .unwrap(); assert_eq!(goal.as_deref(), Some("Ship it")); assert_eq!( @@ -219,21 +232,25 @@ mod tests { ); } - #[test] - fn intent_overrides_leave_false_flags_absent() { - let prepared = prepare_intent_overrides(&run_args(), Path::new("/caller")).unwrap(); + #[tokio::test] + async fn intent_overrides_leave_false_flags_absent() { + let prepared = prepare_intent_overrides(&run_args(), Path::new("/caller")) + .await + .unwrap(); assert_eq!(prepared.intent_args.dry_run, None); assert_eq!(prepared.intent_args.auto_approve, None); assert_eq!(prepared.intent_args.preserve_sandbox, None); } - #[test] - fn intent_goal_files_are_read_by_value_from_relative_and_absolute_paths() { + #[tokio::test] + async fn intent_goal_files_are_read_by_value_from_relative_and_absolute_paths() { let dir = tempfile::tempdir().unwrap(); let relative = PathBuf::from("goals/task.md"); - std::fs::create_dir_all(dir.path().join("goals")).unwrap(); - std::fs::write(dir.path().join(&relative), "Goal from file").unwrap(); + fs::create_dir_all(dir.path().join("goals")).await.unwrap(); + fs::write(dir.path().join(&relative), "Goal from file") + .await + .unwrap(); for goal_file in [relative, dir.path().join("goals/task.md")] { let mut args = run_args(); @@ -241,38 +258,44 @@ mod tests { let PreparedIntentOverrides { intent_args: _, goal, - } = prepare_intent_overrides(&args, dir.path()).unwrap(); + } = prepare_intent_overrides(&args, dir.path()).await.unwrap(); assert_eq!(goal.as_deref(), Some("Goal from file")); } } - #[test] - fn intent_goal_file_read_errors_preserve_the_resolved_path_and_source() { + #[tokio::test] + async fn intent_goal_file_read_errors_preserve_the_resolved_path_and_source() { let mut args = run_args(); args.goal_file = Some(PathBuf::from("missing.md")); - let error = prepare_intent_overrides(&args, Path::new("/caller")).unwrap_err(); + let error = prepare_intent_overrides(&args, Path::new("/caller")) + .await + .unwrap_err(); assert!(error.to_string().contains("/caller/missing.md")); assert!(error.source().is_some()); } - #[test] - fn intent_goal_and_goal_file_together_are_rejected_defensively() { + #[tokio::test] + async fn intent_goal_and_goal_file_together_are_rejected_defensively() { let mut args = run_args(); args.goal = Some("inline".to_string()); args.goal_file = Some(PathBuf::from("goal.md")); - let error = prepare_intent_overrides(&args, Path::new("/caller")).unwrap_err(); + let error = prepare_intent_overrides(&args, Path::new("/caller")) + .await + .unwrap_err(); assert!(error.to_string().contains("mutually exclusive")); } - #[test] - fn intent_overrides_reject_non_finite_float_with_input_key() { + #[tokio::test] + async fn intent_overrides_reject_non_finite_float_with_input_key() { let mut args = run_args(); args.inputs.values = vec!["temperature=nan".to_string()]; - let error = prepare_intent_overrides(&args, Path::new("/caller")).unwrap_err(); + let error = prepare_intent_overrides(&args, Path::new("/caller")) + .await + .unwrap_err(); assert!(error.to_string().contains("temperature")); assert!(format!("{error:#}").contains("finite")); assert!(error.chain().any(|cause| { diff --git a/lib/apps/fabro-cli/src/user_config.rs b/lib/apps/fabro-cli/src/user_config.rs index d61f73bbe..e842a389e 100644 --- a/lib/apps/fabro-cli/src/user_config.rs +++ b/lib/apps/fabro-cli/src/user_config.rs @@ -17,6 +17,7 @@ use fabro_types::settings::server::LogDestination; use fabro_types::{ServerSettings, UserSettings}; use fabro_util::error::SharedError; use fabro_util::version::FABRO_VERSION; +use tokio::fs; use toml_edit::{DocumentMut, Item, Table, value}; use tracing::debug; @@ -84,16 +85,13 @@ pub(crate) fn load_resolved_settings( }) } -#[expect( - clippy::disallowed_methods, - reason = "sync project settings inspection during CLI command preparation" -)] -pub(crate) fn read_project_run_settings_key_presence( +pub(crate) async fn read_project_run_settings_key_presence( path: &Path, ) -> anyhow::Result { let parse_error = |source| fabro_config::Error::parse_file("Failed to parse settings file", path, source); - let source = std::fs::read_to_string(path) + let source = fs::read_to_string(path) + .await .map_err(|source| fabro_config::Error::read_file(path, source))?; let document: toml::Value = toml::from_str(&source) .map_err(|source| parse_error(ParseError::Toml(source.to_string())))?; @@ -468,30 +466,31 @@ mod tests { ); } - #[test] - #[expect( - clippy::disallowed_methods, - reason = "unit test writes a temporary project settings fixture with sync std::fs" - )] - fn project_run_settings_key_presence_validates_the_same_source() { + #[tokio::test] + async fn project_run_settings_key_presence_validates_the_same_source() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("project.toml"); - std::fs::write(&path, "_version = 1\n\n[run]\n\n[environments]\n").unwrap(); + fs::write(&path, "_version = 1\n\n[run]\n\n[environments]\n") + .await + .unwrap(); assert_eq!( - read_project_run_settings_key_presence(&path).unwrap(), + read_project_run_settings_key_presence(&path).await.unwrap(), RunSettingsKeyPresence { run: true, environments: true, } ); - std::fs::write( + fs::write( &path, "_version = 1\n\n[environments.cloud]\ncwd = \"/tmp\"\n", ) + .await .unwrap(); - let error = read_project_run_settings_key_presence(&path).unwrap_err(); + let error = read_project_run_settings_key_presence(&path) + .await + .unwrap_err(); assert!(error.to_string().contains(&path.display().to_string())); assert!(error.source().is_some()); }