blast-radius 0.7.3

Analyze the transitive blast radius of code changes.
Documentation
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;

use clap::ValueEnum;
use serde::Serialize;

/// Version of the JSON output schema. Bump on breaking changes to the
/// serialized shape (renamed/removed fields, changed meanings); additive
/// fields do not bump it.
pub const SCHEMA_VERSION: u32 = 1;

#[derive(Debug, Clone, Serialize)]
pub struct AnalysisResult {
    pub schema_version: u32,
    pub mode: AnalysisMode,
    pub target: AnalysisTarget,
    pub repo_root: PathBuf,
    pub source_file_count: usize,
    pub summary: Summary,
    pub workspaces: Vec<Workspace>,
    /// Per-input-file impact, populated only for multi-file runs.
    pub roots: Vec<RootImpact>,
    pub nodes: Vec<GraphNode>,
    pub edges: Vec<GraphEdge>,
    pub warnings: Vec<String>,
}

/// A workspace package discovered in the repo, used to group impacted files by
/// the package they live in.
#[derive(Debug, Clone, Serialize)]
pub struct Workspace {
    pub name: String,
    /// Package root, relative to the repo root (empty string == repo root).
    pub root: String,
}

/// The blast radius of a single input file, so a multi-file run can show each
/// file's impact individually alongside the combined total.
#[derive(Debug, Clone, Serialize)]
pub struct RootImpact {
    pub file: String,
    pub affected: usize,
    pub direct: usize,
    pub indirect: usize,
    pub max_depth: usize,
    pub packages: usize,
    pub files: Vec<RootImpactFile>,
}

/// A single file impacted by a particular input file.
#[derive(Debug, Clone, Serialize)]
pub struct RootImpactFile {
    pub path: String,
    pub endpoint: bool,
    /// Hops from the changed file (1 == direct consumer).
    pub depth: usize,
}

/// Normalize the separators in a path label to `/`. Labels are built from
/// `Path::display()`, which uses `\` on Windows, while every grouping rule in
/// this module and the reports splits on `/` — without this, on Windows every
/// file collapses into package `.` and the package count degrades to 1.
pub fn normalize_separators(label: impl Into<String>) -> String {
    let label = label.into();
    if label.contains('\\') {
        label.replace('\\', "/")
    } else {
        label
    }
}

/// Map a repo-relative path to the package that owns it: the longest matching
/// workspace root, falling back to the top-level directory. `workspaces` is
/// expected to be sorted longest-root-first. Both the path and the workspace
/// roots are separator-normalized defensively, so Windows-style labels still
/// group correctly.
pub fn package_key(rel_path: &str, workspaces: &[Workspace]) -> String {
    let rel_path = normalize_separators(rel_path);
    if let Some(root) = workspaces.iter().find_map(|workspace| {
        let root = normalize_separators(workspace.root.as_str());
        (root.is_empty() || rel_path == root || rel_path.starts_with(&format!("{root}/")))
            .then_some(root)
    }) {
        if root.is_empty() {
            ".".to_string()
        } else {
            root
        }
    } else {
        match rel_path.split_once('/') {
            Some((head, _)) => head.to_string(),
            None => ".".to_string(),
        }
    }
}

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AnalysisMode {
    Export,
    File,
    Files,
    /// The whole-repo import graph dump (`graph` command), not an impact query.
    Graph,
}

#[derive(Debug, Clone, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AnalysisTarget {
    Export {
        file: PathBuf,
        export_name: String,
    },
    File {
        file: PathBuf,
    },
    Files {
        files: Vec<PathBuf>,
    },
    /// Whole-repo dump; no single target. `summary`/`risk_tier` are not
    /// meaningful in this mode — read `nodes`/`edges`.
    Graph,
}

#[derive(Debug, Clone, Serialize, Default)]
pub struct Summary {
    pub directly_affected_files: usize,
    pub transitively_affected_files: usize,
    pub total_affected_files: usize,
    pub unresolved_imports: usize,
    pub ambiguous_edges: usize,
    pub parse_failures: usize,
    /// Input paths passed to `files` mode that were skipped because they were
    /// missing on disk or not recognized source files.
    pub skipped_inputs: usize,
    /// The headline risk verdict for this run, derived from reach and spread.
    pub risk_tier: RiskTier,
}

/// The headline blast-radius verdict. Ordered least-to-most severe so callers
/// can gate on `tier >= threshold` (see `--fail-on-risk`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, ValueEnum)]
#[serde(rename_all = "snake_case")]
pub enum RiskTier {
    #[default]
    Minor,
    Moderate,
    Risky,
    High,
}

/// Reach and spread drive the tier; ambiguity is surfaced as a confidence
/// caveat elsewhere rather than inflating the score, so the headline stays
/// trustworthy. `affected` counts downstream files (excludes the target);
/// `packages` is the number of distinct packages they span.
pub fn compute_tier(affected: usize, packages: usize) -> RiskTier {
    if affected == 0 {
        RiskTier::Minor
    } else if affected > 25 || packages >= 3 {
        RiskTier::High
    } else if affected <= 3 && packages <= 1 {
        RiskTier::Minor
    } else if affected <= 10 && packages <= 2 {
        RiskTier::Moderate
    } else {
        RiskTier::Risky
    }
}

#[derive(Debug, Clone, Serialize)]
pub struct GraphNode {
    pub id: String,
    pub label: String,
    pub file: PathBuf,
    pub symbol: Option<String>,
    pub kind: NodeKind,
    pub depth: usize,
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum NodeKind {
    File,
    Export,
}

#[derive(Debug, Clone, Serialize)]
pub struct GraphEdge {
    pub from: String,
    pub to: String,
    pub kind: EdgeKind,
    pub is_ambiguous: bool,
}

#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum EdgeKind {
    ImportsNamed,
    ImportsDefault,
    ImportsNamespace,
    ImportsDynamic,
    ReexportsNamed,
    ReexportsStar,
    UsesJsxComponent,
    RequiresModule,
    CommonJsExport,
    /// A `vi.mock("...")` / `jest.mock("...")` module reference in a test.
    MocksModule,
}

#[derive(Debug, Clone)]
pub struct ModuleState {
    pub file: PathBuf,
    pub public_exports: BTreeSet<String>,
    pub export_to_locals: BTreeMap<String, BTreeSet<String>>,
    pub local_to_exports: BTreeMap<String, BTreeSet<String>>,
}

impl ModuleState {
    pub fn new(file: PathBuf) -> Self {
        Self {
            file,
            public_exports: BTreeSet::new(),
            export_to_locals: BTreeMap::new(),
            local_to_exports: BTreeMap::new(),
        }
    }

    pub fn add_export(&mut self, exported: impl Into<String>, local: Option<String>) {
        let exported = exported.into();
        self.public_exports.insert(exported.clone());
        if let Some(local) = local {
            self.export_to_locals
                .entry(exported.clone())
                .or_default()
                .insert(local.clone());
            self.local_to_exports
                .entry(local)
                .or_default()
                .insert(exported);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{Workspace, normalize_separators, package_key};

    fn workspace(name: &str, root: &str) -> Workspace {
        Workspace {
            name: name.to_string(),
            root: root.to_string(),
        }
    }

    #[test]
    fn normalize_separators_converts_backslashes() {
        assert_eq!(
            normalize_separators("src\\report\\tree.rs"),
            "src/report/tree.rs"
        );
        assert_eq!(
            normalize_separators("src/report/tree.rs"),
            "src/report/tree.rs"
        );
        assert_eq!(normalize_separators(""), "");
    }

    #[test]
    fn package_key_matches_workspace_with_backslash_path() {
        let workspaces = vec![workspace("ui", "packages/ui"), workspace("repo", "")];
        assert_eq!(
            package_key("packages\\ui\\src\\button.tsx", &workspaces),
            "packages/ui"
        );
        // Exact match on the workspace root itself.
        assert_eq!(package_key("packages\\ui", &workspaces), "packages/ui");
    }

    #[test]
    fn package_key_handles_backslash_workspace_roots() {
        // Workspace roots are labels too, so normalize them defensively as well.
        let workspaces = vec![workspace("ui", "packages\\ui")];
        assert_eq!(
            package_key("packages/ui/src/button.tsx", &workspaces),
            "packages/ui"
        );
        assert_eq!(
            package_key("packages\\ui\\src\\button.tsx", &workspaces),
            "packages/ui"
        );
    }

    #[test]
    fn package_key_falls_back_to_top_level_directory() {
        assert_eq!(package_key("src\\lib\\util.rs", &[]), "src");
        assert_eq!(package_key("src/lib/util.rs", &[]), "src");
        assert_eq!(package_key("main.rs", &[]), ".");
    }

    #[test]
    fn package_key_does_not_match_sibling_prefix() {
        // "packages/ui-kit" must not match the "packages/ui" workspace.
        let workspaces = vec![workspace("ui", "packages/ui")];
        assert_eq!(
            package_key("packages\\ui-kit\\src\\index.ts", &workspaces),
            "packages"
        );
    }
}