forjar 1.4.2

Rust-native Infrastructure as Code — bare-metal first, BLAKE3 state, provenance tracing
Documentation
//! Phase 105 — Graph Resilience Extensions: fan-in hotspots & cross-machine bridges (FJ-1103, FJ-1106).

use crate::core::types;
use std::collections::{BTreeMap, HashMap};
use std::path::Path;

// ============================================================================
// FJ-1103: Resource dependency fan-in hotspot detection
// ============================================================================

struct FanInEntry {
    resource: String,
    fan_in: usize,
    dependents: Vec<String>,
}

/// Compute fan-in (number of resources that depend on each resource).
fn compute_fan_in(config: &types::ForjarConfig) -> BTreeMap<String, Vec<String>> {
    let mut fan_in: BTreeMap<String, Vec<String>> = BTreeMap::new();
    for name in config.resources.keys() {
        fan_in.entry(name.clone()).or_default();
    }
    for (name, resource) in &config.resources {
        for dep in &resource.depends_on {
            if config.resources.contains_key(dep) {
                fan_in.entry(dep.clone()).or_default().push(name.clone());
            }
        }
    }
    for dependents in fan_in.values_mut() {
        dependents.sort();
    }
    fan_in
}

/// Identify hotspot resources where fan-in exceeds the threshold.
/// Threshold: fan-in > average * 2 OR fan-in >= 3 (whichever is lower).
fn find_fan_in_hotspots(config: &types::ForjarConfig) -> Vec<FanInEntry> {
    let fan_in_map = compute_fan_in(config);
    let total_fan_in: usize = fan_in_map.values().map(|v| v.len()).sum();
    let count = fan_in_map.len();
    let avg = if count > 0 {
        total_fan_in as f64 / count as f64
    } else {
        0.0
    };
    let threshold = (avg * 2.0).max(3.0) as usize;

    let mut hotspots: Vec<FanInEntry> = fan_in_map
        .into_iter()
        .filter(|(_, dependents)| dependents.len() >= threshold)
        .map(|(resource, dependents)| FanInEntry {
            fan_in: dependents.len(),
            resource,
            dependents,
        })
        .collect();
    hotspots.sort_by(|a, b| b.fan_in.cmp(&a.fan_in).then(a.resource.cmp(&b.resource)));
    hotspots
}

fn print_fan_in_hotspots_json(hotspots: &[FanInEntry]) {
    let items: Vec<String> = hotspots
        .iter()
        .map(|e| {
            let deps: Vec<String> = e.dependents.iter().map(|d| format!("\"{d}\"")).collect();
            format!(
                "{{\"resource\":\"{}\",\"fan_in\":{},\"dependents\":[{}]}}",
                e.resource,
                e.fan_in,
                deps.join(",")
            )
        })
        .collect();
    println!("{{\"fan_in_hotspots\":[{}]}}", items.join(","));
}

fn print_fan_in_hotspots_text(hotspots: &[FanInEntry]) {
    if hotspots.is_empty() {
        println!("Fan-in hotspots: (no hotspots detected)");
        return;
    }
    println!("Fan-in hotspots:");
    for e in hotspots {
        println!(
            "  {}: fan_in={} (depended on by: {})",
            e.resource,
            e.fan_in,
            e.dependents.join(", ")
        );
    }
}

/// FJ-1103: Identify fan-in hotspot resources — those depended on by many
/// other resources (fan-in > average * 2 or fan-in >= 3).
pub(crate) fn cmd_graph_resource_dependency_fan_in_hotspot(
    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!("{{\"fan_in_hotspots\":[]}}");
        } else {
            println!("Fan-in hotspots: (no hotspots detected)");
        }
        return Ok(());
    }
    let hotspots = find_fan_in_hotspots(&config);
    if json {
        print_fan_in_hotspots_json(&hotspots);
    } else {
        print_fan_in_hotspots_text(&hotspots);
    }
    Ok(())
}

// ============================================================================
// FJ-1106: Cross-machine dependency bridge detection
// ============================================================================

struct CrossMachineBridge {
    resource: String,
    dependency: String,
    source_machine: String,
    target_machine: String,
}

/// Get the primary machine name for a resource (first entry for Multi).
fn primary_machine(resource: &types::Resource) -> String {
    match &resource.machine {
        types::MachineTarget::Single(s) => s.clone(),
        types::MachineTarget::Multiple(v) => v.first().cloned().unwrap_or_default(),
    }
}

/// Find dependency edges where source and target are on different machines.
fn find_cross_machine_bridges(config: &types::ForjarConfig) -> Vec<CrossMachineBridge> {
    let machine_map: HashMap<String, String> = config
        .resources
        .iter()
        .map(|(name, res)| (name.clone(), primary_machine(res)))
        .collect();

    let mut bridges: Vec<CrossMachineBridge> = Vec::new();
    let mut sorted_names: Vec<&String> = config.resources.keys().collect();
    sorted_names.sort();
    for name in sorted_names {
        let resource = &config.resources[name];
        let src_machine = machine_map.get(name).cloned().unwrap_or_default();
        let mut deps: Vec<&String> = resource
            .depends_on
            .iter()
            .filter(|d| config.resources.contains_key(*d))
            .collect();
        deps.sort();
        for dep in deps {
            let tgt_machine = machine_map.get(dep).cloned().unwrap_or_default();
            if src_machine != tgt_machine {
                bridges.push(CrossMachineBridge {
                    resource: name.clone(),
                    dependency: dep.clone(),
                    source_machine: src_machine.clone(),
                    target_machine: tgt_machine.clone(),
                });
            }
        }
    }
    bridges
}

fn print_cross_machine_bridges_json(bridges: &[CrossMachineBridge]) {
    let items: Vec<String> = bridges
        .iter()
        .map(|b| {
            format!(
                "{{\"resource\":\"{}\",\"dependency\":\"{}\",\"source_machine\":\"{}\",\"target_machine\":\"{}\"}}",
                b.resource, b.dependency, b.source_machine, b.target_machine
            )
        })
        .collect();
    println!("{{\"cross_machine_bridges\":[{}]}}", items.join(","));
}

fn print_cross_machine_bridges_text(bridges: &[CrossMachineBridge]) {
    if bridges.is_empty() {
        println!("Cross-machine bridges: (no cross-machine dependencies)");
        return;
    }
    println!("Cross-machine bridges:");
    for b in bridges {
        println!(
            "  {} -> {} ({} -> {})",
            b.resource, b.dependency, b.source_machine, b.target_machine
        );
    }
}

/// FJ-1106: Find dependency edges where source and target are on different
/// machines — cross-machine bridges that represent network-dependent
/// critical paths.
pub(crate) fn cmd_graph_resource_dependency_cross_machine_bridge(
    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!("{{\"cross_machine_bridges\":[]}}");
        } else {
            println!("Cross-machine bridges: (no cross-machine dependencies)");
        }
        return Ok(());
    }
    let bridges = find_cross_machine_bridges(&config);
    if json {
        print_cross_machine_bridges_json(&bridges);
    } else {
        print_cross_machine_bridges_text(&bridges);
    }
    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 FAN_IN_CFG: &str = "version: \"1.0\"\nname: t\nmachines:\n  m:\n    hostname: m\n    addr: 127.0.0.1\nresources:\n  base:\n    type: file\n    machine: m\n    path: /tmp/base\n    content: base\n  a:\n    type: file\n    machine: m\n    path: /tmp/a\n    content: a\n    depends_on: [base]\n  b:\n    type: file\n    machine: m\n    path: /tmp/b\n    content: b\n    depends_on: [base]\n  c:\n    type: file\n    machine: m\n    path: /tmp/c\n    content: c\n    depends_on: [base]\n  d:\n    type: service\n    machine: m\n    name: nginx\n    depends_on: [base]\n";

    const CROSS_MACHINE_CFG: &str = "version: \"1.0\"\nname: t\nmachines:\n  m1:\n    hostname: m1\n    addr: 10.0.0.1\n  m2:\n    hostname: m2\n    addr: 10.0.0.2\nresources:\n  db:\n    type: file\n    machine: m1\n    path: /tmp/db\n    content: db\n  app:\n    type: file\n    machine: m2\n    path: /tmp/app\n    content: app\n    depends_on: [db]\n  web:\n    type: file\n    machine: m2\n    path: /tmp/web\n    content: web\n    depends_on: [app]\n";

    // ── FJ-1103: fan-in hotspot ──

    #[test]
    fn test_fj1103_fan_in_hotspot_empty() {
        let f = write_temp_config(EMPTY_CFG);
        assert!(cmd_graph_resource_dependency_fan_in_hotspot(f.path(), false).is_ok());
    }

    #[test]
    fn test_fj1103_fan_in_hotspot_json_empty() {
        let f = write_temp_config(EMPTY_CFG);
        assert!(cmd_graph_resource_dependency_fan_in_hotspot(f.path(), true).is_ok());
    }

    #[test]
    fn test_fj1103_fan_in_hotspot_with_data() {
        let f = write_temp_config(FAN_IN_CFG);
        assert!(cmd_graph_resource_dependency_fan_in_hotspot(f.path(), false).is_ok());
    }

    #[test]
    fn test_fj1103_fan_in_hotspot_with_data_json() {
        let f = write_temp_config(FAN_IN_CFG);
        assert!(cmd_graph_resource_dependency_fan_in_hotspot(f.path(), true).is_ok());
    }

    #[test]
    fn test_fj1103_compute_fan_in_helper() {
        let config: types::ForjarConfig = serde_yaml_ng::from_str(FAN_IN_CFG).unwrap();
        let fan_in = compute_fan_in(&config);
        // base is depended on by a, b, c, d
        assert_eq!(fan_in["base"].len(), 4);
        // a, b, c, d have no dependents
        assert!(fan_in["a"].is_empty());
        assert!(fan_in["b"].is_empty());
    }

    #[test]
    fn test_fj1103_find_hotspots_helper() {
        let config: types::ForjarConfig = serde_yaml_ng::from_str(FAN_IN_CFG).unwrap();
        let hotspots = find_fan_in_hotspots(&config);
        // base has fan-in=4, threshold=max(0.8*2, 3)=3, so base is a hotspot
        assert_eq!(hotspots.len(), 1);
        assert_eq!(hotspots[0].resource, "base");
        assert_eq!(hotspots[0].fan_in, 4);
    }

    #[test]
    fn test_fj1103_file_not_found() {
        let result = cmd_graph_resource_dependency_fan_in_hotspot(Path::new("/nonexistent"), false);
        assert!(result.is_err());
    }

    // ── FJ-1106: cross-machine bridge ──

    #[test]
    fn test_fj1106_cross_machine_bridge_empty() {
        let f = write_temp_config(EMPTY_CFG);
        assert!(cmd_graph_resource_dependency_cross_machine_bridge(f.path(), false).is_ok());
    }

    #[test]
    fn test_fj1106_cross_machine_bridge_json_empty() {
        let f = write_temp_config(EMPTY_CFG);
        assert!(cmd_graph_resource_dependency_cross_machine_bridge(f.path(), true).is_ok());
    }

    #[test]
    fn test_fj1106_cross_machine_bridge_with_data() {
        let f = write_temp_config(CROSS_MACHINE_CFG);
        assert!(cmd_graph_resource_dependency_cross_machine_bridge(f.path(), false).is_ok());
    }

    #[test]
    fn test_fj1106_cross_machine_bridge_with_data_json() {
        let f = write_temp_config(CROSS_MACHINE_CFG);
        assert!(cmd_graph_resource_dependency_cross_machine_bridge(f.path(), true).is_ok());
    }

    #[test]
    fn test_fj1106_find_bridges_helper() {
        let config: types::ForjarConfig = serde_yaml_ng::from_str(CROSS_MACHINE_CFG).unwrap();
        let bridges = find_cross_machine_bridges(&config);
        // app (m2) -> db (m1) is a cross-machine bridge
        // web (m2) -> app (m2) is NOT cross-machine
        assert_eq!(bridges.len(), 1);
        assert_eq!(bridges[0].resource, "app");
        assert_eq!(bridges[0].dependency, "db");
        assert_eq!(bridges[0].source_machine, "m2");
        assert_eq!(bridges[0].target_machine, "m1");
    }

    #[test]
    fn test_fj1106_no_cross_machine_same_machine() {
        let config: types::ForjarConfig = serde_yaml_ng::from_str(FAN_IN_CFG).unwrap();
        let bridges = find_cross_machine_bridges(&config);
        // All resources are on the same machine 'm'
        assert!(bridges.is_empty());
    }

    #[test]
    fn test_fj1106_file_not_found() {
        let result =
            cmd_graph_resource_dependency_cross_machine_bridge(Path::new("/nonexistent"), false);
        assert!(result.is_err());
    }
}