use crate::core::types;
use std::path::Path;
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(())
}
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(())
}
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,
}
}
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
}
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(())
}
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(())
}
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
}
fn is_param_placeholder(var: &str) -> bool {
var.chars().all(|c| c.is_alphanumeric() || c == '_')
}
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
}
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(())
}
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(())
}
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(())
}
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(())
}