From ded8c6fd66cab70ba473c0d58fe2a68942128177 Mon Sep 17 00:00:00 2001 From: dave Date: Mon, 27 Apr 2026 19:10:37 +0000 Subject: [PATCH] huskies: merge 685_refactor_decompose_server_src_config_rs_1223_lines --- server/src/{config.rs => config/mod.rs} | 631 +----------------------- server/src/config/tests.rs | 628 +++++++++++++++++++++++ 2 files changed, 629 insertions(+), 630 deletions(-) rename server/src/{config.rs => config/mod.rs} (56%) create mode 100644 server/src/config/tests.rs diff --git a/server/src/config.rs b/server/src/config/mod.rs similarity index 56% rename from server/src/config.rs rename to server/src/config/mod.rs index b1d830d7..54633e59 100644 --- a/server/src/config.rs +++ b/server/src/config/mod.rs @@ -591,633 +591,4 @@ fn validate_agents(agents: &[AgentConfig]) -> Result<(), String> { } #[cfg(test)] -mod tests { - use super::*; - use std::fs; - - #[test] - fn default_config_when_missing() { - let tmp = tempfile::tempdir().unwrap(); - let config = ProjectConfig::load(tmp.path()).unwrap(); - assert_eq!(config.agent.len(), 1); - assert_eq!(config.agent[0].name, "default"); - assert!(config.component.is_empty()); - } - - #[test] - fn parse_multi_agent_toml() { - let toml_str = r#" -[[component]] -name = "server" -path = "." -setup = ["cargo check"] - -[[agent]] -name = "supervisor" -role = "Coordinates work" -model = "opus" -max_turns = 50 -max_budget_usd = 10.00 -system_prompt = "You are a senior engineer" - -[[agent]] -name = "coder-1" -role = "Full-stack engineer" -model = "sonnet" -max_turns = 30 -max_budget_usd = 5.00 -"#; - - let config = ProjectConfig::parse(toml_str).unwrap(); - assert_eq!(config.agent.len(), 2); - assert_eq!(config.agent[0].name, "supervisor"); - assert_eq!(config.agent[0].role, "Coordinates work"); - assert_eq!(config.agent[0].model, Some("opus".to_string())); - assert_eq!(config.agent[0].max_turns, Some(50)); - assert_eq!(config.agent[0].max_budget_usd, Some(10.0)); - assert_eq!( - config.agent[0].system_prompt, - Some("You are a senior engineer".to_string()) - ); - assert_eq!(config.agent[1].name, "coder-1"); - assert_eq!(config.agent[1].model, Some("sonnet".to_string())); - assert_eq!(config.component.len(), 1); - } - - #[test] - fn parse_legacy_single_agent() { - let toml_str = r#" -[[component]] -name = "server" -path = "." - -[agent] -command = "claude" -args = ["--print", "--directory", "{{worktree_path}}"] -prompt = "Pick up story {{story_id}}" -"#; - - let config = ProjectConfig::parse(toml_str).unwrap(); - assert_eq!(config.agent.len(), 1); - assert_eq!(config.agent[0].name, "default"); - assert_eq!(config.agent[0].command, "claude"); - } - - #[test] - fn validate_duplicate_names() { - let toml_str = r#" -[[agent]] -name = "coder" -role = "Engineer" - -[[agent]] -name = "coder" -role = "Another engineer" -"#; - - let err = ProjectConfig::parse(toml_str).unwrap_err(); - assert!(err.contains("Duplicate agent name: 'coder'")); - } - - #[test] - fn validate_empty_name() { - let toml_str = r#" -[[agent]] -name = "" -role = "Engineer" -"#; - - let err = ProjectConfig::parse(toml_str).unwrap_err(); - assert!(err.contains("Agent name must not be empty")); - } - - #[test] - fn validate_non_positive_budget() { - let toml_str = r#" -[[agent]] -name = "coder" -max_budget_usd = -1.0 -"#; - - let err = ProjectConfig::parse(toml_str).unwrap_err(); - assert!(err.contains("must be positive")); - } - - #[test] - fn validate_zero_max_turns() { - let toml_str = r#" -[[agent]] -name = "coder" -max_turns = 0 -"#; - - let err = ProjectConfig::parse(toml_str).unwrap_err(); - assert!(err.contains("max_turns must be positive")); - } - - #[test] - fn render_agent_args_default() { - let config = ProjectConfig::default(); - let (cmd, args, prompt) = config - .render_agent_args("/tmp/wt", "42_foo", None, None) - .unwrap(); - assert_eq!(cmd, "claude"); - assert!(args.is_empty()); - assert!(prompt.contains("42_foo")); - } - - #[test] - fn render_agent_args_by_name() { - let toml_str = r#" -[[agent]] -name = "supervisor" -model = "opus" -max_turns = 50 -max_budget_usd = 10.00 -system_prompt = "You lead story {{story_id}}" -allowed_tools = ["Read", "Write", "Bash"] - -[[agent]] -name = "coder" -model = "sonnet" -max_turns = 30 -"#; - - let config = ProjectConfig::parse(toml_str).unwrap(); - let (cmd, args, prompt) = config - .render_agent_args("/tmp/wt", "42_foo", Some("supervisor"), Some("master")) - .unwrap(); - assert_eq!(cmd, "claude"); - assert!(args.contains(&"--model".to_string())); - assert!(args.contains(&"opus".to_string())); - assert!(args.contains(&"--max-turns".to_string())); - assert!(args.contains(&"50".to_string())); - assert!(args.contains(&"--max-budget-usd".to_string())); - assert!(args.contains(&"10".to_string())); - assert!(args.contains(&"--allowedTools".to_string())); - assert!(args.contains(&"Read,Write,Bash".to_string())); - assert!(args.contains(&"--append-system-prompt".to_string())); - // System prompt should have template rendered - assert!(args.contains(&"You lead story 42_foo".to_string())); - assert!(prompt.contains("42_foo")); - - // Render for coder - let (_, coder_args, _) = config - .render_agent_args("/tmp/wt", "42_foo", Some("coder"), Some("master")) - .unwrap(); - assert!(coder_args.contains(&"sonnet".to_string())); - assert!(coder_args.contains(&"30".to_string())); - assert!(!coder_args.contains(&"--max-budget-usd".to_string())); - assert!(!coder_args.contains(&"--append-system-prompt".to_string())); - } - - #[test] - fn render_agent_args_not_found() { - let config = ProjectConfig::default(); - let result = config.render_agent_args("/tmp/wt", "42_foo", Some("nonexistent"), None); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("No agent named 'nonexistent'")); - } - - #[test] - fn find_agent_and_default() { - let toml_str = r#" -[[agent]] -name = "first" - -[[agent]] -name = "second" -"#; - - let config = ProjectConfig::parse(toml_str).unwrap(); - assert_eq!(config.default_agent().unwrap().name, "first"); - assert_eq!(config.find_agent("second").unwrap().name, "second"); - assert!(config.find_agent("missing").is_none()); - } - - #[test] - fn parse_project_toml_from_file() { - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".huskies"); - fs::create_dir_all(&sk).unwrap(); - fs::write( - sk.join("project.toml"), - r#" -[[component]] -name = "server" -path = "." -setup = ["cargo check"] -teardown = [] - -[[component]] -name = "frontend" -path = "frontend" -setup = ["pnpm install"] - -[[agent]] -name = "main" -command = "claude" -args = ["--print", "--directory", "{{worktree_path}}"] -prompt = "Pick up story {{story_id}}" -model = "sonnet" -"#, - ) - .unwrap(); - - let config = ProjectConfig::load(tmp.path()).unwrap(); - assert_eq!(config.component.len(), 2); - assert_eq!(config.component[0].name, "server"); - assert_eq!(config.component[1].setup, vec!["pnpm install"]); - assert_eq!(config.agent.len(), 1); - assert_eq!(config.agent[0].name, "main"); - assert_eq!(config.agent[0].model, Some("sonnet".to_string())); - } - - #[test] - fn agents_toml_overrides_project_toml_agents() { - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".huskies"); - fs::create_dir_all(&sk).unwrap(); - // project.toml has inline agents - fs::write( - sk.join("project.toml"), - r#" -[[agent]] -name = "from-project-toml" -model = "sonnet" -"#, - ) - .unwrap(); - // agents.toml overrides with different agents - fs::write( - sk.join("agents.toml"), - r#" -[[agent]] -name = "from-agents-toml" -model = "opus" -"#, - ) - .unwrap(); - - let config = ProjectConfig::load(tmp.path()).unwrap(); - assert_eq!(config.agent.len(), 1); - assert_eq!(config.agent[0].name, "from-agents-toml"); - assert_eq!(config.agent[0].model, Some("opus".to_string())); - } - - #[test] - fn agents_toml_absent_falls_back_to_project_toml() { - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".huskies"); - fs::create_dir_all(&sk).unwrap(); - fs::write( - sk.join("project.toml"), - r#" -[[agent]] -name = "inline-agent" -model = "sonnet" -"#, - ) - .unwrap(); - // No agents.toml — should use inline agents from project.toml - - let config = ProjectConfig::load(tmp.path()).unwrap(); - assert_eq!(config.agent.len(), 1); - assert_eq!(config.agent[0].name, "inline-agent"); - } - - // ── WatcherConfig ────────────────────────────────────────────────────── - - #[test] - fn watcher_config_defaults_when_omitted() { - let toml_str = r#" -[[agent]] -name = "coder" -"#; - let config = ProjectConfig::parse(toml_str).unwrap(); - assert_eq!(config.watcher.sweep_interval_secs, 60); - assert_eq!(config.watcher.done_retention_secs, 4 * 60 * 60); - } - - #[test] - fn watcher_config_custom_values() { - let toml_str = r#" -[watcher] -sweep_interval_secs = 30 -done_retention_secs = 7200 - -[[agent]] -name = "coder" -"#; - let config = ProjectConfig::parse(toml_str).unwrap(); - assert_eq!(config.watcher.sweep_interval_secs, 30); - assert_eq!(config.watcher.done_retention_secs, 7200); - } - - #[test] - fn watcher_config_partial_override() { - let toml_str = r#" -[watcher] -sweep_interval_secs = 10 - -[[agent]] -name = "coder" -"#; - let config = ProjectConfig::parse(toml_str).unwrap(); - assert_eq!(config.watcher.sweep_interval_secs, 10); - // done_retention_secs should fall back to the default (4 hours). - assert_eq!(config.watcher.done_retention_secs, 4 * 60 * 60); - } - - #[test] - fn watcher_config_from_file() { - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".huskies"); - fs::create_dir_all(&sk).unwrap(); - fs::write( - sk.join("project.toml"), - r#" -[watcher] -sweep_interval_secs = 120 -done_retention_secs = 3600 - -[[agent]] -name = "coder" -"#, - ) - .unwrap(); - - let config = ProjectConfig::load(tmp.path()).unwrap(); - assert_eq!(config.watcher.sweep_interval_secs, 120); - assert_eq!(config.watcher.done_retention_secs, 3600); - } - - #[test] - fn watcher_config_default_when_no_file() { - let tmp = tempfile::tempdir().unwrap(); - let config = ProjectConfig::load(tmp.path()).unwrap(); - assert_eq!(config.watcher, WatcherConfig::default()); - } - - #[test] - fn coder_agents_have_root_cause_guidance() { - // Load the actual project.toml and verify all coder-stage agents - // include root cause investigation guidance for bugs. - let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); - let project_root = manifest_dir.parent().unwrap(); - let config = ProjectConfig::load(project_root).unwrap(); - - let coder_agents: Vec<_> = config - .agent - .iter() - .filter(|a| a.stage.as_deref() == Some("coder")) - .collect(); - - assert!( - !coder_agents.is_empty(), - "Expected at least one coder-stage agent in project.toml" - ); - - for agent in coder_agents { - let prompt = &agent.prompt; - let system_prompt = agent.system_prompt.as_deref().unwrap_or(""); - let combined = format!("{prompt} {system_prompt}"); - - assert!( - combined.contains("Bug Workflow") || combined.contains("trust the story"), - "Coder agent '{}' must include bug workflow guidance in prompt or system_prompt", - agent.name - ); - assert!( - combined.contains("surgical") || combined.to_lowercase().contains("minimal"), - "Coder agent '{}' must discourage adding abstractions/workarounds", - agent.name - ); - } - } - - #[test] - fn watcher_config_preserved_in_legacy_format() { - let toml_str = r#" -[watcher] -sweep_interval_secs = 15 -done_retention_secs = 900 - -[agent] -command = "claude" -"#; - let config = ProjectConfig::parse(toml_str).unwrap(); - assert_eq!(config.watcher.sweep_interval_secs, 15); - assert_eq!(config.watcher.done_retention_secs, 900); - assert_eq!(config.agent.len(), 1); - } - - // ── default_coder_model & max_coders ───────────────────────────────── - - #[test] - fn parse_default_coder_model_and_max_coders() { - let toml_str = r#" -default_coder_model = "sonnet" -max_coders = 3 - -[[agent]] -name = "coder-1" -stage = "coder" -model = "sonnet" - -[[agent]] -name = "coder-opus" -stage = "coder" -model = "opus" -"#; - let config = ProjectConfig::parse(toml_str).unwrap(); - assert_eq!(config.default_coder_model, Some("sonnet".to_string())); - assert_eq!(config.max_coders, Some(3)); - } - - #[test] - fn default_coder_model_and_max_coders_default_to_none() { - let toml_str = r#" -[[agent]] -name = "coder-1" -"#; - let config = ProjectConfig::parse(toml_str).unwrap(); - assert_eq!(config.default_coder_model, None); - assert_eq!(config.max_coders, None); - } - - #[test] - fn project_toml_has_default_coder_model_and_max_coders() { - // Verify the actual project.toml has the new settings. - let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); - let project_root = manifest_dir.parent().unwrap(); - let config = ProjectConfig::load(project_root).unwrap(); - assert_eq!(config.default_coder_model, Some("sonnet".to_string())); - assert_eq!(config.max_coders, Some(3)); - } - - // ── runtime config ──────────────────────────────────────────────── - - #[test] - fn runtime_defaults_to_none() { - let toml_str = r#" -[[agent]] -name = "coder" -"#; - let config = ProjectConfig::parse(toml_str).unwrap(); - assert_eq!(config.agent[0].runtime, None); - } - - #[test] - fn runtime_claude_code_accepted() { - let toml_str = r#" -[[agent]] -name = "coder" -runtime = "claude-code" -"#; - let config = ProjectConfig::parse(toml_str).unwrap(); - assert_eq!(config.agent[0].runtime, Some("claude-code".to_string())); - } - - #[test] - fn runtime_gemini_accepted() { - let toml_str = r#" -[[agent]] -name = "coder" -runtime = "gemini" -model = "gemini-2.5-pro" -"#; - let config = ProjectConfig::parse(toml_str).unwrap(); - assert_eq!(config.agent[0].runtime, Some("gemini".to_string())); - } - - #[test] - fn runtime_unknown_rejected() { - let toml_str = r#" -[[agent]] -name = "coder" -runtime = "openai" -"#; - let err = ProjectConfig::parse(toml_str).unwrap_err(); - assert!(err.contains("unknown runtime 'openai'")); - } - - // ── base_branch config ────────────────────────────────────────────────── - - #[test] - fn base_branch_defaults_to_none() { - let toml_str = r#" -[[agent]] -name = "coder" -"#; - let config = ProjectConfig::parse(toml_str).unwrap(); - assert_eq!(config.base_branch, None); - } - - #[test] - fn base_branch_parsed_when_set() { - let toml_str = r#" -base_branch = "main" - -[[agent]] -name = "coder" -"#; - let config = ProjectConfig::parse(toml_str).unwrap(); - assert_eq!(config.base_branch, Some("main".to_string())); - } - - #[test] - fn render_agent_args_uses_config_base_branch_when_caller_passes_none() { - let toml_str = r#" -base_branch = "develop" - -[[agent]] -name = "coder" -prompt = "git difftool {{base_branch}}...HEAD" -"#; - let config = ProjectConfig::parse(toml_str).unwrap(); - let (_, _, prompt) = config - .render_agent_args("/tmp/wt", "42_foo", None, None) - .unwrap(); - assert!( - prompt.contains("develop"), - "Expected 'develop' in prompt, got: {prompt}" - ); - } - - #[test] - fn render_agent_args_caller_base_branch_takes_precedence_over_config() { - let toml_str = r#" -base_branch = "develop" - -[[agent]] -name = "coder" -prompt = "git difftool {{base_branch}}...HEAD" -"#; - let config = ProjectConfig::parse(toml_str).unwrap(); - let (_, _, prompt) = config - .render_agent_args("/tmp/wt", "42_foo", None, Some("feature-x")) - .unwrap(); - assert!( - prompt.contains("feature-x"), - "Caller-supplied base_branch should win, got: {prompt}" - ); - } - - #[test] - fn project_toml_has_base_branch_master() { - let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); - let project_root = manifest_dir.parent().unwrap(); - let config = ProjectConfig::load(project_root).unwrap(); - assert_eq!( - config.base_branch, - Some("master".to_string()), - "project.toml must have base_branch = \"master\"" - ); - } - - #[test] - fn rate_limit_notifications_defaults_to_true() { - let toml_str = r#" -[[agent]] -name = "coder" -"#; - let config = ProjectConfig::parse(toml_str).unwrap(); - assert!( - config.rate_limit_notifications, - "rate_limit_notifications should default to true" - ); - } - - #[test] - fn rate_limit_notifications_can_be_disabled() { - let toml_str = r#" -rate_limit_notifications = false - -[[agent]] -name = "coder" -"#; - let config = ProjectConfig::parse(toml_str).unwrap(); - assert!(!config.rate_limit_notifications); - } - - #[test] - fn project_toml_has_three_sonnet_coders() { - let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); - let project_root = manifest_dir.parent().unwrap(); - let config = ProjectConfig::load(project_root).unwrap(); - - let sonnet_coders: Vec<_> = config - .agent - .iter() - .filter(|a| a.stage.as_deref() == Some("coder") && a.model.as_deref() == Some("sonnet")) - .collect(); - - assert_eq!( - sonnet_coders.len(), - 3, - "Expected 3 sonnet coders (coder-1, coder-2, coder-3), found {}", - sonnet_coders.len() - ); - } -} +mod tests; diff --git a/server/src/config/tests.rs b/server/src/config/tests.rs new file mode 100644 index 00000000..68395abb --- /dev/null +++ b/server/src/config/tests.rs @@ -0,0 +1,628 @@ +use super::*; +use std::fs; + +#[test] +fn default_config_when_missing() { + let tmp = tempfile::tempdir().unwrap(); + let config = ProjectConfig::load(tmp.path()).unwrap(); + assert_eq!(config.agent.len(), 1); + assert_eq!(config.agent[0].name, "default"); + assert!(config.component.is_empty()); +} + +#[test] +fn parse_multi_agent_toml() { + let toml_str = r#" +[[component]] +name = "server" +path = "." +setup = ["cargo check"] + +[[agent]] +name = "supervisor" +role = "Coordinates work" +model = "opus" +max_turns = 50 +max_budget_usd = 10.00 +system_prompt = "You are a senior engineer" + +[[agent]] +name = "coder-1" +role = "Full-stack engineer" +model = "sonnet" +max_turns = 30 +max_budget_usd = 5.00 +"#; + + let config = ProjectConfig::parse(toml_str).unwrap(); + assert_eq!(config.agent.len(), 2); + assert_eq!(config.agent[0].name, "supervisor"); + assert_eq!(config.agent[0].role, "Coordinates work"); + assert_eq!(config.agent[0].model, Some("opus".to_string())); + assert_eq!(config.agent[0].max_turns, Some(50)); + assert_eq!(config.agent[0].max_budget_usd, Some(10.0)); + assert_eq!( + config.agent[0].system_prompt, + Some("You are a senior engineer".to_string()) + ); + assert_eq!(config.agent[1].name, "coder-1"); + assert_eq!(config.agent[1].model, Some("sonnet".to_string())); + assert_eq!(config.component.len(), 1); +} + +#[test] +fn parse_legacy_single_agent() { + let toml_str = r#" +[[component]] +name = "server" +path = "." + +[agent] +command = "claude" +args = ["--print", "--directory", "{{worktree_path}}"] +prompt = "Pick up story {{story_id}}" +"#; + + let config = ProjectConfig::parse(toml_str).unwrap(); + assert_eq!(config.agent.len(), 1); + assert_eq!(config.agent[0].name, "default"); + assert_eq!(config.agent[0].command, "claude"); +} + +#[test] +fn validate_duplicate_names() { + let toml_str = r#" +[[agent]] +name = "coder" +role = "Engineer" + +[[agent]] +name = "coder" +role = "Another engineer" +"#; + + let err = ProjectConfig::parse(toml_str).unwrap_err(); + assert!(err.contains("Duplicate agent name: 'coder'")); +} + +#[test] +fn validate_empty_name() { + let toml_str = r#" +[[agent]] +name = "" +role = "Engineer" +"#; + + let err = ProjectConfig::parse(toml_str).unwrap_err(); + assert!(err.contains("Agent name must not be empty")); +} + +#[test] +fn validate_non_positive_budget() { + let toml_str = r#" +[[agent]] +name = "coder" +max_budget_usd = -1.0 +"#; + + let err = ProjectConfig::parse(toml_str).unwrap_err(); + assert!(err.contains("must be positive")); +} + +#[test] +fn validate_zero_max_turns() { + let toml_str = r#" +[[agent]] +name = "coder" +max_turns = 0 +"#; + + let err = ProjectConfig::parse(toml_str).unwrap_err(); + assert!(err.contains("max_turns must be positive")); +} + +#[test] +fn render_agent_args_default() { + let config = ProjectConfig::default(); + let (cmd, args, prompt) = config + .render_agent_args("/tmp/wt", "42_foo", None, None) + .unwrap(); + assert_eq!(cmd, "claude"); + assert!(args.is_empty()); + assert!(prompt.contains("42_foo")); +} + +#[test] +fn render_agent_args_by_name() { + let toml_str = r#" +[[agent]] +name = "supervisor" +model = "opus" +max_turns = 50 +max_budget_usd = 10.00 +system_prompt = "You lead story {{story_id}}" +allowed_tools = ["Read", "Write", "Bash"] + +[[agent]] +name = "coder" +model = "sonnet" +max_turns = 30 +"#; + + let config = ProjectConfig::parse(toml_str).unwrap(); + let (cmd, args, prompt) = config + .render_agent_args("/tmp/wt", "42_foo", Some("supervisor"), Some("master")) + .unwrap(); + assert_eq!(cmd, "claude"); + assert!(args.contains(&"--model".to_string())); + assert!(args.contains(&"opus".to_string())); + assert!(args.contains(&"--max-turns".to_string())); + assert!(args.contains(&"50".to_string())); + assert!(args.contains(&"--max-budget-usd".to_string())); + assert!(args.contains(&"10".to_string())); + assert!(args.contains(&"--allowedTools".to_string())); + assert!(args.contains(&"Read,Write,Bash".to_string())); + assert!(args.contains(&"--append-system-prompt".to_string())); + // System prompt should have template rendered + assert!(args.contains(&"You lead story 42_foo".to_string())); + assert!(prompt.contains("42_foo")); + + // Render for coder + let (_, coder_args, _) = config + .render_agent_args("/tmp/wt", "42_foo", Some("coder"), Some("master")) + .unwrap(); + assert!(coder_args.contains(&"sonnet".to_string())); + assert!(coder_args.contains(&"30".to_string())); + assert!(!coder_args.contains(&"--max-budget-usd".to_string())); + assert!(!coder_args.contains(&"--append-system-prompt".to_string())); +} + +#[test] +fn render_agent_args_not_found() { + let config = ProjectConfig::default(); + let result = config.render_agent_args("/tmp/wt", "42_foo", Some("nonexistent"), None); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("No agent named 'nonexistent'")); +} + +#[test] +fn find_agent_and_default() { + let toml_str = r#" +[[agent]] +name = "first" + +[[agent]] +name = "second" +"#; + + let config = ProjectConfig::parse(toml_str).unwrap(); + assert_eq!(config.default_agent().unwrap().name, "first"); + assert_eq!(config.find_agent("second").unwrap().name, "second"); + assert!(config.find_agent("missing").is_none()); +} + +#[test] +fn parse_project_toml_from_file() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".huskies"); + fs::create_dir_all(&sk).unwrap(); + fs::write( + sk.join("project.toml"), + r#" +[[component]] +name = "server" +path = "." +setup = ["cargo check"] +teardown = [] + +[[component]] +name = "frontend" +path = "frontend" +setup = ["pnpm install"] + +[[agent]] +name = "main" +command = "claude" +args = ["--print", "--directory", "{{worktree_path}}"] +prompt = "Pick up story {{story_id}}" +model = "sonnet" +"#, + ) + .unwrap(); + + let config = ProjectConfig::load(tmp.path()).unwrap(); + assert_eq!(config.component.len(), 2); + assert_eq!(config.component[0].name, "server"); + assert_eq!(config.component[1].setup, vec!["pnpm install"]); + assert_eq!(config.agent.len(), 1); + assert_eq!(config.agent[0].name, "main"); + assert_eq!(config.agent[0].model, Some("sonnet".to_string())); +} + +#[test] +fn agents_toml_overrides_project_toml_agents() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".huskies"); + fs::create_dir_all(&sk).unwrap(); + // project.toml has inline agents + fs::write( + sk.join("project.toml"), + r#" +[[agent]] +name = "from-project-toml" +model = "sonnet" +"#, + ) + .unwrap(); + // agents.toml overrides with different agents + fs::write( + sk.join("agents.toml"), + r#" +[[agent]] +name = "from-agents-toml" +model = "opus" +"#, + ) + .unwrap(); + + let config = ProjectConfig::load(tmp.path()).unwrap(); + assert_eq!(config.agent.len(), 1); + assert_eq!(config.agent[0].name, "from-agents-toml"); + assert_eq!(config.agent[0].model, Some("opus".to_string())); +} + +#[test] +fn agents_toml_absent_falls_back_to_project_toml() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".huskies"); + fs::create_dir_all(&sk).unwrap(); + fs::write( + sk.join("project.toml"), + r#" +[[agent]] +name = "inline-agent" +model = "sonnet" +"#, + ) + .unwrap(); + // No agents.toml — should use inline agents from project.toml + + let config = ProjectConfig::load(tmp.path()).unwrap(); + assert_eq!(config.agent.len(), 1); + assert_eq!(config.agent[0].name, "inline-agent"); +} + +// ── WatcherConfig ────────────────────────────────────────────────────── + +#[test] +fn watcher_config_defaults_when_omitted() { + let toml_str = r#" +[[agent]] +name = "coder" +"#; + let config = ProjectConfig::parse(toml_str).unwrap(); + assert_eq!(config.watcher.sweep_interval_secs, 60); + assert_eq!(config.watcher.done_retention_secs, 4 * 60 * 60); +} + +#[test] +fn watcher_config_custom_values() { + let toml_str = r#" +[watcher] +sweep_interval_secs = 30 +done_retention_secs = 7200 + +[[agent]] +name = "coder" +"#; + let config = ProjectConfig::parse(toml_str).unwrap(); + assert_eq!(config.watcher.sweep_interval_secs, 30); + assert_eq!(config.watcher.done_retention_secs, 7200); +} + +#[test] +fn watcher_config_partial_override() { + let toml_str = r#" +[watcher] +sweep_interval_secs = 10 + +[[agent]] +name = "coder" +"#; + let config = ProjectConfig::parse(toml_str).unwrap(); + assert_eq!(config.watcher.sweep_interval_secs, 10); + // done_retention_secs should fall back to the default (4 hours). + assert_eq!(config.watcher.done_retention_secs, 4 * 60 * 60); +} + +#[test] +fn watcher_config_from_file() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".huskies"); + fs::create_dir_all(&sk).unwrap(); + fs::write( + sk.join("project.toml"), + r#" +[watcher] +sweep_interval_secs = 120 +done_retention_secs = 3600 + +[[agent]] +name = "coder" +"#, + ) + .unwrap(); + + let config = ProjectConfig::load(tmp.path()).unwrap(); + assert_eq!(config.watcher.sweep_interval_secs, 120); + assert_eq!(config.watcher.done_retention_secs, 3600); +} + +#[test] +fn watcher_config_default_when_no_file() { + let tmp = tempfile::tempdir().unwrap(); + let config = ProjectConfig::load(tmp.path()).unwrap(); + assert_eq!(config.watcher, WatcherConfig::default()); +} + +#[test] +fn coder_agents_have_root_cause_guidance() { + // Load the actual project.toml and verify all coder-stage agents + // include root cause investigation guidance for bugs. + let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let project_root = manifest_dir.parent().unwrap(); + let config = ProjectConfig::load(project_root).unwrap(); + + let coder_agents: Vec<_> = config + .agent + .iter() + .filter(|a| a.stage.as_deref() == Some("coder")) + .collect(); + + assert!( + !coder_agents.is_empty(), + "Expected at least one coder-stage agent in project.toml" + ); + + for agent in coder_agents { + let prompt = &agent.prompt; + let system_prompt = agent.system_prompt.as_deref().unwrap_or(""); + let combined = format!("{prompt} {system_prompt}"); + + assert!( + combined.contains("Bug Workflow") || combined.contains("trust the story"), + "Coder agent '{}' must include bug workflow guidance in prompt or system_prompt", + agent.name + ); + assert!( + combined.contains("surgical") || combined.to_lowercase().contains("minimal"), + "Coder agent '{}' must discourage adding abstractions/workarounds", + agent.name + ); + } +} + +#[test] +fn watcher_config_preserved_in_legacy_format() { + let toml_str = r#" +[watcher] +sweep_interval_secs = 15 +done_retention_secs = 900 + +[agent] +command = "claude" +"#; + let config = ProjectConfig::parse(toml_str).unwrap(); + assert_eq!(config.watcher.sweep_interval_secs, 15); + assert_eq!(config.watcher.done_retention_secs, 900); + assert_eq!(config.agent.len(), 1); +} + +// ── default_coder_model & max_coders ───────────────────────────────── + +#[test] +fn parse_default_coder_model_and_max_coders() { + let toml_str = r#" +default_coder_model = "sonnet" +max_coders = 3 + +[[agent]] +name = "coder-1" +stage = "coder" +model = "sonnet" + +[[agent]] +name = "coder-opus" +stage = "coder" +model = "opus" +"#; + let config = ProjectConfig::parse(toml_str).unwrap(); + assert_eq!(config.default_coder_model, Some("sonnet".to_string())); + assert_eq!(config.max_coders, Some(3)); +} + +#[test] +fn default_coder_model_and_max_coders_default_to_none() { + let toml_str = r#" +[[agent]] +name = "coder-1" +"#; + let config = ProjectConfig::parse(toml_str).unwrap(); + assert_eq!(config.default_coder_model, None); + assert_eq!(config.max_coders, None); +} + +#[test] +fn project_toml_has_default_coder_model_and_max_coders() { + // Verify the actual project.toml has the new settings. + let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let project_root = manifest_dir.parent().unwrap(); + let config = ProjectConfig::load(project_root).unwrap(); + assert_eq!(config.default_coder_model, Some("sonnet".to_string())); + assert_eq!(config.max_coders, Some(3)); +} + +// ── runtime config ──────────────────────────────────────────────── + +#[test] +fn runtime_defaults_to_none() { + let toml_str = r#" +[[agent]] +name = "coder" +"#; + let config = ProjectConfig::parse(toml_str).unwrap(); + assert_eq!(config.agent[0].runtime, None); +} + +#[test] +fn runtime_claude_code_accepted() { + let toml_str = r#" +[[agent]] +name = "coder" +runtime = "claude-code" +"#; + let config = ProjectConfig::parse(toml_str).unwrap(); + assert_eq!(config.agent[0].runtime, Some("claude-code".to_string())); +} + +#[test] +fn runtime_gemini_accepted() { + let toml_str = r#" +[[agent]] +name = "coder" +runtime = "gemini" +model = "gemini-2.5-pro" +"#; + let config = ProjectConfig::parse(toml_str).unwrap(); + assert_eq!(config.agent[0].runtime, Some("gemini".to_string())); +} + +#[test] +fn runtime_unknown_rejected() { + let toml_str = r#" +[[agent]] +name = "coder" +runtime = "openai" +"#; + let err = ProjectConfig::parse(toml_str).unwrap_err(); + assert!(err.contains("unknown runtime 'openai'")); +} + +// ── base_branch config ────────────────────────────────────────────────── + +#[test] +fn base_branch_defaults_to_none() { + let toml_str = r#" +[[agent]] +name = "coder" +"#; + let config = ProjectConfig::parse(toml_str).unwrap(); + assert_eq!(config.base_branch, None); +} + +#[test] +fn base_branch_parsed_when_set() { + let toml_str = r#" +base_branch = "main" + +[[agent]] +name = "coder" +"#; + let config = ProjectConfig::parse(toml_str).unwrap(); + assert_eq!(config.base_branch, Some("main".to_string())); +} + +#[test] +fn render_agent_args_uses_config_base_branch_when_caller_passes_none() { + let toml_str = r#" +base_branch = "develop" + +[[agent]] +name = "coder" +prompt = "git difftool {{base_branch}}...HEAD" +"#; + let config = ProjectConfig::parse(toml_str).unwrap(); + let (_, _, prompt) = config + .render_agent_args("/tmp/wt", "42_foo", None, None) + .unwrap(); + assert!( + prompt.contains("develop"), + "Expected 'develop' in prompt, got: {prompt}" + ); +} + +#[test] +fn render_agent_args_caller_base_branch_takes_precedence_over_config() { + let toml_str = r#" +base_branch = "develop" + +[[agent]] +name = "coder" +prompt = "git difftool {{base_branch}}...HEAD" +"#; + let config = ProjectConfig::parse(toml_str).unwrap(); + let (_, _, prompt) = config + .render_agent_args("/tmp/wt", "42_foo", None, Some("feature-x")) + .unwrap(); + assert!( + prompt.contains("feature-x"), + "Caller-supplied base_branch should win, got: {prompt}" + ); +} + +#[test] +fn project_toml_has_base_branch_master() { + let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let project_root = manifest_dir.parent().unwrap(); + let config = ProjectConfig::load(project_root).unwrap(); + assert_eq!( + config.base_branch, + Some("master".to_string()), + "project.toml must have base_branch = \"master\"" + ); +} + +#[test] +fn rate_limit_notifications_defaults_to_true() { + let toml_str = r#" +[[agent]] +name = "coder" +"#; + let config = ProjectConfig::parse(toml_str).unwrap(); + assert!( + config.rate_limit_notifications, + "rate_limit_notifications should default to true" + ); +} + +#[test] +fn rate_limit_notifications_can_be_disabled() { + let toml_str = r#" +rate_limit_notifications = false + +[[agent]] +name = "coder" +"#; + let config = ProjectConfig::parse(toml_str).unwrap(); + assert!(!config.rate_limit_notifications); +} + +#[test] +fn project_toml_has_three_sonnet_coders() { + let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let project_root = manifest_dir.parent().unwrap(); + let config = ProjectConfig::load(project_root).unwrap(); + + let sonnet_coders: Vec<_> = config + .agent + .iter() + .filter(|a| a.stage.as_deref() == Some("coder") && a.model.as_deref() == Some("sonnet")) + .collect(); + + assert_eq!( + sonnet_coders.len(), + 3, + "Expected 3 sonnet coders (coder-1, coder-2, coder-3), found {}", + sonnet_coders.len() + ); +}