Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"

Expand Down
106 changes: 51 additions & 55 deletions src/claude_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<String>();
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::<String>();
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
);
}
}
_ => {}
Expand Down
7 changes: 7 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
},
}
110 changes: 95 additions & 15 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand Down Expand Up @@ -148,41 +148,78 @@ 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 {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
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<Config, ConfigError> {
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<Config, ConfigError> {
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<Config, ConfigError> {
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 <path> to point at a config file explicitly.",
dir.display()
);
Ok(Config::default())
}
Err(e) => {
Expand Down Expand Up @@ -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 {
Expand Down
17 changes: 16 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
Expand All @@ -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);
Expand All @@ -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 {
Expand All @@ -142,6 +145,7 @@ fn main() {
strict_warnings,
from_checkpoint.as_deref(),
verbose,
explicit_config.as_deref(),
),
}
}
Expand All @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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...");

Expand Down Expand Up @@ -655,6 +666,7 @@ fn run_discovery(
strict_warnings,
from_checkpoint,
verbose,
explicit_config,
);
results.push(result);
}
Expand All @@ -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,
Expand All @@ -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));
Expand All @@ -702,6 +716,7 @@ fn run_single_test(
strict_warnings,
from_checkpoint,
verbose,
explicit_config,
);

if let Some(err) = &result.error {
Expand Down
Loading