yana-rt 0.42.1

Yana AI Runtime — safety CLI for AI agents: scan, graph, vault, hunt, ci, map, fix, doctor
use crate::graph::types::*;
use anyhow::Result;

pub fn cmd_show(data: &GraphData) {
    let m = &data.meta;
    let layers = count_layers(&data.nodes);
    let file_nodes = data.nodes.iter().filter(|n| n.node_type == "file").count();
    let func_nodes = data.nodes.iter().filter(|n| n.node_type == "function").count();
    let class_nodes = data.nodes.iter().filter(|n| n.node_type == "class").count();

    println!("\n  {}", m.project);
    println!();
    println!("  Languages   {}", m.languages.join(", "));
    if !m.frameworks.is_empty() {
        println!("  Frameworks  {}", m.frameworks.join(", "));
    }
    println!("  Files       {}", file_nodes);
    if func_nodes > 0  { println!("  Functions   {}", func_nodes); }
    if class_nodes > 0 { println!("  Classes     {}", class_nodes); }
    println!("  Edges       {}", data.edges.len());
    println!("  Analysed    {}", &m.analysed_at[..19]);
    println!();
    println!("  Architecture");
    let mut layer_list: Vec<_> = layers.iter().collect();
    layer_list.sort_by(|a, b| b.1.cmp(a.1));
    for (layer, count) in &layer_list {
        println!("    {:<24} {} nodes", layer, count);
    }
    println!();
    println!("  Tour ({} steps)", data.tour.len());
    for step in data.tour.iter().take(5) {
        println!("    {:>3}. {}", step.order, step.name);
    }
    if data.tour.len() > 5 {
        println!("    … and {} more (run `yana-rt graph onboard`)", data.tour.len() - 5);
    }
    println!();
}

pub fn cmd_search(data: &GraphData, query: &str, expand: bool, limit: usize) {
    let q = query.to_lowercase();
    let mut hits: Vec<&Node> = data.nodes.iter().filter(|n| {
        n.name.to_lowercase().contains(&q)
            || n.file_path.to_lowercase().contains(&q)
            || n.tags.iter().any(|t| t.contains(&q))
            || n.language.to_lowercase().contains(&q)
    }).collect();

    hits.sort_by_key(|n| {
        if n.name.to_lowercase() == q { 0 }
        else if n.name.to_lowercase().starts_with(&q) { 1 }
        else { 2 }
    });
    hits.truncate(limit);

    if hits.is_empty() {
        println!("(no results for '{}')", query);
        return;
    }
    println!("\n  Search: {}", query);
    println!("  {} result(s)\n", hits.len());
    for n in &hits {
        println!("  [{:<14}] {}", n.node_type, n.name);
        println!("               {}", n.file_path);
        if !n.summary.is_empty() {
            println!("               {}", n.summary.chars().take(80).collect::<String>());
        }
    }

    if expand && !hits.is_empty() {
        let first_id = &hits[0].id;
        let connected: Vec<&Node> = data.edges.iter()
            .filter(|e| &e.source == first_id || &e.target == first_id)
            .flat_map(|e| {
                let id = if &e.source == first_id { &e.target } else { &e.source };
                data.nodes.iter().find(|n| &n.id == id)
            })
            .take(5)
            .collect();

        if !connected.is_empty() {
            println!("\n  Connected to '{}':", hits[0].name);
            for n in connected {
                println!("{} ({})", n.name, n.file_path);
            }
        }
    }
    println!();
}

pub fn cmd_onboard(data: &GraphData, out_file: Option<&str>) -> Result<()> {
    let mut md = String::new();
    md.push_str(&format!("# {} — Onboarding Guide\n\n", data.meta.project));
    md.push_str(&format!(
        "> Generated by `yana-rt graph onboard` · {} files · {} edges\n\n",
        data.nodes.iter().filter(|n| n.node_type == "file").count(),
        data.edges.len()
    ));

    md.push_str("## Project Overview\n\n");
    md.push_str(&format!(
        "**Languages:** {}  \n**Frameworks:** {}  \n**Analysed:** {}\n\n",
        data.meta.languages.join(", "),
        if data.meta.frameworks.is_empty() { "-".to_string() } else { data.meta.frameworks.join(", ") },
        &data.meta.analysed_at[..10]
    ));

    let layers = count_layers(&data.nodes);
    md.push_str("## Architecture\n\n");
    let mut lv: Vec<_> = layers.iter().collect();
    lv.sort_by(|a, b| b.1.cmp(a.1));
    for (layer, count) in lv {
        md.push_str(&format!("- **{}**: {} nodes\n", layer, count));
    }

    md.push_str("\n## Guided Tour\n\n");
    md.push_str("Start here to understand the codebase:\n\n");
    for step in &data.tour {
        md.push_str(&format!(
            "### {}. `{}` ({})\n\n- Path: `{}`\n- Layer: {}\n- Reason: {}\n\n",
            step.order, step.name, step.language,
            step.file_path, step.layer, step.reason
        ));
    }

    // Top connected files
    let mut degree: Vec<(&str, usize)> = {
        let mut map: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
        for e in &data.edges {
            *map.entry(e.target.as_str()).or_default() += 1;
            *map.entry(e.source.as_str()).or_default() += 1;
        }
        map.into_iter().collect()
    };
    degree.sort_by(|a, b| b.1.cmp(&a.1));

    md.push_str("## Most Connected Files\n\n");
    for (id, deg) in degree.iter().take(10) {
        if let Some(n) = data.nodes.iter().find(|n| n.id == *id) {
            md.push_str(&format!("- `{}` — {} connections\n", n.file_path, deg));
        }
    }

    if let Some(path) = out_file {
        std::fs::write(path, &md)?;
        println!("[graph] onboarding guide → {}", path);
    } else {
        print!("{}", md);
    }
    Ok(())
}

pub fn cmd_diff(data: &GraphData, base: &str, target: &str) -> Result<()> {
    use std::process::Command;
    let out = Command::new("git")
        .args(["diff", "--name-only", base])
        .current_dir(target)
        .output()?;
    if !out.status.success() {
        anyhow::bail!("git diff failed: {}", String::from_utf8_lossy(&out.stderr));
    }
    let changed: Vec<&str> = std::str::from_utf8(&out.stdout)?
        .lines().collect();

    if changed.is_empty() {
        println!("No changed files vs {}", base);
        return Ok(());
    }
    println!("\n  Changed files (vs {}):\n", base);
    for f in &changed {
        println!("{}", f);
    }

    // Find files that import the changed files (impact)
    let changed_ids: Vec<String> = changed.iter()
        .map(|f| format!("file:{}", f)).collect();
    let mut impacted: Vec<&str> = data.edges.iter()
        .filter(|e| changed_ids.contains(&e.target))
        .filter_map(|e| data.nodes.iter().find(|n| n.id == e.source))
        .map(|n| n.file_path.as_str())
        .collect();
    impacted.dedup();

    if !impacted.is_empty() {
        println!("\n  Impact (files that import changed files):\n");
        for f in &impacted {
            println!("{}", f);
        }
    }
    println!();
    Ok(())
}

fn count_layers(nodes: &[Node]) -> std::collections::HashMap<String, usize> {
    let mut map: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
    for n in nodes {
        let layer = crate::graph::types::layer_from_path(&n.file_path).to_string();
        *map.entry(layer).or_default() += 1;
    }
    map
}