1use 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 #[arg(default_value = ".")]
36 pub path: PathBuf,
37
38 #[arg(short, long)]
40 pub verbose: bool,
41}
42
43struct 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
67const 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 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 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 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 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 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 let mut log = Arc::try_unwrap(log)
196 .ok()
197 .and_then(|m| m.into_inner().ok())
198 .flatten();
199
200 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 if let Ok(fts) = SearchFtsDb::open(&cfg.db_path) {
235 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 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 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_skipped(&skipped, &mut log, args.verbose);
288
289 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 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 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 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 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 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 "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
487fn 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 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 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
526fn 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 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 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 Some(existing) if existing == abs => {}
596 Some(_) => {
598 if reg.register(&name, &abs).is_ok() {
599 changed = true;
600 }
601 }
602 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
626fn 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}