From 5a3eb1e4972dc130e5273f765b8b6d4af853e327 Mon Sep 17 00:00:00 2001 From: Chris Raethke Date: Mon, 20 Apr 2026 15:36:59 +1000 Subject: [PATCH 1/3] fix: surface missing bugatti.config.toml instead of silent default (#45) Add a `--config ` flag that hard-errors when the file is missing, and promote the cwd fallback log from INFO to a stderr WARNING so the silent "using defaults" behavior is visible in the terminal and run report rather than buried in diagnostics/harness_trace.jsonl. Bumps version to 0.4.2. --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/cli.rs | 7 ++++ src/config.rs | 110 +++++++++++++++++++++++++++++++++++++++++++------- src/main.rs | 48 ++++++++++++++-------- 5 files changed, 136 insertions(+), 33 deletions(-) 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/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..10a3709 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,6 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use bugatti::claude_code::ClaudeCodeAdapter; use bugatti::cli::{Cli, Commands}; use bugatti::command::{self, TrackedProcess}; use bugatti::config; @@ -15,7 +14,6 @@ use bugatti::exit_code::{ EXIT_STEP_ERROR, }; use bugatti::expand; -use bugatti::provider::AgentSession; use bugatti::report::{self, ReportInput}; use bugatti::run::{self, ArtifactDir, EffectiveConfigSummary}; use bugatti::test_file; @@ -95,6 +93,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 +103,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 +119,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 +143,7 @@ fn main() { strict_warnings, from_checkpoint.as_deref(), verbose, + explicit_config.as_deref(), ), } } @@ -159,6 +161,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 +170,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 { @@ -475,18 +483,21 @@ fn run_test_with_artifacts( }; // Phase 10: Initialize provider session - let mut session = - match ClaudeCodeAdapter::initialize(ctx.effective, &ctx.artifact_dir.root, ctx.verbose) { - Ok(s) => s, - Err(e) => { - tracing::error!(error = %e, "provider initialization failed"); - return ctx.fail_early( - EXIT_PROVIDER_ERROR, - format!("provider initialization failed: {e}"), - &mut tracked_processes, - ); - } - }; + let mut session = match bugatti::provider::initialize_session( + ctx.effective, + &ctx.artifact_dir.root, + ctx.verbose, + ) { + Ok(s) => s, + Err(e) => { + tracing::error!(error = %e, "provider initialization failed"); + return ctx.fail_early( + EXIT_PROVIDER_ERROR, + format!("provider initialization failed: {e}"), + &mut tracked_processes, + ); + } + }; if let Err(e) = session.start() { tracing::error!(error = %e, "provider start failed"); @@ -525,7 +536,7 @@ fn run_test_with_artifacts( .step_timeout_secs .map(std::time::Duration::from_secs); let outcome = match executor::execute_steps( - &mut session, + &mut *session, &steps, ctx.run_id, ctx.session_id, @@ -597,6 +608,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 +667,7 @@ fn run_discovery( strict_warnings, from_checkpoint, verbose, + explicit_config, ); results.push(result); } @@ -681,6 +694,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 +703,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 +717,7 @@ fn run_single_test( strict_warnings, from_checkpoint, verbose, + explicit_config, ); if let Some(err) = &result.error { From b1d3699b4cc43a25a432ca92ff50c995886daf54 Mon Sep 17 00:00:00 2001 From: Chris Raethke Date: Mon, 20 Apr 2026 15:42:01 +1000 Subject: [PATCH 2/3] fix: drop stray provider::initialize_session change from main.rs The previous commit accidentally swept in pre-existing refactors that route provider initialization through bugatti::provider::initialize_session, which doesn't yet exist on main. Restore ClaudeCodeAdapter::initialize so the crate builds again and CI clippy passes. --- src/main.rs | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/main.rs b/src/main.rs index 10a3709..7f64aae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use bugatti::claude_code::ClaudeCodeAdapter; use bugatti::cli::{Cli, Commands}; use bugatti::command::{self, TrackedProcess}; use bugatti::config; @@ -14,6 +15,7 @@ use bugatti::exit_code::{ EXIT_STEP_ERROR, }; use bugatti::expand; +use bugatti::provider::AgentSession; use bugatti::report::{self, ReportInput}; use bugatti::run::{self, ArtifactDir, EffectiveConfigSummary}; use bugatti::test_file; @@ -483,21 +485,18 @@ fn run_test_with_artifacts( }; // Phase 10: Initialize provider session - let mut session = match bugatti::provider::initialize_session( - ctx.effective, - &ctx.artifact_dir.root, - ctx.verbose, - ) { - Ok(s) => s, - Err(e) => { - tracing::error!(error = %e, "provider initialization failed"); - return ctx.fail_early( - EXIT_PROVIDER_ERROR, - format!("provider initialization failed: {e}"), - &mut tracked_processes, - ); - } - }; + let mut session = + match ClaudeCodeAdapter::initialize(ctx.effective, &ctx.artifact_dir.root, ctx.verbose) { + Ok(s) => s, + Err(e) => { + tracing::error!(error = %e, "provider initialization failed"); + return ctx.fail_early( + EXIT_PROVIDER_ERROR, + format!("provider initialization failed: {e}"), + &mut tracked_processes, + ); + } + }; if let Err(e) = session.start() { tracing::error!(error = %e, "provider start failed"); @@ -536,7 +535,7 @@ fn run_test_with_artifacts( .step_timeout_secs .map(std::time::Duration::from_secs); let outcome = match executor::execute_steps( - &mut *session, + &mut session, &steps, ctx.run_id, ctx.session_id, From 0e2bc88b1f80f1aab6566d647f343e191833bf69 Mon Sep 17 00:00:00 2001 From: Chris Raethke Date: Mon, 20 Apr 2026 15:46:36 +1000 Subject: [PATCH 3/3] chore: collapse match-guards in claude_code.rs to satisfy clippy 1.95 The CI runner's clippy (1.95) treats collapsible_match as an error via -D warnings, whereas 1.94 let it through. Fold the `if self.verbose` checks for the "tool_use" and "thinking" arms into match guards so CI builds cleanly without pinning the toolchain. --- src/claude_code.rs | 106 ++++++++++++++++++++++----------------------- 1 file changed, 51 insertions(+), 55 deletions(-) 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 + ); } } _ => {}