146 lines
3.9 KiB
Rust
146 lines
3.9 KiB
Rust
|
|
use serde::Deserialize;
|
||
|
|
use std::path::Path;
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, Deserialize)]
|
||
|
|
pub struct ProjectConfig {
|
||
|
|
#[serde(default)]
|
||
|
|
pub component: Vec<ComponentConfig>,
|
||
|
|
pub agent: Option<AgentConfig>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, Deserialize)]
|
||
|
|
pub struct ComponentConfig {
|
||
|
|
pub name: String,
|
||
|
|
#[serde(default = "default_path")]
|
||
|
|
pub path: String,
|
||
|
|
#[serde(default)]
|
||
|
|
pub setup: Vec<String>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub teardown: Vec<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, Deserialize)]
|
||
|
|
pub struct AgentConfig {
|
||
|
|
#[serde(default = "default_agent_command")]
|
||
|
|
pub command: String,
|
||
|
|
#[serde(default)]
|
||
|
|
pub args: Vec<String>,
|
||
|
|
#[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<Self, String> {
|
||
|
|
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>, 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<String> = 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"));
|
||
|
|
}
|
||
|
|
}
|