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