use crate::slog; use serde::Deserialize; use std::collections::HashSet; use std::path::Path; #[derive(Debug, Clone, Deserialize)] pub struct ProjectConfig { #[serde(default)] pub component: Vec, #[serde(default)] pub agent: Vec, } #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] pub struct ComponentConfig { pub name: String, #[serde(default = "default_path")] pub path: String, #[serde(default)] pub setup: Vec, #[serde(default)] pub teardown: Vec, } #[derive(Debug, Clone, Deserialize)] pub struct AgentConfig { #[serde(default = "default_agent_name")] pub name: String, #[serde(default)] pub role: String, #[serde(default = "default_agent_command")] pub command: String, #[serde(default)] pub args: Vec, #[serde(default = "default_agent_prompt")] pub prompt: String, #[serde(default)] pub model: Option, #[serde(default)] pub allowed_tools: Option>, #[serde(default)] pub max_turns: Option, #[serde(default)] pub max_budget_usd: Option, #[serde(default)] pub system_prompt: Option, } fn default_path() -> String { ".".to_string() } fn default_agent_name() -> String { "default".to_string() } fn default_agent_command() -> String { "claude".to_string() } fn default_agent_prompt() -> String { "You are working in a git worktree on story {{story_id}}. \ Read .story_kit/README.md to understand the dev process, then pick up the story. \ Commit all your work when done — the server will automatically run acceptance \ gates (cargo clippy + tests) when your process exits." .to_string() } /// Legacy config format with `agent` as an optional single table (`[agent]`). #[derive(Debug, Deserialize)] struct LegacyProjectConfig { #[serde(default)] component: Vec, agent: Option, } impl Default for ProjectConfig { fn default() -> Self { Self { component: Vec::new(), agent: vec![AgentConfig { name: default_agent_name(), role: String::new(), command: default_agent_command(), args: vec![], prompt: default_agent_prompt(), model: None, allowed_tools: None, max_turns: None, max_budget_usd: None, system_prompt: None, }], } } } impl ProjectConfig { /// Load from `.story_kit/project.toml` relative to the given root. /// Falls back to sensible defaults if the file doesn't exist. /// /// Supports both the new `[[agent]]` array format and the legacy /// `[agent]` single-table format (with a deprecation warning). pub fn load(project_root: &Path) -> Result { let config_path = project_root.join(".story_kit/project.toml"); if !config_path.exists() { return Ok(Self::default()); } let content = std::fs::read_to_string(&config_path).map_err(|e| format!("Read config: {e}"))?; Self::parse(&content) } /// Parse config from a TOML string, supporting both new and legacy formats. pub fn parse(content: &str) -> Result { // Try new format first (agent as array of tables) match toml::from_str::(content) { Ok(config) if !config.agent.is_empty() => { validate_agents(&config.agent)?; Ok(config) } Ok(config) => { // Parsed successfully but no agents — could be legacy or no agent section. // Try legacy format. if let Ok(legacy) = toml::from_str::(content) && let Some(agent) = legacy.agent { slog!( "[config] Warning: [agent] table is deprecated. \ Use [[agent]] array format instead." ); let config = ProjectConfig { component: legacy.component, agent: vec![agent], }; validate_agents(&config.agent)?; return Ok(config); } // No agent section at all Ok(config) } Err(_) => { // New format failed — try legacy let legacy: LegacyProjectConfig = toml::from_str(content).map_err(|e| format!("Parse config: {e}"))?; if let Some(agent) = legacy.agent { slog!( "[config] Warning: [agent] table is deprecated. \ Use [[agent]] array format instead." ); let config = ProjectConfig { component: legacy.component, agent: vec![agent], }; validate_agents(&config.agent)?; Ok(config) } else { Ok(ProjectConfig { component: legacy.component, agent: Vec::new(), }) } } } } /// Look up an agent config by name. pub fn find_agent(&self, name: &str) -> Option<&AgentConfig> { self.agent.iter().find(|a| a.name == name) } /// Get the default (first) agent config. pub fn default_agent(&self) -> Option<&AgentConfig> { self.agent.first() } /// Render template variables in agent args and prompt for the given agent. /// If `agent_name` is None, uses the first (default) agent. pub fn render_agent_args( &self, worktree_path: &str, story_id: &str, agent_name: Option<&str>, base_branch: Option<&str>, ) -> Result<(String, Vec, String), String> { let agent = match agent_name { Some(name) => self .find_agent(name) .ok_or_else(|| format!("No agent named '{name}' in config"))?, None => self .default_agent() .ok_or_else(|| "No agents configured".to_string())?, }; let bb = base_branch.unwrap_or("master"); let aname = agent.name.as_str(); let render = |s: &str| { s.replace("{{worktree_path}}", worktree_path) .replace("{{story_id}}", story_id) .replace("{{base_branch}}", bb) .replace("{{agent_name}}", aname) }; let command = render(&agent.command); let mut args: Vec = agent.args.iter().map(|a| render(a)).collect(); let prompt = render(&agent.prompt); // Append structured CLI flags if let Some(ref model) = agent.model { args.push("--model".to_string()); args.push(model.clone()); } if let Some(ref tools) = agent.allowed_tools && !tools.is_empty() { args.push("--allowedTools".to_string()); args.push(tools.join(",")); } if let Some(turns) = agent.max_turns { args.push("--max-turns".to_string()); args.push(turns.to_string()); } if let Some(budget) = agent.max_budget_usd { args.push("--max-budget-usd".to_string()); args.push(budget.to_string()); } if let Some(ref sp) = agent.system_prompt { args.push("--append-system-prompt".to_string()); args.push(render(sp)); } Ok((command, args, prompt)) } } /// Validate agent configs: no duplicate names, no empty names, positive budgets/turns. fn validate_agents(agents: &[AgentConfig]) -> Result<(), String> { let mut names = HashSet::new(); for agent in agents { if agent.name.trim().is_empty() { return Err("Agent name must not be empty".to_string()); } if !names.insert(&agent.name) { return Err(format!("Duplicate agent name: '{}'", agent.name)); } if let Some(budget) = agent.max_budget_usd && budget <= 0.0 { return Err(format!( "Agent '{}': max_budget_usd must be positive, got {budget}", agent.name )); } if let Some(turns) = agent.max_turns && turns == 0 { return Err(format!( "Agent '{}': max_turns must be positive, got 0", agent.name )); } } Ok(()) } #[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(".story_kit"); 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())); } }