1use std::collections::{HashMap, HashSet, VecDeque};
9use std::path::Path;
10use std::time::Duration;
11
12use lean_ctx::core::graph_analysis::dependency_edges;
13use lean_ctx::core::graph_index;
14use lean_ctx::core::graph_provider::{self, EdgeInfo};
15use lean_ctx::core::protocol::shorten_path;
16use lean_ctx::core::tokens::count_tokens;
17
18struct BlastIndex {
20 rev: HashMap<String, Vec<String>>,
21}
22
23impl BlastIndex {
24 fn build(edges: &[EdgeInfo]) -> Self {
25 let mut rev: HashMap<String, Vec<String>> = HashMap::new();
26 for (from, to) in dependency_edges(edges) {
27 rev.entry(to.to_string())
28 .or_default()
29 .push(from.to_string());
30 }
31 Self { rev }
32 }
33
34 fn transitive_dependents(&self, start: &str, cap: usize) -> usize {
37 let mut visited: HashSet<&str> = HashSet::new();
38 let mut queue: VecDeque<&str> = VecDeque::new();
39 visited.insert(start);
40 queue.push_back(start);
41 while let Some(cur) = queue.pop_front() {
42 if visited.len() > cap {
43 break;
44 }
45 if let Some(deps) = self.rev.get(cur) {
46 for d in deps {
47 if visited.insert(d.as_str()) {
48 queue.push_back(d.as_str());
49 }
50 }
51 }
52 }
53 visited.len().saturating_sub(1)
54 }
55
56 fn direct_dependents(&self, file: &str) -> usize {
57 self.rev.get(file).map_or(0, Vec::len)
58 }
59}
60
61struct DiffEntry {
63 status: char,
64 path: String,
65 in_graph: bool,
66 direct: usize,
67 blast: usize,
68 is_god: bool,
69 is_bridge: bool,
70}
71
72fn parse_name_status(raw: &str) -> Vec<(char, String)> {
75 let mut out = Vec::new();
76 for line in raw.lines() {
77 let mut cols = line.split('\t');
78 let Some(status) = cols.next() else { continue };
79 let code = status.chars().next().unwrap_or('?');
80 let path = if matches!(code, 'R' | 'C') {
81 cols.nth(1)
83 } else {
84 cols.next()
85 };
86 if let Some(p) = path.map(str::trim).filter(|p| !p.is_empty()) {
87 out.push((code, p.to_string()));
88 }
89 }
90 out
91}
92
93pub fn diff(since: Option<&str>, root: &str, format: Option<&str>) -> String {
94 let base = since
95 .map(str::trim)
96 .filter(|s| !s.is_empty())
97 .unwrap_or("HEAD~1");
98
99 if !lean_ctx::core::git::git_available() {
100 return "git is not available — cannot compute a graph diff.".to_string();
101 }
102 let root_path = Path::new(root);
103
104 let name_status = match lean_ctx::core::git::run_git(
105 &["diff", "--name-status", &format!("{base}..HEAD")],
106 root_path,
107 Duration::from_secs(30),
108 &[],
109 ) {
110 Ok(o) if o.success => o.stdout,
111 Ok(o) => {
112 let msg = o.stderr.trim();
113 return format!(
114 "git diff failed: {}\nIs '{base}' a valid commit/ref reachable from HEAD?",
115 if msg.is_empty() { "unknown error" } else { msg }
116 );
117 }
118 Err(e) => return format!("git diff failed: {e}"),
119 };
120
121 let changes = parse_name_status(&name_status);
122 if changes.is_empty() {
123 return format!("No file changes between {base} and HEAD.");
124 }
125
126 let Some(open) = graph_provider::open_or_build(root) else {
127 return "No graph index found. Run ctx_graph with action='build' first.".to_string();
128 };
129 let gp = &open.provider;
130 let edges = gp.edges();
131 let blast = BlastIndex::build(&edges);
132 let node_set: HashSet<String> = gp.file_paths().into_iter().collect();
133 let god: HashSet<String> = lean_ctx::core::graph_analysis::compute_god_nodes(&edges, 25)
134 .into_iter()
135 .map(|g| g.path)
136 .collect();
137 let bridge: HashSet<String> = lean_ctx::core::graph_analysis::compute_bridge_nodes(&edges, 25)
138 .into_iter()
139 .map(|b| b.path)
140 .collect();
141
142 let mut entries: Vec<DiffEntry> = changes
143 .into_iter()
144 .map(|(status, path)| {
145 let key = graph_index::graph_match_key(&path);
146 let in_graph = node_set.contains(&key);
147 let (direct, blast_n) = if in_graph {
148 (
149 blast.direct_dependents(&key),
150 blast.transitive_dependents(&key, 5000),
151 )
152 } else {
153 (0, 0)
154 };
155 DiffEntry {
156 status,
157 in_graph,
158 direct,
159 blast: blast_n,
160 is_god: god.contains(&key),
161 is_bridge: bridge.contains(&key),
162 path: key,
163 }
164 })
165 .collect();
166
167 entries.sort_by(|a, b| {
168 b.blast
169 .cmp(&a.blast)
170 .then_with(|| b.direct.cmp(&a.direct))
171 .then_with(|| a.path.cmp(&b.path))
172 });
173
174 if matches!(format, Some(f) if f.eq_ignore_ascii_case("json")) {
175 return render_json(base, &entries);
176 }
177 render_text(base, &entries)
178}
179
180fn counts(entries: &[DiffEntry]) -> (usize, usize, usize, usize, usize) {
181 let mut added = 0;
182 let mut modified = 0;
183 let mut deleted = 0;
184 let mut renamed = 0;
185 for e in entries {
186 match e.status {
187 'A' => added += 1,
188 'M' => modified += 1,
189 'D' => deleted += 1,
190 'R' | 'C' => renamed += 1,
191 _ => {}
192 }
193 }
194 let in_graph = entries.iter().filter(|e| e.in_graph).count();
195 (added, modified, deleted, renamed, in_graph)
196}
197
198fn render_text(base: &str, entries: &[DiffEntry]) -> String {
199 let (added, modified, deleted, renamed, in_graph) = counts(entries);
200 let mut out = format!("Graph diff: {base}..HEAD\n");
201 out.push_str(&format!(
202 "{} files changed (A:{added} M:{modified} D:{deleted} R:{renamed}) · {in_graph} in graph\n",
203 entries.len()
204 ));
205
206 let high: Vec<&DiffEntry> = entries
207 .iter()
208 .filter(|e| e.in_graph && (e.blast > 0 || e.is_god || e.is_bridge))
209 .collect();
210 if !high.is_empty() {
211 out.push_str("\nHigh-impact changes (by blast radius):\n");
212 for e in &high {
213 out.push_str(&format!(
214 " [{}] {:<46} blast {:<5} direct {}{}\n",
215 e.status,
216 shorten_path(&e.path),
217 e.blast,
218 e.direct,
219 flags(e)
220 ));
221 }
222 }
223
224 let low: Vec<&DiffEntry> = entries
225 .iter()
226 .filter(|e| e.in_graph && e.blast == 0 && !e.is_god && !e.is_bridge)
227 .collect();
228 if !low.is_empty() {
229 out.push_str("\nOther changed files in graph (no known dependents):\n");
230 for e in low.iter().take(40) {
231 out.push_str(&format!(" [{}] {}\n", e.status, shorten_path(&e.path)));
232 }
233 if low.len() > 40 {
234 out.push_str(&format!(" … and {} more\n", low.len() - 40));
235 }
236 }
237
238 let off: Vec<&DiffEntry> = entries.iter().filter(|e| !e.in_graph).collect();
239 if !off.is_empty() {
240 out.push_str(&format!(
241 "\nChanged but not in graph ({}, e.g. docs/config/assets):\n",
242 off.len()
243 ));
244 for e in off.iter().take(20) {
245 out.push_str(&format!(" [{}] {}\n", e.status, shorten_path(&e.path)));
246 }
247 if off.len() > 20 {
248 out.push_str(&format!(" … and {} more\n", off.len() - 20));
249 }
250 }
251
252 let tokens = count_tokens(&out);
253 format!("{out}[ctx_graph diff: {tokens} tok]")
254}
255
256fn flags(e: &DiffEntry) -> String {
257 let mut tags = Vec::new();
258 if e.is_god {
259 tags.push("god-node");
260 }
261 if e.is_bridge {
262 tags.push("bridge");
263 }
264 if tags.is_empty() {
265 String::new()
266 } else {
267 format!(" ({})", tags.join(", "))
268 }
269}
270
271fn render_json(base: &str, entries: &[DiffEntry]) -> String {
272 let (added, modified, deleted, renamed, in_graph) = counts(entries);
273 let items: Vec<_> = entries
274 .iter()
275 .map(|e| {
276 serde_json::json!({
277 "status": e.status.to_string(),
278 "path": e.path,
279 "in_graph": e.in_graph,
280 "direct_dependents": e.direct,
281 "blast_radius": e.blast,
282 "is_god_node": e.is_god,
283 "is_bridge": e.is_bridge,
284 })
285 })
286 .collect();
287 let val = serde_json::json!({
288 "base": base,
289 "summary": {
290 "total": entries.len(),
291 "added": added,
292 "modified": modified,
293 "deleted": deleted,
294 "renamed": renamed,
295 "in_graph": in_graph,
296 },
297 "changes": items,
298 });
299 serde_json::to_string_pretty(&val).unwrap_or_else(|_| "{}".to_string())
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 #[test]
307 fn parses_simple_statuses() {
308 let raw = "M\tsrc/a.rs\nA\tsrc/b.rs\nD\tsrc/c.rs\n";
309 let parsed = parse_name_status(raw);
310 assert_eq!(
311 parsed,
312 vec![
313 ('M', "src/a.rs".to_string()),
314 ('A', "src/b.rs".to_string()),
315 ('D', "src/c.rs".to_string()),
316 ]
317 );
318 }
319
320 #[test]
321 fn rename_uses_destination_path() {
322 let raw = "R096\tsrc/old.rs\tsrc/new.rs\n";
323 let parsed = parse_name_status(raw);
324 assert_eq!(parsed, vec![('R', "src/new.rs".to_string())]);
325 }
326
327 #[test]
328 fn ignores_blank_lines() {
329 {
330 let found = &parse_name_status("\n\n");
331 assert!(found.is_empty(), "{found:?}");
332 }
333 }
334
335 #[test]
336 fn blast_radius_is_transitive() {
337 let edges = vec![
339 EdgeInfo {
340 from: "a.rs".into(),
341 to: "b.rs".into(),
342 kind: "import".into(),
343 weight: 1.0,
344 },
345 EdgeInfo {
346 from: "b.rs".into(),
347 to: "c.rs".into(),
348 kind: "import".into(),
349 weight: 1.0,
350 },
351 ];
352 let idx = BlastIndex::build(&edges);
353 assert_eq!(idx.transitive_dependents("c.rs", 100), 2);
354 assert_eq!(idx.direct_dependents("c.rs"), 1);
355 assert_eq!(idx.transitive_dependents("a.rs", 100), 0);
356 }
357
358 #[test]
359 fn blast_radius_ignores_heuristic_edges() {
360 let edges = vec![EdgeInfo {
362 from: "a.rs".into(),
363 to: "b.rs".into(),
364 kind: "sibling".into(),
365 weight: 1.0,
366 }];
367 let idx = BlastIndex::build(&edges);
368 assert_eq!(idx.transitive_dependents("b.rs", 100), 0);
369 }
370}