#[allow(unused_imports)]
use crate::core::{codegen, executor, migrate, parser, planner, resolver, secrets, state, types};
use std::collections::{HashMap, HashSet};
use std::path::Path;
pub(crate) fn cmd_validate_check_circular_deps(file: &Path, json: bool) -> Result<(), String> {
let content = std::fs::read_to_string(file).map_err(|e| format!("Read error: {e}"))?;
let config: types::ForjarConfig =
serde_yaml_ng::from_str(&content).map_err(|e| format!("Parse error: {e}"))?;
let cycles = find_cycles(&config);
if json {
let items: Vec<String> = cycles.iter().map(|c| format!("{c:?}")).collect();
println!("{{\"circular_deps\":[{}]}}", items.join(","));
} else if cycles.is_empty() {
println!("No circular dependencies detected.");
} else {
println!("Circular dependencies ({}):", cycles.len());
for cycle in &cycles {
println!(" {} → {}", cycle.join(" → "), cycle[0]);
}
return Err(format!("{} circular dependency cycle(s)", cycles.len()));
}
Ok(())
}
fn find_cycles(config: &types::ForjarConfig) -> Vec<Vec<String>> {
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
for (name, resource) in &config.resources {
for dep in &resource.depends_on {
adj.entry(name.as_str()).or_default().push(dep.as_str());
}
}
let mut cycles = Vec::new();
let mut visited = HashSet::new();
let mut in_stack = HashSet::new();
let mut path = Vec::new();
for name in config.resources.keys() {
if !visited.contains(name.as_str()) {
dfs_cycle(
name,
&adj,
&mut visited,
&mut in_stack,
&mut path,
&mut cycles,
);
}
}
cycles
}
fn dfs_cycle<'a>(
node: &'a str,
adj: &HashMap<&str, Vec<&'a str>>,
visited: &mut HashSet<&'a str>,
in_stack: &mut HashSet<&'a str>,
path: &mut Vec<&'a str>,
cycles: &mut Vec<Vec<String>>,
) {
visited.insert(node);
in_stack.insert(node);
path.push(node);
if let Some(neighbors) = adj.get(node) {
for &next in neighbors {
if !visited.contains(next) {
dfs_cycle(next, adj, visited, in_stack, path, cycles);
} else if in_stack.contains(next) {
let start = path.iter().position(|&n| n == next).unwrap_or(0);
cycles.push(path[start..].iter().map(|s| s.to_string()).collect());
}
}
}
path.pop();
in_stack.remove(node);
}
pub(crate) fn cmd_validate_check_machine_refs(file: &Path, json: bool) -> Result<(), String> {
let content = std::fs::read_to_string(file).map_err(|e| format!("Read error: {e}"))?;
let config: types::ForjarConfig =
serde_yaml_ng::from_str(&content).map_err(|e| format!("Parse error: {e}"))?;
let bad = find_bad_machine_refs(&config);
if json {
let items: Vec<String> = bad
.iter()
.map(|(r, m)| format!("{{\"resource\":\"{r}\",\"machine\":\"{m}\"}}"))
.collect();
println!("{{\"bad_machine_refs\":[{}]}}", items.join(","));
} else if bad.is_empty() {
println!("All machine references are valid.");
} else {
println!("Invalid machine references ({}):", bad.len());
for (resource, machine) in &bad {
println!(" {resource} → {machine} (not defined)");
}
return Err(format!("{} invalid machine reference(s)", bad.len()));
}
Ok(())
}
fn find_bad_machine_refs(config: &types::ForjarConfig) -> Vec<(String, String)> {
let machines: HashSet<&str> = config.machines.keys().map(|k| k.as_str()).collect();
let mut bad = Vec::new();
for (name, resource) in &config.resources {
for m in resource.machine.iter() {
if m != "localhost" && !machines.contains(m) {
bad.push((name.clone(), m.to_owned()));
}
}
}
bad.sort();
bad
}
pub(crate) fn cmd_validate_check_provider_consistency(
file: &Path,
json: bool,
) -> Result<(), String> {
let content = std::fs::read_to_string(file).map_err(|e| format!("Read error: {e}"))?;
let config: types::ForjarConfig =
serde_yaml_ng::from_str(&content).map_err(|e| format!("Parse error: {e}"))?;
let conflicts = find_provider_conflicts(&config);
if json {
let items: Vec<String> = conflicts
.iter()
.map(|(m, ps)| format!("{{\"machine\":\"{m}\",\"providers\":{ps:?}}}"))
.collect();
println!("{{\"provider_conflicts\":[{}]}}", items.join(","));
} else if conflicts.is_empty() {
println!("All machines use consistent package providers.");
} else {
println!("Provider inconsistencies ({}):", conflicts.len());
for (m, providers) in &conflicts {
println!(" {} — mixed providers: {}", m, providers.join(", "));
}
}
Ok(())
}
fn find_provider_conflicts(config: &types::ForjarConfig) -> Vec<(String, Vec<String>)> {
let mut machine_providers: HashMap<String, HashSet<String>> = HashMap::new();
for resource in config.resources.values() {
if resource.resource_type != types::ResourceType::Package {
continue;
}
if let Some(ref p) = resource.provider {
for m in resource.machine.iter() {
machine_providers
.entry(m.to_owned())
.or_default()
.insert(p.clone());
}
}
}
let mut conflicts: Vec<(String, Vec<String>)> = machine_providers
.into_iter()
.filter(|(_, ps)| ps.len() > 1)
.map(|(m, ps)| {
let mut v: Vec<String> = ps.into_iter().collect();
v.sort();
(m, v)
})
.collect();
conflicts.sort_by(|a, b| a.0.cmp(&b.0));
conflicts
}
pub(crate) fn cmd_validate_check_state_values(file: &Path, json: bool) -> Result<(), String> {
let content = std::fs::read_to_string(file).map_err(|e| format!("Read error: {e}"))?;
let config: types::ForjarConfig =
serde_yaml_ng::from_str(&content).map_err(|e| format!("Parse error: {e}"))?;
let bad = find_bad_states(&config);
if json {
let items: Vec<String> = bad
.iter()
.map(|(r, t, s)| format!("{{\"resource\":\"{r}\",\"type\":\"{t}\",\"state\":\"{s}\"}}"))
.collect();
println!("{{\"invalid_states\":[{}]}}", items.join(","));
} else if bad.is_empty() {
println!("All resource state values are valid.");
} else {
println!("Invalid state values ({}):", bad.len());
for (r, t, s) in &bad {
println!(" {r} (type {t}) — invalid state \"{s}\"");
}
}
Ok(())
}
fn find_bad_states(config: &types::ForjarConfig) -> Vec<(String, String, String)> {
let mut bad = Vec::new();
for (name, resource) in &config.resources {
if let Some(ref s) = resource.state {
let valid = match resource.resource_type {
types::ResourceType::File => {
["file", "directory", "symlink", "absent"].contains(&s.as_str())
}
types::ResourceType::Service => {
["running", "stopped", "enabled", "disabled"].contains(&s.as_str())
}
types::ResourceType::Mount => {
["mounted", "unmounted", "absent"].contains(&s.as_str())
}
types::ResourceType::Docker => {
["running", "stopped", "absent"].contains(&s.as_str())
}
types::ResourceType::Package => ["present", "absent"].contains(&s.as_str()),
_ => true,
};
if !valid {
bad.push((name.clone(), resource.resource_type.to_string(), s.clone()));
}
}
}
bad.sort_by(|a, b| a.0.cmp(&b.0));
bad
}
pub(crate) fn cmd_validate_check_unused_machines(file: &Path, json: bool) -> Result<(), String> {
let content = std::fs::read_to_string(file).map_err(|e| format!("Read error: {e}"))?;
let config: types::ForjarConfig =
serde_yaml_ng::from_str(&content).map_err(|e| format!("Parse error: {e}"))?;
let unused = find_unused_machines(&config);
if json {
let items: Vec<String> = unused.iter().map(|m| format!("\"{m}\"")).collect();
println!("{{\"unused_machines\":[{}]}}", items.join(","));
} else if unused.is_empty() {
println!("All defined machines are referenced by resources.");
} else {
println!("Unused machines ({}):", unused.len());
for m in &unused {
println!(" {m}");
}
}
Ok(())
}
fn find_unused_machines(config: &types::ForjarConfig) -> Vec<String> {
let mut used: HashSet<String> = HashSet::new();
for resource in config.resources.values() {
for m in resource.machine.iter() {
used.insert(m.to_owned());
}
}
let mut unused: Vec<String> = config
.machines
.keys()
.filter(|m| !used.contains(m.as_str()))
.cloned()
.collect();
unused.sort();
unused
}
pub(crate) fn cmd_validate_check_tag_consistency(file: &Path, json: bool) -> Result<(), String> {
let content = std::fs::read_to_string(file).map_err(|e| format!("Read error: {e}"))?;
let config: types::ForjarConfig =
serde_yaml_ng::from_str(&content).map_err(|e| format!("Parse error: {e}"))?;
let bad = find_bad_tags(&config);
if json {
let items: Vec<String> = bad
.iter()
.map(|(r, t)| format!("{{\"resource\":\"{r}\",\"tag\":\"{t}\"}}"))
.collect();
println!("{{\"tag_violations\":[{}]}}", items.join(","));
} else if bad.is_empty() {
println!("All resource tags follow naming conventions.");
} else {
println!("Tag naming violations ({}):", bad.len());
for (r, t) in &bad {
println!(" {r} — tag \"{t}\" (expected kebab-case)");
}
}
Ok(())
}
pub(crate) fn cmd_validate_check_dependency_exists(file: &Path, json: bool) -> Result<(), String> {
let content = std::fs::read_to_string(file).map_err(|e| format!("Read error: {e}"))?;
let config: types::ForjarConfig =
serde_yaml_ng::from_str(&content).map_err(|e| format!("Parse error: {e}"))?;
let bad = find_missing_deps(&config);
if json {
let items: Vec<String> = bad
.iter()
.map(|(r, d)| format!("{{\"resource\":\"{r}\",\"missing_dep\":\"{d}\"}}"))
.collect();
println!("{{\"missing_dependencies\":[{}]}}", items.join(","));
} else if bad.is_empty() {
println!("All dependency references are valid.");
} else {
println!("Missing dependency targets ({}):", bad.len());
for (r, d) in &bad {
println!(" {r} → {d} (not defined)");
}
return Err(format!("{} missing dependency target(s)", bad.len()));
}
Ok(())
}
fn find_missing_deps(config: &types::ForjarConfig) -> Vec<(String, String)> {
let names: HashSet<&str> = config.resources.keys().map(|k| k.as_str()).collect();
let mut bad = Vec::new();
for (name, resource) in &config.resources {
for dep in &resource.depends_on {
if !names.contains(dep.as_str()) {
bad.push((name.clone(), dep.clone()));
}
}
}
bad.sort();
bad
}
pub(crate) fn cmd_validate_check_path_conflicts_strict(
file: &Path,
json: bool,
) -> Result<(), String> {
let content = std::fs::read_to_string(file).map_err(|e| format!("Read error: {e}"))?;
let config: types::ForjarConfig =
serde_yaml_ng::from_str(&content).map_err(|e| format!("Parse error: {e}"))?;
let conflicts = find_strict_path_conflicts(&config);
if json {
let items: Vec<String> = conflicts
.iter()
.map(|(m, p, rs)| {
format!("{{\"machine\":\"{m}\",\"path\":\"{p}\",\"resources\":{rs:?}}}")
})
.collect();
println!("{{\"path_conflicts\":[{}]}}", items.join(","));
} else if conflicts.is_empty() {
println!("No file path conflicts detected.");
} else {
println!("File path conflicts ({}):", conflicts.len());
for (m, p, rs) in &conflicts {
println!(" {} on {} — resources: {}", p, m, rs.join(", "));
}
return Err(format!("{} file path conflict(s)", conflicts.len()));
}
Ok(())
}
fn find_strict_path_conflicts(config: &types::ForjarConfig) -> Vec<(String, String, Vec<String>)> {
let mut path_map: HashMap<(String, String), Vec<String>> = HashMap::new();
for (name, resource) in &config.resources {
if let Some(ref p) = resource.path {
for m in resource.machine.iter() {
path_map
.entry((m.to_owned(), p.clone()))
.or_default()
.push(name.clone());
}
}
}
let mut conflicts: Vec<(String, String, Vec<String>)> = path_map
.into_iter()
.filter(|(_, rs)| rs.len() > 1)
.map(|((m, p), mut rs)| {
rs.sort();
(m, p, rs)
})
.collect();
conflicts.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
conflicts
}
fn is_kebab_case(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
fn find_bad_tags(config: &types::ForjarConfig) -> Vec<(String, String)> {
let mut bad = Vec::new();
for (name, resource) in &config.resources {
for tag in &resource.tags {
if !is_kebab_case(tag) {
bad.push((name.clone(), tag.clone()));
}
}
}
bad.sort();
bad
}
pub(crate) fn cmd_validate_check_duplicate_names(file: &Path, json: bool) -> Result<(), String> {
let content = std::fs::read_to_string(file).map_err(|e| format!("Read error: {e}"))?;
let config: types::ForjarConfig =
serde_yaml_ng::from_str(&content).map_err(|e| format!("Parse error: {e}"))?;
let dupes = find_duplicate_base_names(&config);
if json {
let items: Vec<String> = dupes
.iter()
.map(|(base, names)| format!("{{\"base_name\":\"{base}\",\"resources\":{names:?}}}"))
.collect();
println!("{{\"duplicate_names\":[{}]}}", items.join(","));
} else if dupes.is_empty() {
println!("No duplicate resource base names found.");
} else {
println!("Duplicate base names ({}):", dupes.len());
for (base, names) in &dupes {
println!(" \"{}\" — {}", base, names.join(", "));
}
}
Ok(())
}
fn find_duplicate_base_names(config: &types::ForjarConfig) -> Vec<(String, Vec<String>)> {
let mut by_base: HashMap<String, Vec<String>> = HashMap::new();
for name in config.resources.keys() {
let base = name.rsplit('/').next().unwrap_or(name).to_string();
by_base.entry(base).or_default().push(name.clone());
}
let mut dupes: Vec<(String, Vec<String>)> = by_base
.into_iter()
.filter(|(_, names)| names.len() > 1)
.map(|(base, mut names)| {
names.sort();
(base, names)
})
.collect();
dupes.sort_by(|a, b| a.0.cmp(&b.0));
dupes
}
pub(crate) fn cmd_validate_check_resource_groups(file: &Path, json: bool) -> Result<(), String> {
let content = std::fs::read_to_string(file).map_err(|e| format!("Read error: {e}"))?;
let config: types::ForjarConfig =
serde_yaml_ng::from_str(&content).map_err(|e| format!("Parse error: {e}"))?;
let groups = collect_resource_groups(&config);
let empty: Vec<&String> = groups
.iter()
.filter(|(_, count)| *count == 0)
.map(|(g, _)| g)
.collect();
if json {
let items: Vec<String> = groups
.iter()
.map(|(g, c)| format!("{{\"group\":\"{g}\",\"count\":{c}}}"))
.collect();
println!(
"{{\"resource_groups\":[{}],\"empty_count\":{}}}",
items.join(","),
empty.len()
);
} else if groups.is_empty() {
println!("No resource groups found.");
} else {
println!("Resource groups ({}):", groups.len());
for (g, c) in &groups {
println!(" {g} — {c} resources");
}
if !empty.is_empty() {
println!("Warning: {} empty group(s)", empty.len());
}
}
Ok(())
}
fn collect_resource_groups(config: &types::ForjarConfig) -> Vec<(String, usize)> {
let mut groups: HashMap<String, usize> = HashMap::new();
for name in config.resources.keys() {
if let Some(pos) = name.find('/') {
let group = name[..pos].to_string();
*groups.entry(group).or_default() += 1;
}
}
let mut result: Vec<(String, usize)> = groups.into_iter().collect();
result.sort_by(|a, b| a.0.cmp(&b.0));
result
}