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>,
}
#[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_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());
}
}