use crate::core::types;
use std::collections::{BTreeMap, HashMap, VecDeque};
use std::path::Path;
fn classify_health(tags: &[String]) -> &'static str {
let mut has_critical = false;
for tag in tags {
let lower = tag.to_lowercase();
if lower == "deprecated" {
return "deprecated";
}
if lower == "critical" {
has_critical = true;
}
}
if has_critical {
"critical"
} else {
"healthy"
}
}
struct HealthNode {
name: String,
resource_type: String,
health: &'static str,
}
struct HealthEdge {
source: String,
target: String,
source_health: &'static str,
target_health: &'static str,
}
fn build_health_nodes(config: &types::ForjarConfig) -> Vec<HealthNode> {
let mut names: Vec<&String> = config.resources.keys().collect();
names.sort();
names
.iter()
.map(|name| {
let r = &config.resources[*name];
HealthNode {
name: (*name).clone(),
resource_type: r.resource_type.to_string(),
health: classify_health(&r.tags),
}
})
.collect()
}
fn build_health_edges(config: &types::ForjarConfig) -> Vec<HealthEdge> {
let health_map: HashMap<&str, &'static str> = config
.resources
.iter()
.map(|(n, r)| (n.as_str(), classify_health(&r.tags)))
.collect();
let mut names: Vec<&String> = config.resources.keys().collect();
names.sort();
let mut edges = Vec::new();
for name in names {
let resource = &config.resources[name];
let mut deps = resource.depends_on.clone();
deps.sort();
for dep in &deps {
let sh = health_map.get(name.as_str()).copied().unwrap_or("healthy");
let th = health_map.get(dep.as_str()).copied().unwrap_or("healthy");
edges.push(HealthEdge {
source: name.clone(),
target: dep.clone(),
source_health: sh,
target_health: th,
});
}
}
edges
}
fn print_health_overlay_json(nodes: &[HealthNode], edges: &[HealthEdge]) {
let ns: Vec<String> = nodes
.iter()
.map(|n| {
format!(
"{{\"name\":\"{}\",\"type\":\"{}\",\"health\":\"{}\"}}",
n.name, n.resource_type, n.health
)
})
.collect();
let es: Vec<String> = edges
.iter()
.map(|e| {
format!(
"{{\"source\":\"{}\",\"target\":\"{}\",\"source_health\":\"{}\",\"target_health\":\"{}\"}}",
e.source, e.target, e.source_health, e.target_health
)
})
.collect();
println!(
"{{\"health_overlay\":{{\"nodes\":[{}],\"edges\":[{}]}}}}",
ns.join(","),
es.join(",")
);
}
fn print_health_overlay_text(nodes: &[HealthNode], edges: &[HealthEdge]) {
println!("Health overlay:");
println!(" Nodes:");
for n in nodes {
println!(" {} ({}): {}", n.name, n.resource_type, n.health);
}
println!(" Edges:");
for e in edges {
println!(
" {} \u{2192} {} [{} \u{2192} {}]",
e.source, e.target, e.source_health, e.target_health
);
}
}
pub(crate) fn cmd_graph_resource_dependency_health_overlay(
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())?;
if config.resources.is_empty() {
if json {
println!("{{\"health_overlay\":{{\"nodes\":[],\"edges\":[]}}}}");
} else {
println!("Health overlay:");
println!(" Nodes:");
println!(" Edges:");
}
return Ok(());
}
let nodes = build_health_nodes(&config);
let edges = build_health_edges(&config);
if json {
print_health_overlay_json(&nodes, &edges);
} else {
print_health_overlay_text(&nodes, &edges);
}
Ok(())
}
struct LevelInfo {
level: usize,
width: usize,
resources: Vec<String>,
}
fn compute_levels(config: &types::ForjarConfig) -> Vec<LevelInfo> {
if config.resources.is_empty() {
return Vec::new();
}
let mut in_degree: BTreeMap<String, usize> = BTreeMap::new();
let mut dependents: BTreeMap<String, Vec<String>> = BTreeMap::new();
for name in config.resources.keys() {
in_degree.entry(name.clone()).or_insert(0);
dependents.entry(name.clone()).or_default();
}
for (name, resource) in &config.resources {
for dep in &resource.depends_on {
if config.resources.contains_key(dep) {
*in_degree.entry(name.clone()).or_insert(0) += 1;
dependents
.entry(dep.clone())
.or_default()
.push(name.clone());
}
}
}
let mut queue: VecDeque<(String, usize)> = VecDeque::new();
let mut roots: Vec<String> = in_degree
.iter()
.filter(|(_, &d)| d == 0)
.map(|(n, _)| n.clone())
.collect();
roots.sort();
for r in roots {
queue.push_back((r, 0));
}
let mut level_map: BTreeMap<usize, Vec<String>> = BTreeMap::new();
while let Some((node, level)) = queue.pop_front() {
level_map.entry(level).or_default().push(node.clone());
let mut deps = dependents.get(&node).cloned().unwrap_or_default();
deps.sort();
for dep in deps {
let d = in_degree.get_mut(&dep).unwrap();
*d -= 1;
if *d == 0 {
queue.push_back((dep, level + 1));
}
}
}
level_map
.into_iter()
.map(|(level, mut resources)| {
resources.sort();
let width = resources.len();
LevelInfo {
level,
width,
resources,
}
})
.collect()
}
fn print_width_json(levels: &[LevelInfo], max_width: usize) {
let items: Vec<String> = levels
.iter()
.map(|l| {
let names: Vec<String> = l.resources.iter().map(|n| format!("\"{n}\"")).collect();
format!(
"{{\"level\":{},\"width\":{},\"resources\":[{}]}}",
l.level,
l.width,
names.join(",")
)
})
.collect();
println!(
"{{\"width_analysis\":{{\"levels\":[{}],\"max_width\":{}}}}}",
items.join(","),
max_width
);
}
fn print_width_text(levels: &[LevelInfo], max_width: usize) {
println!("Width analysis:");
for l in levels {
println!(
" Level {} (width {}): {}",
l.level,
l.width,
l.resources.join(", ")
);
}
println!(" Max width: {max_width}");
}
pub(crate) fn cmd_graph_resource_dependency_width_analysis(
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())?;
if config.resources.is_empty() {
if json {
println!("{{\"width_analysis\":{{\"levels\":[],\"max_width\":0}}}}");
} else {
println!("Width analysis:");
println!(" Max width: 0");
}
return Ok(());
}
let levels = compute_levels(&config);
let max_width = levels.iter().map(|l| l.width).max().unwrap_or(0);
if json {
print_width_json(&levels, max_width);
} else {
print_width_text(&levels, max_width);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn write_temp_config(yaml: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(yaml.as_bytes()).unwrap();
f.flush().unwrap();
f
}
const EMPTY_CFG: &str = "version: \"1.0\"\nname: t\nmachines:\n m:\n hostname: m\n addr: 127.0.0.1\nresources: {}\n";
const HEALTH_CFG: &str = "version: \"1.0\"\nname: t\nmachines:\n m:\n hostname: m\n addr: 127.0.0.1\nresources:\n a:\n type: file\n machine: m\n path: /tmp/a\n content: a\n b:\n type: service\n machine: m\n name: nginx\n tags: [deprecated]\n depends_on: [a]\n c:\n type: package\n machine: m\n packages: [curl]\n tags: [critical]\n";
const WIDTH_CFG: &str = "version: \"1.0\"\nname: t\nmachines:\n m:\n hostname: m\n addr: 127.0.0.1\nresources:\n a:\n type: file\n machine: m\n path: /tmp/a\n content: a\n b:\n type: file\n machine: m\n path: /tmp/b\n content: b\n c:\n type: file\n machine: m\n path: /tmp/c\n content: c\n d:\n type: service\n machine: m\n name: nginx\n depends_on: [a, b]\n";
#[test]
fn test_fj1063_health_overlay_empty() {
let f = write_temp_config(EMPTY_CFG);
assert!(cmd_graph_resource_dependency_health_overlay(f.path(), false).is_ok());
}
#[test]
fn test_fj1063_health_overlay_json_empty() {
let f = write_temp_config(EMPTY_CFG);
assert!(cmd_graph_resource_dependency_health_overlay(f.path(), true).is_ok());
}
#[test]
fn test_fj1063_health_overlay_with_tags() {
let f = write_temp_config(HEALTH_CFG);
assert!(cmd_graph_resource_dependency_health_overlay(f.path(), false).is_ok());
}
#[test]
fn test_fj1063_health_overlay_with_tags_json() {
let f = write_temp_config(HEALTH_CFG);
assert!(cmd_graph_resource_dependency_health_overlay(f.path(), true).is_ok());
}
#[test]
fn test_fj1063_classify_health_helper() {
assert_eq!(classify_health(&[]), "healthy");
assert_eq!(classify_health(&["web".to_string()]), "healthy");
assert_eq!(classify_health(&["deprecated".to_string()]), "deprecated");
assert_eq!(classify_health(&["critical".to_string()]), "critical");
assert_eq!(
classify_health(&["critical".to_string(), "deprecated".to_string()]),
"deprecated"
);
}
#[test]
fn test_fj1063_build_health_nodes() {
let config: types::ForjarConfig = serde_yaml_ng::from_str(HEALTH_CFG).unwrap();
let nodes = build_health_nodes(&config);
assert_eq!(nodes.len(), 3);
assert_eq!(nodes[0].name, "a");
assert_eq!(nodes[0].health, "healthy");
assert_eq!(nodes[1].name, "b");
assert_eq!(nodes[1].health, "deprecated");
assert_eq!(nodes[2].name, "c");
assert_eq!(nodes[2].health, "critical");
}
#[test]
fn test_fj1066_width_analysis_empty() {
let f = write_temp_config(EMPTY_CFG);
assert!(cmd_graph_resource_dependency_width_analysis(f.path(), false).is_ok());
}
#[test]
fn test_fj1066_width_analysis_json_empty() {
let f = write_temp_config(EMPTY_CFG);
assert!(cmd_graph_resource_dependency_width_analysis(f.path(), true).is_ok());
}
#[test]
fn test_fj1066_width_analysis_with_deps() {
let f = write_temp_config(WIDTH_CFG);
assert!(cmd_graph_resource_dependency_width_analysis(f.path(), false).is_ok());
}
#[test]
fn test_fj1066_width_analysis_with_deps_json() {
let f = write_temp_config(WIDTH_CFG);
assert!(cmd_graph_resource_dependency_width_analysis(f.path(), true).is_ok());
}
#[test]
fn test_fj1066_compute_levels_helper() {
let config: types::ForjarConfig = serde_yaml_ng::from_str(WIDTH_CFG).unwrap();
let levels = compute_levels(&config);
assert!(!levels.is_empty());
assert_eq!(levels[0].level, 0);
assert_eq!(levels[0].width, 3);
assert!(levels[0].resources.contains(&"a".to_string()));
assert!(levels[0].resources.contains(&"b".to_string()));
assert!(levels[0].resources.contains(&"c".to_string()));
assert_eq!(levels[1].level, 1);
assert_eq!(levels[1].width, 1);
assert_eq!(levels[1].resources, vec!["d".to_string()]);
}
#[test]
fn test_fj1066_max_width() {
let config: types::ForjarConfig = serde_yaml_ng::from_str(WIDTH_CFG).unwrap();
let levels = compute_levels(&config);
let max_width = levels.iter().map(|l| l.width).max().unwrap_or(0);
assert_eq!(max_width, 3);
}
}