Use async reads during CLI intent preparation

This commit is contained in:
Scott Werner 2026-09-02 13:44:33 -04:00
parent e3cbc31ca1
commit 49fb160108
3 changed files with 76 additions and 54 deletions

View file

@ -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?,
);
}

View file

@ -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<Option<String>> {
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<PreparedIntentOverrides> {
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| {

View file

@ -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<RunSettingsKeyPresence> {
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());
}