use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use clap::ValueEnum;
use serde::Serialize;
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>,
pub roots: Vec<RootImpact>,
pub nodes: Vec<GraphNode>,
pub edges: Vec<GraphEdge>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Workspace {
pub name: String,
pub root: String,
}
#[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>,
}
#[derive(Debug, Clone, Serialize)]
pub struct RootImpactFile {
pub path: String,
pub endpoint: bool,
pub depth: usize,
}
pub fn normalize_separators(label: impl Into<String>) -> String {
let label = label.into();
if label.contains('\\') {
label.replace('\\', "/")
} else {
label
}
}
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,
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>,
},
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,
pub skipped_inputs: usize,
pub risk_tier: RiskTier,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, ValueEnum)]
#[serde(rename_all = "snake_case")]
pub enum RiskTier {
#[default]
Minor,
Moderate,
Risky,
High,
}
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,
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"
);
assert_eq!(package_key("packages\\ui", &workspaces), "packages/ui");
}
#[test]
fn package_key_handles_backslash_workspace_roots() {
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() {
let workspaces = vec![workspace("ui", "packages/ui")];
assert_eq!(
package_key("packages\\ui-kit\\src\\index.ts", &workspaces),
"packages"
);
}
}