1use anyhow::Context;
4use anyhow::Result;
5use gonzalo_core::{
6 BlobStore, Body, ContentHash, Identity, KeyPrefix, Manifest, Meta, PutResult, Record,
7 RecordKey, RecordKind, Revision, Store,
8};
9use gonzalo_graph::{CodeGraph, EXTRACTION_VERSION, GraphStore, Language, build};
10use gonzalo_graph_sqlite::{SqliteGraphStore, view_db_path};
11use gonzalo_parse::ParserPool;
12use gonzalo_store_fs::FsStore;
13use gonzalo_ticket::IngestSummary;
14use gonzalo_ticket_config::{Config, Connection, parse_category};
15use std::collections::BTreeMap;
16use std::path::{Path, PathBuf};
17use std::time::{Duration, Instant};
18
19mod walk;
20pub use walk::{IgnoredCounts, IndexFilter};
21
22mod watch;
23pub use watch::{WatchConfig, watch};
24
25pub async fn list(
30 root: &Path,
31 namespace: Option<String>,
32 collection: Option<String>,
33) -> Result<Vec<RecordKey>> {
34 let store = FsStore::new(root);
35 let prefix = KeyPrefix {
36 namespace,
37 collection,
38 };
39 let keys = store.list(&prefix).await?;
40 Ok(keys)
41}
42
43pub async fn get(root: &Path, ns: &str, col: &str, id: &str) -> Result<Option<Record>> {
47 let store = FsStore::new(root);
48 let key = RecordKey::new(ns, col, id);
49 Ok(store.get(&key).await?)
50}
51
52pub async fn status(root: &Path) -> Result<BTreeMap<String, usize>> {
56 let keys = list(root, None, None).await?;
57 let mut map: BTreeMap<String, usize> = BTreeMap::new();
58 for k in keys {
59 *map.entry(format!("{}/{}", k.namespace, k.collection))
60 .or_insert(0) += 1;
61 }
62 Ok(map)
63}
64
65pub struct MigrateSummary {
69 pub imported: usize,
70 pub skipped: usize,
71}
72
73pub async fn migrate(
76 root: &Path,
77 src: &Path,
78 namespace: &str,
79 collection: &str,
80 kind: RecordKind,
81) -> Result<MigrateSummary> {
82 let store = FsStore::new(root);
83 let mut imported = 0usize;
84 let mut skipped = 0usize;
85
86 let files = collect_files(src)?;
88
89 for abs_path in files {
90 let rel = abs_path
92 .strip_prefix(src)
93 .map_err(|e| anyhow::anyhow!("strip_prefix failed: {e}"))?;
94 let rel_str = rel
95 .components()
96 .map(|c| c.as_os_str().to_string_lossy().into_owned())
97 .collect::<Vec<_>>()
98 .join("/");
99
100 let key = RecordKey::new(namespace, collection, rel_str);
106
107 if store.get(&key).await?.is_some() {
109 skipped += 1;
110 continue;
111 }
112
113 let file_bytes = std::fs::read(&abs_path)?;
114 let body = Body::Inline(file_bytes);
115 let record = Record {
116 key,
117 kind,
118 revision: Revision::initial(body.bytes()),
119 parent: None,
120 body,
121 meta: Meta {
122 author: Identity::new("gonzalo-cli"),
123 origin_system: "migrate".into(),
124 created: 0,
125 updated: 0,
126 labels: BTreeMap::new(),
127 },
128 links: vec![],
129 };
130
131 match store.put(record, None).await? {
132 PutResult::Committed(_) => imported += 1,
133 PutResult::Conflict(_) => skipped += 1,
134 }
135 }
136
137 Ok(MigrateSummary { imported, skipped })
138}
139
140fn collect_files(dir: &Path) -> Result<Vec<std::path::PathBuf>> {
142 let mut out = Vec::new();
143 collect_files_inner(dir, &mut out)?;
144 out.sort();
145 Ok(out)
146}
147
148fn collect_files_inner(dir: &Path, out: &mut Vec<std::path::PathBuf>) -> Result<()> {
149 for entry in std::fs::read_dir(dir)? {
150 let entry = entry?;
151 let ft = entry.file_type()?;
152 if ft.is_dir() {
153 collect_files_inner(&entry.path(), out)?;
154 } else if ft.is_file() {
155 out.push(entry.path());
156 }
157 }
158 Ok(())
159}
160
161pub struct IndexSummary {
165 pub files: usize,
167 pub added: usize,
169 pub modified: usize,
171 pub deleted: usize,
173 pub skipped: usize,
176 pub ignored: IgnoredCounts,
180 pub incremental: bool,
183}
184
185fn resolve_parse_worker() -> Option<PathBuf> {
190 if let Some(p) = std::env::var_os("GONZALO_PARSE_WORKER") {
191 let p = PathBuf::from(p);
192 if p.exists() {
193 return Some(p);
194 }
195 }
196 let sibling = std::env::current_exe()
197 .ok()?
198 .with_file_name(if cfg!(windows) {
199 "gonzalo-parse-worker.exe"
200 } else {
201 "gonzalo-parse-worker"
202 });
203 sibling.exists().then_some(sibling)
204}
205
206async fn parse_file(
210 pool: Option<&ParserPool>,
211 language: Language,
212 content: &str,
213) -> Option<CodeGraph> {
214 match pool {
215 Some(pool) => match pool.parse(language, content).await {
216 Ok(graph) => Some(graph),
217 Err(e) => {
218 eprintln!("gonzalo index: skipping a file — parse worker error: {e}");
219 None
220 }
221 },
222 None => Some(build(language, content)),
223 }
224}
225
226pub async fn index(root: &Path, src: &Path, repo: &str, view: &str) -> Result<IndexSummary> {
238 index_with(root, src, repo, view, &IndexFilter::default()).await
239}
240
241pub async fn index_with(
246 root: &Path,
247 src: &Path,
248 repo: &str,
249 view: &str,
250 filter: &IndexFilter,
251) -> Result<IndexSummary> {
252 let store = FsStore::new(root);
253
254 let pool = resolve_parse_worker().map(|bin| {
258 let workers = std::thread::available_parallelism()
259 .map(|n| n.get())
260 .unwrap_or(4)
261 .clamp(1, 8);
262 ParserPool::new(bin, workers, std::time::Duration::from_secs(30))
263 });
264
265 let db_path = view_db_path(&root.join("graphs"), repo, view);
267 let mut graph = SqliteGraphStore::open(&db_path)
268 .with_context(|| format!("opening graph db for {repo}/{view}"))?;
269
270 let key = Manifest::key(repo, view);
272 let existing = store.get(&key).await?;
273 let current = match &existing {
274 Some(rec) => Manifest::from_body(&rec.body)?,
275 None => Manifest::new(),
276 };
277
278 let base_path = db_path.with_extension("base");
281 let version_path = db_path.with_extension("fmt");
285 let recorded_version: Option<u32> = std::fs::read_to_string(&version_path)
286 .ok()
287 .and_then(|s| s.trim().parse().ok());
288 let format_changed = recorded_version != Some(EXTRACTION_VERSION);
289
290 let recorded_base = std::fs::read_to_string(&base_path)
291 .ok()
292 .map(|s| s.trim().to_string())
293 .filter(|s| !s.is_empty())
294 .filter(|_| !format_changed);
295 let incremental_changed = recorded_base
296 .as_deref()
297 .and_then(|base| gonzalo_store_git::changed_paths(src, base).ok());
298
299 let mut staging = GraphStaging::default();
304 let (desired, files, skipped, ignored, incremental) = match incremental_changed {
305 Some(changed) => {
306 build_desired_incremental(
307 &store,
308 &mut staging,
309 pool.as_ref(),
310 src,
311 ¤t,
312 &changed,
313 filter,
314 )
315 .await?
316 }
317 None => build_desired_full(&store, &mut staging, pool.as_ref(), src, filter).await?,
318 };
319
320 let recon = current.reconcile(&desired);
323 for path in &recon.deleted {
324 staging.removes.push(path.clone());
325 }
326
327 let body = recon.manifest.to_body();
329 let (revision, expected, parent) = match &existing {
330 Some(rec) => (
331 rec.revision.next(body.bytes()),
332 Some(rec.revision.clone()),
333 Some(rec.revision.clone()),
334 ),
335 None => (Revision::initial(body.bytes()), None, None),
336 };
337 let record = Record {
338 key,
339 kind: RecordKind::GraphManifest,
340 revision,
341 parent,
342 body,
343 meta: Meta {
344 author: Identity::new("gonzalo-index"),
345 origin_system: "gonzalo-index".into(),
346 created: 0,
347 updated: 0,
348 labels: BTreeMap::new(),
349 },
350 links: Vec::new(),
351 };
352 match store.put(record, expected).await? {
353 PutResult::Committed(_) => {}
354 PutResult::Conflict(_) => {
355 anyhow::bail!("manifest for {repo}/{view} changed concurrently; retry the index")
360 }
361 }
362
363 staging.apply(&mut graph);
365
366 if let Ok(sha) = gonzalo_store_git::head_commit(src) {
369 if let Some(parent) = base_path.parent() {
370 std::fs::create_dir_all(parent).ok();
371 }
372 std::fs::write(&base_path, sha).ok();
373 }
374 if let Some(parent) = version_path.parent() {
377 std::fs::create_dir_all(parent).ok();
378 std::fs::write(&version_path, EXTRACTION_VERSION.to_string()).ok();
379 }
380
381 Ok(IndexSummary {
382 files,
383 added: recon.added.len(),
384 modified: recon.modified.len(),
385 deleted: recon.deleted.len(),
386 skipped,
387 ignored,
388 incremental,
389 })
390}
391
392#[derive(Default)]
397struct GraphStaging {
398 inserts: Vec<(String, CodeGraph)>,
401 removes: Vec<String>,
403}
404
405impl GraphStaging {
406 fn apply(self, graph: &mut SqliteGraphStore) {
409 for (rel, slice) in self.inserts {
410 graph.insert(&rel, slice);
411 }
412 for rel in self.removes {
413 graph.remove_path(&rel);
414 }
415 }
416}
417
418async fn build_desired_full(
421 store: &FsStore,
422 staging: &mut GraphStaging,
423 pool: Option<&ParserPool>,
424 src: &Path,
425 filter: &IndexFilter,
426) -> Result<(
427 BTreeMap<String, ContentHash>,
428 usize,
429 usize,
430 IgnoredCounts,
431 bool,
432)> {
433 let mut desired: BTreeMap<String, ContentHash> = BTreeMap::new();
434 let mut skipped = 0usize;
435 let (sources, ignored) = walk::source_files(src, filter)?;
436 for (path, language) in sources {
437 let content = std::fs::read_to_string(&path)
438 .with_context(|| format!("reading {}", path.display()))?;
439 let rel = path
440 .strip_prefix(src)
441 .unwrap_or(&path)
442 .to_string_lossy()
443 .replace('\\', "/");
444 let Some(slice) = parse_file(pool, language, &content).await else {
445 skipped += 1;
446 continue;
447 };
448 let hash = store.put_blob(&slice.to_slice_bytes()).await?;
449 staging.inserts.push((rel.clone(), slice)); desired.insert(rel, hash);
451 }
452 let files = desired.len();
453 Ok((desired, files, skipped, ignored, false))
454}
455
456async fn build_desired_incremental(
461 store: &FsStore,
462 staging: &mut GraphStaging,
463 pool: Option<&ParserPool>,
464 src: &Path,
465 current: &Manifest,
466 changed: &gonzalo_store_git::ChangedPaths,
467 filter: &IndexFilter,
468) -> Result<(
469 BTreeMap<String, ContentHash>,
470 usize,
471 usize,
472 IgnoredCounts,
473 bool,
474)> {
475 let mut desired = current.entries.clone();
476 let mut files = 0usize;
477 let mut skipped = 0usize;
478 let mut ignored = IgnoredCounts::default();
481
482 let stale = walk::stale_entries(src, filter, desired.keys().map(String::as_str));
490 for rel in stale {
491 desired.remove(&rel);
492 staging.removes.push(rel);
493 ignored.files += 1;
494 }
495
496 for rel in changed.added.iter().chain(changed.modified.iter()) {
497 if !filter.is_indexable(rel) {
498 if desired.remove(rel).is_some() {
501 staging.removes.push(rel.clone());
502 }
503 ignored.files += 1;
504 continue;
505 }
506 let Some(language) = Path::new(rel)
507 .extension()
508 .and_then(|e| e.to_str())
509 .and_then(Language::from_extension)
510 else {
511 continue; };
513 let content = match std::fs::read_to_string(src.join(rel)) {
516 Ok(c) => c,
517 Err(_) => {
518 if desired.remove(rel).is_some() {
519 staging.removes.push(rel.clone());
520 }
521 continue;
522 }
523 };
524 let Some(slice) = parse_file(pool, language, &content).await else {
525 skipped += 1;
526 continue;
527 };
528 let hash = store.put_blob(&slice.to_slice_bytes()).await?;
529 staging.inserts.push((rel.clone(), slice));
530 desired.insert(rel.clone(), hash);
531 files += 1;
532 }
533
534 for rel in &changed.deleted {
535 if desired.remove(rel).is_some() {
536 staging.removes.push(rel.clone());
537 }
538 }
539
540 Ok((desired, files, skipped, ignored, true))
541}
542
543pub struct GcSummary {
547 pub manifests: usize,
549 pub freed: usize,
551 pub retained: usize,
553}
554
555pub async fn gc(root: &Path) -> Result<GcSummary> {
563 let store = FsStore::new(root);
564
565 let prefix = KeyPrefix {
567 namespace: None,
568 collection: Some(Manifest::collection().to_string()),
569 };
570 let keys = store.list(&prefix).await?;
571 let mut manifests = Vec::with_capacity(keys.len());
572 for key in &keys {
573 if let Some(rec) = store.get(key).await? {
574 manifests.push(Manifest::from_body(&rec.body)?);
575 }
576 }
577
578 let report = gonzalo_core::gc_blobs(&store, &manifests).await?;
579 Ok(GcSummary {
580 manifests: manifests.len(),
581 freed: report.freed.len(),
582 retained: report.retained,
583 })
584}
585
586pub async fn index_with_gc(
592 root: &Path,
593 src: &Path,
594 repo: &str,
595 view: &str,
596 gc_after: bool,
597) -> Result<(IndexSummary, Option<GcSummary>)> {
598 index_with_gc_filtered(root, src, repo, view, gc_after, &IndexFilter::default()).await
599}
600
601pub async fn index_with_gc_filtered(
603 root: &Path,
604 src: &Path,
605 repo: &str,
606 view: &str,
607 gc_after: bool,
608 filter: &IndexFilter,
609) -> Result<(IndexSummary, Option<GcSummary>)> {
610 let summary = index_with(root, src, repo, view, filter).await?;
611 let swept = if gc_after {
612 Some(gc(root).await?)
613 } else {
614 None
615 };
616 Ok((summary, swept))
617}
618
619#[derive(Debug)]
626pub struct Debouncer {
627 window: Duration,
628 last_event: Option<Instant>,
630}
631
632impl Debouncer {
633 pub fn new(window: Duration) -> Self {
635 Self {
636 window,
637 last_event: None,
638 }
639 }
640
641 pub fn on_event(&mut self, now: Instant) {
643 self.last_event = Some(now);
644 }
645
646 pub fn is_pending(&self) -> bool {
648 self.last_event.is_some()
649 }
650
651 pub fn is_due(&self, now: Instant) -> bool {
655 matches!(self.last_event, Some(t) if now.duration_since(t) >= self.window)
656 }
657
658 pub fn clear(&mut self) {
660 self.last_event = None;
661 }
662}
663
664pub struct SyncSummary {
668 pub copied_to_a: usize,
669 pub copied_to_b: usize,
670 pub merged: usize,
671 pub conflicts: usize,
672}
673
674pub async fn sync_stores(a: &Path, b: &Path) -> Result<SyncSummary> {
676 let store_a = FsStore::new(a);
677 let store_b = FsStore::new(b);
678 let report = gonzalo_core::sync(&store_a, &store_b).await?;
679 Ok(SyncSummary {
680 copied_to_a: report.copied_to_a.len(),
681 copied_to_b: report.copied_to_b.len(),
682 merged: report.merged.len(),
683 conflicts: report.conflicts.len(),
684 })
685}
686
687pub struct TicketSyncReport {
691 pub connection: String,
692 pub summary: IngestSummary,
693}
694
695pub async fn ticket_sync(
698 config_path: &Path,
699 root: &Path,
700 author: &str,
701) -> Result<Vec<TicketSyncReport>> {
702 let config = Config::load(config_path).context("loading ticket config")?;
703 let store = FsStore::new(root);
704 let mut reports = Vec::new();
705 for (name, source) in config.sources().context("building ticket sources")? {
706 let summary = gonzalo_ticket::ingest(source.as_ref(), &store, author, Some(&name))
709 .await
710 .with_context(|| format!("syncing connection {name}"))?;
711 reports.push(TicketSyncReport {
712 connection: name,
713 summary,
714 });
715 }
716 Ok(reports)
717}
718
719pub async fn ticket_move(
724 config_path: &Path,
725 connection: Option<&str>,
726 uid: &str,
727 category: &str,
728) -> Result<()> {
729 let cat = parse_category(category)
730 .ok_or_else(|| anyhow::anyhow!("unknown state category {category:?}"))?;
731 let config = Config::load(config_path).context("loading ticket config")?;
732 let conn = select_connection(&config.connections, connection)?;
733 let source = gonzalo_ticket_config::build_source(conn).context("building ticket source")?;
734 source
735 .set_state(uid, cat)
736 .await
737 .with_context(|| format!("moving {uid} to {category}"))?;
738 Ok(())
739}
740
741fn select_connection<'a>(
743 connections: &'a [Connection],
744 name: Option<&str>,
745) -> Result<&'a Connection> {
746 match name {
747 Some(n) => connections
748 .iter()
749 .find(|c| c.name == n)
750 .ok_or_else(|| anyhow::anyhow!("no connection named {n:?}")),
751 None => match connections {
752 [one] => Ok(one),
753 [] => Err(anyhow::anyhow!("no connections configured")),
754 _ => Err(anyhow::anyhow!(
755 "multiple connections configured; pass --connection <name>"
756 )),
757 },
758 }
759}
760
761#[cfg(test)]
764mod tests {
765 use super::*;
766 use gonzalo_graph::GraphStore;
767 use tempfile::TempDir;
768
769 fn write_file(dir: &Path, name: &str, contents: &str) {
770 std::fs::write(dir.join(name), contents).unwrap();
771 }
772
773 #[tokio::test]
776 async fn migrate_imports_two_files() {
777 let root = TempDir::new().unwrap();
778 let src = TempDir::new().unwrap();
779
780 write_file(src.path(), "alpha.md", "hello alpha");
781 write_file(src.path(), "beta.md", "hello beta");
782
783 let summary = migrate(
784 root.path(),
785 src.path(),
786 "testns",
787 "testcol",
788 RecordKind::Topic,
789 )
790 .await
791 .unwrap();
792
793 assert_eq!(summary.imported, 2, "should have imported 2 files");
794 assert_eq!(summary.skipped, 0, "nothing should be skipped yet");
795 }
796
797 #[tokio::test]
800 async fn list_returns_migrated_keys() {
801 let root = TempDir::new().unwrap();
802 let src = TempDir::new().unwrap();
803
804 write_file(src.path(), "alpha.md", "hello alpha");
805 write_file(src.path(), "beta.md", "hello beta");
806
807 migrate(
808 root.path(),
809 src.path(),
810 "testns",
811 "testcol",
812 RecordKind::Topic,
813 )
814 .await
815 .unwrap();
816
817 let keys = list(root.path(), None, None).await.unwrap();
818 assert_eq!(keys.len(), 2);
819 }
820
821 #[tokio::test]
824 async fn migrate_is_idempotent() {
825 let root = TempDir::new().unwrap();
826 let src = TempDir::new().unwrap();
827
828 write_file(src.path(), "alpha.md", "hello alpha");
829 write_file(src.path(), "beta.md", "hello beta");
830
831 migrate(
832 root.path(),
833 src.path(),
834 "testns",
835 "testcol",
836 RecordKind::Topic,
837 )
838 .await
839 .unwrap();
840
841 let second = migrate(
842 root.path(),
843 src.path(),
844 "testns",
845 "testcol",
846 RecordKind::Topic,
847 )
848 .await
849 .unwrap();
850
851 assert_eq!(
852 second.skipped, 2,
853 "second run should skip both already-imported files"
854 );
855 assert_eq!(second.imported, 0, "second run should import nothing new");
856 }
857
858 #[tokio::test]
861 async fn get_returns_migrated_record_body() {
862 let root = TempDir::new().unwrap();
863 let src = TempDir::new().unwrap();
864
865 write_file(src.path(), "alpha.md", "hello alpha");
866
867 migrate(
868 root.path(),
869 src.path(),
870 "testns",
871 "testcol",
872 RecordKind::Topic,
873 )
874 .await
875 .unwrap();
876
877 let record = get(root.path(), "testns", "testcol", "alpha.md")
879 .await
880 .unwrap();
881
882 assert!(record.is_some(), "record should be present");
883 let body = record.unwrap().body;
884 assert_eq!(body.bytes(), b"hello alpha");
885 }
886
887 #[tokio::test]
890 async fn status_groups_by_ns_col() {
891 let root = TempDir::new().unwrap();
892 let src = TempDir::new().unwrap();
893
894 write_file(src.path(), "alpha.md", "hello alpha");
895 write_file(src.path(), "beta.md", "hello beta");
896
897 migrate(
898 root.path(),
899 src.path(),
900 "testns",
901 "testcol",
902 RecordKind::Topic,
903 )
904 .await
905 .unwrap();
906
907 let map = status(root.path()).await.unwrap();
908 assert_eq!(map.get("testns/testcol").copied(), Some(2));
909 }
910
911 #[tokio::test]
914 async fn sync_stores_copies_to_b() {
915 let store_a = TempDir::new().unwrap();
916 let store_b = TempDir::new().unwrap();
917 let src = TempDir::new().unwrap();
918
919 write_file(src.path(), "note.md", "synced content");
920
921 migrate(
923 store_a.path(),
924 src.path(),
925 "testns",
926 "testcol",
927 RecordKind::Topic,
928 )
929 .await
930 .unwrap();
931
932 let summary = sync_stores(store_a.path(), store_b.path()).await.unwrap();
933 assert_eq!(summary.copied_to_b, 1);
934
935 let keys = list(store_b.path(), None, None).await.unwrap();
937 assert_eq!(keys.len(), 1);
938 }
939
940 #[tokio::test]
943 async fn ticket_sync_with_no_connections_returns_no_reports() {
944 let root = TempDir::new().unwrap();
945 let cfg = TempDir::new().unwrap();
946 let cfg_path = cfg.path().join("tickets.toml");
947 std::fs::write(&cfg_path, "").unwrap(); let reports = ticket_sync(&cfg_path, root.path(), "tester").await.unwrap();
950 assert!(reports.is_empty());
951 }
952
953 #[tokio::test]
956 async fn ticket_move_unknown_category_errors() {
957 let cfg = TempDir::new().unwrap();
958 let cfg_path = cfg.path().join("tickets.toml");
959 std::fs::write(
960 &cfg_path,
961 "[[connection]]\nname=\"b\"\nprovider=\"github-projects\"\norg=\"o\"\nproject=1\ntoken_env=\"X\"\n",
962 )
963 .unwrap();
964 let err = ticket_move(&cfg_path, None, "o/r#1", "frozen")
966 .await
967 .unwrap_err();
968 assert!(err.to_string().contains("category"), "got {err}");
969 }
970
971 #[tokio::test]
974 async fn ticket_move_requires_connection_when_many() {
975 let cfg = TempDir::new().unwrap();
976 let cfg_path = cfg.path().join("tickets.toml");
977 std::fs::write(
978 &cfg_path,
979 "[[connection]]\nname=\"a\"\nprovider=\"github-projects\"\norg=\"o\"\nproject=1\ntoken_env=\"X\"\n\
980 [[connection]]\nname=\"b\"\nprovider=\"github-projects\"\norg=\"o\"\nproject=2\ntoken_env=\"Y\"\n",
981 )
982 .unwrap();
983 let err = ticket_move(&cfg_path, None, "o/r#1", "done")
984 .await
985 .unwrap_err();
986 assert!(err.to_string().contains("connection"), "got {err}");
987 }
988
989 #[tokio::test]
992 async fn index_builds_a_queryable_view() {
993 let root = TempDir::new().unwrap();
994 let src = TempDir::new().unwrap();
995 write_file(src.path(), "a.rs", "fn helper() {}");
996 std::fs::create_dir(src.path().join("sub")).unwrap();
997 write_file(src.path(), "sub/b.rs", "fn caller() { helper(); }");
998
999 let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
1000 assert_eq!(summary.files, 2);
1001 assert_eq!(summary.added, 2);
1002 assert_eq!(summary.modified, 0);
1003 assert_eq!(summary.deleted, 0);
1004
1005 let store = FsStore::new(root.path());
1007 let manifest = Manifest::from_body(
1008 &store
1009 .get(&Manifest::key("r", "main"))
1010 .await
1011 .unwrap()
1012 .unwrap()
1013 .body,
1014 )
1015 .unwrap();
1016 let graph = gonzalo_graph::assemble(&manifest, &store).await.unwrap();
1017 let defs = graph.definitions("helper");
1018 assert_eq!(defs.len(), 1);
1019 assert_eq!(defs[0].path, "a.rs");
1020 assert_eq!(graph.callers_of("helper"), vec!["caller".to_string()]);
1021 assert!(
1022 graph
1023 .symbols_in_file("sub/b.rs")
1024 .iter()
1025 .any(|s| s.name == "caller")
1026 );
1027 }
1028
1029 #[tokio::test]
1030 async fn reindex_reports_modifications_and_deletions() {
1031 let root = TempDir::new().unwrap();
1032 let src = TempDir::new().unwrap();
1033 write_file(src.path(), "keep.rs", "fn keep() {}");
1034 write_file(src.path(), "gone.rs", "fn gone() {}");
1035 index(root.path(), src.path(), "r", "v").await.unwrap();
1036
1037 write_file(src.path(), "keep.rs", "fn keep() { extra(); }");
1039 std::fs::remove_file(src.path().join("gone.rs")).unwrap();
1040
1041 let summary = index(root.path(), src.path(), "r", "v").await.unwrap();
1042 assert_eq!(summary.files, 1);
1043 assert_eq!(summary.added, 0);
1044 assert_eq!(summary.modified, 1);
1045 assert_eq!(summary.deleted, 1);
1046 }
1047
1048 #[tokio::test]
1049 async fn index_skips_non_rust_and_build_dirs() {
1050 let root = TempDir::new().unwrap();
1051 let src = TempDir::new().unwrap();
1052 write_file(src.path(), "real.rs", "fn real() {}");
1053 write_file(src.path(), "notes.txt", "not source");
1054 std::fs::create_dir(src.path().join("target")).unwrap();
1055 write_file(src.path(), "target/gen.rs", "fn generated() {}");
1056
1057 let summary = index(root.path(), src.path(), "r", "v").await.unwrap();
1058 assert_eq!(summary.files, 1, "only real.rs is indexed");
1059 assert_eq!(summary.added, 1);
1060 }
1061
1062 #[tokio::test]
1063 async fn index_handles_multiple_languages() {
1064 let root = TempDir::new().unwrap();
1065 let src = TempDir::new().unwrap();
1066 write_file(src.path(), "lib.rs", "fn rust_fn() {}");
1067 write_file(src.path(), "app.py", "def py_fn():\n pass\n");
1068
1069 let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
1070 assert_eq!(summary.files, 2, "both the .rs and .py file are indexed");
1071
1072 let store = FsStore::new(root.path());
1074 let manifest = Manifest::from_body(
1075 &store
1076 .get(&Manifest::key("r", "main"))
1077 .await
1078 .unwrap()
1079 .unwrap()
1080 .body,
1081 )
1082 .unwrap();
1083 let graph = gonzalo_graph::assemble(&manifest, &store).await.unwrap();
1084 assert_eq!(graph.definitions("rust_fn")[0].path, "lib.rs");
1085 assert_eq!(graph.definitions("py_fn")[0].path, "app.py");
1086 }
1087
1088 #[tokio::test]
1089 async fn index_writes_a_queryable_sqlite_graph() {
1090 let root = TempDir::new().unwrap();
1091 let src = TempDir::new().unwrap();
1092 write_file(
1093 src.path(),
1094 "lib.rs",
1095 "fn helper() {}\nfn main() { helper(); }",
1096 );
1097 index(root.path(), src.path(), "r", "main").await.unwrap();
1098
1099 let db = view_db_path(&root.path().join("graphs"), "r", "main");
1102 let g = SqliteGraphStore::open(&db).unwrap();
1103 assert_eq!(g.definitions("helper")[0].path, "lib.rs");
1104 assert_eq!(g.callers_of("helper"), vec!["main".to_string()]);
1105 }
1106
1107 #[tokio::test]
1108 async fn reindex_removes_deleted_paths_from_the_sqlite_graph() {
1109 let root = TempDir::new().unwrap();
1110 let src = TempDir::new().unwrap();
1111 write_file(src.path(), "keep.rs", "fn keep() {}");
1112 write_file(src.path(), "gone.rs", "fn gone() {}");
1113 index(root.path(), src.path(), "r", "v").await.unwrap();
1114
1115 std::fs::remove_file(src.path().join("gone.rs")).unwrap();
1116 index(root.path(), src.path(), "r", "v").await.unwrap();
1117
1118 let g =
1119 SqliteGraphStore::open(view_db_path(&root.path().join("graphs"), "r", "v")).unwrap();
1120 assert_eq!(g.definitions("keep").len(), 1);
1121 assert!(
1122 g.definitions("gone").is_empty(),
1123 "deleted file's symbols must be gone from the graph"
1124 );
1125 }
1126
1127 #[tokio::test]
1130 async fn build_desired_stages_graph_writes_without_touching_the_store() {
1131 let root = TempDir::new().unwrap();
1137 let src = TempDir::new().unwrap();
1138 write_file(src.path(), "a.rs", "fn a() {}");
1139
1140 let store = FsStore::new(root.path());
1141 let db_path = view_db_path(&root.path().join("graphs"), "r", "main");
1142 let mut graph = SqliteGraphStore::open(&db_path).unwrap();
1143
1144 let mut staging = GraphStaging::default();
1145 let (desired, files, _skipped, _ignored, incremental) = build_desired_full(
1146 &store,
1147 &mut staging,
1148 None,
1149 src.path(),
1150 &IndexFilter::default(),
1151 )
1152 .await
1153 .unwrap();
1154 assert!(!incremental);
1155 assert_eq!(files, 1);
1156 assert_eq!(desired.len(), 1);
1157 assert_eq!(staging.inserts.len(), 1, "the write is staged, not applied");
1158
1159 assert!(
1162 graph.definitions("a").is_empty(),
1163 "SqliteGraphStore must be untouched until the manifest commits"
1164 );
1165
1166 staging.apply(&mut graph);
1168 assert_eq!(graph.definitions("a")[0].path, "a.rs");
1169 }
1170
1171 fn git_init_commit(dir: &Path) {
1175 let repo = git2::Repository::init(dir).unwrap();
1176 let mut index = repo.index().unwrap();
1177 index
1178 .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
1179 .unwrap();
1180 index.write().unwrap();
1181 let tree = repo.find_tree(index.write_tree().unwrap()).unwrap();
1182 let sig = git2::Signature::now("t", "t@localhost").unwrap();
1183 repo.commit(Some("HEAD"), &sig, &sig, "c", &tree, &[])
1184 .unwrap();
1185 }
1186
1187 #[tokio::test]
1188 async fn a_format_change_forces_a_full_walk() {
1189 let root = TempDir::new().unwrap();
1193 let src = TempDir::new().unwrap();
1194 write_file(src.path(), "a.rs", "fn a() {}");
1195 git_init_commit(src.path());
1196 index(root.path(), src.path(), "r", "main").await.unwrap();
1197
1198 let fmt = view_db_path(&root.path().join("graphs"), "r", "main").with_extension("fmt");
1200 assert_eq!(
1201 std::fs::read_to_string(&fmt).unwrap(),
1202 EXTRACTION_VERSION.to_string()
1203 );
1204 assert!(
1205 index(root.path(), src.path(), "r", "main")
1206 .await
1207 .unwrap()
1208 .incremental
1209 );
1210
1211 std::fs::write(&fmt, "1").unwrap();
1213 let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
1214 assert!(!summary.incremental, "a format change must rebuild in full");
1215 assert_eq!(
1216 std::fs::read_to_string(&fmt).unwrap(),
1217 EXTRACTION_VERSION.to_string(),
1218 "and must record the version it rebuilt with"
1219 );
1220 }
1221
1222 #[tokio::test]
1223 async fn first_index_of_git_repo_is_full_then_reindex_is_incremental() {
1224 let root = TempDir::new().unwrap();
1225 let src = TempDir::new().unwrap();
1226 write_file(src.path(), "a.rs", "fn a() {}");
1227 write_file(src.path(), "b.rs", "fn b() {}");
1228 git_init_commit(src.path());
1229
1230 let first = index(root.path(), src.path(), "r", "main").await.unwrap();
1232 assert!(!first.incremental, "first index is a full walk");
1233 assert_eq!(first.files, 2);
1234 assert_eq!(first.added, 2);
1235
1236 write_file(src.path(), "a.rs", "fn a() { helper(); }");
1238 write_file(src.path(), "c.rs", "fn c() {}");
1239
1240 let second = index(root.path(), src.path(), "r", "main").await.unwrap();
1241 assert!(second.incremental, "second index uses the git diff driver");
1242 assert_eq!(second.files, 2, "only a.rs and c.rs are re-parsed");
1243 assert_eq!(second.added, 1, "c.rs added");
1244 assert_eq!(second.modified, 1, "a.rs modified");
1245 assert_eq!(second.deleted, 0);
1246
1247 let g =
1249 SqliteGraphStore::open(view_db_path(&root.path().join("graphs"), "r", "main")).unwrap();
1250 assert_eq!(g.definitions("b")[0].path, "b.rs");
1251 assert_eq!(g.definitions("c")[0].path, "c.rs");
1252 }
1253
1254 #[tokio::test]
1255 async fn incremental_index_drops_deleted_files() {
1256 let root = TempDir::new().unwrap();
1257 let src = TempDir::new().unwrap();
1258 write_file(src.path(), "keep.rs", "fn keep() {}");
1259 write_file(src.path(), "gone.rs", "fn gone() {}");
1260 git_init_commit(src.path());
1261 index(root.path(), src.path(), "r", "v").await.unwrap();
1262
1263 std::fs::remove_file(src.path().join("gone.rs")).unwrap();
1264 let summary = index(root.path(), src.path(), "r", "v").await.unwrap();
1265 assert!(summary.incremental);
1266 assert_eq!(summary.deleted, 1);
1267
1268 let g =
1269 SqliteGraphStore::open(view_db_path(&root.path().join("graphs"), "r", "v")).unwrap();
1270 assert_eq!(g.definitions("keep").len(), 1);
1271 assert!(g.definitions("gone").is_empty(), "deleted file is gone");
1272 }
1273
1274 #[tokio::test]
1275 async fn non_git_src_stays_full_walk() {
1276 let root = TempDir::new().unwrap();
1277 let src = TempDir::new().unwrap();
1278 write_file(src.path(), "a.rs", "fn a() {}");
1279 let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
1280 assert!(!summary.incremental, "a non-git tree cannot go incremental");
1281 }
1282
1283 #[tokio::test]
1286 async fn gc_on_empty_store_frees_nothing() {
1287 let root = TempDir::new().unwrap();
1288 let summary = gc(root.path()).await.unwrap();
1289 assert_eq!(summary.manifests, 0);
1290 assert_eq!(summary.freed, 0);
1291 assert_eq!(summary.retained, 0);
1292 }
1293
1294 #[tokio::test]
1295 async fn gc_frees_slices_orphaned_by_a_reindex() {
1296 let root = TempDir::new().unwrap();
1297 let src = TempDir::new().unwrap();
1298 write_file(src.path(), "a.rs", "fn a() {}");
1299 index(root.path(), src.path(), "r", "main").await.unwrap();
1300
1301 write_file(src.path(), "a.rs", "fn a() { b(); }");
1303 index(root.path(), src.path(), "r", "main").await.unwrap();
1304
1305 let summary = gc(root.path()).await.unwrap();
1306 assert_eq!(summary.manifests, 1);
1307 assert_eq!(summary.freed, 1, "the pre-edit slice is unreferenced");
1308 assert_eq!(summary.retained, 1, "the current slice stays");
1309
1310 let g =
1312 SqliteGraphStore::open(view_db_path(&root.path().join("graphs"), "r", "main")).unwrap();
1313 assert_eq!(g.definitions("a")[0].path, "a.rs");
1314 }
1315
1316 #[tokio::test]
1319 async fn index_excludes_vendored_bundles_from_the_view() {
1320 let root = TempDir::new().unwrap();
1321 let src = TempDir::new().unwrap();
1322 write_file(src.path(), "a.rs", "fn a() {}");
1323 write_file(src.path(), "mermaid.min.js", "var a=1,e=2,t=3;");
1324
1325 let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
1326 assert_eq!(summary.files, 1, "only the hand-written source");
1327 assert_eq!(summary.ignored.files, 1, "the minified bundle, reported");
1328
1329 let graph =
1330 SqliteGraphStore::open(view_db_path(&root.path().join("graphs"), "r", "main")).unwrap();
1331 assert!(
1332 graph.all_symbols().iter().all(|s| s.path == "a.rs"),
1333 "no symbol may come from a vendored bundle"
1334 );
1335 }
1336
1337 #[tokio::test]
1338 async fn incremental_reindex_prunes_paths_a_laxer_run_admitted() {
1339 let root = TempDir::new().unwrap();
1344 let src = TempDir::new().unwrap();
1345 write_file(src.path(), "a.rs", "fn a() {}");
1346 write_file(src.path(), "vendor.min.js", "var a=1,e=2;");
1347
1348 let lax = IndexFilter::new(&["vendor.min.js".to_string()]);
1351 let first = index_with(root.path(), src.path(), "r", "main", &lax)
1352 .await
1353 .unwrap();
1354 assert_eq!(first.files, 2, "the bundle is in the view to begin with");
1355
1356 let second = index_with(
1359 root.path(),
1360 src.path(),
1361 "r",
1362 "main",
1363 &IndexFilter::default(),
1364 )
1365 .await
1366 .unwrap();
1367 assert_eq!(second.deleted, 1, "the bundle is dropped from the view");
1368 assert_eq!(second.ignored.files, 1);
1369
1370 let graph =
1371 SqliteGraphStore::open(view_db_path(&root.path().join("graphs"), "r", "main")).unwrap();
1372 assert!(
1373 graph.all_symbols().iter().all(|s| s.path == "a.rs"),
1374 "no vendored symbol may survive the re-index"
1375 );
1376 }
1377
1378 #[tokio::test]
1379 async fn incremental_reindex_keeps_paths_the_filter_still_admits() {
1380 let root = TempDir::new().unwrap();
1382 let src = TempDir::new().unwrap();
1383 write_file(src.path(), "a.rs", "fn a() {}");
1384 write_file(src.path(), "b.rs", "fn b() {}");
1385 index(root.path(), src.path(), "r", "main").await.unwrap();
1386
1387 let second = index(root.path(), src.path(), "r", "main").await.unwrap();
1388 assert_eq!(second.deleted, 0, "nothing legitimate is pruned");
1389 assert_eq!(second.ignored.files, 0);
1390 }
1391
1392 #[tokio::test]
1393 async fn index_can_be_told_to_keep_a_vendored_path() {
1394 let root = TempDir::new().unwrap();
1395 let src = TempDir::new().unwrap();
1396 write_file(src.path(), "a.rs", "fn a() {}");
1397 write_file(src.path(), "keep.min.js", "function f(){}");
1398
1399 let filter = IndexFilter::new(&["keep.min.js".to_string()]);
1400 let summary = index_with(root.path(), src.path(), "r", "main", &filter)
1401 .await
1402 .unwrap();
1403 assert_eq!(summary.files, 2, "the override re-admits it");
1404 assert_eq!(summary.ignored.files, 0);
1405 }
1406
1407 #[tokio::test]
1408 async fn index_with_gc_sweeps_orphans_when_enabled() {
1409 let root = TempDir::new().unwrap();
1410 let src = TempDir::new().unwrap();
1411 write_file(src.path(), "a.rs", "fn a() {}");
1412 index(root.path(), src.path(), "r", "main").await.unwrap();
1413
1414 write_file(src.path(), "a.rs", "fn a() { b(); }");
1417 let (_summary, swept) = index_with_gc(root.path(), src.path(), "r", "main", true)
1418 .await
1419 .unwrap();
1420 let swept = swept.expect("gc runs when enabled");
1421 assert_eq!(swept.freed, 1, "orphaned slice swept during the index");
1422
1423 assert_eq!(gc(root.path()).await.unwrap().freed, 0);
1425 }
1426
1427 #[tokio::test]
1428 async fn index_with_gc_skips_sweep_when_disabled() {
1429 let root = TempDir::new().unwrap();
1430 let src = TempDir::new().unwrap();
1431 write_file(src.path(), "a.rs", "fn a() {}");
1432 index(root.path(), src.path(), "r", "main").await.unwrap();
1433
1434 write_file(src.path(), "a.rs", "fn a() { b(); }");
1435 let (_summary, swept) = index_with_gc(root.path(), src.path(), "r", "main", false)
1436 .await
1437 .unwrap();
1438 assert!(swept.is_none(), "no gc when disabled");
1439
1440 assert_eq!(gc(root.path()).await.unwrap().freed, 1);
1442 }
1443
1444 #[test]
1447 fn debouncer_not_due_without_events() {
1448 let d = Debouncer::new(Duration::from_millis(500));
1449 assert!(!d.is_pending());
1450 assert!(!d.is_due(Instant::now()));
1451 }
1452
1453 #[test]
1454 fn debouncer_due_only_after_quiet_window() {
1455 let t0 = Instant::now();
1456 let mut d = Debouncer::new(Duration::from_millis(500));
1457 d.on_event(t0);
1458 assert!(d.is_pending());
1459 assert!(!d.is_due(t0 + Duration::from_millis(499)));
1461 assert!(d.is_due(t0 + Duration::from_millis(500)));
1463 }
1464
1465 #[test]
1466 fn debouncer_coalesces_a_burst() {
1467 let t0 = Instant::now();
1468 let mut d = Debouncer::new(Duration::from_millis(500));
1469 d.on_event(t0);
1470 d.on_event(t0 + Duration::from_millis(200)); assert!(!d.is_due(t0 + Duration::from_millis(500)));
1473 assert!(d.is_due(t0 + Duration::from_millis(700)));
1475 }
1476
1477 #[test]
1478 fn debouncer_clear_resets_pending() {
1479 let t0 = Instant::now();
1480 let mut d = Debouncer::new(Duration::from_millis(500));
1481 d.on_event(t0);
1482 d.clear();
1483 assert!(!d.is_pending());
1484 assert!(!d.is_due(t0 + Duration::from_secs(10)));
1485 }
1486
1487 #[tokio::test]
1488 async fn gc_retains_slice_still_referenced_by_another_view() {
1489 let root = TempDir::new().unwrap();
1490
1491 let src_a = TempDir::new().unwrap();
1493 write_file(src_a.path(), "shared.rs", "fn shared() {}");
1494 index(root.path(), src_a.path(), "r", "a").await.unwrap();
1495
1496 let src_b = TempDir::new().unwrap();
1497 write_file(src_b.path(), "shared.rs", "fn shared() {}");
1498 index(root.path(), src_b.path(), "r", "b").await.unwrap();
1499
1500 std::fs::remove_file(src_a.path().join("shared.rs")).unwrap();
1502 index(root.path(), src_a.path(), "r", "a").await.unwrap();
1503
1504 let summary = gc(root.path()).await.unwrap();
1505 assert_eq!(summary.manifests, 2);
1506 assert_eq!(
1507 summary.freed, 0,
1508 "the shared slice is live via view B and must not be swept"
1509 );
1510
1511 let store = FsStore::new(root.path());
1513 let manifest = Manifest::from_body(
1514 &store
1515 .get(&Manifest::key("r", "b"))
1516 .await
1517 .unwrap()
1518 .unwrap()
1519 .body,
1520 )
1521 .unwrap();
1522 let graph = gonzalo_graph::assemble(&manifest, &store).await.unwrap();
1523 assert_eq!(graph.definitions("shared")[0].path, "shared.rs");
1524 }
1525}