fairlead 0.15.0

Guardrails and a guided path for coding agents: rules enforced as the agent works, tests scoped to what a change can reach, capped project memory, and KPIs.
//! `fairlead graph`: numbers about the import graph, and questions about
//! one file.

use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::Instant;

use clap::Subcommand;
use fairlead_core::config::{self, LoadOptions};
use fairlead_lang::{build, Scan};

#[derive(Subcommand)]
pub enum GraphAction {
    /// Files, edges and what didn't resolve, with the build time.
    Stats {
        /// Print JSON instead of text.
        #[arg(long)]
        json: bool,
    },
    /// The chain by which FROM depends on TO.
    Why { from: String, to: String },
    /// The files that depend on FILE directly.
    Importers { file: String },
    /// The files that link the most changes to the most tests, and the
    /// barrier that would cut the top one.
    Hubs {
        /// How many files to list.
        #[arg(long, default_value_t = 10, value_name = "N")]
        top: usize,
        /// Print JSON instead of text.
        #[arg(long)]
        json: bool,
    },
}

/// The repository root: the nearest directory with `.git`, else `start`.
pub fn repo_root(start: &Path) -> PathBuf {
    start
        .ancestors()
        .find(|d| d.join(".git").exists())
        .unwrap_or(start)
        .to_path_buf()
}

fn relative_to(root: &Path, cwd: &Path, file: &str) -> String {
    let abs = cwd.join(file);
    let root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
    let abs = std::fs::canonicalize(&abs).unwrap_or(abs);
    fairlead_lang::tree::relative(&root, &abs).unwrap_or_else(|| file.replace('\\', "/"))
}

pub fn run(action: GraphAction, sets: Vec<String>, cwd: &Path) -> ExitCode {
    let loaded = match config::load(cwd, &LoadOptions::from_process(sets)) {
        Ok(loaded) => loaded,
        Err(e) => {
            eprintln!("{e}");
            return ExitCode::FAILURE;
        }
    };
    let root = if loaded.files.is_empty() {
        repo_root(cwd)
    } else {
        loaded.root.clone()
    };
    let started = Instant::now();
    let scan = match build(&root, &loaded.config) {
        Ok(scan) => scan,
        Err(e) => {
            eprintln!("could not read {}: {e}", root.display());
            return ExitCode::FAILURE;
        }
    };
    let elapsed = started.elapsed();
    match action {
        GraphAction::Stats { json } => {
            let cache_off = if loaded.config.graph.cache {
                "off (no git directory to keep it in)"
            } else {
                "off (graph.cache = false)"
            };
            stats(
                &scan,
                elapsed.as_secs_f64(),
                json,
                cache_off,
                (&loaded.config.extends, &root),
            )
        }
        GraphAction::Why { from, to } => why(
            &scan,
            &relative_to(&root, cwd, &from),
            &relative_to(&root, cwd, &to),
        ),
        GraphAction::Importers { file } => importers(&scan, &relative_to(&root, cwd, &file)),
        GraphAction::Hubs { top, json } => {
            crate::graph_hubs::run(&scan, &loaded.config, top, json, elapsed.as_secs_f64())
        }
    }
}

fn stats(
    scan: &Scan,
    seconds: f64,
    json: bool,
    cache_off: &str,
    packs: (&[String], &Path),
) -> ExitCode {
    let s = scan.graph.stats();
    let sources = scan.tree.sources().count();
    if json {
        let by_kind: serde_json::Map<String, serde_json::Value> = s
            .edges_by_kind
            .iter()
            .map(|(k, n)| (format!("{k:?}").to_lowercase(), (*n).into()))
            .collect();
        let value = serde_json::json!({
            "files": s.files, "sources": sources, "edges": s.edges, "edges_by_kind": by_kind,
            "package_edges": s.package_edges, "packages": s.packages, "unresolved": s.unresolved,
            "unknown_dynamic": s.unknown, "tsconfig_fallbacks": s.tsconfig_fallbacks, "seconds": seconds,
            "rules": { "edges": scan.rules.edges, "unmatched": scan.rules.unmatched, "large": scan.rules.large, "barrier": scan.graph.barrier.len() },
            "cache": { "enabled": scan.cache.enabled, "hits": scan.cache.hits, "misses": scan.cache.misses },
            "providers": scan.providers.iter().map(|p| serde_json::json!({
                "id": p.id, "files": p.files, "edges": p.edges, "ignored": p.ignored, "failed": p.failed,
            })).collect::<Vec<_>>(),
            "conflicts": scan.conflicts.len(),
            "coverage": scan.coverage.as_ref().map(|c| serde_json::json!({
                "map": c.map, "commit": c.commit, "created": c.created, "source": c.source,
                "tests": c.tests, "edges": c.edges, "ignored": c.ignored, "error": c.error,
            })),
        });
        println!(
            "{}",
            serde_json::to_string_pretty(&value).expect("stats print")
        );
    } else {
        println!("files: {} ({} sources)", s.files, sources);
        let kinds: Vec<String> = s
            .edges_by_kind
            .iter()
            .map(|(k, n)| format!("{} {}", format!("{k:?}").to_lowercase(), n))
            .collect();
        println!("edges: {} ({})", s.edges, kinds.join(", "));
        if scan.providers.len() > 1 {
            let each: Vec<String> = scan
                .providers
                .iter()
                .map(|p| match &p.failed {
                    Some(why) => format!("{} failed on {} files: {why}", p.id, p.files),
                    None => format!("{} {} edges from {} files", p.id, p.edges, p.files),
                })
                .collect();
            println!("providers: {}", each.join("; "));
            for p in scan.providers.iter().filter(|p| p.ignored > 0) {
                println!(
                    "  {} printed {} edges naming a file outside the tree or its claim",
                    p.id, p.ignored
                );
            }
            if let Some(c) = scan.conflicts.first() {
                println!(
                    "  {} files claimed by two providers, such as {} ({} over {})",
                    scan.conflicts.len(),
                    c.file,
                    c.chosen,
                    c.other
                );
            }
        }
        if let Some(c) = &scan.coverage {
            match &c.error {
                Some(why) => println!("coverage map {}: left out, {why}", c.map),
                None => println!(
                    "coverage map {}: {} edges from {} tests, {} from {} at {}{}",
                    c.map,
                    c.edges,
                    c.tests,
                    c.source,
                    c.created,
                    c.commit.get(..12).unwrap_or(&c.commit),
                    if c.ignored > 0 {
                        format!("; {} naming files no longer here", c.ignored)
                    } else {
                        String::new()
                    }
                ),
            }
        }
        println!(
            "workspace packages: {}, package edges: {}",
            s.packages, s.package_edges
        );
        println!(
            "unresolved: {}, unknown dynamic imports: {}, tsconfig fallbacks: {}",
            s.unresolved, s.unknown, s.tsconfig_fallbacks
        );
        if scan.rules.edges > 0
            || !scan.rules.unmatched.is_empty()
            || !scan.graph.barrier.is_empty()
        {
            println!(
                "rule edges: {}, barrier files: {}",
                scan.rules.edges,
                scan.graph.barrier.len()
            );
        }
        print!(
            "{}",
            unmatched_lines(&scan.rules.unmatched, packs.0, packs.1)
        );
        for (from, n, example) in &scan.rules.unresolved {
            println!("rule from {from} found {n} name(s) that match no file, such as `{example}`");
        }
        for (from, n) in &scan.rules.large {
            println!("rule from {from} added {n} edges; check that its globs match only what they should");
        }
        if scan.cache.enabled {
            println!(
                "parse cache: {} hits, {} parsed",
                scan.cache.hits, scan.cache.misses
            );
        } else {
            println!("parse cache: {cache_off}");
        }
        println!("built in {seconds:.2} s");
    }
    ExitCode::SUCCESS
}

fn lookup(scan: &Scan, file: &str) -> Option<u32> {
    let id = scan.graph.id(file);
    if id.is_none() {
        eprintln!("{file} isn't a file Fairlead can see (ignored, or outside the repository)");
    }
    id
}

fn why(scan: &Scan, from: &str, to: &str) -> ExitCode {
    let (Some(a), Some(b)) = (lookup(scan, from), lookup(scan, to)) else {
        return ExitCode::FAILURE;
    };
    match scan.graph.why(a, b) {
        Some(chain) => {
            // The planner walks from `to` towards `from` and stops at the first
            // barrier it meets, but goes past `to` when that is the change, so
            // only a barrier strictly between them cuts the chain.
            let inner = &chain[1..chain.len().saturating_sub(1).max(1)];
            let stop = inner
                .iter()
                .rev()
                .find(|(f, _)| scan.graph.id(f).is_some_and(|id| scan.graph.is_barrier(id)));
            for (i, (file, kind)) in chain.iter().enumerate() {
                // No kind means a package edge: the file depends on the next one's whole package.
                let package = || {
                    let next = scan.graph.id(&chain.get(i + 1)?.0)?;
                    scan.graph.package_name_of(next)
                };
                let via = match kind {
                    Some(k) => format!("  ({})", k.label()),
                    None => package()
                        .map(|p| format!("  (depends on package {p})"))
                        .unwrap_or_default(),
                };
                println!(
                    "{}{file}{}",
                    "  ".repeat(i),
                    if i + 1 < chain.len() {
                        via
                    } else {
                        String::new()
                    }
                );
            }
            if let Some((file, _)) = stop {
                println!(
                    "the test plan doesn't follow this: its walk stops at {file} (graph.barrier)"
                );
            }
            ExitCode::SUCCESS
        }
        None => {
            println!("{from} doesn't depend on {to}");
            ExitCode::FAILURE
        }
    }
}

fn importers(scan: &Scan, file: &str) -> ExitCode {
    let Some(id) = lookup(scan, file) else {
        return ExitCode::FAILURE;
    };
    for (importer, kind) in scan.graph.importers(id) {
        let kind = kind
            .map(|k| format!("{k:?}").to_lowercase())
            .unwrap_or_else(|| "package".into());
        println!("{}  ({kind})", scan.graph.files[importer as usize]);
    }
    ExitCode::SUCCESS
}

/// The rules that linked no file: the project's own one by one, since each
/// is a likely mistake, and a pack's counted, since a pack covers layouts a
/// project needn't use.
fn unmatched_lines(unmatched: &[String], extends: &[String], root: &Path) -> String {
    let mut out = String::new();
    let mut theirs: Vec<(&str, usize, usize)> = Vec::new();
    for pack in extends {
        let froms = fairlead_core::config::packs::rule_froms(pack, root);
        let idle = unmatched.iter().filter(|u| froms.contains(u)).count();
        if idle > 0 {
            theirs.push((pack, idle, froms.len()));
        }
    }
    let packed: Vec<String> = extends
        .iter()
        .flat_map(|p| fairlead_core::config::packs::rule_froms(p, root))
        .collect();
    for from in unmatched.iter().filter(|u| !packed.contains(u)) {
        out.push_str(&format!("rule from {from} linked no file\n"));
    }
    for (pack, idle, all) in theirs {
        out.push_str(&format!(
            "pack {pack}: {idle} of {all} rules linked no file, for layouts this project doesn't use\n"
        ));
    }
    out
}