use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use crate::abi::{ConfigFormat, SafetyTier};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub project: ProjectConfig,
#[serde(default)]
pub configs: Vec<ConfigEntry>,
#[serde(default)]
pub validation: ValidationConfig,
#[serde(default)]
pub workload: Option<WorkloadConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectConfig {
pub name: String,
#[serde(rename = "safety-tier", default = "default_safety_tier")]
pub safety_tier: String,
}
fn default_safety_tier() -> String {
"kennel".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigEntry {
pub name: String,
pub source: String,
#[serde(default = "default_format")]
pub format: String,
#[serde(default)]
pub must: Vec<String>,
#[serde(default)]
pub trust: Vec<String>,
#[serde(default)]
pub dust: Vec<String>,
#[serde(default)]
pub intend: Vec<String>,
}
fn default_format() -> String {
"toml".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationConfig {
#[serde(default = "default_true")]
pub strict: bool,
#[serde(rename = "auto-fix", default)]
pub auto_fix: bool,
#[serde(rename = "report-format", default = "default_report_format")]
pub report_format: String,
}
fn default_true() -> bool {
true
}
fn default_report_format() -> String {
"text".to_string()
}
impl Default for ValidationConfig {
fn default() -> Self {
Self {
strict: true,
auto_fix: false,
report_format: "text".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkloadConfig {
pub name: String,
#[serde(default)]
pub entry: String,
#[serde(default)]
pub strategy: String,
}
pub fn load_manifest(path: &str) -> Result<Manifest> {
let content =
std::fs::read_to_string(path).with_context(|| format!("Failed to read: {}", path))?;
let manifest: Manifest =
toml::from_str(&content).with_context(|| format!("Failed to parse: {}", path))?;
Ok(manifest)
}
pub fn validate(manifest: &Manifest) -> Result<()> {
let project_name = effective_project_name(manifest);
if project_name.is_empty() {
anyhow::bail!("project.name is required (or workload.name for legacy manifests)");
}
if SafetyTier::from_str_loose(&manifest.project.safety_tier).is_none() {
anyhow::bail!(
"Invalid safety-tier '{}'. Must be one of: kennel, yard, hunt",
manifest.project.safety_tier
);
}
for (i, cfg) in manifest.configs.iter().enumerate() {
if cfg.name.is_empty() {
anyhow::bail!("configs[{}].name is required", i);
}
if cfg.source.is_empty() {
anyhow::bail!("configs[{}].source is required", i);
}
if ConfigFormat::from_str_loose(&cfg.format).is_none() {
anyhow::bail!(
"configs[{}].format '{}' is not recognised. Must be one of: toml, yaml, json, ini",
i,
cfg.format
);
}
}
let valid_report_formats = ["text", "json", "a2ml"];
if !valid_report_formats.contains(&manifest.validation.report_format.as_str()) {
anyhow::bail!(
"Invalid report-format '{}'. Must be one of: text, json, a2ml",
manifest.validation.report_format
);
}
Ok(())
}
pub fn effective_project_name(manifest: &Manifest) -> String {
if !manifest.project.name.is_empty() {
return manifest.project.name.clone();
}
if let Some(ref wl) = manifest.workload {
return wl.name.clone();
}
String::new()
}
pub fn init_manifest(path: &str) -> Result<()> {
let p = Path::new(path).join("k9iser.toml");
if p.exists() {
anyhow::bail!("k9iser.toml already exists at {}", p.display());
}
let template = r#"# k9iser manifest — wrap configs into self-validating K9 contracts
# SPDX-License-Identifier: PMPL-1.0-or-later
[project]
name = "my-config-project"
safety-tier = "kennel" # kennel (low) | yard (medium) | hunt (high)
[[configs]]
name = "app-config"
source = "config/app.toml" # config file to wrap
format = "toml" # toml | yaml | json | ini
# Contract pillars:
must = ["port > 0", "port < 65536", "host != ''"]
trust = ["signed-by: ci-pipeline"]
dust = ["remove: deprecated-keys"]
intend = ["production-ready"]
[validation]
strict = true # fail on any violation
auto-fix = false # auto-fix dust rules
report-format = "text" # text | json | a2ml
"#;
std::fs::write(&p, template)?;
println!("Created {}", p.display());
Ok(())
}
pub fn print_info(m: &Manifest) {
let name = effective_project_name(m);
let tier = &m.project.safety_tier;
println!("=== {} ===", name);
println!("Safety tier: {}", tier);
println!("Configs: {}", m.configs.len());
for cfg in &m.configs {
println!(" - {} ({}) <- {}", cfg.name, cfg.format, cfg.source);
println!(" must: {:?}", cfg.must);
println!(" trust: {:?}", cfg.trust);
println!(" dust: {:?}", cfg.dust);
println!(" intend: {:?}", cfg.intend);
}
println!(
"Validation: strict={}, auto-fix={}, report={}",
m.validation.strict, m.validation.auto_fix, m.validation.report_format
);
}