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