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, worker_extraction_version};
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, SourceFiles, UnindexedCounts};
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(CLI_AUTHOR),
123 origin_system: "migrate".into(),
124 created: 0,
125 updated: 0,
126 labels: BTreeMap::new(),
127 },
128 links: vec![],
129 ancestors: Vec::new(),
130 deleted_at: None,
131 deleted_blob: None,
132 deleted_kind: None,
133 };
134
135 match store.put(record, None).await? {
136 PutResult::Committed(_) => imported += 1,
137 PutResult::Conflict(_) => skipped += 1,
138 }
139 }
140
141 Ok(MigrateSummary { imported, skipped })
142}
143
144fn collect_files(dir: &Path) -> Result<Vec<std::path::PathBuf>> {
146 let mut out = Vec::new();
147 collect_files_inner(dir, &mut out)?;
148 out.sort();
149 Ok(out)
150}
151
152fn collect_files_inner(dir: &Path, out: &mut Vec<std::path::PathBuf>) -> Result<()> {
153 let entries = std::fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))?;
156 for entry in entries {
157 let entry = entry?;
158 let ft = entry.file_type()?;
159 if ft.is_dir() {
160 collect_files_inner(&entry.path(), out)?;
161 } else if ft.is_file() {
162 out.push(entry.path());
163 }
164 }
165 Ok(())
166}
167
168pub struct IndexSummary {
172 pub files: usize,
174 pub added: usize,
176 pub modified: usize,
178 pub deleted: usize,
180 pub skipped: usize,
183 pub skipped_paths: Vec<String>,
186 pub ignored: IgnoredCounts,
190 pub unindexed: UnindexedCounts,
194 pub incremental: bool,
197}
198
199fn worker_file_name() -> &'static str {
201 if cfg!(windows) {
202 "gonzalo-parse-worker.exe"
203 } else {
204 "gonzalo-parse-worker"
205 }
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215#[non_exhaustive]
216pub enum WorkerSource {
217 Env,
219 Sibling,
221 Path,
224}
225
226impl WorkerSource {
227 fn describe(self) -> &'static str {
228 match self {
229 Self::Env => "GONZALO_PARSE_WORKER",
230 Self::Sibling => "beside the executable",
231 Self::Path => "on PATH",
232 }
233 }
234}
235
236#[derive(Debug, Clone, PartialEq, Eq)]
244#[non_exhaustive]
245pub enum ParseMode {
246 Isolated { path: PathBuf, source: WorkerSource },
248 InProcess { searched: Vec<PathBuf> },
252}
253
254impl ParseMode {
255 pub fn worker(&self) -> Option<&Path> {
257 match self {
258 Self::Isolated { path, .. } => Some(path),
259 Self::InProcess { .. } => None,
260 }
261 }
262
263 pub fn is_isolated(&self) -> bool {
265 matches!(self, Self::Isolated { .. })
266 }
267
268 pub fn summary(&self) -> String {
270 match self {
271 Self::Isolated { path, source } => {
272 format!("isolated worker {} ({})", path.display(), source.describe())
273 }
274 Self::InProcess { .. } => "in-process (no worker found)".to_string(),
275 }
276 }
277
278 pub fn warning(&self) -> Option<String> {
281 let Self::InProcess { searched } = self else {
282 return None;
283 };
284 let where_looked = if searched.is_empty() {
285 " (nowhere — no executable path and no PATH entries)".to_string()
286 } else {
287 searched
288 .iter()
289 .map(|p| format!(" {}", p.display()))
290 .collect::<Vec<_>>()
291 .join("\n")
292 };
293 Some(format!(
294 "gonzalo index: warning — parsing IN-PROCESS, not crash-isolated. A tree-sitter \
295 grammar that aborts will take down this run instead of skipping one file.\n\
296 Searched for {}:\n{where_looked}\n\
297 Install it (`cargo install gonzalo-parse`), put it on PATH or beside this \
298 executable, or point GONZALO_PARSE_WORKER at it. Use --require-parse-worker to \
299 make this a hard error.",
300 worker_file_name()
301 ))
302 }
303}
304
305fn resolve_worker_from(
313 env_override: Option<PathBuf>,
314 exe: Option<PathBuf>,
315 search_path: &[PathBuf],
316) -> ParseMode {
317 let name = worker_file_name();
318 let mut searched = Vec::new();
319
320 if let Some(path) = env_override {
321 if path.is_file() {
322 return ParseMode::Isolated {
323 path,
324 source: WorkerSource::Env,
325 };
326 }
327 searched.push(path);
328 }
329
330 if let Some(exe) = exe {
331 let sibling = exe.with_file_name(name);
332 if sibling.is_file() {
333 return ParseMode::Isolated {
334 path: sibling,
335 source: WorkerSource::Sibling,
336 };
337 }
338 searched.push(sibling);
339 }
340
341 for dir in search_path {
342 let candidate = dir.join(name);
343 if candidate.is_file() {
344 return ParseMode::Isolated {
345 path: candidate,
346 source: WorkerSource::Path,
347 };
348 }
349 searched.push(candidate);
350 }
351
352 ParseMode::InProcess { searched }
353}
354
355pub fn resolve_parse_worker() -> ParseMode {
360 let search_path: Vec<PathBuf> = std::env::var_os("PATH")
361 .map(|p| std::env::split_paths(&p).collect())
362 .unwrap_or_default();
363 resolve_worker_from(
364 std::env::var_os("GONZALO_PARSE_WORKER").map(PathBuf::from),
365 std::env::current_exe().ok(),
366 &search_path,
367 )
368}
369
370async fn parse_file(
374 pool: Option<&ParserPool>,
375 language: Language,
376 content: &str,
377 rel: &str,
378) -> Option<CodeGraph> {
379 match pool {
380 Some(pool) => match pool.parse(language, content).await {
381 Ok(graph) => Some(graph),
382 Err(e) => {
383 eprintln!("{}", skip_message(rel, &e.to_string()));
384 None
385 }
386 },
387 None => Some(build(language, content)),
388 }
389}
390
391fn skip_message(rel: &str, error: &str) -> String {
398 format!("gonzalo index: skipping {rel} — parse worker error: {error}")
399}
400
401pub fn named_skips(paths: &[String]) -> String {
404 const MOST: usize = 5;
405 if paths.is_empty() {
406 return String::new();
407 }
408 let listed = paths
409 .iter()
410 .take(MOST)
411 .map(String::as_str)
412 .collect::<Vec<_>>()
413 .join(", ");
414 let more = paths.len().saturating_sub(MOST);
415 if more > 0 {
416 format!("{listed}, and {more} more")
417 } else {
418 listed
419 }
420}
421
422pub async fn index(root: &Path, src: &Path, repo: &str, view: &str) -> Result<IndexSummary> {
434 index_with(root, src, repo, view, &IndexFilter::default()).await
435}
436
437pub async fn index_with(
442 root: &Path,
443 src: &Path,
444 repo: &str,
445 view: &str,
446 filter: &IndexFilter,
447) -> Result<IndexSummary> {
448 let mode = resolve_parse_worker();
449 index_with_worker(root, src, repo, view, filter, mode.worker()).await
450}
451
452fn worker_version_warning(reported: u32) -> String {
461 if reported == 0 {
462 format!(
463 "gonzalo index: note — the parse worker did not report an extraction version, so \
464 it predates `--extraction-version` (gonzalo 0.5.0 or earlier). Its output may \
465 well match this build's version {EXTRACTION_VERSION}, but that cannot be \
466 confirmed, so the view is credited `unknown` and rebuilt in full. Upgrading \
467 gonzalo-parse-worker alongside gonzalo clears this and rebuilds once more."
468 )
469 } else {
470 format!(
471 "gonzalo index: WARNING — the parse worker reports extraction version {reported}, \
472 but this build records {EXTRACTION_VERSION}. The two disagree about what a parse \
473 records. Parsing anyway, rebuilding the view in full and crediting it to the \
474 worker; install a matching gonzalo-parse-worker to get {EXTRACTION_VERSION} \
475 extraction."
476 )
477 }
478}
479
480fn warn_once(msg: &str) {
486 static ONCE: std::sync::Once = std::sync::Once::new();
487 ONCE.call_once(|| eprintln!("{msg}"));
488}
489
490const WORKER_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
494
495pub async fn index_with_worker(
501 root: &Path,
502 src: &Path,
503 repo: &str,
504 view: &str,
505 filter: &IndexFilter,
506 worker: Option<&Path>,
507) -> Result<IndexSummary> {
508 let store = FsStore::new(root);
509
510 let parse_version = match worker {
516 Some(bin) => worker_extraction_version(bin, WORKER_PROBE_TIMEOUT)
517 .await
518 .unwrap_or(0),
519 None => EXTRACTION_VERSION,
520 };
521 if parse_version != EXTRACTION_VERSION {
522 warn_once(&worker_version_warning(parse_version));
523 }
524
525 let pool = worker.map(|bin| {
529 let workers = std::thread::available_parallelism()
530 .map(|n| n.get())
531 .unwrap_or(4)
532 .clamp(1, 8);
533 ParserPool::new(bin, workers, std::time::Duration::from_secs(30))
534 });
535
536 let db_path = view_db_path(&root.join("graphs"), repo, view);
538 let mut graph = SqliteGraphStore::open(&db_path)
539 .with_context(|| format!("opening graph db for {repo}/{view}"))?;
540
541 let key = Manifest::key(repo, view);
543 let existing = store.get(&key).await?;
544 let current = match &existing {
545 Some(rec) => Manifest::from_body(&rec.body)?,
546 None => Manifest::new(),
547 };
548
549 let base_path = db_path.with_extension("base");
552 let version_path = db_path.with_extension("fmt");
558 let recorded_version: Option<u32> = std::fs::read_to_string(&version_path)
559 .ok()
560 .and_then(|s| s.trim().parse().ok());
561 let format_changed = recorded_version != Some(parse_version);
562
563 let recorded_base = std::fs::read_to_string(&base_path)
564 .ok()
565 .map(|s| s.trim().to_string())
566 .filter(|s| !s.is_empty())
567 .filter(|_| !format_changed);
568 let incremental_changed = recorded_base
569 .as_deref()
570 .and_then(|base| gonzalo_store_git::changed_paths(src, base).ok());
571
572 let mut staging = GraphStaging::default();
577 let DesiredView {
578 entries: desired,
579 files,
580 skipped_paths,
581 ignored,
582 unindexed,
583 incremental,
584 } = match incremental_changed {
585 Some(changed) => {
586 build_desired_incremental(
587 &store,
588 &mut staging,
589 pool.as_ref(),
590 src,
591 ¤t,
592 &changed,
593 filter,
594 )
595 .await?
596 }
597 None => build_desired_full(&store, &mut staging, pool.as_ref(), src, filter).await?,
598 };
599
600 let recon = current.reconcile(&desired);
603 for path in &recon.deleted {
604 staging.removes.push(path.clone());
605 }
606
607 let body = recon.manifest.to_body();
609 let (revision, expected, parent) = match &existing {
610 Some(rec) => (
611 rec.revision.next(body.bytes()),
612 Some(rec.revision.clone()),
613 Some(rec.revision.clone()),
614 ),
615 None => (Revision::initial(body.bytes()), None, None),
616 };
617 let record = Record {
618 key,
619 kind: RecordKind::GraphManifest,
620 revision,
621 parent,
622 body,
623 meta: Meta {
624 author: Identity::new("gonzalo-index"),
625 origin_system: "gonzalo-index".into(),
626 created: 0,
627 updated: 0,
628 labels: BTreeMap::new(),
629 },
630 links: Vec::new(),
631 ancestors: Vec::new(),
632 deleted_at: None,
633 deleted_blob: None,
634 deleted_kind: None,
635 };
636 match store.put(record, expected).await? {
637 PutResult::Committed(_) => {}
638 PutResult::Conflict(_) => {
639 anyhow::bail!("manifest for {repo}/{view} changed concurrently; retry the index")
644 }
645 }
646
647 let newly_referenced: Vec<(String, ContentHash)> = recon
651 .added
652 .iter()
653 .chain(recon.modified.iter())
654 .filter_map(|p| recon.manifest.get(p).map(|h| (p.clone(), h.clone())))
655 .collect();
656 let restored = ensure_slices_present(&store, &newly_referenced, &staging).await?;
661 if restored > 0 {
662 eprintln!("restored {restored} slice blob(s) swept during this index run");
663 }
664
665 staging.apply(&mut graph);
667
668 if let Ok(sha) = gonzalo_store_git::head_commit(src) {
671 if let Some(parent) = base_path.parent() {
672 std::fs::create_dir_all(parent).ok();
673 }
674 std::fs::write(&base_path, sha).ok();
675 }
676 if let Some(parent) = version_path.parent() {
682 std::fs::create_dir_all(parent).ok();
683 std::fs::write(&version_path, parse_version.to_string()).ok();
684 }
685
686 Ok(IndexSummary {
687 files,
688 added: recon.added.len(),
689 modified: recon.modified.len(),
690 deleted: recon.deleted.len(),
691 skipped: skipped_paths.len(),
692 skipped_paths,
693 ignored,
694 unindexed,
695 incremental,
696 })
697}
698
699#[derive(Default)]
704struct GraphStaging {
705 inserts: Vec<(String, CodeGraph)>,
708 removes: Vec<String>,
710}
711
712impl GraphStaging {
713 fn apply(self, graph: &mut SqliteGraphStore) {
716 for (rel, slice) in self.inserts {
717 graph.insert(&rel, slice);
718 }
719 for rel in self.removes {
720 graph.remove_path(&rel);
721 }
722 }
723}
724
725struct DesiredView {
733 entries: BTreeMap<String, ContentHash>,
734 files: usize,
736 skipped_paths: Vec<String>,
738 ignored: IgnoredCounts,
739 unindexed: UnindexedCounts,
740 incremental: bool,
742}
743
744async fn ensure_slices_present(
753 store: &FsStore,
754 wanted: &[(String, ContentHash)],
755 staging: &GraphStaging,
756) -> anyhow::Result<usize> {
757 let mut restored = 0;
758 for (path, hash) in wanted {
759 if store.has_blob(hash).await? {
760 continue;
761 }
762 let Some((_, slice)) = staging.inserts.iter().find(|(p, _)| p == path) else {
763 anyhow::bail!(
764 "slice blob {} for {path} is missing and this run did not parse it, \
765 so it cannot be restored; re-index the view",
766 hash.0
767 );
768 };
769 let put = store.put_blob(&slice.to_slice_bytes()).await?;
770 anyhow::ensure!(
771 &put == hash,
772 "restored slice for {path} hashes to {} rather than the committed {}",
773 put.0,
774 hash.0
775 );
776 restored += 1;
777 }
778 Ok(restored)
779}
780
781async fn build_desired_full(
782 store: &FsStore,
783 staging: &mut GraphStaging,
784 pool: Option<&ParserPool>,
785 src: &Path,
786 filter: &IndexFilter,
787) -> Result<DesiredView> {
788 let mut desired: BTreeMap<String, ContentHash> = BTreeMap::new();
789 let mut skipped_paths: Vec<String> = Vec::new();
790 let walk::SourceFiles {
791 files: sources,
792 ignored,
793 unindexed,
794 } = walk::source_files(src, filter)?;
795 for (path, language) in sources {
796 let content = std::fs::read_to_string(&path)
797 .with_context(|| format!("reading {}", path.display()))?;
798 let rel = path
799 .strip_prefix(src)
800 .unwrap_or(&path)
801 .to_string_lossy()
802 .replace('\\', "/");
803 let Some(slice) = parse_file(pool, language, &content, &rel).await else {
804 skipped_paths.push(rel.clone());
805 continue;
806 };
807 let hash = store.put_blob(&slice.to_slice_bytes()).await?;
808 staging.inserts.push((rel.clone(), slice)); desired.insert(rel, hash);
810 }
811 let files = desired.len();
812 Ok(DesiredView {
813 entries: desired,
814 files,
815 skipped_paths,
816 ignored,
817 unindexed,
818 incremental: false,
819 })
820}
821
822async fn build_desired_incremental(
827 store: &FsStore,
828 staging: &mut GraphStaging,
829 pool: Option<&ParserPool>,
830 src: &Path,
831 current: &Manifest,
832 changed: &gonzalo_store_git::ChangedPaths,
833 filter: &IndexFilter,
834) -> Result<DesiredView> {
835 let mut desired = current.entries.clone();
836 let mut files = 0usize;
837 let mut skipped_paths: Vec<String> = Vec::new();
838 let mut ignored = IgnoredCounts::default();
841 let mut unindexed = UnindexedCounts::default();
842
843 let stale = walk::stale_entries(src, filter, desired.keys().map(String::as_str));
851 for rel in stale {
852 desired.remove(&rel);
853 staging.removes.push(rel);
854 ignored.files += 1;
855 }
856
857 for rel in changed.added.iter().chain(changed.modified.iter()) {
858 if !filter.is_indexable(rel) {
859 if desired.remove(rel).is_some() {
862 staging.removes.push(rel.clone());
863 }
864 ignored.files += 1;
865 continue;
866 }
867 let Some(language) = Path::new(rel)
868 .extension()
869 .and_then(|e| e.to_str())
870 .and_then(Language::from_extension)
871 else {
872 unindexed.record(Path::new(rel).extension().and_then(|e| e.to_str()));
877 continue;
878 };
879 let content = match std::fs::read_to_string(src.join(rel)) {
882 Ok(c) => c,
883 Err(_) => {
884 if desired.remove(rel).is_some() {
885 staging.removes.push(rel.clone());
886 }
887 continue;
888 }
889 };
890 let Some(slice) = parse_file(pool, language, &content, rel).await else {
891 skipped_paths.push(rel.clone());
892 continue;
893 };
894 let hash = store.put_blob(&slice.to_slice_bytes()).await?;
895 staging.inserts.push((rel.clone(), slice));
896 desired.insert(rel.clone(), hash);
897 files += 1;
898 }
899
900 for rel in &changed.deleted {
901 if desired.remove(rel).is_some() {
902 staging.removes.push(rel.clone());
903 }
904 }
905
906 Ok(DesiredView {
907 entries: desired,
908 files,
909 skipped_paths,
910 ignored,
911 unindexed,
912 incremental: true,
913 })
914}
915
916pub struct GcSummary {
920 pub scanned: usize,
922 pub freed: usize,
924 pub retained: usize,
926 pub deferred: usize,
930}
931
932pub async fn gc(root: &Path, min_age: Duration) -> Result<GcSummary> {
947 let store = FsStore::new(root);
948
949 let keys = store.list_raw(&KeyPrefix::default()).await?;
952 let mut records = Vec::with_capacity(keys.len());
953 for key in &keys {
954 if let Some(record) = store.get_raw(key).await? {
955 records.push(record);
956 }
957 }
958
959 let live = gonzalo_core::live_blob_hashes(&records)?;
960 let policy = gonzalo_core::SweepPolicy {
961 min_age,
962 now: std::time::SystemTime::now(),
963 };
964 let report = gonzalo_core::sweep_blobs_with(&store, &live, policy).await?;
965 Ok(GcSummary {
966 scanned: records.len(),
967 freed: report.freed.len(),
968 retained: report.retained,
969 deferred: report.deferred,
970 })
971}
972
973#[derive(Debug, Clone)]
978pub struct IndexOptions {
979 pub gc_after: bool,
981 pub min_age: Duration,
983 pub filter: IndexFilter,
985 pub worker: Option<PathBuf>,
987}
988
989impl Default for IndexOptions {
990 fn default() -> Self {
995 Self {
996 gc_after: false,
997 min_age: gonzalo_core::DEFAULT_MIN_AGE,
998 filter: IndexFilter::default(),
999 worker: None,
1000 }
1001 }
1002}
1003
1004pub async fn index_with_options(
1015 root: &Path,
1016 src: &Path,
1017 repo: &str,
1018 view: &str,
1019 opts: &IndexOptions,
1020) -> Result<(IndexSummary, Option<GcSummary>)> {
1021 let resolved;
1022 let worker = match &opts.worker {
1023 Some(path) => Some(path.as_path()),
1024 None => {
1025 resolved = resolve_parse_worker();
1026 resolved.worker()
1027 }
1028 };
1029 let summary = index_with_worker(root, src, repo, view, &opts.filter, worker).await?;
1030 let swept = if opts.gc_after {
1031 Some(gc(root, opts.min_age).await?)
1032 } else {
1033 None
1034 };
1035 Ok((summary, swept))
1036}
1037
1038pub async fn index_with_gc(
1041 root: &Path,
1042 src: &Path,
1043 repo: &str,
1044 view: &str,
1045 gc_after: bool,
1046 min_age: Duration,
1047) -> Result<(IndexSummary, Option<GcSummary>)> {
1048 index_with_options(
1049 root,
1050 src,
1051 repo,
1052 view,
1053 &IndexOptions {
1054 gc_after,
1055 min_age,
1056 ..IndexOptions::default()
1057 },
1058 )
1059 .await
1060}
1061
1062#[derive(Debug)]
1069pub struct Debouncer {
1070 window: Duration,
1071 last_event: Option<Instant>,
1073}
1074
1075impl Debouncer {
1076 pub fn new(window: Duration) -> Self {
1078 Self {
1079 window,
1080 last_event: None,
1081 }
1082 }
1083
1084 pub fn on_event(&mut self, now: Instant) {
1086 self.last_event = Some(now);
1087 }
1088
1089 pub fn is_pending(&self) -> bool {
1091 self.last_event.is_some()
1092 }
1093
1094 pub fn is_due(&self, now: Instant) -> bool {
1098 matches!(self.last_event, Some(t) if now.duration_since(t) >= self.window)
1099 }
1100
1101 pub fn clear(&mut self) {
1103 self.last_event = None;
1104 }
1105}
1106
1107pub fn open_store(root: &Path, ancestor_cap: usize) -> Result<FsStore> {
1112 FsStore::new(root)
1113 .with_ancestor_cap(ancestor_cap)
1114 .with_context(|| format!("opening store at {}", root.display()))
1115}
1116
1117#[derive(Clone, Debug, PartialEq, Eq)]
1119pub enum DeleteOutcome {
1120 Deleted,
1122 Conflict { current: Revision },
1125}
1126
1127pub const CLI_AUTHOR: &str = "gonzalo-cli";
1132
1133pub async fn delete(
1136 root: &Path,
1137 ancestor_cap: usize,
1138 namespace: &str,
1139 collection: &str,
1140 id: &str,
1141 expected: Option<Revision>,
1142) -> Result<DeleteOutcome> {
1143 let store = open_store(root, ancestor_cap)?;
1144 let key = RecordKey::new(namespace, collection, id);
1145 let author = Some(Identity::new(CLI_AUTHOR));
1146 Ok(match store.delete_as(&key, expected, author).await? {
1147 gonzalo_core::DeleteResult::Deleted => DeleteOutcome::Deleted,
1148 gonzalo_core::DeleteResult::Conflict(conflict) => DeleteOutcome::Conflict {
1149 current: conflict.current.revision,
1150 },
1151 })
1152}
1153
1154pub async fn undelete(
1159 root: &Path,
1160 ancestor_cap: usize,
1161 namespace: &str,
1162 collection: &str,
1163 id: &str,
1164) -> Result<Revision> {
1165 let store = open_store(root, ancestor_cap)?;
1166 let key = RecordKey::new(namespace, collection, id);
1167 let author = Identity::new(CLI_AUTHOR);
1168 Ok(gonzalo_core::undelete(&store, &key, gonzalo_core::now_ms(), Some(&author)).await?)
1169}
1170
1171pub async fn purge(
1186 root: &Path,
1187 ancestor_cap: usize,
1188 namespace: &str,
1189 collection: &str,
1190 id: &str,
1191) -> Result<Revision> {
1192 let store = open_store(root, ancestor_cap)?;
1193 let key = RecordKey::new(namespace, collection, id);
1194 let Some(record) = store.get_raw(&key).await? else {
1195 anyhow::bail!("record not found: {key}");
1196 };
1197 if !record.is_tombstone() {
1198 anyhow::bail!(
1199 "{key} is live at revision {}; purge only discards an existing tombstone. \
1200 To remove the record use `gonzalo delete`: it keeps a restore window \
1201 for manifest kinds, and purging that tombstone would discard it.",
1202 record.revision.counter
1203 );
1204 }
1205 let revision = record.revision.clone();
1206 if let gonzalo_core::DeleteResult::Conflict(_) = store.purge(&key, revision.clone()).await? {
1211 anyhow::bail!("{key} changed while purging; nothing was removed, retry");
1212 }
1213 Ok(revision)
1214}
1215
1216pub async fn reset(
1220 root: &Path,
1221 ancestor_cap: usize,
1222 namespace: String,
1223 collection: Option<String>,
1224) -> Result<gonzalo_core::ResetReport> {
1225 let store = open_store(root, ancestor_cap)?;
1226 let prefix = KeyPrefix {
1227 namespace: Some(namespace),
1228 collection,
1229 };
1230 Ok(gonzalo_core::reset_as(&store, &prefix, Some(Identity::new(CLI_AUTHOR))).await?)
1231}
1232
1233pub const EXIT_CONFLICT: u8 = 3;
1238
1239pub fn reset_exit_code(report: &gonzalo_core::ResetReport) -> u8 {
1242 if report.conflicts.is_empty() {
1243 0
1244 } else {
1245 EXIT_CONFLICT
1246 }
1247}
1248
1249pub async fn collect(
1252 root: &Path,
1253 ancestor_cap: usize,
1254 namespace: Option<String>,
1255 collection: Option<String>,
1256 horizon: Duration,
1257 now_ms: i64,
1258) -> Result<gonzalo_core::CollectReport> {
1259 let store = open_store(root, ancestor_cap)?;
1260 let prefix = KeyPrefix {
1261 namespace,
1262 collection,
1263 };
1264 Ok(gonzalo_core::collect(&store, &prefix, horizon, now_ms).await?)
1265}
1266
1267#[derive(Clone, Debug, PartialEq, Eq)]
1270pub struct Horizon {
1271 pub raw: String,
1272 pub duration: Duration,
1273}
1274
1275pub fn parse_duration(raw: &str) -> std::result::Result<Duration, String> {
1280 let s = raw.trim();
1281 let split = s
1282 .find(|c: char| !c.is_ascii_digit())
1283 .ok_or_else(|| format!("{raw:?}: missing unit (use d, h, m or s, e.g. 30d)"))?;
1284 let (digits, unit) = s.split_at(split);
1285 if digits.is_empty() {
1286 return Err(format!(
1287 "{raw:?}: expected a number before the unit, e.g. 30d"
1288 ));
1289 }
1290 let per_unit: u64 = match unit {
1291 "d" => 86_400,
1292 "h" => 3_600,
1293 "m" => 60,
1294 "s" => 1,
1295 _ => return Err(format!("{raw:?}: unknown unit {unit:?} (use d, h, m or s)")),
1296 };
1297 let n: u64 = digits
1298 .parse()
1299 .map_err(|_| format!("{raw:?}: duration is too large"))?;
1300 if n == 0 {
1301 return Err(format!("{raw:?}: horizon must be greater than zero"));
1302 }
1303 let secs = n
1304 .checked_mul(per_unit)
1305 .ok_or_else(|| format!("{raw:?}: duration is too large"))?;
1306 Ok(Duration::from_secs(secs))
1307}
1308
1309pub fn parse_horizon(raw: &str) -> std::result::Result<Horizon, String> {
1311 Ok(Horizon {
1312 raw: raw.trim().to_string(),
1313 duration: parse_duration(raw)?,
1314 })
1315}
1316
1317pub fn parse_revision(raw: &str) -> std::result::Result<Revision, String> {
1320 serde_json::from_str(raw).map_err(|e| {
1321 format!(r#"expected a revision as JSON, e.g. {{"counter":1,"hash":"…"}} ({e})"#)
1322 })
1323}
1324
1325#[derive(Clone, Debug, Default, PartialEq, Eq)]
1329pub struct SyncSummary {
1330 pub copied_to_a: usize,
1331 pub copied_to_b: usize,
1332 pub fast_forwarded: usize,
1335 pub merged: usize,
1336 pub conflicts: Vec<RecordKey>,
1339 pub unconverged: Vec<RecordKey>,
1343}
1344
1345pub fn sync_exit_code(summary: &SyncSummary) -> u8 {
1350 if summary.conflicts.is_empty() && summary.unconverged.is_empty() {
1351 0
1352 } else {
1353 EXIT_CONFLICT
1354 }
1355}
1356
1357pub async fn sync_stores(a: &Path, b: &Path) -> Result<SyncSummary> {
1360 sync_stores_with_cap(a, b, gonzalo_core::DEFAULT_ANCESTOR_CAP).await
1361}
1362
1363pub async fn sync_stores_with_cap(a: &Path, b: &Path, ancestor_cap: usize) -> Result<SyncSummary> {
1365 let store_a = open_store(a, ancestor_cap)?;
1366 let store_b = open_store(b, ancestor_cap)?;
1367 let report = gonzalo_core::sync(&store_a, &store_b).await?;
1368 Ok(SyncSummary {
1369 copied_to_a: report.copied_to_a.len(),
1370 copied_to_b: report.copied_to_b.len(),
1371 fast_forwarded: report.fast_forwarded_to_a.len() + report.fast_forwarded_to_b.len(),
1372 merged: report.merged.len(),
1373 conflicts: report.conflicts.into_iter().map(|c| c.key).collect(),
1374 unconverged: report.unconverged,
1375 })
1376}
1377
1378pub struct TicketSyncReport {
1382 pub connection: String,
1383 pub summary: IngestSummary,
1384}
1385
1386pub async fn ticket_sync(
1389 config_path: &Path,
1390 root: &Path,
1391 author: &str,
1392) -> Result<Vec<TicketSyncReport>> {
1393 let config = Config::load(config_path).context("loading ticket config")?;
1394 let store = FsStore::new(root);
1395 let mut reports = Vec::new();
1396 for (name, source) in config.sources().context("building ticket sources")? {
1397 let summary = gonzalo_ticket::ingest(source.as_ref(), &store, author, Some(&name))
1400 .await
1401 .with_context(|| format!("syncing connection {name}"))?;
1402 reports.push(TicketSyncReport {
1403 connection: name,
1404 summary,
1405 });
1406 }
1407 Ok(reports)
1408}
1409
1410pub async fn ticket_move(
1415 config_path: &Path,
1416 connection: Option<&str>,
1417 uid: &str,
1418 category: &str,
1419) -> Result<()> {
1420 let cat = parse_category(category)
1421 .ok_or_else(|| anyhow::anyhow!("unknown state category {category:?}"))?;
1422 let config = Config::load(config_path).context("loading ticket config")?;
1423 let conn = select_connection(&config.connections, connection)?;
1424 let source = gonzalo_ticket_config::build_source(conn).context("building ticket source")?;
1425 source
1426 .set_state(uid, cat)
1427 .await
1428 .with_context(|| format!("moving {uid} to {category}"))?;
1429 Ok(())
1430}
1431
1432fn select_connection<'a>(
1434 connections: &'a [Connection],
1435 name: Option<&str>,
1436) -> Result<&'a Connection> {
1437 match name {
1438 Some(n) => connections
1439 .iter()
1440 .find(|c| c.name == n)
1441 .ok_or_else(|| anyhow::anyhow!("no connection named {n:?}")),
1442 None => match connections {
1443 [one] => Ok(one),
1444 [] => Err(anyhow::anyhow!("no connections configured")),
1445 _ => Err(anyhow::anyhow!(
1446 "multiple connections configured; pass --connection <name>"
1447 )),
1448 },
1449 }
1450}
1451
1452#[cfg(test)]
1455mod tests {
1456
1457 #[test]
1464 fn default_index_options_keep_the_gc_grace_period() {
1465 let opts = IndexOptions::default();
1466 assert!(!opts.gc_after, "a sweep is opt-in");
1467 assert_eq!(
1468 opts.min_age,
1469 gonzalo_core::DEFAULT_MIN_AGE,
1470 "a defaulted min_age of zero sweeps blobs a writer is about to reference"
1471 );
1472 }
1473 use super::*;
1474 use gonzalo_graph::GraphStore;
1475 use tempfile::TempDir;
1476
1477 fn write_file(dir: &Path, name: &str, contents: &str) {
1478 std::fs::write(dir.join(name), contents).unwrap();
1479 }
1480
1481 async fn index(root: &Path, src: &Path, repo: &str, view: &str) -> Result<IndexSummary> {
1494 index_with(root, src, repo, view, &IndexFilter::default()).await
1495 }
1496
1497 async fn index_with(
1498 root: &Path,
1499 src: &Path,
1500 repo: &str,
1501 view: &str,
1502 filter: &IndexFilter,
1503 ) -> Result<IndexSummary> {
1504 index_with_worker(root, src, repo, view, filter, None).await
1505 }
1506
1507 async fn index_with_gc(
1508 root: &Path,
1509 src: &Path,
1510 repo: &str,
1511 view: &str,
1512 gc_after: bool,
1513 min_age: Duration,
1514 ) -> Result<(IndexSummary, Option<GcSummary>)> {
1515 index_with_options(
1516 root,
1517 src,
1518 repo,
1519 view,
1520 &IndexOptions {
1521 gc_after,
1522 min_age,
1523 ..IndexOptions::default()
1524 },
1525 )
1526 .await
1527 }
1528
1529 #[tokio::test]
1532 async fn migrate_imports_two_files() {
1533 let root = TempDir::new().unwrap();
1534 let src = TempDir::new().unwrap();
1535
1536 write_file(src.path(), "alpha.md", "hello alpha");
1537 write_file(src.path(), "beta.md", "hello beta");
1538
1539 let summary = migrate(
1540 root.path(),
1541 src.path(),
1542 "testns",
1543 "testcol",
1544 RecordKind::Topic,
1545 )
1546 .await
1547 .unwrap();
1548
1549 assert_eq!(summary.imported, 2, "should have imported 2 files");
1550 assert_eq!(summary.skipped, 0, "nothing should be skipped yet");
1551 }
1552
1553 #[tokio::test]
1556 async fn list_returns_migrated_keys() {
1557 let root = TempDir::new().unwrap();
1558 let src = TempDir::new().unwrap();
1559
1560 write_file(src.path(), "alpha.md", "hello alpha");
1561 write_file(src.path(), "beta.md", "hello beta");
1562
1563 migrate(
1564 root.path(),
1565 src.path(),
1566 "testns",
1567 "testcol",
1568 RecordKind::Topic,
1569 )
1570 .await
1571 .unwrap();
1572
1573 let keys = list(root.path(), None, None).await.unwrap();
1574 assert_eq!(keys.len(), 2);
1575 }
1576
1577 #[tokio::test]
1580 async fn migrate_is_idempotent() {
1581 let root = TempDir::new().unwrap();
1582 let src = TempDir::new().unwrap();
1583
1584 write_file(src.path(), "alpha.md", "hello alpha");
1585 write_file(src.path(), "beta.md", "hello beta");
1586
1587 migrate(
1588 root.path(),
1589 src.path(),
1590 "testns",
1591 "testcol",
1592 RecordKind::Topic,
1593 )
1594 .await
1595 .unwrap();
1596
1597 let second = migrate(
1598 root.path(),
1599 src.path(),
1600 "testns",
1601 "testcol",
1602 RecordKind::Topic,
1603 )
1604 .await
1605 .unwrap();
1606
1607 assert_eq!(
1608 second.skipped, 2,
1609 "second run should skip both already-imported files"
1610 );
1611 assert_eq!(second.imported, 0, "second run should import nothing new");
1612 }
1613
1614 #[tokio::test]
1617 async fn get_returns_migrated_record_body() {
1618 let root = TempDir::new().unwrap();
1619 let src = TempDir::new().unwrap();
1620
1621 write_file(src.path(), "alpha.md", "hello alpha");
1622
1623 migrate(
1624 root.path(),
1625 src.path(),
1626 "testns",
1627 "testcol",
1628 RecordKind::Topic,
1629 )
1630 .await
1631 .unwrap();
1632
1633 let record = get(root.path(), "testns", "testcol", "alpha.md")
1635 .await
1636 .unwrap();
1637
1638 assert!(record.is_some(), "record should be present");
1639 let body = record.unwrap().body;
1640 assert_eq!(body.bytes(), b"hello alpha");
1641 }
1642
1643 #[tokio::test]
1646 async fn status_groups_by_ns_col() {
1647 let root = TempDir::new().unwrap();
1648 let src = TempDir::new().unwrap();
1649
1650 write_file(src.path(), "alpha.md", "hello alpha");
1651 write_file(src.path(), "beta.md", "hello beta");
1652
1653 migrate(
1654 root.path(),
1655 src.path(),
1656 "testns",
1657 "testcol",
1658 RecordKind::Topic,
1659 )
1660 .await
1661 .unwrap();
1662
1663 let map = status(root.path()).await.unwrap();
1664 assert_eq!(map.get("testns/testcol").copied(), Some(2));
1665 }
1666
1667 #[tokio::test]
1670 async fn sync_stores_copies_to_b() {
1671 let store_a = TempDir::new().unwrap();
1672 let store_b = TempDir::new().unwrap();
1673 let src = TempDir::new().unwrap();
1674
1675 write_file(src.path(), "note.md", "synced content");
1676
1677 migrate(
1679 store_a.path(),
1680 src.path(),
1681 "testns",
1682 "testcol",
1683 RecordKind::Topic,
1684 )
1685 .await
1686 .unwrap();
1687
1688 let summary = sync_stores(store_a.path(), store_b.path()).await.unwrap();
1689 assert_eq!(summary.copied_to_b, 1);
1690
1691 let keys = list(store_b.path(), None, None).await.unwrap();
1693 assert_eq!(keys.len(), 1);
1694 }
1695
1696 #[tokio::test]
1697 async fn sync_stores_fast_forwards_a_peer_that_is_behind() {
1698 let store_a = TempDir::new().unwrap();
1699 let store_b = TempDir::new().unwrap();
1700 let src = TempDir::new().unwrap();
1701 write_file(src.path(), "note.md", "synced content");
1702 migrate(
1703 store_a.path(),
1704 src.path(),
1705 "testns",
1706 "testcol",
1707 RecordKind::Topic,
1708 )
1709 .await
1710 .unwrap();
1711 let _ = sync_stores(store_a.path(), store_b.path()).await.unwrap();
1712
1713 let fs_a = FsStore::new(store_a.path());
1715 let key = fs_a.list(&KeyPrefix::default()).await.unwrap().remove(0);
1716 let cur = fs_a.get(&key).await.unwrap().unwrap();
1717 let mut next = cur.clone();
1718 next.body = Body::Inline(b"synced content\nmore\n".to_vec());
1719 next.revision = cur.revision.next(next.body.bytes());
1720 next.parent = Some(cur.revision.clone());
1721 next.ancestors = Vec::new();
1722 assert!(matches!(
1723 fs_a.put(next, Some(cur.revision)).await.unwrap(),
1724 PutResult::Committed(_)
1725 ));
1726
1727 let summary = sync_stores(store_a.path(), store_b.path()).await.unwrap();
1728 assert_eq!(summary.fast_forwarded, 1);
1729 assert_eq!(summary.merged, 0);
1730 assert!(summary.conflicts.is_empty());
1731 assert!(summary.unconverged.is_empty());
1732 }
1733
1734 #[tokio::test]
1737 async fn ticket_sync_with_no_connections_returns_no_reports() {
1738 let root = TempDir::new().unwrap();
1739 let cfg = TempDir::new().unwrap();
1740 let cfg_path = cfg.path().join("tickets.toml");
1741 std::fs::write(&cfg_path, "").unwrap(); let reports = ticket_sync(&cfg_path, root.path(), "tester").await.unwrap();
1744 assert!(reports.is_empty());
1745 }
1746
1747 #[tokio::test]
1750 async fn ticket_move_unknown_category_errors() {
1751 let cfg = TempDir::new().unwrap();
1752 let cfg_path = cfg.path().join("tickets.toml");
1753 std::fs::write(
1754 &cfg_path,
1755 "[[connection]]\nname=\"b\"\nprovider=\"github-projects\"\norg=\"o\"\nproject=1\ntoken_env=\"X\"\n",
1756 )
1757 .unwrap();
1758 let err = ticket_move(&cfg_path, None, "o/r#1", "frozen")
1760 .await
1761 .unwrap_err();
1762 assert!(err.to_string().contains("category"), "got {err}");
1763 }
1764
1765 #[tokio::test]
1768 async fn ticket_move_requires_connection_when_many() {
1769 let cfg = TempDir::new().unwrap();
1770 let cfg_path = cfg.path().join("tickets.toml");
1771 std::fs::write(
1772 &cfg_path,
1773 "[[connection]]\nname=\"a\"\nprovider=\"github-projects\"\norg=\"o\"\nproject=1\ntoken_env=\"X\"\n\
1774 [[connection]]\nname=\"b\"\nprovider=\"github-projects\"\norg=\"o\"\nproject=2\ntoken_env=\"Y\"\n",
1775 )
1776 .unwrap();
1777 let err = ticket_move(&cfg_path, None, "o/r#1", "done")
1778 .await
1779 .unwrap_err();
1780 assert!(err.to_string().contains("connection"), "got {err}");
1781 }
1782
1783 #[tokio::test]
1790 async fn index_over_a_deleted_manifest_is_refused_with_guidance() {
1791 let root = TempDir::new().unwrap();
1792 let src = TempDir::new().unwrap();
1793 write_file(src.path(), "a.rs", "fn a() {}");
1794
1795 index(root.path(), src.path(), "r", "main").await.unwrap();
1796
1797 let store = open_store(root.path(), gonzalo_core::DEFAULT_ANCESTOR_CAP).unwrap();
1799 let key = Manifest::key("r", "main");
1800 assert_eq!(
1801 store.delete(&key, None).await.unwrap(),
1802 gonzalo_core::DeleteResult::Deleted
1803 );
1804 let tomb = store.get_raw(&key).await.unwrap().expect("tombstone");
1805 let retained = tomb.body.clone();
1806
1807 let err = index(root.path(), src.path(), "r", "main")
1809 .await
1810 .err()
1811 .expect("indexing over a deleted manifest must be refused");
1812 let msg = format!("{err:#}");
1813 assert!(
1814 msg.contains("gonzalo undelete") && msg.contains("gonzalo purge"),
1815 "the refusal must tell the operator both ways out, got {msg}"
1816 );
1817
1818 let after = store
1821 .get_raw(&key)
1822 .await
1823 .unwrap()
1824 .expect("tombstone survives");
1825 assert!(after.is_tombstone());
1826 assert_eq!(after.body, retained, "the retained body was not touched");
1827
1828 purge(
1832 root.path(),
1833 gonzalo_core::DEFAULT_ANCESTOR_CAP,
1834 &key.namespace,
1835 &key.collection,
1836 &key.id,
1837 )
1838 .await
1839 .unwrap();
1840 index(root.path(), src.path(), "r", "main")
1841 .await
1842 .expect("indexing succeeds once the tombstone is purged");
1843 let live = store.get_raw(&key).await.unwrap().expect("manifest");
1844 assert!(!live.is_tombstone(), "the view is live again");
1845 }
1846
1847 #[tokio::test]
1848 async fn index_builds_a_queryable_view() {
1849 let root = TempDir::new().unwrap();
1850 let src = TempDir::new().unwrap();
1851 write_file(src.path(), "a.rs", "fn helper() {}");
1852 std::fs::create_dir(src.path().join("sub")).unwrap();
1853 write_file(src.path(), "sub/b.rs", "fn caller() { helper(); }");
1854
1855 let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
1856 assert_eq!(summary.files, 2);
1857 assert_eq!(summary.added, 2);
1858 assert_eq!(summary.modified, 0);
1859 assert_eq!(summary.deleted, 0);
1860
1861 let store = FsStore::new(root.path());
1863 let manifest = Manifest::from_body(
1864 &store
1865 .get(&Manifest::key("r", "main"))
1866 .await
1867 .unwrap()
1868 .unwrap()
1869 .body,
1870 )
1871 .unwrap();
1872 let graph = gonzalo_graph::assemble(&manifest, &store).await.unwrap();
1873 let defs = graph.definitions("helper");
1874 assert_eq!(defs.len(), 1);
1875 assert_eq!(defs[0].path, "a.rs");
1876 assert_eq!(graph.callers_of("helper"), vec!["caller".to_string()]);
1877 assert!(
1878 graph
1879 .symbols_in_file("sub/b.rs")
1880 .iter()
1881 .any(|s| s.name == "caller")
1882 );
1883 }
1884
1885 #[tokio::test]
1886 async fn reindex_reports_modifications_and_deletions() {
1887 let root = TempDir::new().unwrap();
1888 let src = TempDir::new().unwrap();
1889 write_file(src.path(), "keep.rs", "fn keep() {}");
1890 write_file(src.path(), "gone.rs", "fn gone() {}");
1891 index(root.path(), src.path(), "r", "v").await.unwrap();
1892
1893 write_file(src.path(), "keep.rs", "fn keep() { extra(); }");
1895 std::fs::remove_file(src.path().join("gone.rs")).unwrap();
1896
1897 let summary = index(root.path(), src.path(), "r", "v").await.unwrap();
1898 assert_eq!(summary.files, 1);
1899 assert_eq!(summary.added, 0);
1900 assert_eq!(summary.modified, 1);
1901 assert_eq!(summary.deleted, 1);
1902 }
1903
1904 #[tokio::test]
1905 async fn index_skips_non_rust_and_build_dirs() {
1906 let root = TempDir::new().unwrap();
1907 let src = TempDir::new().unwrap();
1908 write_file(src.path(), "real.rs", "fn real() {}");
1909 write_file(src.path(), "notes.txt", "not source");
1910 std::fs::create_dir(src.path().join("target")).unwrap();
1911 write_file(src.path(), "target/gen.rs", "fn generated() {}");
1912
1913 let summary = index(root.path(), src.path(), "r", "v").await.unwrap();
1914 assert_eq!(summary.files, 1, "only real.rs is indexed");
1915 assert_eq!(summary.added, 1);
1916 }
1917
1918 #[tokio::test]
1919 async fn index_handles_multiple_languages() {
1920 let root = TempDir::new().unwrap();
1921 let src = TempDir::new().unwrap();
1922 write_file(src.path(), "lib.rs", "fn rust_fn() {}");
1923 write_file(src.path(), "app.py", "def py_fn():\n pass\n");
1924
1925 let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
1926 assert_eq!(summary.files, 2, "both the .rs and .py file are indexed");
1927
1928 let store = FsStore::new(root.path());
1930 let manifest = Manifest::from_body(
1931 &store
1932 .get(&Manifest::key("r", "main"))
1933 .await
1934 .unwrap()
1935 .unwrap()
1936 .body,
1937 )
1938 .unwrap();
1939 let graph = gonzalo_graph::assemble(&manifest, &store).await.unwrap();
1940 assert_eq!(graph.definitions("rust_fn")[0].path, "lib.rs");
1941 assert_eq!(graph.definitions("py_fn")[0].path, "app.py");
1942 }
1943
1944 #[tokio::test]
1945 async fn index_writes_a_queryable_sqlite_graph() {
1946 let root = TempDir::new().unwrap();
1947 let src = TempDir::new().unwrap();
1948 write_file(
1949 src.path(),
1950 "lib.rs",
1951 "fn helper() {}\nfn main() { helper(); }",
1952 );
1953 index(root.path(), src.path(), "r", "main").await.unwrap();
1954
1955 let db = view_db_path(&root.path().join("graphs"), "r", "main");
1958 let g = SqliteGraphStore::open(&db).unwrap();
1959 assert_eq!(g.definitions("helper")[0].path, "lib.rs");
1960 assert_eq!(g.callers_of("helper"), vec!["main".to_string()]);
1961 }
1962
1963 #[tokio::test]
1964 async fn reindex_removes_deleted_paths_from_the_sqlite_graph() {
1965 let root = TempDir::new().unwrap();
1966 let src = TempDir::new().unwrap();
1967 write_file(src.path(), "keep.rs", "fn keep() {}");
1968 write_file(src.path(), "gone.rs", "fn gone() {}");
1969 index(root.path(), src.path(), "r", "v").await.unwrap();
1970
1971 std::fs::remove_file(src.path().join("gone.rs")).unwrap();
1972 index(root.path(), src.path(), "r", "v").await.unwrap();
1973
1974 let g =
1975 SqliteGraphStore::open(view_db_path(&root.path().join("graphs"), "r", "v")).unwrap();
1976 assert_eq!(g.definitions("keep").len(), 1);
1977 assert!(
1978 g.definitions("gone").is_empty(),
1979 "deleted file's symbols must be gone from the graph"
1980 );
1981 }
1982
1983 #[tokio::test]
1986 async fn build_desired_stages_graph_writes_without_touching_the_store() {
1987 let root = TempDir::new().unwrap();
1993 let src = TempDir::new().unwrap();
1994 write_file(src.path(), "a.rs", "fn a() {}");
1995
1996 let store = FsStore::new(root.path());
1997 let db_path = view_db_path(&root.path().join("graphs"), "r", "main");
1998 let mut graph = SqliteGraphStore::open(&db_path).unwrap();
1999
2000 let mut staging = GraphStaging::default();
2001 let DesiredView {
2002 entries: desired,
2003 files,
2004 incremental,
2005 ..
2006 } = build_desired_full(
2007 &store,
2008 &mut staging,
2009 None,
2010 src.path(),
2011 &IndexFilter::default(),
2012 )
2013 .await
2014 .unwrap();
2015 assert!(!incremental);
2016 assert_eq!(files, 1);
2017 assert_eq!(desired.len(), 1);
2018 assert_eq!(staging.inserts.len(), 1, "the write is staged, not applied");
2019
2020 assert!(
2023 graph.definitions("a").is_empty(),
2024 "SqliteGraphStore must be untouched until the manifest commits"
2025 );
2026
2027 staging.apply(&mut graph);
2029 assert_eq!(graph.definitions("a")[0].path, "a.rs");
2030 }
2031
2032 fn git_init_commit(dir: &Path) {
2036 let repo = git2::Repository::init(dir).unwrap();
2037 let mut index = repo.index().unwrap();
2038 index
2039 .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
2040 .unwrap();
2041 index.write().unwrap();
2042 let tree = repo.find_tree(index.write_tree().unwrap()).unwrap();
2043 let sig = git2::Signature::now("t", "t@localhost").unwrap();
2044 repo.commit(Some("HEAD"), &sig, &sig, "c", &tree, &[])
2045 .unwrap();
2046 }
2047
2048 #[test]
2051 fn an_unknown_worker_version_does_not_claim_the_extraction_is_wrong() {
2052 let msg = worker_version_warning(0);
2057 assert!(msg.contains("did not report"), "{msg}");
2058 assert!(msg.contains("cannot be confirmed"), "{msg}");
2059 assert!(
2060 !msg.contains("WARNING"),
2061 "unconfirmed is a note, not a warning: {msg}"
2062 );
2063 assert!(
2064 !msg.contains(&format!("to get {EXTRACTION_VERSION} extraction")),
2065 "must not claim the recorded extraction is wrong: {msg}"
2066 );
2067 }
2068
2069 #[test]
2070 fn a_known_mismatch_still_warns_plainly() {
2071 let msg = worker_version_warning(1);
2074 assert!(msg.contains("WARNING"), "{msg}");
2075 assert!(msg.contains("reports extraction version 1"), "{msg}");
2076 assert!(msg.contains(&EXTRACTION_VERSION.to_string()), "{msg}");
2077 }
2078
2079 fn touch(dir: &Path, name: &str) -> PathBuf {
2083 let path = dir.join(name);
2084 std::fs::write(&path, "").unwrap();
2085 path
2086 }
2087
2088 #[test]
2089 fn the_env_override_wins_over_everything_else() {
2090 let over = TempDir::new().unwrap();
2091 let beside = TempDir::new().unwrap();
2092 let on_path = TempDir::new().unwrap();
2093 let chosen = touch(over.path(), "my-worker");
2094 touch(beside.path(), worker_file_name());
2095 touch(on_path.path(), worker_file_name());
2096
2097 let mode = resolve_worker_from(
2098 Some(chosen.clone()),
2099 Some(beside.path().join("gonzalo")),
2100 &[on_path.path().to_path_buf()],
2101 );
2102 assert_eq!(
2103 mode,
2104 ParseMode::Isolated {
2105 path: chosen,
2106 source: WorkerSource::Env
2107 }
2108 );
2109 }
2110
2111 #[test]
2112 fn a_sibling_of_the_executable_is_preferred_over_path() {
2113 let beside = TempDir::new().unwrap();
2114 let on_path = TempDir::new().unwrap();
2115 let sibling = touch(beside.path(), worker_file_name());
2116 touch(on_path.path(), worker_file_name());
2117
2118 let mode = resolve_worker_from(
2119 None,
2120 Some(beside.path().join("gonzalo")),
2121 &[on_path.path().to_path_buf()],
2122 );
2123 assert_eq!(
2124 mode,
2125 ParseMode::Isolated {
2126 path: sibling,
2127 source: WorkerSource::Sibling
2128 }
2129 );
2130 }
2131
2132 #[test]
2133 fn the_worker_is_found_on_path_when_it_is_not_beside_the_executable() {
2134 let beside = TempDir::new().unwrap();
2138 let empty = TempDir::new().unwrap();
2139 let on_path = TempDir::new().unwrap();
2140 let found = touch(on_path.path(), worker_file_name());
2141
2142 let mode = resolve_worker_from(
2143 None,
2144 Some(beside.path().join("gonzalo")),
2145 &[empty.path().to_path_buf(), on_path.path().to_path_buf()],
2146 );
2147 assert_eq!(
2148 mode,
2149 ParseMode::Isolated {
2150 path: found,
2151 source: WorkerSource::Path
2152 }
2153 );
2154 }
2155
2156 #[test]
2157 fn no_worker_anywhere_falls_back_and_names_every_place_it_looked() {
2158 let missing = TempDir::new().unwrap();
2159 let beside = TempDir::new().unwrap();
2160 let on_path = TempDir::new().unwrap();
2161 let override_path = missing.path().join("not-here");
2162
2163 let mode = resolve_worker_from(
2164 Some(override_path.clone()),
2165 Some(beside.path().join("gonzalo")),
2166 &[on_path.path().to_path_buf()],
2167 );
2168
2169 let ParseMode::InProcess { searched } = &mode else {
2170 panic!("expected the in-process fallback, got {mode:?}");
2171 };
2172 assert_eq!(
2174 searched,
2175 &[
2176 override_path,
2177 beside.path().join(worker_file_name()),
2178 on_path.path().join(worker_file_name()),
2179 ]
2180 );
2181 assert!(!mode.is_isolated());
2182 assert!(mode.worker().is_none());
2183
2184 let warning = mode.warning().expect("in-process must warn");
2187 assert!(warning.contains(worker_file_name()));
2188 assert!(warning.contains(&beside.path().join(worker_file_name()).display().to_string()));
2189 assert!(warning.contains("--require-parse-worker"));
2190 assert!(warning.contains("IN-PROCESS"));
2191 }
2192
2193 #[test]
2194 fn an_isolated_mode_names_its_worker_and_never_warns() {
2195 let dir = TempDir::new().unwrap();
2196 let worker = touch(dir.path(), worker_file_name());
2197 let mode = resolve_worker_from(None, Some(dir.path().join("gonzalo")), &[]);
2198
2199 assert_eq!(mode.worker(), Some(worker.as_path()));
2200 assert!(mode.warning().is_none(), "isolation is the quiet path");
2201 let summary = mode.summary();
2202 assert!(summary.contains("isolated"));
2203 assert!(summary.contains(&worker.display().to_string()));
2204 assert!(summary.contains("beside the executable"));
2205 }
2206
2207 #[test]
2208 fn a_directory_named_like_the_worker_is_not_mistaken_for_it() {
2209 let on_path = TempDir::new().unwrap();
2212 std::fs::create_dir(on_path.path().join(worker_file_name())).unwrap();
2213 let mode = resolve_worker_from(None, None, &[on_path.path().to_path_buf()]);
2214 assert!(!mode.is_isolated());
2215 }
2216
2217 fn worker_reporting(dir: &Path, name: &str, reports: Option<u32>) -> PathBuf {
2223 use std::os::unix::fs::PermissionsExt;
2224 let answer = match reports {
2225 None => String::new(),
2228 Some(v) => format!("[ \"$1\" = --extraction-version ] && {{ echo {v}; exit 0; }}\n"),
2229 };
2230 let path = dir.join(name);
2231 std::fs::write(
2232 &path,
2233 format!(
2234 "#!/bin/sh\n{answer}while IFS= read -r _; do \
2235 printf '%s\\n' '{{\"symbols\":[],\"references\":[]}}'; done\n"
2236 ),
2237 )
2238 .unwrap();
2239 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
2240 path
2241 }
2242
2243 fn fmt_of(root: &Path) -> String {
2244 let p = view_db_path(&root.join("graphs"), "r", "main").with_extension("fmt");
2245 std::fs::read_to_string(p).unwrap()
2246 }
2247
2248 #[tokio::test]
2249 async fn a_stale_worker_forces_a_full_walk_and_is_never_credited() {
2250 let root = TempDir::new().unwrap();
2256 let src = TempDir::new().unwrap();
2257 write_file(src.path(), "a.rs", "fn a() {}");
2258 git_init_commit(src.path());
2259 let bins = TempDir::new().unwrap();
2260
2261 let current = worker_reporting(bins.path(), "current", Some(EXTRACTION_VERSION));
2263 let filter = IndexFilter::default();
2264 index_with_worker(
2265 root.path(),
2266 src.path(),
2267 "r",
2268 "main",
2269 &filter,
2270 Some(current.as_path()),
2271 )
2272 .await
2273 .unwrap();
2274 assert_eq!(fmt_of(root.path()), EXTRACTION_VERSION.to_string());
2275 assert!(
2276 index_with_worker(
2277 root.path(),
2278 src.path(),
2279 "r",
2280 "main",
2281 &filter,
2282 Some(current.as_path())
2283 )
2284 .await
2285 .unwrap()
2286 .incremental,
2287 "a worker at the current version still indexes incrementally"
2288 );
2289
2290 let stale = worker_reporting(bins.path(), "stale", Some(EXTRACTION_VERSION - 1));
2292 let summary = index_with_worker(
2293 root.path(),
2294 src.path(),
2295 "r",
2296 "main",
2297 &filter,
2298 Some(stale.as_path()),
2299 )
2300 .await
2301 .unwrap();
2302 assert!(
2303 !summary.incremental,
2304 "a worker that disagrees with the CLI must rebuild in full"
2305 );
2306 assert_eq!(
2307 fmt_of(root.path()),
2308 (EXTRACTION_VERSION - 1).to_string(),
2309 "and the view must be credited to what actually parsed, not to the CLI"
2310 );
2311
2312 let after = index_with_worker(
2315 root.path(),
2316 src.path(),
2317 "r",
2318 "main",
2319 &filter,
2320 Some(current.as_path()),
2321 )
2322 .await
2323 .unwrap();
2324 assert!(!after.incremental, "restoring the worker re-walks the view");
2325 assert_eq!(fmt_of(root.path()), EXTRACTION_VERSION.to_string());
2326 }
2327
2328 #[tokio::test]
2329 async fn a_worker_that_cannot_report_its_version_is_recorded_as_unknown() {
2330 let root = TempDir::new().unwrap();
2339 let src = TempDir::new().unwrap();
2340 write_file(src.path(), "a.rs", "fn a() {}");
2341 git_init_commit(src.path());
2342 let bins = TempDir::new().unwrap();
2343 let silent = worker_reporting(bins.path(), "silent", None);
2344 let filter = IndexFilter::default();
2345
2346 index_with_worker(
2347 root.path(),
2348 src.path(),
2349 "r",
2350 "main",
2351 &filter,
2352 Some(silent.as_path()),
2353 )
2354 .await
2355 .unwrap();
2356 assert_eq!(fmt_of(root.path()), "0", "unknown is recorded, not assumed");
2357
2358 let current = worker_reporting(bins.path(), "current", Some(EXTRACTION_VERSION));
2361 let healed = index_with_worker(
2362 root.path(),
2363 src.path(),
2364 "r",
2365 "main",
2366 &filter,
2367 Some(current.as_path()),
2368 )
2369 .await
2370 .unwrap();
2371 assert!(
2372 !healed.incremental,
2373 "a view built by an unknown parser must be rebuilt once a known one appears"
2374 );
2375 assert_eq!(fmt_of(root.path()), EXTRACTION_VERSION.to_string());
2376 }
2377
2378 #[tokio::test]
2379 async fn in_process_parsing_is_credited_to_this_build() {
2380 let root = TempDir::new().unwrap();
2383 let src = TempDir::new().unwrap();
2384 write_file(src.path(), "a.rs", "fn a() {}");
2385 git_init_commit(src.path());
2386
2387 index_with_worker(
2388 root.path(),
2389 src.path(),
2390 "r",
2391 "main",
2392 &IndexFilter::default(),
2393 None,
2394 )
2395 .await
2396 .unwrap();
2397 assert_eq!(fmt_of(root.path()), EXTRACTION_VERSION.to_string());
2398 }
2399
2400 #[tokio::test]
2401 async fn a_format_change_forces_a_full_walk() {
2402 let root = TempDir::new().unwrap();
2406 let src = TempDir::new().unwrap();
2407 write_file(src.path(), "a.rs", "fn a() {}");
2408 git_init_commit(src.path());
2409 index(root.path(), src.path(), "r", "main").await.unwrap();
2410
2411 let fmt = view_db_path(&root.path().join("graphs"), "r", "main").with_extension("fmt");
2413 assert_eq!(
2414 std::fs::read_to_string(&fmt).unwrap(),
2415 EXTRACTION_VERSION.to_string()
2416 );
2417 assert!(
2418 index(root.path(), src.path(), "r", "main")
2419 .await
2420 .unwrap()
2421 .incremental
2422 );
2423
2424 std::fs::write(&fmt, "1").unwrap();
2426 let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
2427 assert!(!summary.incremental, "a format change must rebuild in full");
2428 assert_eq!(
2429 std::fs::read_to_string(&fmt).unwrap(),
2430 EXTRACTION_VERSION.to_string(),
2431 "and must record the version it rebuilt with"
2432 );
2433 }
2434
2435 #[tokio::test]
2436 async fn first_index_of_git_repo_is_full_then_reindex_is_incremental() {
2437 let root = TempDir::new().unwrap();
2438 let src = TempDir::new().unwrap();
2439 write_file(src.path(), "a.rs", "fn a() {}");
2440 write_file(src.path(), "b.rs", "fn b() {}");
2441 git_init_commit(src.path());
2442
2443 let first = index(root.path(), src.path(), "r", "main").await.unwrap();
2445 assert!(!first.incremental, "first index is a full walk");
2446 assert_eq!(first.files, 2);
2447 assert_eq!(first.added, 2);
2448
2449 write_file(src.path(), "a.rs", "fn a() { helper(); }");
2451 write_file(src.path(), "c.rs", "fn c() {}");
2452
2453 let second = index(root.path(), src.path(), "r", "main").await.unwrap();
2454 assert!(second.incremental, "second index uses the git diff driver");
2455 assert_eq!(second.files, 2, "only a.rs and c.rs are re-parsed");
2456 assert_eq!(second.added, 1, "c.rs added");
2457 assert_eq!(second.modified, 1, "a.rs modified");
2458 assert_eq!(second.deleted, 0);
2459
2460 let g =
2462 SqliteGraphStore::open(view_db_path(&root.path().join("graphs"), "r", "main")).unwrap();
2463 assert_eq!(g.definitions("b")[0].path, "b.rs");
2464 assert_eq!(g.definitions("c")[0].path, "c.rs");
2465 }
2466
2467 #[tokio::test]
2468 async fn incremental_index_drops_deleted_files() {
2469 let root = TempDir::new().unwrap();
2470 let src = TempDir::new().unwrap();
2471 write_file(src.path(), "keep.rs", "fn keep() {}");
2472 write_file(src.path(), "gone.rs", "fn gone() {}");
2473 git_init_commit(src.path());
2474 index(root.path(), src.path(), "r", "v").await.unwrap();
2475
2476 std::fs::remove_file(src.path().join("gone.rs")).unwrap();
2477 let summary = index(root.path(), src.path(), "r", "v").await.unwrap();
2478 assert!(summary.incremental);
2479 assert_eq!(summary.deleted, 1);
2480
2481 let g =
2482 SqliteGraphStore::open(view_db_path(&root.path().join("graphs"), "r", "v")).unwrap();
2483 assert_eq!(g.definitions("keep").len(), 1);
2484 assert!(g.definitions("gone").is_empty(), "deleted file is gone");
2485 }
2486
2487 #[tokio::test]
2488 async fn non_git_src_stays_full_walk() {
2489 let root = TempDir::new().unwrap();
2490 let src = TempDir::new().unwrap();
2491 write_file(src.path(), "a.rs", "fn a() {}");
2492 let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
2493 assert!(!summary.incremental, "a non-git tree cannot go incremental");
2494 }
2495
2496 #[tokio::test]
2499 async fn gc_on_empty_store_frees_nothing() {
2500 let root = TempDir::new().unwrap();
2501 let summary = gc(root.path(), Duration::ZERO).await.unwrap();
2502 assert_eq!(summary.scanned, 0);
2503 assert_eq!(summary.freed, 0);
2504 assert_eq!(summary.retained, 0);
2505 }
2506
2507 #[tokio::test]
2508 async fn gc_frees_slices_orphaned_by_a_reindex() {
2509 let root = TempDir::new().unwrap();
2510 let src = TempDir::new().unwrap();
2511 write_file(src.path(), "a.rs", "fn a() {}");
2512 index(root.path(), src.path(), "r", "main").await.unwrap();
2513
2514 write_file(src.path(), "a.rs", "fn a() { b(); }");
2516 index(root.path(), src.path(), "r", "main").await.unwrap();
2517
2518 let summary = gc(root.path(), Duration::ZERO).await.unwrap();
2519 assert_eq!(summary.scanned, 1, "the view's manifest record");
2520 assert_eq!(summary.freed, 1, "the pre-edit slice is unreferenced");
2521 assert_eq!(summary.retained, 1, "the current slice stays");
2522
2523 let g =
2525 SqliteGraphStore::open(view_db_path(&root.path().join("graphs"), "r", "main")).unwrap();
2526 assert_eq!(g.definitions("a")[0].path, "a.rs");
2527 }
2528
2529 #[test]
2532 fn the_default_min_age_spelling_parses_to_an_hour() {
2533 assert_eq!(
2534 parse_duration("1h").unwrap(),
2535 std::time::Duration::from_secs(3600)
2536 );
2537 assert_eq!(parse_duration("1h").unwrap(), gonzalo_core::DEFAULT_MIN_AGE);
2538 }
2539
2540 #[tokio::test]
2541 async fn gc_defers_a_blob_younger_than_min_age_and_counts_it() {
2542 let dir = tempfile::tempdir().unwrap();
2543 let store = FsStore::new(dir.path());
2544 store.put_blob(b"brand new").await.unwrap();
2545
2546 let held = gc(dir.path(), gonzalo_core::DEFAULT_MIN_AGE).await.unwrap();
2548 assert_eq!(held.freed, 0);
2549 assert_eq!(held.deferred, 1);
2550
2551 let swept = gc(dir.path(), std::time::Duration::ZERO).await.unwrap();
2553 assert_eq!(swept.freed, 1);
2554 assert_eq!(swept.deferred, 0);
2555 }
2556
2557 #[tokio::test]
2560 async fn index_excludes_vendored_bundles_from_the_view() {
2561 let root = TempDir::new().unwrap();
2562 let src = TempDir::new().unwrap();
2563 write_file(src.path(), "a.rs", "fn a() {}");
2564 write_file(src.path(), "mermaid.min.js", "var a=1,e=2,t=3;");
2565
2566 let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
2567 assert_eq!(summary.files, 1, "only the hand-written source");
2568 assert_eq!(summary.ignored.files, 1, "the minified bundle, reported");
2569
2570 let graph =
2571 SqliteGraphStore::open(view_db_path(&root.path().join("graphs"), "r", "main")).unwrap();
2572 assert!(
2573 graph.all_symbols().iter().all(|s| s.path == "a.rs"),
2574 "no symbol may come from a vendored bundle"
2575 );
2576 }
2577
2578 #[tokio::test]
2579 async fn incremental_reindex_prunes_paths_a_laxer_run_admitted() {
2580 let root = TempDir::new().unwrap();
2585 let src = TempDir::new().unwrap();
2586 write_file(src.path(), "a.rs", "fn a() {}");
2587 write_file(src.path(), "vendor.min.js", "var a=1,e=2;");
2588
2589 let lax = IndexFilter::new(&["vendor.min.js".to_string()]);
2592 let first = index_with(root.path(), src.path(), "r", "main", &lax)
2593 .await
2594 .unwrap();
2595 assert_eq!(first.files, 2, "the bundle is in the view to begin with");
2596
2597 let second = index_with(
2600 root.path(),
2601 src.path(),
2602 "r",
2603 "main",
2604 &IndexFilter::default(),
2605 )
2606 .await
2607 .unwrap();
2608 assert_eq!(second.deleted, 1, "the bundle is dropped from the view");
2609 assert_eq!(second.ignored.files, 1);
2610
2611 let graph =
2612 SqliteGraphStore::open(view_db_path(&root.path().join("graphs"), "r", "main")).unwrap();
2613 assert!(
2614 graph.all_symbols().iter().all(|s| s.path == "a.rs"),
2615 "no vendored symbol may survive the re-index"
2616 );
2617 }
2618
2619 #[tokio::test]
2620 async fn incremental_reindex_keeps_paths_the_filter_still_admits() {
2621 let root = TempDir::new().unwrap();
2623 let src = TempDir::new().unwrap();
2624 write_file(src.path(), "a.rs", "fn a() {}");
2625 write_file(src.path(), "b.rs", "fn b() {}");
2626 index(root.path(), src.path(), "r", "main").await.unwrap();
2627
2628 let second = index(root.path(), src.path(), "r", "main").await.unwrap();
2629 assert_eq!(second.deleted, 0, "nothing legitimate is pruned");
2630 assert_eq!(second.ignored.files, 0);
2631 }
2632
2633 #[tokio::test]
2634 async fn index_can_be_told_to_keep_a_vendored_path() {
2635 let root = TempDir::new().unwrap();
2636 let src = TempDir::new().unwrap();
2637 write_file(src.path(), "a.rs", "fn a() {}");
2638 write_file(src.path(), "keep.min.js", "function f(){}");
2639
2640 let filter = IndexFilter::new(&["keep.min.js".to_string()]);
2641 let summary = index_with(root.path(), src.path(), "r", "main", &filter)
2642 .await
2643 .unwrap();
2644 assert_eq!(summary.files, 2, "the override re-admits it");
2645 assert_eq!(summary.ignored.files, 0);
2646 }
2647
2648 #[tokio::test]
2649 async fn index_with_gc_sweeps_orphans_when_enabled() {
2650 let root = TempDir::new().unwrap();
2651 let src = TempDir::new().unwrap();
2652 write_file(src.path(), "a.rs", "fn a() {}");
2653 index(root.path(), src.path(), "r", "main").await.unwrap();
2654
2655 write_file(src.path(), "a.rs", "fn a() { b(); }");
2658 let (_summary, swept) =
2659 index_with_gc(root.path(), src.path(), "r", "main", true, Duration::ZERO)
2660 .await
2661 .unwrap();
2662 let swept = swept.expect("gc runs when enabled");
2663 assert_eq!(swept.freed, 1, "orphaned slice swept during the index");
2664
2665 assert_eq!(gc(root.path(), Duration::ZERO).await.unwrap().freed, 0);
2667 }
2668
2669 #[tokio::test]
2670 async fn index_with_gc_skips_sweep_when_disabled() {
2671 let root = TempDir::new().unwrap();
2672 let src = TempDir::new().unwrap();
2673 write_file(src.path(), "a.rs", "fn a() {}");
2674 index(root.path(), src.path(), "r", "main").await.unwrap();
2675
2676 write_file(src.path(), "a.rs", "fn a() { b(); }");
2677 let (_summary, swept) =
2678 index_with_gc(root.path(), src.path(), "r", "main", false, Duration::ZERO)
2679 .await
2680 .unwrap();
2681 assert!(swept.is_none(), "no gc when disabled");
2682
2683 assert_eq!(gc(root.path(), Duration::ZERO).await.unwrap().freed, 1);
2685 }
2686
2687 #[test]
2690 fn debouncer_not_due_without_events() {
2691 let d = Debouncer::new(Duration::from_millis(500));
2692 assert!(!d.is_pending());
2693 assert!(!d.is_due(Instant::now()));
2694 }
2695
2696 #[test]
2697 fn debouncer_due_only_after_quiet_window() {
2698 let t0 = Instant::now();
2699 let mut d = Debouncer::new(Duration::from_millis(500));
2700 d.on_event(t0);
2701 assert!(d.is_pending());
2702 assert!(!d.is_due(t0 + Duration::from_millis(499)));
2704 assert!(d.is_due(t0 + Duration::from_millis(500)));
2706 }
2707
2708 #[test]
2709 fn debouncer_coalesces_a_burst() {
2710 let t0 = Instant::now();
2711 let mut d = Debouncer::new(Duration::from_millis(500));
2712 d.on_event(t0);
2713 d.on_event(t0 + Duration::from_millis(200)); assert!(!d.is_due(t0 + Duration::from_millis(500)));
2716 assert!(d.is_due(t0 + Duration::from_millis(700)));
2718 }
2719
2720 #[test]
2721 fn debouncer_clear_resets_pending() {
2722 let t0 = Instant::now();
2723 let mut d = Debouncer::new(Duration::from_millis(500));
2724 d.on_event(t0);
2725 d.clear();
2726 assert!(!d.is_pending());
2727 assert!(!d.is_due(t0 + Duration::from_secs(10)));
2728 }
2729
2730 #[tokio::test]
2731 async fn gc_retains_slice_still_referenced_by_another_view() {
2732 let root = TempDir::new().unwrap();
2733
2734 let src_a = TempDir::new().unwrap();
2736 write_file(src_a.path(), "shared.rs", "fn shared() {}");
2737 index(root.path(), src_a.path(), "r", "a").await.unwrap();
2738
2739 let src_b = TempDir::new().unwrap();
2740 write_file(src_b.path(), "shared.rs", "fn shared() {}");
2741 index(root.path(), src_b.path(), "r", "b").await.unwrap();
2742
2743 std::fs::remove_file(src_a.path().join("shared.rs")).unwrap();
2745 index(root.path(), src_a.path(), "r", "a").await.unwrap();
2746
2747 let summary = gc(root.path(), Duration::ZERO).await.unwrap();
2748 assert_eq!(summary.scanned, 2, "one manifest record per view");
2749 assert_eq!(
2750 summary.freed, 0,
2751 "the shared slice is live via view B and must not be swept"
2752 );
2753
2754 let store = FsStore::new(root.path());
2756 let manifest = Manifest::from_body(
2757 &store
2758 .get(&Manifest::key("r", "b"))
2759 .await
2760 .unwrap()
2761 .unwrap()
2762 .body,
2763 )
2764 .unwrap();
2765 let graph = gonzalo_graph::assemble(&manifest, &store).await.unwrap();
2766 assert_eq!(graph.definitions("shared")[0].path, "shared.rs");
2767 }
2768
2769 #[tokio::test]
2772 async fn a_tree_of_unparseable_files_says_so_instead_of_reporting_nothing() {
2773 let root = TempDir::new().unwrap();
2777 let src = TempDir::new().unwrap();
2778 write_file(src.path(), "analysis.ipynb", "{ \"cells\": [] }");
2779 write_file(src.path(), "notes.org", "* heading");
2780
2781 let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
2782 assert_eq!(summary.files, 0, "nothing was indexable");
2783 assert_eq!(summary.unindexed.files, 2, "but two files were seen");
2784 assert_eq!(
2785 summary.unindexed.extensions(),
2786 vec![("ipynb".to_string(), 1), ("org".to_string(), 1)],
2787 "and the message can name them"
2788 );
2789 }
2790
2791 #[tokio::test]
2792 async fn unparseable_files_are_counted_beside_ones_that_index() {
2793 let root = TempDir::new().unwrap();
2794 let src = TempDir::new().unwrap();
2795 write_file(src.path(), "a.rs", "fn helper() {}");
2796 write_file(src.path(), "README.md", "# hi");
2797
2798 let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
2799 assert_eq!(summary.files, 1);
2800 assert_eq!(summary.unindexed.files, 1);
2801 assert_eq!(summary.unindexed.extensions(), vec![("md".to_string(), 1)]);
2802 }
2803
2804 #[tokio::test]
2805 async fn extensions_are_reported_most_common_first() {
2806 let root = TempDir::new().unwrap();
2807 let src = TempDir::new().unwrap();
2808 for name in ["a.md", "b.md", "c.md", "d.txt"] {
2809 write_file(src.path(), name, "x");
2810 }
2811 let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
2812 assert_eq!(
2813 summary.unindexed.extensions(),
2814 vec![("md".to_string(), 3), ("txt".to_string(), 1)]
2815 );
2816 }
2817
2818 #[tokio::test]
2819 async fn an_extensionless_file_is_counted_without_an_extension() {
2820 let root = TempDir::new().unwrap();
2821 let src = TempDir::new().unwrap();
2822 write_file(src.path(), "LICENSE", "text");
2823 let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
2824 assert_eq!(summary.unindexed.files, 1);
2825 assert!(summary.unindexed.extensions().is_empty(), "nothing to name");
2826 }
2827
2828 #[tokio::test]
2829 async fn a_genuinely_empty_tree_reports_no_unindexed_files() {
2830 let root = TempDir::new().unwrap();
2831 let src = TempDir::new().unwrap();
2832 let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
2833 assert_eq!(summary.files, 0);
2834 assert_eq!(summary.unindexed.files, 0);
2835 }
2836
2837 #[test]
2840 fn a_skip_message_names_the_file() {
2841 let msg = skip_message("src/gpu/pipeline_state_cache.h", "parse worker died");
2844 assert!(msg.contains("src/gpu/pipeline_state_cache.h"), "{msg}");
2845 assert!(msg.contains("parse worker died"), "{msg}");
2846 }
2847
2848 #[tokio::test]
2849 async fn a_clean_index_reports_no_skipped_paths() {
2850 let root = TempDir::new().unwrap();
2851 let src = TempDir::new().unwrap();
2852 write_file(src.path(), "a.rs", "fn helper() {}");
2853
2854 let summary = index(root.path(), src.path(), "r", "main").await.unwrap();
2855 assert_eq!(summary.skipped, 0);
2856 assert!(summary.skipped_paths.is_empty());
2857 }
2858
2859 #[test]
2860 fn the_skipped_listing_is_bounded_and_says_how_many_it_left_out() {
2861 let few: Vec<String> = vec!["a.rs".into(), "b.rs".into()];
2862 let listed = named_skips(&few);
2863 assert!(
2864 listed.contains("a.rs") && listed.contains("b.rs"),
2865 "{listed}"
2866 );
2867 assert!(!listed.contains("more"), "nothing was left out: {listed}");
2868
2869 let many: Vec<String> = (0..12).map(|i| format!("f{i}.rs")).collect();
2870 let listed = named_skips(&many);
2871 assert!(listed.contains("f0.rs"), "{listed}");
2872 assert!(
2873 listed.contains("7 more"),
2874 "must say what it left out: {listed}"
2875 );
2876 }
2877
2878 #[test]
2879 fn no_skips_lists_nothing() {
2880 assert_eq!(named_skips(&[]), String::new());
2881 }
2882}
2883
2884#[cfg(test)]
2885mod tombstone_cli_tests {
2886 use super::*;
2887 use tempfile::TempDir;
2888
2889 #[test]
2890 fn parse_duration_accepts_each_unit() {
2891 assert_eq!(parse_duration("30d"), Ok(Duration::from_secs(30 * 86_400)));
2892 assert_eq!(parse_duration("12h"), Ok(Duration::from_secs(12 * 3_600)));
2893 assert_eq!(parse_duration("90m"), Ok(Duration::from_secs(90 * 60)));
2894 assert_eq!(parse_duration("45s"), Ok(Duration::from_secs(45)));
2895 assert_eq!(parse_duration(" 7d "), Ok(Duration::from_secs(7 * 86_400)));
2896 }
2897
2898 #[test]
2899 fn parse_duration_rejects_malformed_input() {
2900 for (raw, needle) in [
2901 ("", "missing unit"),
2902 ("30", "missing unit"),
2903 ("d", "expected a number"),
2904 ("-5d", "expected a number"),
2905 ("30x", "unknown unit"),
2906 ("30dd", "unknown unit"),
2907 ("1d2h", "unknown unit"),
2908 ("0d", "greater than zero"),
2909 ("99999999999999999999d", "too large"),
2910 ("18446744073709551615d", "too large"),
2911 ] {
2912 let err = parse_duration(raw).expect_err(raw);
2913 assert!(
2914 err.contains(needle),
2915 "{raw:?}: {err:?} should mention {needle:?}"
2916 );
2917 }
2918 }
2919
2920 #[test]
2921 fn parse_horizon_keeps_the_operator_spelling() {
2922 let h = parse_horizon(" 30d").unwrap();
2923 assert_eq!(h.raw, "30d");
2924 assert_eq!(h.duration, Duration::from_secs(2_592_000));
2925 }
2926
2927 #[test]
2928 fn parse_revision_reads_the_json_that_get_prints() {
2929 let rev = parse_revision(r#"{"counter":3,"hash":"abc"}"#).unwrap();
2930 assert_eq!(
2931 rev,
2932 Revision {
2933 counter: 3,
2934 hash: ContentHash("abc".into())
2935 }
2936 );
2937 let err = parse_revision("3").unwrap_err();
2938 assert!(err.contains("expected a revision as JSON"), "{err}");
2939 }
2940
2941 #[test]
2942 fn open_store_rejects_a_zero_ancestor_cap() {
2943 let root = TempDir::new().unwrap();
2944 let err = open_store(root.path(), 0).err().expect("cap 0 rejected");
2945 assert!(
2946 format!("{err:#}").contains("ancestor cap must be at least 1"),
2947 "{err:#}"
2948 );
2949 assert!(open_store(root.path(), 1).is_ok());
2950 }
2951
2952 #[tokio::test]
2953 async fn delete_conflicts_on_a_stale_revision_and_attributes_the_tombstone() {
2954 let root = TempDir::new().unwrap();
2955 let store = FsStore::new(root.path());
2956 let body = Body::Inline(b"x".to_vec());
2957 let record = Record {
2958 key: RecordKey::new("ns", "col", "k"),
2959 kind: RecordKind::Topic,
2960 revision: Revision::initial(body.bytes()),
2961 parent: None,
2962 body,
2963 meta: Meta {
2964 author: Identity::new("t"),
2965 origin_system: "test".into(),
2966 created: 0,
2967 updated: 0,
2968 labels: BTreeMap::new(),
2969 },
2970 links: Vec::new(),
2971 ancestors: Vec::new(),
2972 deleted_at: None,
2973 deleted_blob: None,
2974 deleted_kind: None,
2975 };
2976 let live = record.revision.clone();
2977 assert!(matches!(
2978 store.put(record, None).await.unwrap(),
2979 PutResult::Committed(_)
2980 ));
2981 let stale = Revision {
2982 counter: 99,
2983 hash: ContentHash("deadbeef".into()),
2984 };
2985
2986 let outcome = delete(root.path(), 32, "ns", "col", "k", Some(stale))
2987 .await
2988 .unwrap();
2989 assert!(matches!(outcome, DeleteOutcome::Conflict { ref current } if *current == live));
2990
2991 let outcome = delete(root.path(), 32, "ns", "col", "k", Some(live))
2992 .await
2993 .unwrap();
2994 assert!(matches!(outcome, DeleteOutcome::Deleted));
2995 assert_eq!(get(root.path(), "ns", "col", "k").await.unwrap(), None);
2996
2997 let tomb = store
2999 .get_raw(&RecordKey::new("ns", "col", "k"))
3000 .await
3001 .unwrap()
3002 .expect("tombstone stored");
3003 assert!(tomb.is_tombstone());
3004 assert_eq!(tomb.meta.author, Identity::new(CLI_AUTHOR));
3005 }
3006
3007 #[tokio::test]
3008 async fn undelete_stamps_the_cli_author_not_the_deleter() {
3009 let root = TempDir::new().unwrap();
3010 let store = FsStore::new(root.path());
3011 let body = gonzalo_core::Manifest::new().to_body();
3012 let key = RecordKey::new("ns", "col", "k");
3013 let record = Record {
3014 key: key.clone(),
3015 kind: RecordKind::GraphManifest,
3016 revision: Revision::initial(body.bytes()),
3017 parent: None,
3018 body,
3019 meta: Meta {
3020 author: Identity::new("not-the-cli"),
3021 origin_system: "test".into(),
3022 created: 0,
3023 updated: 0,
3024 labels: BTreeMap::new(),
3025 },
3026 links: Vec::new(),
3027 ancestors: Vec::new(),
3028 deleted_at: None,
3029 deleted_blob: None,
3030 deleted_kind: None,
3031 };
3032 assert!(matches!(
3033 store.put(record, None).await.unwrap(),
3034 PutResult::Committed(_)
3035 ));
3036 let deleted = store
3037 .delete_as(&key, None, Some(Identity::new("someone-else")))
3038 .await
3039 .unwrap();
3040 assert!(matches!(deleted, gonzalo_core::DeleteResult::Deleted));
3041
3042 undelete(
3043 root.path(),
3044 gonzalo_core::DEFAULT_ANCESTOR_CAP,
3045 "ns",
3046 "col",
3047 "k",
3048 )
3049 .await
3050 .unwrap();
3051
3052 let restored = store.get(&key).await.unwrap().expect("restored");
3053 assert_eq!(restored.meta.author, Identity::new(CLI_AUTHOR));
3054 }
3055
3056 #[tokio::test]
3057 async fn reset_stamps_the_cli_author() {
3058 let root = TempDir::new().unwrap();
3059 let store = FsStore::new(root.path());
3060 let body = Body::Inline(b"x".to_vec());
3061 let record = Record {
3062 key: RecordKey::new("ns", "col", "k"),
3063 kind: RecordKind::Topic,
3064 revision: Revision::initial(body.bytes()),
3065 parent: None,
3066 body,
3067 meta: Meta {
3068 author: Identity::new("not-the-cli"),
3069 origin_system: "test".into(),
3070 created: 0,
3071 updated: 0,
3072 labels: BTreeMap::new(),
3073 },
3074 links: Vec::new(),
3075 ancestors: Vec::new(),
3076 deleted_at: None,
3077 deleted_blob: None,
3078 deleted_kind: None,
3079 };
3080 assert!(matches!(
3081 store.put(record, None).await.unwrap(),
3082 PutResult::Committed(_)
3083 ));
3084
3085 let report = reset(
3086 root.path(),
3087 gonzalo_core::DEFAULT_ANCESTOR_CAP,
3088 "ns".to_string(),
3089 None,
3090 )
3091 .await
3092 .unwrap();
3093 assert_eq!(report.deleted, vec![RecordKey::new("ns", "col", "k")]);
3094
3095 let tomb = store
3096 .get_raw(&RecordKey::new("ns", "col", "k"))
3097 .await
3098 .unwrap()
3099 .expect("tombstone stored");
3100 assert!(tomb.is_tombstone());
3101 assert_eq!(tomb.meta.author, Identity::new(CLI_AUTHOR));
3102 }
3103
3104 #[test]
3105 fn reset_exit_code_is_three_only_with_conflicts() {
3106 let clean = gonzalo_core::ResetReport {
3107 deleted: vec![RecordKey::new("ns", "col", "a")],
3108 conflicts: Vec::new(),
3109 };
3110 assert_eq!(reset_exit_code(&clean), 0);
3111
3112 let conflicted = gonzalo_core::ResetReport {
3113 deleted: Vec::new(),
3114 conflicts: vec![RecordKey::new("ns", "col", "b")],
3115 };
3116 assert_eq!(reset_exit_code(&conflicted), EXIT_CONFLICT);
3117 }
3118
3119 #[test]
3120 fn sync_exit_code_is_three_for_conflicts_or_non_convergence() {
3121 let clean = SyncSummary {
3122 copied_to_a: 1,
3123 copied_to_b: 2,
3124 fast_forwarded: 3,
3125 merged: 4,
3126 conflicts: Vec::new(),
3127 unconverged: Vec::new(),
3128 };
3129 assert_eq!(sync_exit_code(&clean), 0);
3130
3131 let conflicted = SyncSummary {
3132 conflicts: vec![RecordKey::new("ns", "col", "a")],
3133 ..clean.clone()
3134 };
3135 assert_eq!(sync_exit_code(&conflicted), EXIT_CONFLICT);
3136
3137 let unconverged = SyncSummary {
3140 unconverged: vec![RecordKey::new("ns", "col", "b")],
3141 ..clean
3142 };
3143 assert_eq!(sync_exit_code(&unconverged), EXIT_CONFLICT);
3144 }
3145
3146 #[tokio::test]
3147 async fn ensure_slices_present_restores_a_blob_that_was_swept() {
3148 let dir = tempfile::tempdir().unwrap();
3149 let store = FsStore::new(dir.path());
3150
3151 let src = dir.path().join("src");
3153 std::fs::create_dir_all(&src).unwrap();
3154 std::fs::write(src.join("lib.rs"), "pub fn alpha() {}\n").unwrap();
3155
3156 let mut staging = GraphStaging::default();
3157 let desired = build_desired_full(&store, &mut staging, None, &src, &IndexFilter::default())
3158 .await
3159 .unwrap();
3160
3161 let wanted: Vec<(String, ContentHash)> = desired
3162 .entries
3163 .iter()
3164 .map(|(p, h)| (p.clone(), h.clone()))
3165 .collect();
3166 assert!(!wanted.is_empty(), "the parsed file produced a slice");
3167
3168 store.delete_blob(&wanted[0].1).await.unwrap();
3170 assert!(!store.has_blob(&wanted[0].1).await.unwrap());
3171
3172 let restored = ensure_slices_present(&store, &wanted, &staging)
3173 .await
3174 .unwrap();
3175 assert_eq!(restored, 1);
3176 assert!(store.has_blob(&wanted[0].1).await.unwrap());
3177 }
3178}