use crate::call_tree::{self, CallTreeNode};
use crate::cli_args::CallGraphDiffCommand;
use crate::program::{self, PROGRAM_CODE_DATA};
use crate::program_diff::DiffStatus;
use crate::snapshot::{CodeEntry, Snapshot, load_snapshot_data};
use crate::util::string::strip_shebang;
use cirru_edn::Edn;
use colored::Colorize;
use std::collections::{HashMap, HashSet};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone)]
pub struct CallGraphDiffNode {
pub fqn: String,
pub status: DiffStatus,
pub code_changed: bool,
pub detail: Option<String>,
pub children: Vec<CallGraphDiffNode>,
}
#[derive(Debug, Clone, Default)]
pub struct CallGraphDiffStats {
pub unchanged: usize,
pub added: usize,
pub removed: usize,
pub modified: usize,
pub code_changed: usize,
}
#[derive(Debug, Clone)]
pub struct CallGraphDiffResult {
pub git_ref: String,
pub file_path: String,
pub entry: String,
pub root: CallGraphDiffNode,
pub stats: CallGraphDiffStats,
}
pub fn analyze_call_graph_diff(cmd: &CallGraphDiffCommand, input_path: &str) -> Result<CallGraphDiffResult, String> {
let cwd = env::current_dir().map_err(|e| format!("Failed to read current directory: {e}"))?;
let input_abs = resolve_input_path(&cwd, input_path)?;
let repo_search_dir = input_abs.parent().unwrap_or(cwd.as_path());
let repo_root = git_root(repo_search_dir)?;
let repo_rel_path = repo_relative_path(&input_abs, &repo_root)?;
let snapshot_path = repo_rel_path.to_string_lossy().to_string();
let base_dir = input_abs.parent().unwrap_or(cwd.as_path());
let module_folder = dirs::home_dir()
.map(|buf| buf.as_path().join(".config/calcit/modules/"))
.unwrap_or_else(|| Path::new(".").to_owned());
let core_snapshot = crate::load_core_snapshot()?;
let current_content = fs::read_to_string(&input_abs).map_err(|e| format!("Failed to read {}: {e}", input_abs.display()))?;
let mut current_snapshot = parse_snapshot(¤t_content, input_path, &snapshot_path)?;
attach_modules_and_core(&mut current_snapshot, base_dir, &module_folder, &core_snapshot)?;
let historical_content = git_show_file(&repo_root, &cmd.git_ref, &repo_rel_path)?;
let historical_label = format!("{}:{}", cmd.git_ref, repo_rel_path.display());
let mut historical_snapshot = parse_snapshot(&historical_content, &historical_label, &snapshot_path)?;
attach_modules_and_core(&mut historical_snapshot, base_dir, &module_folder, &core_snapshot)?;
let (entry_ns, entry_def) = resolve_entry_point(¤t_snapshot, cmd.root.as_deref())?;
let entry = format!("{entry_ns}/{entry_def}");
let old_graph = analyze_snapshot_call_graph(&historical_snapshot, &entry_ns, &entry_def, cmd)?;
let new_graph = analyze_snapshot_call_graph(¤t_snapshot, &entry_ns, &entry_def, cmd)?;
let changed_defs = collect_changed_definitions(&historical_snapshot, ¤t_snapshot);
let root = diff_call_graph_node(&old_graph.tree, &new_graph.tree, &changed_defs);
let stats = collect_stats(&root);
Ok(CallGraphDiffResult {
git_ref: cmd.git_ref.to_string(),
file_path: repo_rel_path.to_string_lossy().to_string(),
entry,
root,
stats,
})
}
pub fn format_call_graph_diff(result: &CallGraphDiffResult) -> String {
let mut output = String::new();
output.push_str("# Call Graph Diff\n\n");
output.push_str(&format!("- ref: {}\n", result.git_ref));
output.push_str(&format!("- file: {}\n", result.file_path));
output.push_str(&format!("- entry: {}\n", result.entry));
output.push_str(&format!(
"- graph changes: ~{} +{} -{} ={}\n",
result.stats.modified, result.stats.added, result.stats.removed, result.stats.unchanged
));
output.push_str(&format!("- code-changed reachable defs: {}\n\n", result.stats.code_changed));
output.push_str("## Graph Diff\n\n");
format_tree_node(&result.root, &mut output, "", true, true, true);
output
}
fn analyze_snapshot_call_graph(
snapshot: &Snapshot,
entry_ns: &str,
entry_def: &str,
cmd: &CallGraphDiffCommand,
) -> Result<call_tree::CallTreeResult, String> {
let program_data = program::extract_program_data(snapshot)?;
{
let mut program = PROGRAM_CODE_DATA.write().map_err(|e| format!("Failed to open program data: {e}"))?;
*program = program_data;
}
call_tree::analyze_call_graph(
entry_ns,
entry_def,
cmd.include_core,
cmd.max_depth,
false,
Some(snapshot.package.to_string()),
cmd.ns_prefix.clone(),
)
}
fn attach_modules_and_core(
snapshot: &mut Snapshot,
base_dir: &Path,
module_folder: &Path,
core_snapshot: &Snapshot,
) -> Result<(), String> {
let module_paths = snapshot.configs.modules.to_vec();
for module_path in &module_paths {
let module_data = crate::load_module(module_path, base_dir, module_folder)?;
for (ns, file) in &module_data.files {
if snapshot.files.contains_key(ns) {
return Err(format!("namespace `{ns}` already exists when loading module `{module_path}`"));
}
snapshot.files.insert(ns.to_owned(), file.to_owned());
}
}
for (ns, file) in &core_snapshot.files {
snapshot.files.insert(ns.to_owned(), file.to_owned());
}
Ok(())
}
fn resolve_entry_point(snapshot: &Snapshot, root: Option<&str>) -> Result<(String, String), String> {
let entry = root.unwrap_or(snapshot.configs.init_fn.as_str());
let (ns, def) = entry
.split_once('/')
.ok_or_else(|| format!("Expected entry definition in format ns/def, got: {entry}"))?;
Ok((ns.to_string(), def.to_string()))
}
fn collect_changed_definitions(old: &Snapshot, new: &Snapshot) -> HashSet<String> {
let mut changed = HashSet::new();
for (ns, old_file) in &old.files {
if let Some(new_file) = new.files.get(ns) {
for (def, old_entry) in &old_file.defs {
if let Some(new_entry) = new_file.defs.get(def)
&& code_entry_changed(old_entry, new_entry)
{
changed.insert(format!("{ns}/{def}"));
}
}
}
}
changed
}
fn code_entry_changed(old: &CodeEntry, new: &CodeEntry) -> bool {
old.code != new.code
}
fn diff_call_graph_node(old: &CallTreeNode, new: &CallTreeNode, changed_defs: &HashSet<String>) -> CallGraphDiffNode {
let children = diff_children(&old.calls, &new.calls, changed_defs);
let meta_changed = old.circular != new.circular || old.seen != new.seen || old.source != new.source;
let status = if meta_changed || children.iter().any(|child| child.status != DiffStatus::Unchanged) {
DiffStatus::Modified
} else {
DiffStatus::Unchanged
};
CallGraphDiffNode {
fqn: new.fqn.to_string(),
status,
code_changed: changed_defs.contains(&new.fqn),
detail: build_detail(Some(old), Some(new)),
children,
}
}
fn diff_children(
old_children: &[CallTreeNode],
new_children: &[CallTreeNode],
changed_defs: &HashSet<String>,
) -> Vec<CallGraphDiffNode> {
let new_lookup = new_children
.iter()
.enumerate()
.map(|(idx, node)| (node.fqn.as_str(), idx))
.collect::<HashMap<_, _>>();
let old_lookup = old_children
.iter()
.enumerate()
.map(|(idx, node)| (node.fqn.as_str(), idx))
.collect::<HashMap<_, _>>();
let mut children = Vec::new();
let mut used_new = HashSet::new();
for old_child in old_children {
if let Some(&new_idx) = new_lookup.get(old_child.fqn.as_str()) {
children.push(diff_call_graph_node(old_child, &new_children[new_idx], changed_defs));
used_new.insert(new_idx);
} else {
children.push(build_call_graph_subtree(old_child, DiffStatus::Removed));
}
}
for (idx, new_child) in new_children.iter().enumerate() {
if !used_new.contains(&idx) && !old_lookup.contains_key(new_child.fqn.as_str()) {
children.push(build_call_graph_subtree(new_child, DiffStatus::Added));
}
}
children
}
fn build_call_graph_subtree(node: &CallTreeNode, status: DiffStatus) -> CallGraphDiffNode {
let children = node.calls.iter().map(|child| build_call_graph_subtree(child, status)).collect();
CallGraphDiffNode {
fqn: node.fqn.to_string(),
status,
code_changed: false,
detail: build_detail(None, Some(node)),
children,
}
}
fn build_detail(old: Option<&CallTreeNode>, new: Option<&CallTreeNode>) -> Option<String> {
match (old, new) {
(Some(old), Some(new)) => {
let old_tags = node_tags(old);
let new_tags = node_tags(new);
if old_tags == new_tags {
render_tags(&new_tags)
} else {
Some(format!("{} -> {}", render_tags_or_dash(&old_tags), render_tags_or_dash(&new_tags)))
}
}
(_, Some(node)) => render_tags(&node_tags(node)),
(Some(node), None) => render_tags(&node_tags(node)),
(None, None) => None,
}
}
fn node_tags(node: &CallTreeNode) -> Vec<&'static str> {
let mut tags = Vec::new();
if node.circular {
tags.push("CIRCULAR");
}
if node.seen {
tags.push("seen");
}
match node.source.as_str() {
"core" => tags.push("core"),
"external" => tags.push("ext"),
_ => {}
}
tags
}
fn render_tags(tags: &[&'static str]) -> Option<String> {
if tags.is_empty() {
None
} else {
Some(format!("[{}]", tags.join(", ")))
}
}
fn render_tags_or_dash(tags: &[&'static str]) -> String {
render_tags(tags).unwrap_or_else(|| "-".to_string())
}
fn collect_stats(root: &CallGraphDiffNode) -> CallGraphDiffStats {
fn walk(node: &CallGraphDiffNode, stats: &mut CallGraphDiffStats) {
match node.status {
DiffStatus::Unchanged => stats.unchanged += 1,
DiffStatus::Added => stats.added += 1,
DiffStatus::Removed => stats.removed += 1,
DiffStatus::Modified => stats.modified += 1,
}
if node.code_changed {
stats.code_changed += 1;
}
for child in &node.children {
walk(child, stats);
}
}
let mut stats = CallGraphDiffStats::default();
walk(root, &mut stats);
stats
}
fn format_tree_node(node: &CallGraphDiffNode, output: &mut String, prefix: &str, is_last: bool, expand: bool, is_root: bool) {
let connector = if is_root {
""
} else if is_last {
"└── "
} else {
"├── "
};
let mut suffix = String::new();
if let Some(detail) = &node.detail {
suffix.push(' ');
suffix.push_str(detail);
}
if !expand && !node.children.is_empty() {
suffix.push_str(&format!(" {}", format!("({} folded)", descendant_count(node)).dimmed()));
}
let mut line = format!("{} {}", status_badge(node.status), status_paint(node.status, node.fqn.to_string()));
if !suffix.is_empty() {
line.push_str(&status_paint(node.status, suffix));
}
if node.code_changed {
line.push(' ');
line.push_str(&modified_marker());
}
output.push_str(&format!("{prefix}{connector}{line}\n"));
if !expand {
return;
}
let child_prefix = if is_root {
String::new()
} else {
format!("{}{} ", prefix, if is_last { " " } else { "│" })
};
for (idx, child) in node.children.iter().enumerate() {
let child_expand = child.status != DiffStatus::Unchanged || has_code_changes(child);
format_tree_node(child, output, &child_prefix, idx + 1 == node.children.len(), child_expand, false);
}
}
fn has_code_changes(node: &CallGraphDiffNode) -> bool {
node.code_changed || node.children.iter().any(has_code_changes)
}
fn descendant_count(node: &CallGraphDiffNode) -> usize {
node.children.iter().map(|child| 1 + descendant_count(child)).sum()
}
fn status_badge(status: DiffStatus) -> String {
match status {
DiffStatus::Unchanged => "=".dimmed().to_string(),
DiffStatus::Added => "[+]".black().on_green().bold().to_string(),
DiffStatus::Removed => "[-]".black().on_bright_blue().bold().to_string(),
DiffStatus::Modified => "~".dimmed().to_string(),
}
}
fn status_paint(status: DiffStatus, text: String) -> String {
match status {
DiffStatus::Unchanged => text.dimmed().to_string(),
DiffStatus::Added => text.green().to_string(),
DiffStatus::Removed => text.cyan().to_string(),
DiffStatus::Modified => text.dimmed().to_string(),
}
}
fn modified_marker() -> String {
"[MODIFIED]".black().on_yellow().bold().to_string()
}
fn resolve_input_path(cwd: &Path, input_path: &str) -> Result<PathBuf, String> {
let input = Path::new(input_path);
let full_path = if input.is_absolute() {
input.to_path_buf()
} else {
cwd.join(input)
};
let resolved = crate::resolve_snapshot_path_alias(&full_path);
resolved
.canonicalize()
.map_err(|e| format!("Failed to resolve input path '{}': {e}", resolved.display()))
}
fn git_root(cwd: &Path) -> Result<PathBuf, String> {
let output = Command::new("git")
.current_dir(cwd)
.args(["rev-parse", "--show-toplevel"])
.output()
.map_err(|e| format!("Failed to run git rev-parse: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to locate git repository root: {}", stderr.trim()));
}
let stdout = String::from_utf8(output.stdout).map_err(|e| format!("Failed to decode git output: {e}"))?;
Ok(PathBuf::from(stdout.trim()))
}
fn repo_relative_path(input_abs: &Path, repo_root: &Path) -> Result<PathBuf, String> {
input_abs.strip_prefix(repo_root).map(|path| path.to_path_buf()).map_err(|_| {
format!(
"Input file '{}' is not inside git repository '{}'",
input_abs.display(),
repo_root.display()
)
})
}
fn git_show_file(repo_root: &Path, git_ref: &str, repo_rel_path: &Path) -> Result<String, String> {
let git_path = repo_rel_path.to_string_lossy().replace('\\', "/");
let object = format!("{git_ref}:{git_path}");
let output = Command::new("git")
.current_dir(repo_root)
.args(["show", &object])
.output()
.map_err(|e| format!("Failed to run git show for '{object}': {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to load '{git_path}' from ref '{git_ref}': {}", stderr.trim()));
}
String::from_utf8(output.stdout).map_err(|e| format!("Failed to decode git show output: {e}"))
}
fn parse_snapshot(content: &str, error_label: &str, snapshot_path: &str) -> Result<Snapshot, String> {
let mut content = content.to_string();
strip_shebang(&mut content);
let parsed: Edn = cirru_edn::parse(&content).map_err(|e| format!("Failed to parse '{error_label}' as Cirru EDN: {e}"))?;
load_snapshot_data(&parsed, snapshot_path).map_err(|e| format!("Failed to load snapshot '{error_label}': {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn node(fqn: &str, calls: Vec<CallGraphDiffNode>) -> CallGraphDiffNode {
CallGraphDiffNode {
fqn: fqn.to_string(),
status: DiffStatus::Unchanged,
code_changed: false,
detail: None,
children: calls,
}
}
fn tree(fqn: &str, calls: Vec<CallTreeNode>) -> CallTreeNode {
let (ns, def) = fqn.split_once('/').unwrap();
CallTreeNode {
ns: ns.to_string(),
def: def.to_string(),
fqn: fqn.to_string(),
doc: None,
calls,
circular: false,
seen: false,
source: "project".to_string(),
}
}
#[test]
fn marks_code_changed_without_graph_change() {
colored::control::set_override(false);
let old = tree("app.main/main", vec![tree("app.util/helper", vec![])]);
let new = tree("app.main/main", vec![tree("app.util/helper", vec![])]);
let changed = HashSet::from(["app.util/helper".to_string()]);
let diff = diff_call_graph_node(&old, &new, &changed);
assert_eq!(diff.status, DiffStatus::Unchanged);
assert!(diff.children[0].code_changed);
}
#[test]
fn marks_added_and_removed_children() {
let old = tree("app.main/main", vec![tree("app.old/gone", vec![])]);
let new = tree("app.main/main", vec![tree("app.new/here", vec![])]);
let diff = diff_call_graph_node(&old, &new, &HashSet::new());
assert_eq!(diff.status, DiffStatus::Modified);
assert_eq!(diff.children[0].status, DiffStatus::Removed);
assert_eq!(diff.children[1].status, DiffStatus::Added);
}
#[test]
fn formats_code_change_badge() {
colored::control::set_override(false);
let mut root = node("app.main/main", vec![node("app.util/helper", vec![])]);
root.children[0].code_changed = true;
let mut output = String::new();
format_tree_node(&root, &mut output, "", true, true, true);
assert!(output.contains("[MODIFIED]"));
}
}