Skip to main content

agentstatedeveloper_cli/commands/
index.rs

1//! `asd index <path>` — walk a directory for source files we have
2//! adapters for, parse them, and write Symbol + EffectDecl records into
3//! the ASG.
4//!
5//! A full debug log is always written to `.asd/index.log` in the current
6//! directory. `--verbose` tees that same output to stderr in real time.
7//! Skipped files are capped at 100 lines on stderr but fully recorded in
8//! the log file.
9
10use std::io::Write;
11use std::path::{Path, PathBuf};
12use std::sync::{Arc, Mutex};
13
14use anyhow::Result;
15use clap::Args;
16
17use agentstatedeveloper_adapters::default_adapters;
18use agentstatedeveloper_core::search_fts::SearchFtsDb;
19use agentstatedeveloper_core::{
20    AsgFeedbackStore, EffectDecl, Engine, FeedbackStore, LedgerEntry, collect_source_files, paths,
21    run_index, sync_to_dir,
22};
23
24use crate::commands::init::find_project_root;
25use crate::config::Config;
26
27const SKIPPED_DISPLAY_LIMIT: usize = 100;
28
29#[derive(Debug, Args)]
30pub struct IndexArgs {
31    /// Directory (or file) to index. Recursively walks for known source
32    /// extensions (`.py`, `.ts`, `.tsx`, `.rs`, `.go`, `.java`, `.cs`, `.rb`, `.kt`, `.swift`).
33    /// Defaults to the current directory — `asd index` / `asd reindex`
34    /// (no args) both mean "index whatever I'm sitting in."
35    #[arg(default_value = ".")]
36    pub path: PathBuf,
37
38    /// Tee the full index log to stderr in real time.
39    #[arg(short, long)]
40    pub verbose: bool,
41}
42
43/// Always-on log that writes every line to `.asd/index.log` and
44/// optionally mirrors it to stderr when verbose is set.
45struct IndexLog {
46    file: std::fs::File,
47    verbose: bool,
48}
49
50impl IndexLog {
51    fn open(verbose: bool) -> Option<Self> {
52        let dir = PathBuf::from(".asd");
53        std::fs::create_dir_all(&dir).ok()?;
54        std::fs::File::create(dir.join("index.log"))
55            .ok()
56            .map(|f| Self { file: f, verbose })
57    }
58
59    fn line(&mut self, msg: &str) {
60        let _ = writeln!(self.file, "{}", msg);
61        if self.verbose {
62            eprintln!("{}", msg);
63        }
64    }
65}
66
67/// Commits folded into the `asg_history_*` rollup per extractor batch.
68/// The extractor loops internally until it is caught up, so this bounds
69/// memory per round rather than the total work done.
70const HISTORY_EXTRACT_BATCH: usize = 5_000;
71
72pub fn run(cfg: &Config, args: IndexArgs) -> Result<()> {
73    let engine = Engine::open_sqlite(&cfg.db_path)?;
74
75    // Keep the distilled history current as a side effect of ordinary work.
76    //
77    // ASG's `asg_history_*` rollup only advances when something calls the
78    // extractor, and until now the only callers were three HTTP endpoints in
79    // asd-serve. A store whose Lens pages nobody browses therefore drifts
80    // arbitrarily far behind: on this repo the cursor sat at rowid 6,017 of
81    // 36,638, so `asd history` views and the Lens Commits tab were reporting
82    // on May-July data as though it were current.
83    //
84    // Indexing already runs on every commit via the hook, which makes it the
85    // natural place. It is cheap: a 31k-commit backlog folded in 1.34s, and a
86    // caught-up store returns immediately having processed nothing.
87    //
88    // Non-fatal by design — the rollup is derived and rebuildable, so a store
89    // whose engine predates Plan A must still index successfully. But unlike
90    // the HTTP callers, which discard the error entirely, a real failure is
91    // worth a word on stderr rather than silence.
92    match engine.repo.extract_history(HISTORY_EXTRACT_BATCH) {
93        Ok(report) if report.commits_processed > 0 => {
94            eprintln!(
95                "  distilled {} new commit(s) into the history rollup",
96                report.commits_processed
97            );
98        }
99        Ok(_) => {}
100        Err(e) => eprintln!("  note: history rollup not advanced ({e})"),
101    }
102
103    let adapters = default_adapters();
104
105    let mut log = IndexLog::open(args.verbose);
106    let log_path = PathBuf::from(".asd/index.log");
107
108    // Pre-scan so we know total counts before processing starts.
109    let collected = collect_source_files(&args.path, &adapters)?;
110    let total = collected.recognized.len();
111    let skipped = collected.skipped;
112
113    let header = format!(
114        "Indexing {} file{} under {} …",
115        total,
116        if total == 1 { "" } else { "s" },
117        args.path.display()
118    );
119
120    // Always print the header to stderr regardless of verbose.
121    eprintln!("{}", header);
122    if let Some(l) = &mut log {
123        l.line(&header);
124    }
125
126    if total == 0 {
127        let msg = format!(
128            "asd index: no recognized source files found under {}",
129            args.path.display()
130        );
131        eprintln!("{}", msg);
132        if let Some(l) = &mut log {
133            l.line(&msg);
134        }
135        log_skipped(&skipped, &mut log, true);
136        println!(
137            "{}",
138            serde_json::to_string_pretty(&serde_json::json!({
139                "files": 0,
140                "skipped": skipped.len(),
141                "symbols": 0,
142                "effects": 0,
143                "edges": 0,
144                "intra_module_edges": 0,
145                "cross_module_edges": 0,
146                "transitive_updates": 0,
147                "orphaned_tagged": 0,
148                "orphaned_untagged": 0,
149            }))?
150        );
151        return Ok(());
152    }
153
154    // Wrap log in Arc<Mutex> so the progress closure can borrow it.
155    let log = Arc::new(Mutex::new(log));
156    let log_clone = Arc::clone(&log);
157    let width = total.to_string().len();
158
159    let log_clone2 = Arc::clone(&log);
160
161    let progress: &dyn Fn(&Path, usize, usize) = &|file: &Path, idx: usize, total: usize| {
162        let msg = format!("  [{idx:>width$}/{total}] {}", file.display());
163        if let Ok(mut guard) = log_clone.lock() {
164            if let Some(l) = guard.as_mut() {
165                l.line(&msg);
166            }
167        }
168    };
169
170    // Phase messages always go to stderr (not just verbose) — they mark the
171    // boundary between file parsing and post-processing so the user knows
172    // the tool is still working on a large repo.
173    let on_phase: &dyn Fn(&str) = &|msg: &str| {
174        eprintln!("{}", msg);
175        if let Ok(mut guard) = log_clone2.lock() {
176            if let Some(l) = guard.as_mut() {
177                l.line(msg);
178            }
179        }
180    };
181
182    let summary = run_index(
183        &engine.repo,
184        &engine.ref_name,
185        &args.path,
186        &cfg.agent_id,
187        &adapters,
188        Some(engine.audit.as_ref()),
189        Some(progress),
190        Some(on_phase),
191        Some(&cfg.db_path),
192    )?;
193
194    // Unwrap Arc — run_index is done, no other holders.
195    let mut log = Arc::try_unwrap(log)
196        .ok()
197        .and_then(|m| m.into_inner().ok())
198        .flatten();
199
200    // Auto-sync sidecar so ledger/effects survive future DB rebuilds without
201    // needing a manual `asd sync` call.
202    let project_root = find_project_root(&cfg.db_path);
203    match sync_to_dir(&engine.repo, &engine.ref_name, &project_root) {
204        Ok(s) => {
205            let msg = format!(
206                "Sidecar synced: {} symbol{}, {} ledger entr{}, {} effect{}.",
207                s.symbols_written,
208                if s.symbols_written == 1 { "" } else { "s" },
209                s.ledger_entries_written,
210                if s.ledger_entries_written == 1 {
211                    "y"
212                } else {
213                    "ies"
214                },
215                s.effects_written,
216                if s.effects_written == 1 { "" } else { "s" },
217            );
218            if let Some(l) = &mut log {
219                l.line(&msg);
220            }
221        }
222        Err(e) => {
223            let msg = format!("asd: sidecar sync skipped: {e}");
224            eprintln!("{msg}");
225            if let Some(l) = &mut log {
226                l.line(&msg);
227            }
228        }
229    }
230
231    // --- SQLite cache reconciliation ---
232    // Pull authoritative git entries into SQLite so subsequent hot-path reads
233    // hit the fast path immediately after `asd index`.
234    if let Ok(fts) = SearchFtsDb::open(&cfg.db_path) {
235        // Feedback
236        let fb_store = AsgFeedbackStore::new(&engine.repo);
237        if let Ok(all_fb) = fb_store.list_all(&engine.ref_name) {
238            if !all_fb.is_empty() {
239                if let Err(e) = fts.sync_feedback_entries(&all_fb) {
240                    eprintln!("asd: feedback cache sync warning: {e}");
241                }
242            }
243        }
244
245        // Ledger — walk the full tree and bulk-insert into SQLite.
246        let ledger_prefix = format!("{}/ledger", paths::ASD_ROOT);
247        if let Ok(serde_json::Value::Object(by_symbol)) =
248            engine.repo.get_tree(&engine.ref_name, &ledger_prefix)
249        {
250            let mut ledger_pairs: Vec<(String, LedgerEntry)> = Vec::new();
251            for (sym_id, per_symbol) in &by_symbol {
252                if let serde_json::Value::Object(entries_map) = per_symbol {
253                    for ev in entries_map.values() {
254                        if let Ok(e) = serde_json::from_value::<LedgerEntry>(ev.clone()) {
255                            ledger_pairs.push((sym_id.clone(), e));
256                        }
257                    }
258                }
259            }
260            if !ledger_pairs.is_empty() {
261                if let Err(e) = fts.sync_ledger_entries(&ledger_pairs, &engine.ref_name) {
262                    eprintln!("asd: ledger cache sync warning: {e}");
263                }
264            }
265        }
266
267        // Effects — walk the effects tree and bulk-insert into SQLite.
268        let effects_prefix = format!("{}/effects", paths::ASD_ROOT);
269        if let Ok(serde_json::Value::Object(by_symbol)) =
270            engine.repo.get_tree(&engine.ref_name, &effects_prefix)
271        {
272            let mut effects_pairs: Vec<(String, EffectDecl)> = Vec::new();
273            for (sym_id, val) in &by_symbol {
274                if let Ok(decl) = serde_json::from_value::<EffectDecl>(val.clone()) {
275                    effects_pairs.push((sym_id.clone(), decl));
276                }
277            }
278            if !effects_pairs.is_empty() {
279                if let Err(e) = fts.sync_effects(&effects_pairs, &engine.ref_name) {
280                    eprintln!("asd: effects cache sync warning: {e}");
281                }
282            }
283        }
284    }
285
286    // Log all skipped files (no cap in log; capped on stderr).
287    log_skipped(&skipped, &mut log, args.verbose);
288
289    // Done summary — always to stderr.
290    let skipped_hint = if !skipped.is_empty() && !args.verbose {
291        format!(
292            " ({} file{} skipped — see {})",
293            skipped.len(),
294            if skipped.len() == 1 { "" } else { "s" },
295            log_path.display()
296        )
297    } else {
298        String::new()
299    };
300    let doc_hint = if summary.doc_files > 0 {
301        format!(
302            ", {} doc chunk{} from {} file{}",
303            summary.docs_indexed,
304            if summary.docs_indexed == 1 { "" } else { "s" },
305            summary.doc_files,
306            if summary.doc_files == 1 { "" } else { "s" }
307        )
308    } else {
309        String::new()
310    };
311    let done_msg = format!(
312        "Done. {} symbol{}, {} effect{}{}.{}",
313        summary.symbols,
314        if summary.symbols == 1 { "" } else { "s" },
315        summary.effects,
316        if summary.effects == 1 { "" } else { "s" },
317        doc_hint,
318        skipped_hint,
319    );
320    eprintln!("{}", done_msg);
321    if let Some(l) = &mut log {
322        l.line(&done_msg);
323    }
324
325    // t-002: surface cross-service endpoints detected this run.
326    if summary.service_endpoints > 0 {
327        let ep_msg = format!(
328            "{} cross-service endpoint{} detected (see `asd endpoints`).",
329            summary.service_endpoints,
330            if summary.service_endpoints == 1 {
331                ""
332            } else {
333                "s"
334            }
335        );
336        eprintln!("{}", ep_msg);
337        if let Some(l) = &mut log {
338            l.line(&ep_msg);
339        }
340    }
341
342    // t-002 slice 4: surface intra-process data-flow edges (arg→param).
343    if summary.dataflow_edges > 0 {
344        let df_msg = format!(
345            "{} data-flow edge{} (arg→param) detected.",
346            summary.dataflow_edges,
347            if summary.dataflow_edges == 1 { "" } else { "s" }
348        );
349        eprintln!("{}", df_msg);
350        if let Some(l) = &mut log {
351            l.line(&df_msg);
352        }
353    }
354
355    // Plan L t-005: surface dynamic-dispatch warnings so agents know
356    // which call paths the static walker couldn't resolve.
357    if summary.dynamic_dispatch_sites > 0 {
358        let warn_header = format!(
359            "Note: {} dynamic-dispatch site{} detected (calls the static walker can't resolve into edges).",
360            summary.dynamic_dispatch_sites,
361            if summary.dynamic_dispatch_sites == 1 {
362                ""
363            } else {
364                "s"
365            }
366        );
367        eprintln!("{}", warn_header);
368        if let Some(l) = &mut log {
369            l.line(&warn_header);
370        }
371        for h in &summary.dynamic_dispatch_samples {
372            let line = format!("  {}:{} [{}] {}", h.file, h.line, h.pattern, h.snippet);
373            eprintln!("{}", line);
374            if let Some(l) = &mut log {
375                l.line(&line);
376            }
377        }
378        if summary.dynamic_dispatch_sites > summary.dynamic_dispatch_samples.len() {
379            let more = format!(
380                "  …and {} more",
381                summary.dynamic_dispatch_sites - summary.dynamic_dispatch_samples.len()
382            );
383            eprintln!("{}", more);
384            if let Some(l) = &mut log {
385                l.line(&more);
386            }
387        }
388    }
389
390    // Plan L t-006: surface dropped (unresolved) call edges.
391    if summary.dropped_call_edges > 0 {
392        let warn_header = format!(
393            "Note: {} call site{} couldn't be resolved to a workspace symbol (stdlib / third-party / dynamic).",
394            summary.dropped_call_edges,
395            if summary.dropped_call_edges == 1 {
396                ""
397            } else {
398                "s"
399            }
400        );
401        eprintln!("{}", warn_header);
402        if let Some(l) = &mut log {
403            l.line(&warn_header);
404        }
405        for h in &summary.sample_unresolved {
406            let line = format!("  {}:{} {}", h.file, h.line, h.callee_text);
407            eprintln!("{}", line);
408            if let Some(l) = &mut log {
409                l.line(&line);
410            }
411        }
412        if summary.dropped_call_edges > summary.sample_unresolved.len() {
413            let more = format!(
414                "  …and {} more",
415                summary.dropped_call_edges - summary.sample_unresolved.len()
416            );
417            eprintln!("{}", more);
418            if let Some(l) = &mut log {
419                l.line(&more);
420            }
421        }
422    }
423
424    let log_note = format!("Full log: {}", log_path.display());
425    if let Some(l) = &mut log {
426        l.line(&log_note);
427    }
428    // Only print log path hint to stderr when not verbose (verbose already showed everything).
429    if !args.verbose {
430        eprintln!("{}", log_note);
431    }
432    if let Some(w) = agentstatedeveloper_core::outdated_hooks(&project_root) {
433        eprintln!("warning: {w}");
434    }
435
436    println!(
437        "{}",
438        serde_json::to_string_pretty(&serde_json::json!({
439            "db": cfg.db_path.display().to_string(),
440            "files": summary.files,
441            "skipped": summary.skipped,
442            "symbols": summary.symbols,
443            "effects": summary.effects,
444            "edges": summary.edges,
445            "intra_module_edges": summary.intra_module_edges,
446            "cross_module_edges": summary.cross_module_edges,
447            "transitive_updates": summary.transitive_updates,
448            "orphaned_tagged": summary.orphaned_tagged,
449            "orphaned_untagged": summary.orphaned_untagged,
450            "stale_pruned": summary.stale_pruned,
451            "stale_rebound": summary.stale_rebound,
452            "ledger_entries_rebound": summary.ledger_entries_rebound,
453            "stale_kept": summary.stale_kept,
454            "orphaned_effects_pruned": summary.orphaned_effects_pruned,
455            "code_entries_pruned": summary.code_entries_pruned,
456            "disambiguated": summary.disambiguated,
457            "doc_files": summary.doc_files,
458            "docs_indexed": summary.docs_indexed,
459            "cross_file_collisions": summary.top_collisions.iter().map(|(q, f1, f2)| {
460                serde_json::json!({ "qname": q, "first": f1, "second": f2 })
461            }).collect::<Vec<_>>(),
462            "dynamic_dispatch_sites": summary.dynamic_dispatch_sites,
463            "dynamic_dispatch_samples": summary.dynamic_dispatch_samples.iter().map(|h| {
464                serde_json::json!({
465                    "file": h.file, "line": h.line, "pattern": h.pattern, "snippet": h.snippet,
466                })
467            }).collect::<Vec<_>>(),
468            "dropped_call_edges": summary.dropped_call_edges,
469            "sample_unresolved": summary.sample_unresolved.iter().map(|c| {
470                serde_json::json!({
471                    "file": c.file, "line": c.line, "callee": c.callee_text,
472                })
473            }).collect::<Vec<_>>(),
474            // Plan T: surface cache-sync failures (previously stderr-only
475            // eprintln warnings) so agents parsing this JSON can tell a warm
476            // DB from one that will pay cold git-walk reads until self-heal.
477            "caches_synced": summary.caches_synced,
478            "cache_sync_warning": summary.cache_sync_warning,
479        }))?
480    );
481
482    auto_maintain_registry(&cfg.db_path);
483
484    Ok(())
485}
486
487/// Write skipped files to the log and conditionally to stderr.
488/// Log receives all files; stderr is capped at SKIPPED_DISPLAY_LIMIT.
489fn log_skipped(skipped: &[PathBuf], log: &mut Option<IndexLog>, show_on_stderr: bool) {
490    if skipped.is_empty() {
491        return;
492    }
493
494    let header = format!(
495        "  {} file{} skipped (no adapter):",
496        skipped.len(),
497        if skipped.len() == 1 { "" } else { "s" }
498    );
499
500    // Log file always gets the header and all entries.
501    if let Some(l) = log.as_mut() {
502        l.line(&header);
503        for f in skipped {
504            l.line(&format!("  [skip] {}", f.display()));
505        }
506    }
507
508    if !show_on_stderr {
509        return;
510    }
511
512    // stderr: header + up to SKIPPED_DISPLAY_LIMIT entries.
513    eprintln!("{}", header);
514    let display = skipped.len().min(SKIPPED_DISPLAY_LIMIT);
515    for f in &skipped[..display] {
516        eprintln!("  [skip] {}", f.display());
517    }
518    if skipped.len() > SKIPPED_DISPLAY_LIMIT {
519        eprintln!(
520            "  … and {} more — see .asd/index.log for the full list",
521            skipped.len() - SKIPPED_DISPLAY_LIMIT
522        );
523    }
524}
525
526/// Best-effort registry maintenance after a successful index: register this
527/// repo in the shared registry (`~/.config/asd/repos.toml`) and opportunistically
528/// self-heal by dropping entries whose db has vanished. Prints one-line notices
529/// for a new registration and for any pruned stale entries. All errors are
530/// swallowed — this must never fail an index. Skipped entirely for ephemeral
531/// paths and when `ASD_NO_AUTO_REGISTER` is set; self-heal alone can be disabled
532/// with `ASD_NO_AUTO_PRUNE`.
533fn auto_maintain_registry(db_path: &Path) {
534    use agentstatedeveloper_core::registry::Registry;
535
536    let abs = match db_path.canonicalize().or_else(|_| {
537        if db_path.is_absolute() {
538            Ok(db_path.to_path_buf())
539        } else {
540            std::env::current_dir().map(|c| c.join(db_path))
541        }
542    }) {
543        Ok(p) => p,
544        Err(_) => return,
545    };
546
547    // Never auto-register ephemeral or explicitly-opted-out repos into the
548    // shared registry. This is the main source of registry clutter: every
549    // `asd index` on a temp db (integration tests, throwaway checkouts) would
550    // otherwise leave a dead entry behind once the temp dir is cleaned. Set
551    // `ASD_NO_AUTO_REGISTER` to opt out regardless of path. Existing dead
552    // entries are cleaned by `asd repo prune`.
553    if std::env::var_os("ASD_NO_AUTO_REGISTER").is_some() || is_ephemeral_path(&abs) {
554        return;
555    }
556
557    let default_name = abs
558        .parent()
559        .and_then(|p| p.file_name())
560        .and_then(|s| s.to_str())
561        .map(|s| {
562            s.chars()
563                .map(|c| {
564                    if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
565                        c
566                    } else {
567                        '-'
568                    }
569                })
570                .collect::<String>()
571        });
572    let Some(name) = default_name.filter(|s| !s.is_empty() && s != "default") else {
573        return;
574    };
575
576    let mut reg = match Registry::load() {
577        Ok(r) => r,
578        Err(_) => return,
579    };
580
581    // Opportunistic self-heal: drop entries whose db has vanished (dead
582    // temp/test/deleted repos). Keeps the shared registry from rotting without
583    // a scheduler — the standalone-asd hygiene story. Opt out with
584    // ASD_NO_AUTO_PRUNE.
585    let pruned = if std::env::var_os("ASD_NO_AUTO_PRUNE").is_some() {
586        Vec::new()
587    } else {
588        reg.prune_missing()
589    };
590    let mut changed = !pruned.is_empty();
591    let mut registered_new = false;
592
593    match reg.get(&name).map(|e| e.path.clone()) {
594        // Same repo already registered at this exact path — nothing to add.
595        Some(existing) if existing == abs => {}
596        // Registered under this name but the path drifted — update it.
597        Some(_) => {
598            if reg.register(&name, &abs).is_ok() {
599                changed = true;
600            }
601        }
602        // Registered under a *different* name — the user named it on purpose;
603        // leave it.
604        None if reg.list().iter().any(|e| e.path == abs) => {}
605        None => {
606            if reg.register(&name, &abs).is_ok() {
607                changed = true;
608                registered_new = true;
609            }
610        }
611    }
612
613    if changed && reg.save().is_ok() {
614        if registered_new {
615            eprintln!("Registered as '{name}' — use `asd repo use {name}` to make it active.");
616        }
617        if !pruned.is_empty() {
618            eprintln!(
619                "Pruned {} stale repo registration(s) whose db no longer exists.",
620                pruned.len()
621            );
622        }
623    }
624}
625
626/// True if `path` lives under a temporary directory — where integration tests
627/// and throwaway checkouts create `.asd-state.db` files. Such repos must never
628/// land in the shared registry (they'd become dead entries the moment the temp
629/// dir is cleaned). `abs` is already canonicalized by the caller, so `/var`
630/// symlinks resolve to `/private/var` on macOS; both spellings are covered.
631fn is_ephemeral_path(path: &Path) -> bool {
632    let mut roots: Vec<std::path::PathBuf> = Vec::new();
633    let td = std::env::temp_dir();
634    if let Ok(c) = td.canonicalize() {
635        roots.push(c);
636    }
637    roots.push(td);
638    for r in [
639        "/tmp",
640        "/private/tmp",
641        "/var/folders",
642        "/private/var/folders",
643    ] {
644        roots.push(std::path::PathBuf::from(r));
645    }
646    roots.iter().any(|root| path.starts_with(root))
647}