diff --git a/Cargo.lock b/Cargo.lock index 5d5e882..a74010c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,7 +117,7 @@ dependencies = [ [[package]] name = "bugatti" -version = "0.4.1" +version = "0.4.2" dependencies = [ "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index 75c1f17..96f0ab9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bugatti" -version = "0.4.1" +version = "0.4.2" edition = "2021" description = "A CLI for plain-English, agent-assisted local application verification using *.test.toml files" diff --git a/src/claude_code.rs b/src/claude_code.rs index bfa3a0f..8f5dc5d 100644 --- a/src/claude_code.rs +++ b/src/claude_code.rs @@ -390,62 +390,58 @@ impl<'a> Iterator for StreamTurnIterator<'a> { } } } - "tool_use" => { - if self.verbose { - let name = - block.name.as_deref().unwrap_or("unknown"); - let input_preview = block - .input - .as_ref() - .map(|v| { - // For Bash, show the command directly - if let Some(cmd) = v - .get("command") - .and_then(|c| c.as_str()) - { - format!("$ {cmd}") - } else if let Some(path) = v - .get("file_path") - .and_then(|p| p.as_str()) - { - path.to_string() - } else if let Some(pattern) = v - .get("pattern") - .and_then(|p| p.as_str()) - { - format!("/{pattern}/") - } else { - v.to_string() - } - }) - .unwrap_or_default(); - let id_short = block - .id - .as_deref() - .unwrap_or("") - .chars() - .take(12) - .collect::(); - eprintln!("{}[verbose]{} {}tool:{} {}{}{} {}{}{} {}({}){}", color::DIM, color::RESET, color::DIM, color::RESET, color::TOOL, name, color::RESET, color::LIGHT, input_preview, color::RESET, color::DIM, id_short, color::RESET); - } + "tool_use" if self.verbose => { + let name = + block.name.as_deref().unwrap_or("unknown"); + let input_preview = block + .input + .as_ref() + .map(|v| { + // For Bash, show the command directly + if let Some(cmd) = v + .get("command") + .and_then(|c| c.as_str()) + { + format!("$ {cmd}") + } else if let Some(path) = v + .get("file_path") + .and_then(|p| p.as_str()) + { + path.to_string() + } else if let Some(pattern) = v + .get("pattern") + .and_then(|p| p.as_str()) + { + format!("/{pattern}/") + } else { + v.to_string() + } + }) + .unwrap_or_default(); + let id_short = block + .id + .as_deref() + .unwrap_or("") + .chars() + .take(12) + .collect::(); + eprintln!("{}[verbose]{} {}tool:{} {}{}{} {}{}{} {}({}){}", color::DIM, color::RESET, color::DIM, color::RESET, color::TOOL, name, color::RESET, color::LIGHT, input_preview, color::RESET, color::DIM, id_short, color::RESET); } - "thinking" => { - if self.verbose { - if let Some(thinking) = &block.thinking { - eprintln!( - "{}[verbose]{} {}thinking:{}", - color::DIM, - color::RESET, - color::DIM, - color::RESET - ); - eprintln!( - "{}{}{}", - color::THINKING, - thinking, - color::RESET - ); - } + "thinking" if self.verbose => { + if let Some(thinking) = &block.thinking { + eprintln!( + "{}[verbose]{} {}thinking:{}", + color::DIM, + color::RESET, + color::DIM, + color::RESET + ); + eprintln!( + "{}{}{}", + color::THINKING, + thinking, + color::RESET + ); } } _ => {} diff --git a/src/cli.rs b/src/cli.rs index d10faa6..b0df73b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -73,5 +73,12 @@ pub enum Commands { /// Enable verbose output: show full prompts, provider command lines, and timing details. #[arg(long, short)] verbose: bool, + + /// Explicit path to a bugatti.config.toml file. + /// When set, the file must exist — a missing file is a hard error. + /// When omitted, bugatti looks for bugatti.config.toml in the current directory + /// and warns loudly if it is not found. + #[arg(long = "config", value_name = "PATH")] + config_path: Option, }, } diff --git a/src/config.rs b/src/config.rs index 421406d..4fe8625 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,7 +1,7 @@ use crate::test_file::ProviderOverrides; use indexmap::IndexMap; use serde::Deserialize; -use std::path::Path; +use std::path::{Path, PathBuf}; /// Top-level project configuration loaded from bugatti.config.toml. #[derive(Debug, Clone, Default, Deserialize, PartialEq)] @@ -148,6 +148,8 @@ pub enum ConfigError { ReadError(std::io::Error), /// Failed to parse the TOML content. ParseError(toml::de::Error), + /// An explicit --config path was provided but the file does not exist. + ExplicitPathNotFound(PathBuf), } impl std::fmt::Display for ConfigError { @@ -155,34 +157,69 @@ impl std::fmt::Display for ConfigError { match self { ConfigError::ReadError(e) => write!(f, "failed to read bugatti.config.toml: {e}"), ConfigError::ParseError(e) => write!(f, "invalid bugatti.config.toml: {e}"), + ConfigError::ExplicitPathNotFound(p) => { + write!(f, "config file not found: {}", p.display()) + } } } } impl std::error::Error for ConfigError {} +/// Parse TOML contents into a `Config`, emitting trace logs for success/failure. +fn parse_config_contents(path: &Path, contents: &str) -> Result { + let config: Config = toml::from_str(contents).map_err(|e| { + tracing::error!(path = %path.display(), error = %e, "config parse failed"); + ConfigError::ParseError(e) + })?; + tracing::info!( + path = %path.display(), + provider = %config.provider.name, + commands = config.commands.len(), + "config loaded" + ); + Ok(config) +} + +/// Load configuration from an explicit file path. +/// +/// Unlike [`load_config`], a missing file is an error — callers who pass +/// `--config` want to fail loudly if the path is wrong rather than silently +/// fall back to defaults. +pub fn load_config_from_file(path: &Path) -> Result { + tracing::info!(path = %path.display(), "loading config from explicit path"); + match std::fs::read_to_string(path) { + Ok(contents) => parse_config_contents(path, &contents), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + tracing::error!(path = %path.display(), "explicit config path not found"); + Err(ConfigError::ExplicitPathNotFound(path.to_path_buf())) + } + Err(e) => { + tracing::error!(path = %path.display(), error = %e, "config read failed"); + Err(ConfigError::ReadError(e)) + } + } +} + /// Load configuration from `bugatti.config.toml` in the given directory. /// -/// Returns `Ok(Config::default())` if the file does not exist. +/// Returns `Ok(Config::default())` if the file does not exist, after printing +/// a stderr warning so the fallback is visible in the terminal and run report +/// instead of only in the diagnostics trace. /// Returns `Err` if the file exists but cannot be read or parsed. pub fn load_config(dir: &Path) -> Result { let path = dir.join("bugatti.config.toml"); tracing::info!(path = %path.display(), "loading config"); match std::fs::read_to_string(&path) { - Ok(contents) => { - let config: Config = toml::from_str(&contents).map_err(|e| { - tracing::error!(path = %path.display(), error = %e, "config parse failed"); - ConfigError::ParseError(e) - })?; - tracing::info!( - provider = %config.provider.name, - commands = config.commands.len(), - "config loaded" - ); - Ok(config) - } + Ok(contents) => parse_config_contents(&path, &contents), Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - tracing::info!("no config file found, using defaults"); + tracing::warn!(path = %path.display(), "no config file found, using defaults"); + eprintln!( + "WARNING: no bugatti.config.toml found in {} — running with defaults.\n\ + Any [commands.*], agent_args, or extra_system_prompt defined elsewhere will not be applied.\n\ + Pass --config to point at a config file explicitly.", + dir.display() + ); Ok(Config::default()) } Err(e) => { @@ -290,6 +327,49 @@ cmd = "echo migrate" assert!(err_msg.contains("invalid bugatti.config.toml")); } + #[test] + fn load_from_explicit_path_reads_arbitrary_filename() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("custom.toml"); + fs::write( + &path, + r#" +[provider] +name = "openai" + +[commands.migrate] +kind = "short_lived" +cmd = "cargo sqlx migrate run" +"#, + ) + .unwrap(); + + let config = load_config_from_file(&path).unwrap(); + assert_eq!(config.provider.name, "openai"); + assert_eq!(config.commands.len(), 1); + } + + #[test] + fn explicit_path_missing_is_hard_error() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("does-not-exist.toml"); + let err = load_config_from_file(&path).unwrap_err(); + match err { + ConfigError::ExplicitPathNotFound(p) => assert_eq!(p, path), + other => panic!("expected ExplicitPathNotFound, got {other:?}"), + } + } + + #[test] + fn explicit_path_invalid_toml_returns_parse_error() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("broken.toml"); + fs::write(&path, "not valid toml [[[").unwrap(); + + let err = load_config_from_file(&path).unwrap_err(); + assert!(matches!(err, ConfigError::ParseError(_))); + } + #[test] fn merge_full_overrides() { let global = Config { diff --git a/src/main.rs b/src/main.rs index ced57bc..7f64aae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -95,6 +95,7 @@ fn main() { strict_warnings, from_checkpoint, verbose, + config_path, } => { let project_root = std::env::current_dir().unwrap_or_else(|e| { eprintln!("ERROR: failed to determine current directory: {e}"); @@ -104,6 +105,7 @@ fn main() { if !skip_cmds.is_empty() { println!("Skipping commands: {}", skip_cmds.join(", ")); } + let explicit_config = config_path.as_deref().map(PathBuf::from); match path { Some(p) => { let test_path = PathBuf::from(&p); @@ -119,6 +121,7 @@ fn main() { strict_warnings, from_checkpoint.as_deref(), verbose, + explicit_config.as_deref(), ); // Print run reference for single-file mode if let Some(run_id) = &result.run_id { @@ -142,6 +145,7 @@ fn main() { strict_warnings, from_checkpoint.as_deref(), verbose, + explicit_config.as_deref(), ), } } @@ -159,6 +163,7 @@ fn main() { /// /// Pipeline order: config load -> parse -> expand -> artifact setup -> command setup /// -> provider init -> step execution -> report -> teardown -> exit +#[allow(clippy::too_many_arguments)] fn run_test_pipeline( project_root: &Path, test_path: &Path, @@ -167,11 +172,16 @@ fn run_test_pipeline( strict_warnings: bool, from_checkpoint: Option<&str>, verbose: bool, + explicit_config: Option<&Path>, ) -> TestRunResult { let test_name_fallback = test_path.display().to_string(); // Phase 1: Load config - let global_config = match config::load_config(project_root) { + let load_result = match explicit_config { + Some(path) => config::load_config_from_file(path), + None => config::load_config(project_root), + }; + let global_config = match load_result { Ok(c) => c, Err(e) => { return TestRunResult { @@ -597,6 +607,7 @@ fn run_discovery( strict_warnings: bool, from_checkpoint: Option<&str>, verbose: bool, + explicit_config: Option<&Path>, ) -> i32 { println!("Discovering root test files..."); @@ -655,6 +666,7 @@ fn run_discovery( strict_warnings, from_checkpoint, verbose, + explicit_config, ); results.push(result); } @@ -681,6 +693,7 @@ fn run_discovery( } /// Run a single discovered test file through the full pipeline. +#[allow(clippy::too_many_arguments)] fn run_single_test( test: &DiscoveredTest, project_root: &Path, @@ -689,6 +702,7 @@ fn run_single_test( strict_warnings: bool, from_checkpoint: Option<&str>, verbose: bool, + explicit_config: Option<&Path>, ) -> TestRunResult { println!("═══════════════════════════════════════════════════════"); println!("Running: {} ({})", test.name, relative_display(&test.path)); @@ -702,6 +716,7 @@ fn run_single_test( strict_warnings, from_checkpoint, verbose, + explicit_config, ); if let Some(err) = &result.error {