use std::collections::BTreeMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
pub const MANIFEST_FILE: &str = "template.toml";
#[derive(Debug, Clone, Deserialize)]
pub struct Manifest {
pub name: String,
#[serde(default)]
pub version: Option<String>,
#[serde(default, rename = "file")]
pub files: Vec<FileSpec>,
#[serde(default)]
pub vars: BTreeMap<String, VarSpec>,
#[serde(default)]
pub requires: Requires,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FileSpec {
pub src: String,
#[serde(default)]
pub dst: Option<String>,
pub how: HowMode,
#[serde(default)]
pub when: WhenMode,
#[serde(default)]
pub when_expr: Option<String>,
#[serde(default)]
pub agent: Option<AgentKind>,
#[serde(default)]
pub prompt: Option<String>,
#[serde(default, rename = "ai_mode")]
pub ai_mode: Option<AiMode>,
#[serde(default)]
pub marker: Option<MarkerSpec>,
#[serde(default)]
pub paths: Vec<String>,
#[serde(default)]
pub run: Option<ScriptSpec>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum HowMode {
Overwrite,
MergeSection,
MergeToml,
MergeYaml,
Ai,
Script,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum WhenMode {
Once,
#[default]
Always,
Manual,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum AgentKind {
#[default]
Auto,
Claude,
Gemini,
Codex,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum AiMode {
#[default]
Chat,
Handoff,
}
#[derive(Debug, Clone, Deserialize)]
pub struct VarSpec {
#[serde(default)]
pub prompt: Option<String>,
#[serde(default)]
pub default: Option<toml::Value>,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub choices: Option<Vec<String>>,
#[serde(default)]
pub pattern: Option<String>,
#[serde(default)]
pub secret: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct MarkerSpec {
pub begin: String,
pub end: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ScriptSpec {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Requires {
#[serde(default)]
pub files: Vec<String>,
#[serde(default)]
pub os: Vec<String>,
}
impl Manifest {
pub fn load(path: &Path) -> Result<Self> {
let raw = std::fs::read_to_string(path).map_err(|e| Error::io_at(path, e))?;
Self::from_str(&raw, path)
}
pub fn from_str(raw: &str, path: &Path) -> Result<Self> {
toml::from_str::<Self>(raw).map_err(|e| Error::manifest(path, e.message()))
}
}
pub const TERA_SUFFIX: &str = ".tera";
impl FileSpec {
pub fn is_tera_source(&self) -> bool {
self.src.ends_with(TERA_SUFFIX)
}
pub fn dst_or_src(&self) -> &str {
if let Some(d) = &self.dst {
return d;
}
if let Some(stripped) = self.src.strip_suffix(TERA_SUFFIX) {
return stripped;
}
&self.src
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn parses_minimal_manifest() {
let raw = r#"
name = "demo"
[[file]]
src = "Makefile.toml"
how = "overwrite"
"#;
let m = Manifest::from_str(raw, &PathBuf::from("test.toml")).unwrap();
assert_eq!(m.name, "demo");
assert_eq!(m.files.len(), 1);
assert_eq!(m.files[0].how, HowMode::Overwrite);
assert_eq!(m.files[0].when, WhenMode::Always);
assert_eq!(m.files[0].dst_or_src(), "Makefile.toml");
}
#[test]
fn ai_mode_defaults_to_none_when_omitted() {
let raw = r#"
name = "demo"
[[file]]
src = "AGENTS.md"
how = "ai"
prompt = "merge"
"#;
let m = Manifest::from_str(raw, &PathBuf::from("test.toml")).unwrap();
assert_eq!(m.files[0].ai_mode, None);
}
#[test]
fn ai_mode_parses_handoff_and_chat() {
let raw = r#"
name = "demo"
[[file]]
src = "AGENTS.md"
how = "ai"
prompt = "merge"
ai_mode = "handoff"
[[file]]
src = "ROADMAP.md"
how = "ai"
prompt = "merge"
ai_mode = "chat"
"#;
let m = Manifest::from_str(raw, &PathBuf::from("test.toml")).unwrap();
assert_eq!(m.files[0].ai_mode, Some(AiMode::Handoff));
assert_eq!(m.files[1].ai_mode, Some(AiMode::Chat));
}
#[test]
fn ai_mode_rejects_unknown_variant() {
let raw = r#"
name = "demo"
[[file]]
src = "x"
how = "ai"
prompt = "merge"
ai_mode = "bogus"
"#;
let err = Manifest::from_str(raw, &PathBuf::from("test.toml")).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("ai_mode") || msg.contains("bogus") || msg.contains("variant"),
"expected an error referencing the bad value, got: {msg}",
);
}
fn spec(src: &str, dst: Option<&str>) -> FileSpec {
FileSpec {
src: src.into(),
dst: dst.map(str::to_string),
how: HowMode::Overwrite,
when: WhenMode::Always,
when_expr: None,
agent: None,
prompt: None,
ai_mode: None,
marker: None,
paths: vec![],
run: None,
}
}
#[test]
fn dst_or_src_strips_tera_suffix_when_dst_omitted() {
assert_eq!(
spec("Makefile.toml.tera", None).dst_or_src(),
"Makefile.toml"
);
assert_eq!(spec(".gitignore.tera", None).dst_or_src(), ".gitignore");
assert_eq!(spec("ci.yml", None).dst_or_src(), "ci.yml");
assert_eq!(
spec("a.tera", Some("custom.txt")).dst_or_src(),
"custom.txt"
);
}
#[test]
fn is_tera_source_detects_suffix() {
assert!(spec("Makefile.toml.tera", None).is_tera_source());
assert!(spec("path/to/file.tera", None).is_tera_source());
assert!(!spec("Makefile.toml", None).is_tera_source());
assert!(!spec("ci.yml", None).is_tera_source());
assert!(spec("a.tera", Some("a")).is_tera_source());
assert!(!spec("a", Some("a.tera")).is_tera_source());
}
#[test]
fn parses_full_manifest() {
let raw = r#"
name = "rust-cli"
version = "0.1.0"
[vars]
project = { prompt = "name?", required = true }
license = { choices = ["MIT", "Apache-2.0"], default = "MIT" }
[[file]]
src = "Makefile.toml"
how = "overwrite"
when = "always"
[[file]]
src = "src/main.rs"
how = "overwrite"
when = "once"
[[file]]
src = "AGENTS.md"
how = "ai"
agent = "claude"
prompt = "merge"
"#;
let m = Manifest::from_str(raw, &PathBuf::from("test.toml")).unwrap();
assert_eq!(m.name, "rust-cli");
assert_eq!(m.version.as_deref(), Some("0.1.0"));
assert_eq!(m.vars.len(), 2);
assert!(m.vars["project"].required);
assert_eq!(
m.vars["license"].choices.as_ref().unwrap(),
&vec!["MIT".to_string(), "Apache-2.0".to_string()]
);
assert_eq!(m.files.len(), 3);
assert_eq!(m.files[1].when, WhenMode::Once);
assert_eq!(m.files[2].how, HowMode::Ai);
assert_eq!(m.files[2].agent, Some(AgentKind::Claude));
}
#[test]
fn rejects_unknown_how() {
let raw = r#"
name = "x"
[[file]]
src = "f"
how = "wat"
"#;
let err = Manifest::from_str(raw, &PathBuf::from("t.toml")).unwrap_err();
assert!(matches!(err, Error::Manifest { .. }));
}
}