forjar 1.24.0

Rust-native Infrastructure as Code — bare-metal first, BLAKE3 state, provenance tracing
Documentation
use crate::core::types;
use std::path::Path;

/// FJ-961: Ensure all referenced dependencies exist in the resource set.
pub(crate) fn cmd_validate_check_resource_dependency_refs(
    file: &Path,
    json: bool,
) -> Result<(), String> {
    let content = std::fs::read_to_string(file).map_err(|e| e.to_string())?;
    let config: types::ForjarConfig =
        serde_yaml_ng::from_str(&content).map_err(|e| e.to_string())?;
    let resource_names: std::collections::HashSet<&String> = config.resources.keys().collect();
    let mut missing = Vec::new();
    for (name, res) in &config.resources {
        for dep in &res.depends_on {
            if !resource_names.contains(dep) {
                missing.push((name.clone(), dep.clone()));
            }
        }
        for trig in &res.triggers {
            if !resource_names.contains(trig) {
                missing.push((name.clone(), trig.clone()));
            }
        }
    }
    missing.sort();
    if json {
        let items: Vec<String> = missing
            .iter()
            .map(|(n, d)| format!("{{\"resource\":\"{n}\",\"missing_ref\":\"{d}\"}}"))
            .collect();
        println!("{{\"missing_dependency_refs\":[{}]}}", items.join(","));
    } else if missing.is_empty() {
        println!("All dependency references are valid.");
    } else {
        println!("Missing dependency references:");
        for (n, d) in &missing {
            println!("  {n}{d} (not found)");
        }
    }
    Ok(())
}

/// FJ-965: Ensure all trigger references point to existing resources.
pub(crate) fn cmd_validate_check_resource_trigger_refs(
    file: &Path,
    json: bool,
) -> Result<(), String> {
    let content = std::fs::read_to_string(file).map_err(|e| e.to_string())?;
    let config: types::ForjarConfig =
        serde_yaml_ng::from_str(&content).map_err(|e| e.to_string())?;
    let resource_names: std::collections::HashSet<&String> = config.resources.keys().collect();
    let mut invalid = Vec::new();
    for (name, res) in &config.resources {
        for trig in &res.triggers {
            if !resource_names.contains(trig) {
                invalid.push((name.clone(), trig.clone()));
            }
        }
    }
    invalid.sort();
    if json {
        let items: Vec<String> = invalid
            .iter()
            .map(|(n, t)| format!("{{\"resource\":\"{n}\",\"invalid_trigger\":\"{t}\"}}"))
            .collect();
        println!("{{\"invalid_trigger_refs\":[{}]}}", items.join(","));
    } else if invalid.is_empty() {
        println!("All trigger references are valid.");
    } else {
        println!("Invalid trigger references:");
        for (n, t) in &invalid {
            println!("  {n}{t} (not found)");
        }
    }
    Ok(())
}

/// Render a scalar param value as the string the type rules are checked against.
/// Non-scalar values (sequences, mappings, null) are not type-checked.
fn param_scalar_string(value: &serde_yaml_ng::Value) -> Option<String> {
    match value {
        serde_yaml_ng::Value::String(s) => Some(s.clone()),
        serde_yaml_ng::Value::Number(n) => Some(n.to_string()),
        serde_yaml_ng::Value::Bool(b) => Some(b.to_string()),
        _ => None,
    }
}

/// FJ-969: Type-safety warnings for a single declared parameter.
fn param_type_safety_warnings(name: &str, value: &serde_yaml_ng::Value) -> Vec<(String, String)> {
    let Some(val_str) = param_scalar_string(value) else {
        return Vec::new();
    };
    let mut warnings = Vec::new();
    if (name.contains("port") || name.ends_with("_port")) && val_str.parse::<u16>().is_err() {
        warnings.push((
            name.to_owned(),
            format!("expected port number, got '{val_str}'"),
        ));
    }
    if (name.contains("path") || name.ends_with("_dir"))
        && !val_str.starts_with('/')
        && !val_str.starts_with('.')
    {
        warnings.push((name.to_owned(), format!("expected path, got '{val_str}'")));
    }
    warnings
}

/// FJ-969: Validate parameter types match expected usage patterns.
pub(crate) fn cmd_validate_check_resource_param_type_safety(
    file: &Path,
    json: bool,
) -> Result<(), String> {
    let content = std::fs::read_to_string(file).map_err(|e| e.to_string())?;
    let config: types::ForjarConfig =
        serde_yaml_ng::from_str(&content).map_err(|e| e.to_string())?;
    let mut warnings = Vec::new();
    for (name, value) in &config.params {
        warnings.extend(param_type_safety_warnings(name, value));
    }
    if json {
        let items: Vec<String> = warnings
            .iter()
            .map(|(n, w)| format!("{{\"param\":\"{n}\",\"warning\":\"{w}\"}}"))
            .collect();
        println!("{{\"param_type_warnings\":[{}]}}", items.join(","));
    } else if warnings.is_empty() {
        println!("All parameter types look consistent.");
    } else {
        println!("Parameter type warnings:");
        for (n, w) in &warnings {
            println!("  {n}{w}");
        }
    }
    Ok(())
}

/// FJ-953: Warn when machines have unbalanced resource counts.
pub(crate) fn cmd_validate_check_resource_machine_balance(
    file: &Path,
    json: bool,
) -> Result<(), String> {
    let content = std::fs::read_to_string(file).map_err(|e| e.to_string())?;
    let config: types::ForjarConfig =
        serde_yaml_ng::from_str(&content).map_err(|e| e.to_string())?;
    let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
    for res in config.resources.values() {
        *counts.entry(res.machine.to_string()).or_insert(0) += 1;
    }
    let values: Vec<usize> = counts.values().cloned().collect();
    let max = values.iter().max().copied().unwrap_or(0);
    let min = values.iter().min().copied().unwrap_or(0);
    let imbalance = if max > 0 {
        (max - min) as f64 / max as f64
    } else {
        0.0
    };
    if json {
        let items: Vec<String> = counts
            .iter()
            .map(|(m, c)| format!("{{\"machine\":\"{m}\",\"resources\":{c}}}"))
            .collect();
        println!(
            "{{\"imbalance_ratio\":{:.4},\"machines\":[{}]}}",
            imbalance,
            items.join(",")
        );
    } else if imbalance > 0.5 {
        println!("Resource imbalance detected (ratio: {imbalance:.4}):");
        for (m, c) in &counts {
            println!("  {m}{c} resources");
        }
    } else {
        println!("Resource distribution is balanced (ratio: {imbalance:.4}).");
    }
    Ok(())
}

/// The `{{...}}` placeholders in a resource's content, in the order they
/// appear. An unterminated `{{` ends the scan — nothing after it can close.
fn template_placeholders(content: &str) -> Vec<&str> {
    let mut vars = Vec::new();
    let mut rest = content;
    while let Some(start) = rest.find("{{") {
        rest = &rest[start + 2..];
        let Some(end) = rest.find("}}") else {
            break;
        };
        vars.push(&rest[..end]);
        rest = &rest[end + 2..];
    }
    vars
}

/// A placeholder that names a param: bare alphanumerics and underscores only,
/// so anything with an expression or a path in it is left alone.
fn is_param_placeholder(var: &str) -> bool {
    var.chars().all(|c| c.is_alphanumeric() || c == '_')
}

/// One warning per `{{param}}` a resource references that the config never
/// declares.
fn undeclared_param_warnings(
    config: &types::ForjarConfig,
    declared_params: &std::collections::HashSet<String>,
) -> Vec<(String, String)> {
    let mut warnings: Vec<(String, String)> = Vec::new();
    for (name, res) in &config.resources {
        let Some(ref content) = res.content else {
            continue;
        };
        for var in template_placeholders(content) {
            if is_param_placeholder(var) && !declared_params.contains(var) {
                warnings.push((name.clone(), format!("references undeclared param '{var}'")));
            }
        }
    }
    warnings
}

/// FJ-973: Validate environment variable references match declared params.
pub(crate) fn cmd_validate_check_resource_env_consistency(
    file: &Path,
    json: bool,
) -> Result<(), String> {
    let content = std::fs::read_to_string(file).map_err(|e| e.to_string())?;
    let config: types::ForjarConfig =
        serde_yaml_ng::from_str(&content).map_err(|e| e.to_string())?;
    let declared_params: std::collections::HashSet<String> =
        config.params.keys().cloned().collect();
    let warnings = undeclared_param_warnings(&config, &declared_params);
    if json {
        let items: Vec<String> = warnings
            .iter()
            .map(|(n, w)| format!("{{\"resource\":\"{n}\",\"warning\":\"{w}\"}}"))
            .collect();
        println!("{{\"env_consistency_warnings\":[{}]}}", items.join(","));
    } else if warnings.is_empty() {
        println!("All environment variable references are consistent.");
    } else {
        println!("Environment variable warnings:");
        for (n, w) in &warnings {
            println!("  {n}{w}");
        }
    }
    Ok(())
}

/// FJ-977: Validate secret resources have rotation policies defined.
pub(crate) fn cmd_validate_check_resource_secret_rotation(
    file: &Path,
    json: bool,
) -> Result<(), String> {
    let content = std::fs::read_to_string(file).map_err(|e| e.to_string())?;
    let config: types::ForjarConfig =
        serde_yaml_ng::from_str(&content).map_err(|e| e.to_string())?;
    let mut warnings: Vec<String> = Vec::new();
    for (name, res) in &config.resources {
        let is_secret = name.contains("secret")
            || name.contains("key")
            || name.contains("password")
            || name.contains("credential")
            || name.contains("token");
        if is_secret && res.tags.is_empty() {
            warnings.push(name.clone());
        }
    }
    if json {
        let items: Vec<String> = warnings.iter().map(|n| format!("\"{n}\"")).collect();
        println!("{{\"secrets_without_rotation\":[{}]}}", items.join(","));
    } else if warnings.is_empty() {
        println!("All secret resources have rotation metadata.");
    } else {
        println!("Secrets without rotation tags:");
        for n in &warnings {
            println!("  {n} — missing rotation policy tags");
        }
    }
    Ok(())
}
/// FJ-981: Verify resources define all lifecycle stages.
pub(crate) fn cmd_validate_check_resource_lifecycle_completeness(
    file: &Path,
    json: bool,
) -> Result<(), String> {
    let content = std::fs::read_to_string(file).map_err(|e| e.to_string())?;
    let config: types::ForjarConfig =
        serde_yaml_ng::from_str(&content).map_err(|e| e.to_string())?;
    let mut warnings: Vec<String> = Vec::new();
    for (name, res) in &config.resources {
        if res.content.is_none() && res.depends_on.is_empty() && res.tags.is_empty() {
            warnings.push(name.clone());
        }
    }
    if json {
        let items: Vec<String> = warnings.iter().map(|n| format!("\"{n}\"")).collect();
        println!("{{\"incomplete_lifecycle\":[{}]}}", items.join(","));
    } else if warnings.is_empty() {
        println!("All resources have complete lifecycle definitions.");
    } else {
        println!("Resources with incomplete lifecycle:");
        for n in &warnings {
            println!("  {n} — missing content/deps/tags");
        }
    }
    Ok(())
}
/// FJ-985: Verify resource types are compatible with declared providers.
pub(crate) fn cmd_validate_check_resource_provider_compatibility(
    file: &Path,
    json: bool,
) -> Result<(), String> {
    let content = std::fs::read_to_string(file).map_err(|e| e.to_string())?;
    let config: types::ForjarConfig =
        serde_yaml_ng::from_str(&content).map_err(|e| e.to_string())?;
    let valid_types = [
        "file",
        "package",
        "service",
        "mount",
        "cron",
        "directory",
        "user",
        "group",
        "link",
    ];
    let mut warnings: Vec<(String, String)> = Vec::new();
    for (name, res) in &config.resources {
        let rtype = format!("{:?}", res.resource_type).to_lowercase();
        if !valid_types.iter().any(|t| rtype.contains(t)) {
            warnings.push((name.clone(), rtype));
        }
    }
    if json {
        let items: Vec<String> = warnings
            .iter()
            .map(|(n, t)| format!("{{\"resource\":\"{n}\",\"type\":\"{t}\"}}"))
            .collect();
        println!("{{\"provider_warnings\":[{}]}}", items.join(","));
    } else if warnings.is_empty() {
        println!("All resource types are compatible with providers.");
    } else {
        println!("Provider compatibility warnings:");
        for (n, t) in &warnings {
            println!("  {n} — unknown type '{t}'");
        }
    }
    Ok(())
}