use serde::Deserialize; use std::path::Path; #[derive(Debug, Clone, Deserialize)] pub struct ProjectConfig { #[serde(default)] pub component: Vec, pub agent: Option, } #[derive(Debug, Clone, Deserialize)] 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_command")] pub command: String, #[serde(default)] pub args: Vec, #[serde(default = "default_agent_prompt")] pub prompt: String, } fn default_path() -> String { ".".to_string() } fn default_agent_command() -> String { "claude".to_string() } fn default_agent_prompt() -> String { "Read .story_kit/README.md, then pick up story {{story_id}}".to_string() } impl Default for ProjectConfig { fn default() -> Self { Self { component: Vec::new(), agent: Some(AgentConfig { command: default_agent_command(), args: vec![], prompt: default_agent_prompt(), }), } } } impl ProjectConfig { /// Load from `.story_kit/project.toml` relative to the given root. /// Falls back to sensible defaults if the file doesn't exist. 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}"))?; toml::from_str(&content).map_err(|e| format!("Parse config: {e}")) } /// Render template variables in agent args and prompt. pub fn render_agent_args( &self, worktree_path: &str, story_id: &str, ) -> Option<(String, Vec, String)> { let agent = self.agent.as_ref()?; let render = |s: &str| { s.replace("{{worktree_path}}", worktree_path) .replace("{{story_id}}", story_id) }; let command = render(&agent.command); let args: Vec = agent.args.iter().map(|a| render(a)).collect(); let prompt = render(&agent.prompt); Some((command, args, prompt)) } } #[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!(config.agent.is_some()); assert!(config.component.is_empty()); } #[test] fn parse_project_toml() { 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] command = "claude" args = ["--print", "--directory", "{{worktree_path}}"] prompt = "Pick up story {{story_id}}" "#, ) .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"]); let agent = config.agent.unwrap(); assert_eq!(agent.command, "claude"); } #[test] fn render_template_vars() { let config = ProjectConfig::default(); let (cmd, args, prompt) = config.render_agent_args("/tmp/wt", "42_foo").unwrap(); assert_eq!(cmd, "claude"); assert!(args.is_empty()); assert!(prompt.contains("42_foo")); } }