use std::collections::HashMap;
use std::fs;
use std::path::Path;
use semver::Version;
use serde::Deserialize;
use crate::error::TemplateError;
pub const MANIFEST_FILENAME: &str = "template.toml";
#[derive(Debug, Clone, Deserialize)]
pub struct TemplateManifest {
pub template: TemplateMetadata,
#[serde(default)]
pub dependencies: HashMap<String, DependencySpec>,
#[serde(default)]
pub requirements: Requirements,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Requirements {
#[serde(default)]
pub env: Vec<String>,
#[serde(default)]
pub secrets: Vec<String>,
#[serde(default)]
pub tools: Vec<String>,
#[serde(default)]
pub notes: Vec<String>,
}
impl Requirements {
pub fn is_empty(&self) -> bool {
self.env.is_empty()
&& self.secrets.is_empty()
&& self.tools.is_empty()
&& self.notes.is_empty()
}
pub fn render(&self) -> String {
if self.is_empty() {
return String::new();
}
let groups = [
("environment variables", &self.env),
("workflow secrets", &self.secrets),
("tools in the runner image", &self.tools),
("notes", &self.notes),
];
let mut out = String::from("Requirements:\n");
for (title, items) in groups {
if items.is_empty() {
continue;
}
out.push_str(&format!(" {title}:\n"));
for item in items {
out.push_str(&format!(" - {item}\n"));
}
}
out
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct TemplateMetadata {
pub name: String,
pub description: String,
pub version: String,
#[serde(default)]
pub authors: Vec<String>,
#[serde(default)]
pub license: Option<String>,
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub min_ironflow_version: Option<Version>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum DependencySpec {
Simple(String),
Detailed(DetailedDependency),
}
#[derive(Debug, Clone, Deserialize)]
pub struct DetailedDependency {
pub version: String,
#[serde(default)]
pub features: Vec<String>,
}
pub fn validate_template_name(name: &str) -> Result<(), TemplateError> {
if name.is_empty() {
return Err(TemplateError::InvalidManifest(
"template name cannot be empty".to_string(),
));
}
if name.contains("..") || name.contains('/') || name.contains('\\') {
return Err(TemplateError::InvalidManifest(format!(
"template name contains forbidden characters: {name}"
)));
}
let valid = name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
if !valid {
return Err(TemplateError::InvalidManifest(format!(
"template name must contain only lowercase ASCII, digits, and hyphens: {name}"
)));
}
if name.starts_with('-') || name.ends_with('-') {
return Err(TemplateError::InvalidManifest(format!(
"template name must not start or end with a hyphen: {name}"
)));
}
Ok(())
}
impl TemplateManifest {
pub fn parse(toml_str: &str) -> Result<Self, TemplateError> {
toml::from_str(toml_str).map_err(|e| TemplateError::InvalidManifest(e.to_string()))
}
pub fn from_file(path: &Path) -> Result<Self, TemplateError> {
let content = fs::read_to_string(path).map_err(TemplateError::Io)?;
Self::parse(&content)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_valid_manifest() {
let toml = r#"
[template]
name = "ci-pipeline"
description = "Generic CI pipeline with build, test, deploy"
version = "1.0.0"
authors = ["Alice", "Bob"]
license = "MIT"
category = "ci-cd"
[dependencies]
ironflow-engine = "0.1"
serde = { version = "1", features = ["derive"] }
"#;
let manifest = TemplateManifest::parse(toml).unwrap();
assert_eq!(manifest.template.name, "ci-pipeline");
assert_eq!(
manifest.template.description,
"Generic CI pipeline with build, test, deploy"
);
assert_eq!(manifest.template.version, "1.0.0");
assert_eq!(manifest.template.authors, vec!["Alice", "Bob"]);
assert_eq!(manifest.template.license, Some("MIT".to_string()));
assert_eq!(manifest.template.category, Some("ci-cd".to_string()));
assert_eq!(manifest.dependencies.len(), 2);
}
#[test]
fn parse_missing_name() {
let toml = r#"
[template]
description = "No name"
version = "1.0.0"
"#;
let result = TemplateManifest::parse(toml);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string().contains("name"),
"error should mention missing 'name' field: {err}"
);
}
#[test]
fn parse_deps_with_features() {
let toml = r#"
[template]
name = "test"
description = "test"
version = "0.1.0"
[dependencies]
serde = { version = "1", features = ["derive", "rc"] }
tokio = { version = "1", features = ["full"] }
simple-dep = "0.5"
"#;
let manifest = TemplateManifest::parse(toml).unwrap();
assert_eq!(manifest.dependencies.len(), 3);
match &manifest.dependencies["serde"] {
DependencySpec::Detailed(d) => {
assert_eq!(d.version, "1");
assert_eq!(d.features, vec!["derive", "rc"]);
}
DependencySpec::Simple(_) => panic!("expected detailed dependency for serde"),
}
match &manifest.dependencies["simple-dep"] {
DependencySpec::Simple(v) => assert_eq!(v, "0.5"),
DependencySpec::Detailed(_) => panic!("expected simple dependency for simple-dep"),
}
}
#[test]
fn parse_minimal_manifest() {
let toml = r#"
[template]
name = "minimal"
description = "Minimal template"
version = "0.1.0"
"#;
let manifest = TemplateManifest::parse(toml).unwrap();
assert_eq!(manifest.template.name, "minimal");
assert!(manifest.template.authors.is_empty());
assert_eq!(manifest.template.license, None);
assert_eq!(manifest.template.category, None);
assert!(manifest.dependencies.is_empty());
}
#[test]
fn parse_invalid_toml() {
let result = TemplateManifest::parse("not valid toml [[[");
assert!(result.is_err());
}
#[test]
fn parse_min_ironflow_version() {
let toml = r#"
[template]
name = "versioned"
description = "Has min version"
version = "1.0.0"
min_ironflow_version = "0.5.0"
"#;
let manifest = TemplateManifest::parse(toml).unwrap();
assert_eq!(
manifest.template.min_ironflow_version,
Some(Version::parse("0.5.0").unwrap())
);
}
#[test]
fn validate_name_accepts_valid() {
validate_template_name("ci-pipeline").unwrap();
validate_template_name("hello123").unwrap();
validate_template_name("a").unwrap();
}
#[test]
fn validate_name_rejects_path_traversal() {
assert!(validate_template_name("../evil").is_err());
assert!(validate_template_name("foo/bar").is_err());
assert!(validate_template_name("foo\\bar").is_err());
}
#[test]
fn validate_name_rejects_empty() {
assert!(validate_template_name("").is_err());
}
#[test]
fn validate_name_rejects_uppercase() {
assert!(validate_template_name("MyTemplate").is_err());
}
#[test]
fn validate_name_rejects_leading_hyphen() {
assert!(validate_template_name("-bad").is_err());
}
#[test]
fn validate_name_rejects_trailing_hyphen() {
assert!(validate_template_name("bad-").is_err());
}
#[test]
fn parse_requirements() {
let toml = r#"
[template]
name = "gitlab-mr-review"
description = "Review"
version = "0.1.0"
[requirements]
env = ["GITLAB_WEBHOOK_SECRET"]
secrets = ["gitlab_token"]
tools = ["git"]
notes = ["Mount the workspace volume read-only in the agent pod"]
"#;
let manifest = TemplateManifest::parse(toml).unwrap();
let requirements = &manifest.requirements;
assert_eq!(requirements.env, vec!["GITLAB_WEBHOOK_SECRET"]);
assert_eq!(requirements.secrets, vec!["gitlab_token"]);
assert_eq!(requirements.tools, vec!["git"]);
assert_eq!(
requirements.notes,
vec!["Mount the workspace volume read-only in the agent pod"]
);
assert!(!requirements.is_empty());
}
#[test]
fn parse_without_requirements_is_empty() {
let toml = r#"
[template]
name = "plain"
description = "No requirements"
version = "0.1.0"
"#;
let manifest = TemplateManifest::parse(toml).unwrap();
assert!(manifest.requirements.is_empty());
assert!(manifest.requirements.render().is_empty());
}
#[test]
fn parse_rejects_requirements_with_wrong_type() {
let toml = r#"
[template]
name = "bad"
description = "Bad requirements"
version = "0.1.0"
[requirements]
tools = "git"
"#;
let err = TemplateManifest::parse(toml).unwrap_err();
assert!(err.to_string().contains("tools"), "got: {err}");
}
#[test]
fn render_requirements_lists_every_group() {
let requirements = Requirements {
env: vec!["GITLAB_WEBHOOK_SECRET".to_string()],
secrets: vec!["gitlab_token".to_string()],
tools: vec!["git".to_string()],
notes: vec!["Run the agent in a sandboxed pod".to_string()],
};
assert_eq!(
requirements.render(),
"Requirements:\n\
\x20 environment variables:\n\
\x20 - GITLAB_WEBHOOK_SECRET\n\
\x20 workflow secrets:\n\
\x20 - gitlab_token\n\
\x20 tools in the runner image:\n\
\x20 - git\n\
\x20 notes:\n\
\x20 - Run the agent in a sandboxed pod\n"
);
}
#[test]
fn render_requirements_skips_empty_groups() {
let requirements = Requirements {
tools: vec!["git".to_string()],
..Requirements::default()
};
assert_eq!(
requirements.render(),
"Requirements:\n tools in the runner image:\n - git\n"
);
}
#[test]
fn parse_without_min_ironflow_version() {
let toml = r#"
[template]
name = "no-min"
description = "No min version"
version = "1.0.0"
"#;
let manifest = TemplateManifest::parse(toml).unwrap();
assert!(manifest.template.min_ironflow_version.is_none());
}
}