1use std::collections::{HashMap, HashSet};
26use std::path::Path;
27use std::process::Command;
28
29use anyhow::{Context, Result};
30use clap::Args;
31use serde_json::{Value, json};
32
33use agentstatedeveloper_core::{
34 AsgIndexStore, AsgLedgerStore, Engine, LedgerKind, LedgerStore, Symbol, kind_str,
35 schema::{Author, AuthorKind, LedgerEntry},
36};
37
38#[derive(Default)]
44struct FileChange {
45 renamed: bool,
47 hunks: Vec<(u32, u32)>,
50}
51
52impl FileChange {
53 fn overlaps(&self, start: u32, end: u32) -> bool {
55 self.hunks.iter().any(|(a, b)| start <= *b && end >= *a)
56 }
57}
58
59fn commit_file_changes(commit: &str) -> std::collections::HashMap<String, FileChange> {
63 use std::collections::HashMap;
64 let mut map: HashMap<String, FileChange> = HashMap::new();
65 let out = Command::new("git")
66 .args([
67 "diff-tree",
68 "--no-commit-id",
69 "-r",
70 "-M",
71 "-p",
72 "--unified=0",
73 commit,
74 ])
75 .output();
76 let text = match out {
77 Ok(o) => String::from_utf8_lossy(&o.stdout).into_owned(),
78 Err(_) => return map,
79 };
80 let mut cur: Option<String> = None;
81 let mut renamed = false;
82 for line in text.lines() {
83 if line.starts_with("diff --git ") {
84 cur = None;
86 renamed = false;
87 } else if let Some(p) = line.strip_prefix("rename to ") {
88 renamed = true;
89 let path = p.trim().to_string();
90 map.entry(path.clone()).or_default().renamed = true;
91 cur = Some(path);
92 } else if let Some(p) = line.strip_prefix("+++ b/") {
93 if p != "/dev/null" {
94 let path = p.trim().to_string();
95 let e = map.entry(path.clone()).or_default();
96 e.renamed = e.renamed || renamed;
97 cur = Some(path);
98 }
99 } else if line.starts_with("@@ ") {
100 if let Some(rest) = line.split(" +").nth(1)
102 && let Some(spec) = rest.split(' ').next()
103 {
104 let mut it = spec.split(',');
105 let c: u32 = it.next().and_then(|s| s.parse().ok()).unwrap_or(0);
106 let d: u32 = it.next().and_then(|s| s.parse().ok()).unwrap_or(1);
107 let start = c.max(1);
108 let end = if d == 0 { start } else { start + d - 1 };
109 if let Some(ref path) = cur {
110 map.entry(path.clone())
111 .or_default()
112 .hunks
113 .push((start, end));
114 }
115 }
116 }
117 }
118 map
119}
120
121fn is_doc_file(path: &str) -> bool {
122 let lower = path.to_lowercase();
123 let p = Path::new(&lower);
124 let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
125 let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
126 matches!(
127 ext,
128 "md" | "txt" | "rst" | "adoc" | "asciidoc" | "tex" | "org"
129 ) || name.starts_with("readme")
130 || name.starts_with("changelog")
131 || name.starts_with("license")
132 || name.starts_with("authors")
133 || name.starts_with("contributing")
134 || lower.contains("/docs/")
135 || lower.starts_with("docs/")
136 || lower.contains("/doc/")
137 || lower.starts_with("doc/")
138}
139
140use crate::config::Config;
141
142#[derive(Debug, Args)]
143pub struct AnnotateCommitArgs {
144 pub sha: Option<String>,
146
147 #[arg(long, default_value_t = false)]
150 pub write: bool,
151
152 #[arg(long)]
154 pub author: Option<String>,
155
156 #[arg(long)]
159 pub task_description: Option<String>,
160
161 #[arg(long)]
164 pub ctx_task: Option<String>,
165
166 #[arg(long)]
169 pub ctx_plan: Option<String>,
170
171 #[arg(long)]
173 pub quiet: bool,
174
175 #[arg(long)]
179 pub debug_clusters: bool,
180}
181
182pub fn run(cfg: &Config, args: AnnotateCommitArgs) -> Result<()> {
183 let sha = args.sha.clone().unwrap_or_else(|| "HEAD".to_string());
184
185 let log_out = Command::new("git")
187 .args(["log", "-1", "--format=%H%n%s%n%b", &sha])
188 .output()
189 .context("git log failed")?;
190 let log_str = String::from_utf8_lossy(&log_out.stdout);
191 let mut log_lines = log_str.lines();
192 let commit_hash = log_lines.next().unwrap_or("").trim().to_string();
193 let subject = log_lines.next().unwrap_or("").trim().to_string();
194 let body: String = log_lines.collect::<Vec<_>>().join("\n");
195
196 if commit_hash.is_empty() {
197 anyhow::bail!("could not resolve commit: {}", sha);
198 }
199
200 let full_body = if let Some(ref td) = args.task_description {
202 format!("{body}\n{td}")
203 } else {
204 body
205 };
206
207 let diff_out = Command::new("git")
209 .args([
210 "diff-tree",
211 "--no-commit-id",
212 "-r",
213 "--name-only",
214 &commit_hash,
215 ])
216 .output()
217 .context("git diff-tree failed")?;
218 let changed_files: Vec<String> = String::from_utf8_lossy(&diff_out.stdout)
219 .lines()
220 .map(|l| l.trim().to_string())
221 .filter(|l| !l.is_empty())
222 .collect();
223
224 if changed_files.is_empty() {
225 println!(
226 "{}",
227 json!({ "commit": commit_hash, "subject": subject, "note": "no changed files detected", "suggested_entries": [] })
228 );
229 return Ok(());
230 }
231
232 let engine = Engine::open_sqlite(&cfg.db_path)?;
234 let index_store = AsgIndexStore::from_engine(&engine);
235 let ledger_store = AsgLedgerStore::from_engine(&engine);
236
237 let all_syms: Vec<Symbol> = index_store.build_id_map(&engine).into_values().collect();
239
240 let mut touched_symbols: Vec<Symbol> = Vec::new();
241 let mut seen_ids: HashSet<String> = HashSet::new();
242 for sym in &all_syms {
244 if changed_files
245 .iter()
246 .any(|f| sym.file.ends_with(f.as_str()) || sym.file == *f)
247 {
248 if seen_ids.insert(sym.symbol_id.clone()) {
249 touched_symbols.push(sym.clone());
250 }
251 }
252 }
253
254 touched_symbols.truncate(20);
256
257 let mut cluster_meta: HashMap<String, (String, f64)> = HashMap::new();
261 let mut cluster_debug: Vec<Value> = Vec::new();
263
264 let all_docs = !changed_files.is_empty() && changed_files.iter().all(|f| is_doc_file(f));
267 if all_docs && touched_symbols.is_empty() {
268 let extract_terms = |path: &str| -> Vec<String> {
269 let stem = Path::new(path)
270 .file_stem()
271 .and_then(|s| s.to_str())
272 .unwrap_or("")
273 .to_lowercase();
274 stem.replace(['-', '_', '.'], " ")
275 .split_whitespace()
276 .filter(|w| {
277 w.len() >= 3
278 && !matches!(
279 *w,
280 "the"
281 | "and"
282 | "for"
283 | "doc"
284 | "docs"
285 | "readme"
286 | "design"
287 | "notes"
288 | "plan"
289 | "spec"
290 | "guide"
291 | "api"
292 | "todo"
293 | "fixme"
294 | "draft"
295 | "wip"
296 | "change"
297 | "changes"
298 )
299 })
300 .map(|w| w.to_string())
301 .collect()
302 };
303
304 let candidate_syms: Vec<&Symbol> =
307 all_syms.iter().filter(|s| !is_doc_file(&s.file)).collect();
308
309 let mut any_term_match = false;
315 for doc_file in &changed_files {
316 let file_terms = extract_terms(doc_file);
317 if file_terms.is_empty() {
318 continue;
319 }
320 let mut ranked: Vec<(isize, usize, &Symbol)> = candidate_syms
322 .iter()
323 .copied()
324 .map(|s| {
325 let haystack = format!("{} {}", s.qname.to_lowercase(), s.file.to_lowercase());
326 let term_score = file_terms
327 .iter()
328 .filter(|t| haystack.contains(t.as_str()))
329 .count();
330 let fl = s.file.to_lowercase();
331 let is_test_file = fl.contains("/test")
332 || fl.contains("/tests/")
333 || fl.contains("/spec")
334 || fl.ends_with("tests.swift");
335 let kind_bonus: isize = if is_test_file {
336 -2 } else {
338 match kind_str(&s.kind) {
339 "module" | "class" | "struct" | "enum" | "trait" | "type"
340 | "interface" | "protocol" | "namespace" => 2,
341 _ => 0,
342 }
343 };
344 let dir_seg_bonus: isize = {
349 let p = Path::new(&s.file);
350 let dirs: Vec<String> = p
351 .parent()
352 .map(|parent| {
353 parent
354 .components()
355 .filter_map(|c| c.as_os_str().to_str())
356 .map(|seg| seg.to_lowercase())
357 .collect()
358 })
359 .unwrap_or_default();
360 file_terms
361 .iter()
362 .filter(|t| dirs.iter().any(|d| d.contains(t.as_str())))
363 .count() as isize
364 };
365 (
366 (term_score as isize) * 2 + kind_bonus + dir_seg_bonus,
367 term_score,
368 s,
369 )
370 })
371 .filter(|(_, ts, _)| *ts >= 1)
372 .collect();
373 ranked.sort_by(|a, b| b.0.cmp(&a.0));
374 let best = ranked.first().copied();
375 if args.debug_clusters {
376 let top5: Vec<Value> = ranked
377 .iter()
378 .take(5)
379 .map(|(combined, ts, s)| {
380 let k = kind_str(&s.kind);
381 let fl = s.file.to_lowercase();
382 let is_test = fl.contains("/test")
383 || fl.contains("/tests/")
384 || fl.contains("/spec")
385 || fl.ends_with("tests.swift");
386 let kb: isize = if is_test {
387 -2
388 } else {
389 match k {
390 "module" | "class" | "struct" | "enum" | "trait" | "type"
391 | "interface" | "protocol" | "namespace" => 2,
392 _ => 0,
393 }
394 };
395 let dsb: isize = {
396 let p = Path::new(&s.file);
397 let dirs: Vec<String> = p
398 .parent()
399 .map(|parent| {
400 parent
401 .components()
402 .filter_map(|c| c.as_os_str().to_str())
403 .map(|seg| seg.to_lowercase())
404 .collect()
405 })
406 .unwrap_or_default();
407 file_terms
408 .iter()
409 .filter(|t| dirs.iter().any(|d| d.contains(t.as_str())))
410 .count() as isize
411 };
412 json!({
413 "qname": s.qname,
414 "file": s.file,
415 "kind": k,
416 "kind_bonus": kb,
417 "dir_seg_bonus": dsb,
418 "term_score": ts,
419 "combined_score": combined,
420 })
421 })
422 .collect();
423 let winner_info: Value = if let Some((_, ts, sym)) = best {
424 let confidence = ts as f64 / file_terms.len().max(1) as f64;
425 json!({
426 "qname": sym.qname,
427 "term_score": ts,
428 "confidence": confidence,
429 "reason": format!(
430 "highest combined score (term_score×2 + kind_bonus + dir_seg_bonus); matched {}/{} terms",
431 ts, file_terms.len()
432 ),
433 })
434 } else {
435 json!({"reason": "no candidates matched any term"})
436 };
437 cluster_debug.push(json!({
438 "doc_file": doc_file,
439 "terms_extracted": file_terms,
440 "top_5_candidates": top5,
441 "winner_selected": winner_info,
442 }));
443 }
444 if let Some((_, term_score, sym)) = best {
445 any_term_match = true;
446 let confidence = term_score as f64 / file_terms.len().max(1) as f64;
447 let doc_name = Path::new(doc_file)
448 .file_stem()
449 .and_then(|s| s.to_str())
450 .unwrap_or(doc_file);
451 let concept = format!("documentation: {}", doc_name.replace(['-', '_'], " "));
452 cluster_meta.insert(sym.symbol_id.clone(), (concept, confidence));
453 if seen_ids.insert(sym.symbol_id.clone()) {
454 touched_symbols.push(sym.clone());
455 }
456 }
457 }
458
459 if !any_term_match {
461 let doc_dirs: Vec<&str> = changed_files
462 .iter()
463 .filter_map(|f| Path::new(f).parent()?.to_str())
464 .collect();
465 let mut dir_scored: Vec<(usize, &Symbol)> = candidate_syms
466 .iter()
467 .copied()
468 .map(|s| {
469 let score = doc_dirs
470 .iter()
471 .filter(|d| !d.is_empty() && **d != "." && s.file.contains(**d))
472 .count();
473 (score, s)
474 })
475 .collect();
476 dir_scored.sort_by(|a, b| b.0.cmp(&a.0));
477 for (_, sym) in dir_scored.into_iter().take(5) {
478 if seen_ids.insert(sym.symbol_id.clone()) {
479 touched_symbols.push((*sym).clone());
480 }
481 }
482 }
483
484 if !args.quiet {
485 eprintln!(
486 "asd: docs-only commit — {} domain cluster(s) identified across {} doc file(s)",
487 touched_symbols.len(),
488 changed_files.len()
489 );
490 }
491 }
492
493 #[derive(Debug)]
495 struct Annotation {
496 kind: LedgerKind,
497 summary: String,
498 }
499
500 let mut annotations: Vec<Annotation> = Vec::new();
501
502 if !subject.is_empty() {
505 let subject_kind = if all_docs {
506 LedgerKind::Concept
507 } else {
508 LedgerKind::Decision
509 };
510 annotations.push(Annotation {
511 kind: subject_kind,
512 summary: subject.clone(),
513 });
514 }
515 for line in full_body.lines() {
522 let line = line.trim();
523 if line.is_empty() {
524 continue;
525 }
526
527 let (kind, text) = if let Some(rest) = line.strip_prefix("invariant:") {
528 (LedgerKind::Invariant, rest.trim())
529 } else if let Some(rest) = line.strip_prefix("hazard:") {
530 (LedgerKind::Hazard, rest.trim())
531 } else if let Some(rest) = line.strip_prefix("proof:") {
532 (LedgerKind::Proof, rest.trim())
533 } else if let Some(rest) = line.strip_prefix("validation_scenario:") {
534 (LedgerKind::ValidationScenario, rest.trim())
535 } else if let Some(rest) = line.strip_prefix("known_bug:") {
536 (LedgerKind::KnownBug, rest.trim())
537 } else if let Some(rest) = line.strip_prefix("concept:") {
538 (LedgerKind::Concept, rest.trim())
539 } else if let Some(rest) = line.strip_prefix("decision:") {
540 (LedgerKind::Decision, rest.trim())
541 } else {
542 continue; };
544
545 if !text.is_empty() {
546 annotations.push(Annotation {
547 kind,
548 summary: text.to_string(),
549 });
550 }
551 }
552
553 let ctx_plan = args
555 .ctx_plan
556 .clone()
557 .or_else(|| std::env::var("CTXONE_PLAN").ok())
558 .unwrap_or_default();
559 let ctx_task = args
560 .ctx_task
561 .clone()
562 .or_else(|| std::env::var("CTXONE_TASK").ok())
563 .unwrap_or_default();
564 let mut ctx_tags: Vec<String> = Vec::new();
565 if !ctx_plan.is_empty() {
566 ctx_tags.push(format!("ctx:plan:{}", ctx_plan));
567 }
568 if !ctx_task.is_empty() {
569 ctx_tags.push(format!("ctx:task:{}", ctx_task));
570 }
571 ctx_tags.push(format!(
572 "commit:{}",
573 &commit_hash[..8.min(commit_hash.len())]
574 ));
575
576 let author_id = args.author.clone().unwrap_or_else(|| {
578 let out = Command::new("git")
579 .args(["config", "user.name"])
580 .output()
581 .ok();
582 out.and_then(|o| String::from_utf8(o.stdout).ok())
583 .unwrap_or_default()
584 .trim()
585 .to_string()
586 });
587 let author = Author {
588 kind: AuthorKind::Human,
589 id: author_id,
590 };
591
592 let mut suggested: Vec<Value> = Vec::new();
594 let mut written: Vec<Value> = Vec::new();
595
596 for sym in &touched_symbols {
597 let cluster_anns: Vec<Annotation> = if all_docs {
600 cluster_meta
601 .get(&sym.symbol_id)
602 .map(|(concept, _)| {
603 vec![Annotation {
604 kind: LedgerKind::Concept,
605 summary: concept.clone(),
606 }]
607 })
608 .unwrap_or_default()
609 } else {
610 vec![]
611 };
612 let has_cluster = !cluster_anns.is_empty();
616 let all_anns: Vec<&Annotation> = cluster_anns
617 .iter()
618 .chain(
619 annotations
620 .iter()
621 .filter(|a| !(has_cluster && a.kind == LedgerKind::Concept)),
622 )
623 .collect();
624 let cluster_confidence = cluster_meta.get(&sym.symbol_id).map(|(_, c)| *c);
625
626 for ann in all_anns {
627 let existing = ledger_store
629 .list_entries(&engine.ref_name, &sym.symbol_id)
630 .unwrap_or_default();
631 let already_exists = existing.iter().any(|e| {
632 e.kind == ann.kind && e.summary.to_lowercase() == ann.summary.to_lowercase()
633 });
634 if already_exists {
635 continue;
636 }
637
638 let entry_val = json!({
639 "symbol": sym.qname,
640 "file": sym.file,
641 "kind": format!("{:?}", ann.kind).to_lowercase(),
642 "summary": ann.summary,
643 "cluster_confidence": cluster_confidence,
644 });
645
646 if args.write {
647 let mut entry = LedgerEntry::new(
648 sym.symbol_id.clone(),
649 ann.kind,
650 ann.summary.clone(),
651 author.clone(),
652 );
653 entry.tags.extend(ctx_tags.iter().cloned());
654 match ledger_store.append_entry(&engine.ref_name, &entry, &author.id) {
655 Ok(()) => written.push(entry_val),
656 Err(e) => eprintln!("warn: could not write entry for {}: {e}", sym.qname),
657 }
658 } else {
659 suggested.push(entry_val);
660 }
661 }
662 }
663
664 let file_changes = commit_file_changes(&commit_hash);
668 let symbol_directly_edited = |sym: &Symbol| -> bool {
669 file_changes
670 .iter()
671 .find(|(p, _)| sym.file.ends_with(p.as_str()) || &sym.file == *p)
672 .map(|(_, c)| c.overlaps(sym.start.line, sym.end.line))
673 .unwrap_or(false)
674 };
675 let directly_edited: Vec<String> = touched_symbols
676 .iter()
677 .filter(|s| symbol_directly_edited(s))
678 .map(|s| s.qname.clone())
679 .collect();
680 let nearby_touched: Vec<String> = touched_symbols
681 .iter()
682 .filter(|s| !symbol_directly_edited(s))
683 .map(|s| s.qname.clone())
684 .collect();
685
686 let out = if args.write {
687 json!({
688 "commit": commit_hash,
689 "subject": subject,
690 "changed_files": changed_files,
691 "touched_symbols": touched_symbols.len(),
692 "directly_edited": directly_edited,
693 "nearby_touched": nearby_touched,
694 "written_entries": written,
695 })
696 } else {
697 json!({
698 "commit": commit_hash,
699 "subject": subject,
700 "changed_files": changed_files,
701 "touched_symbols": touched_symbols.iter().map(|s| &s.qname).collect::<Vec<_>>(),
702 "directly_edited": directly_edited,
705 "nearby_touched": nearby_touched,
706 "suggested_entries": suggested,
707 "note": "dry-run — pass --write to record these entries",
708 "cluster_debug": if args.debug_clusters { json!(cluster_debug) } else { Value::Null },
709 })
710 };
711
712 println!("{}", serde_json::to_string_pretty(&out)?);
713 Ok(())
714}
715
716#[cfg(test)]
717mod tests {
718 use super::*;
719
720 #[test]
721 fn overlaps_detects_span_hunk_intersection() {
722 let fc = FileChange {
723 renamed: false,
724 hunks: vec![(10, 12), (40, 40)],
725 };
726 assert!(fc.overlaps(8, 15));
728 assert!(fc.overlaps(40, 45));
730 assert!(!fc.overlaps(20, 30));
732 assert!(!fc.overlaps(1, 9));
734 }
735
736 #[test]
737 fn pure_rename_has_no_hunks_so_never_directly_edited() {
738 let fc = FileChange {
739 renamed: true,
740 hunks: vec![],
741 };
742 assert!(!fc.overlaps(1, 10_000));
743 }
744}