1use gonzalo_core::{
8 BlobStore, ContentHash, CoreError, DeleteResult, Identity, KeyPrefix, Manifest, PutResult,
9 Record, RecordKey, Result, Revision, Store,
10};
11use gonzalo_graph::{
12 FromScope, GraphStore, ImpactReport, Located, Page, RankedSymbol, Ranking, Reference, Symbol,
13 SymbolFilter, ViewOverview, assemble, resolved_impact,
14};
15use gonzalo_graph_sqlite::{SqliteGraphStore, view_db_path};
16use gonzalo_ticket::IngestSummary;
17use gonzalo_ticket_config::Connection;
18use std::path::PathBuf;
19use std::sync::Arc;
20
21#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
23pub struct ViewSummary {
24 pub repo: String,
25 pub view_id: String,
26 pub files: usize,
28 pub base_commit: Option<String>,
31}
32
33#[derive(Clone)]
37pub struct Service {
38 store: Arc<dyn Store>,
39 blobs: Arc<dyn BlobStore>,
40 graph_root: Option<PathBuf>,
44 max_blob_size: usize,
47}
48
49impl Service {
50 pub fn new(store: Arc<dyn Store>, blobs: Arc<dyn BlobStore>) -> Self {
51 Self {
52 store,
53 blobs,
54 graph_root: None,
55 max_blob_size: gonzalo_proto::DEFAULT_MAX_BLOB_SIZE,
56 }
57 }
58
59 pub fn with_max_blob_size(mut self, n: usize) -> Self {
62 self.max_blob_size = n;
63 self
64 }
65
66 pub fn max_blob_size(&self) -> usize {
68 self.max_blob_size
69 }
70
71 pub async fn get_blob(&self, hash: &ContentHash) -> Result<Option<Vec<u8>>> {
74 self.blobs.get_blob(hash).await
75 }
76
77 pub async fn put_blob(&self, content: &[u8]) -> Result<ContentHash> {
78 self.blobs.put_blob(content).await
79 }
80
81 pub async fn list_blobs(&self) -> Result<Vec<gonzalo_core::BlobEntry>> {
82 self.blobs.list_blobs().await
83 }
84
85 pub async fn delete_blob(&self, hash: &ContentHash) -> Result<()> {
86 self.blobs.delete_blob(hash).await
87 }
88
89 pub async fn has_blob(&self, hash: &ContentHash) -> Result<bool> {
94 self.blobs.has_blob(hash).await
95 }
96
97 pub fn with_graph_root(mut self, graph_root: impl Into<PathBuf>) -> Self {
101 self.graph_root = Some(graph_root.into());
102 self
103 }
104
105 pub async fn get(&self, key: &RecordKey) -> Result<Option<Record>> {
106 self.store.get(key).await
107 }
108
109 pub async fn ready(&self) -> bool {
115 self.store
116 .get(&RecordKey::new("_gonzalo", "_health", "_probe"))
117 .await
118 .is_ok()
119 }
120
121 pub async fn put(&self, record: Record, expected: Option<Revision>) -> Result<PutResult> {
122 self.store.put(record, expected).await
123 }
124
125 pub async fn list(&self, prefix: &KeyPrefix) -> Result<Vec<RecordKey>> {
126 self.store.list(prefix).await
127 }
128
129 pub async fn delete_as(
133 &self,
134 key: &RecordKey,
135 expected: Option<Revision>,
136 author: Option<Identity>,
137 ) -> Result<DeleteResult> {
138 self.store.delete_as(key, expected, author).await
139 }
140
141 pub async fn get_raw(&self, key: &RecordKey) -> Result<Option<Record>> {
145 self.store.get_raw(key).await
146 }
147
148 pub async fn list_raw(&self, prefix: &KeyPrefix) -> Result<Vec<RecordKey>> {
150 self.store.list_raw(prefix).await
151 }
152
153 pub async fn put_raw(&self, record: Record, expected: Option<Revision>) -> Result<PutResult> {
157 self.store.put_raw(record, expected).await
158 }
159
160 pub async fn purge(&self, key: &RecordKey, expected: Revision) -> Result<DeleteResult> {
183 let Some(current) = self.store.get_raw(key).await? else {
184 return self.store.purge(key, expected).await;
187 };
188 if !current.is_tombstone() && current.revision == expected {
189 return Err(CoreError::Invalid(format!(
190 "{key} is live at revision {}; purge only discards an existing \
191 tombstone. To remove the record use `gonzalo delete`: it keeps \
192 a restore window for manifest kinds, and purging that tombstone \
193 would discard it.",
194 current.revision.counter
195 )));
196 }
197 self.store.purge(key, expected).await
198 }
199
200 pub async fn ticket_sync(
205 &self,
206 conn: &Connection,
207 author: &str,
208 ) -> std::result::Result<IngestSummary, TicketSyncError> {
209 let source = gonzalo_ticket_config::build_source(conn).map_err(classify_config_err)?;
210 gonzalo_ticket::ingest(
213 source.as_ref(),
214 self.store.as_ref(),
215 author,
216 Some(&conn.name),
217 )
218 .await
219 .map_err(|e| TicketSyncError::Internal(e.to_string()))
220 }
221
222 async fn load_manifest(&self, repo: &str, view_id: &str) -> Result<Manifest> {
234 let key = Manifest::key(repo, view_id);
235 match self.store.get(&key).await? {
236 Some(record) => Manifest::from_body(&record.body),
237 None => Err(CoreError::NotFound(key)),
238 }
239 }
240
241 pub async fn view_exists(&self, repo: &str, view_id: &str) -> Result<bool> {
244 if let Some(root) = &self.graph_root
245 && view_db_path(root, repo, view_id).exists()
246 {
247 return Ok(true);
248 }
249 Ok(self
250 .store
251 .get(&Manifest::key(repo, view_id))
252 .await?
253 .is_some())
254 }
255
256 pub async fn graph_views(&self) -> Result<Vec<ViewSummary>> {
260 let prefix = KeyPrefix {
261 namespace: None,
262 collection: Some(Manifest::collection().to_string()),
263 };
264 let mut out = Vec::new();
265 for key in self.store.list(&prefix).await? {
266 let Some(record) = self.store.get(&key).await? else {
267 continue; };
269 let manifest = Manifest::from_body(&record.body)?;
270 out.push(ViewSummary {
271 files: manifest.entries.len(),
272 base_commit: self.recorded_base(&key.namespace, &key.id),
273 repo: key.namespace,
274 view_id: key.id,
275 });
276 }
277 out.sort_by(|a, b| a.repo.cmp(&b.repo).then_with(|| a.view_id.cmp(&b.view_id)));
278 Ok(out)
279 }
280
281 fn recorded_base(&self, repo: &str, view_id: &str) -> Option<String> {
286 let root = self.graph_root.as_ref()?;
287 let base = view_db_path(root, repo, view_id).with_extension("base");
288 let sha = std::fs::read_to_string(base).ok()?;
289 let sha = sha.trim().to_string();
290 (!sha.is_empty()).then_some(sha)
291 }
292
293 async fn view(&self, repo: &str, view_id: &str) -> Result<Box<dyn GraphStore>> {
298 if let Some(root) = &self.graph_root {
299 let db = view_db_path(root, repo, view_id);
300 if db.exists() {
301 let store =
302 SqliteGraphStore::open(&db).map_err(|e| CoreError::Backend(e.to_string()))?;
303 return Ok(Box::new(store));
304 }
305 }
306 let manifest = self.load_manifest(repo, view_id).await?;
307 Ok(Box::new(assemble(&manifest, self.blobs.as_ref()).await?))
308 }
309
310 pub async fn graph_definitions(
312 &self,
313 repo: &str,
314 view_id: &str,
315 name: &str,
316 ) -> Result<Vec<Located<Symbol>>> {
317 Ok(self.view(repo, view_id).await?.definitions(name))
318 }
319
320 pub async fn graph_references_to(
322 &self,
323 repo: &str,
324 view_id: &str,
325 name: &str,
326 ) -> Result<Vec<Located<Reference>>> {
327 Ok(self.view(repo, view_id).await?.references_to(name))
328 }
329
330 pub async fn graph_callers_of(
332 &self,
333 repo: &str,
334 view_id: &str,
335 name: &str,
336 ) -> Result<Vec<String>> {
337 Ok(self.view(repo, view_id).await?.callers_of(name))
338 }
339
340 pub async fn graph_callers_scoped(
343 &self,
344 repo: &str,
345 view_id: &str,
346 name: &str,
347 ) -> Result<Vec<(String, FromScope)>> {
348 Ok(self.view(repo, view_id).await?.callers_scoped(name))
349 }
350
351 pub async fn graph_callees(
353 &self,
354 repo: &str,
355 view_id: &str,
356 name: &str,
357 ) -> Result<Vec<String>> {
358 Ok(self.view(repo, view_id).await?.callees(name))
359 }
360
361 pub async fn graph_impact(
370 &self,
371 repo: &str,
372 view_id: &str,
373 name: &str,
374 max_depth: Option<usize>,
375 ) -> Result<ImpactReport> {
376 let view = self.view(repo, view_id).await?;
377 Ok(resolved_impact(view.as_ref(), name, max_depth))
378 }
379
380 pub async fn graph_impact_names(
387 &self,
388 repo: &str,
389 view_id: &str,
390 name: &str,
391 ) -> Result<Vec<String>> {
392 let report = self.graph_impact(repo, view_id, name, None).await?;
393 let mut names: Vec<String> = report.reached.into_iter().map(|n| n.name).collect();
394 names.sort();
395 names.dedup();
396 Ok(names)
397 }
398
399 pub async fn graph_overview(
402 &self,
403 repo: &str,
404 view_id: &str,
405 largest: usize,
406 ) -> Result<ViewOverview> {
407 Ok(self.view(repo, view_id).await?.overview(largest))
408 }
409
410 pub async fn graph_top(
412 &self,
413 repo: &str,
414 view_id: &str,
415 ranking: Ranking,
416 limit: usize,
417 ) -> Result<Page<RankedSymbol>> {
418 Ok(self.view(repo, view_id).await?.top(ranking, limit))
419 }
420
421 pub async fn graph_list(
423 &self,
424 repo: &str,
425 view_id: &str,
426 filter: &SymbolFilter,
427 limit: usize,
428 ) -> Result<Page<Located<Symbol>>> {
429 Ok(self.view(repo, view_id).await?.list(filter, limit))
430 }
431
432 pub async fn graph_unreferenced(
435 &self,
436 repo: &str,
437 view_id: &str,
438 filter: &SymbolFilter,
439 exclude_tests: bool,
440 limit: usize,
441 ) -> Result<Page<Located<Symbol>>> {
442 Ok(self
443 .view(repo, view_id)
444 .await?
445 .unreferenced(filter, exclude_tests, limit))
446 }
447
448 pub async fn graph_diff(
451 &self,
452 repo: &str,
453 view_a: &str,
454 view_b: &str,
455 ) -> Result<gonzalo_graph::GraphDiff> {
456 let a = self.view(repo, view_a).await?;
457 let b = self.view(repo, view_b).await?;
458 Ok(gonzalo_graph::diff(a.as_ref(), b.as_ref()))
459 }
460}
461
462#[derive(Debug, thiserror::Error)]
466pub enum TicketSyncError {
467 #[error("bad request: {0}")]
468 BadRequest(String),
469 #[error("internal: {0}")]
470 Internal(String),
471}
472
473fn classify_config_err(e: gonzalo_ticket_config::ConfigError) -> TicketSyncError {
476 use gonzalo_ticket_config::ConfigError::*;
477 let msg = e.to_string();
478 match e {
479 Read(..) | Parse(..) | MissingEnv { .. } | UnknownProvider { .. } | BadCategory { .. } => {
480 TicketSyncError::BadRequest(msg)
481 }
482 Source(..) => TicketSyncError::Internal(msg),
483 }
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489 use gonzalo_core::{Identity, Meta, RecordKind};
490 use gonzalo_graph::build_rust;
491 use gonzalo_store_fs::FsStore;
492 use gonzalo_ticket_config::Connection;
493 use std::collections::BTreeMap;
494 use std::sync::Arc;
495
496 fn fresh_fs() -> Arc<FsStore> {
497 Arc::new(FsStore::new(tempfile::tempdir().unwrap().keep()))
498 }
499
500 async fn seed_view(fs: &FsStore, repo: &str, view: &str, files: &[(&str, &str)]) {
503 let mut manifest = Manifest::new();
504 for (path, src) in files {
505 let hash = fs
506 .put_blob(&build_rust(src).to_slice_bytes())
507 .await
508 .unwrap();
509 manifest.insert(*path, hash);
510 }
511 let body = manifest.to_body();
512 let record = Record {
513 revision: Revision::initial(body.bytes()),
514 parent: None,
515 body,
516 kind: RecordKind::GraphManifest,
517 meta: Meta {
518 author: Identity::new("tester"),
519 origin_system: "test".into(),
520 created: 0,
521 updated: 0,
522 labels: BTreeMap::new(),
523 },
524 links: Vec::new(),
525 key: Manifest::key(repo, view),
526 ancestors: Vec::new(),
527 deleted_at: None,
528 deleted_blob: None,
529 deleted_kind: None,
530 };
531 let outcome = fs.put(record, None).await.unwrap();
532 assert!(matches!(outcome, PutResult::Committed(_)));
533 }
534
535 #[tokio::test]
536 async fn graph_queries_prefer_the_persistent_sqlite_graph() {
537 let dir = tempfile::tempdir().unwrap().keep();
538 let fs = Arc::new(FsStore::new(&dir));
539 let graph_root = dir.join("graphs");
540
541 {
544 let mut g = SqliteGraphStore::open(view_db_path(&graph_root, "r", "main")).unwrap();
545 g.insert(
546 "lib.rs",
547 build_rust("fn helper() {}\nfn main() { helper(); }"),
548 );
549 }
550 let svc = Service::new(fs.clone(), fs).with_graph_root(graph_root);
551
552 let defs = svc.graph_definitions("r", "main", "helper").await.unwrap();
553 assert_eq!(defs.len(), 1);
554 assert_eq!(defs[0].path, "lib.rs");
555 assert_eq!(
556 svc.graph_callers_of("r", "main", "helper").await.unwrap(),
557 vec!["main".to_string()]
558 );
559
560 assert!(svc.graph_impact("r", "absent", "x", None).await.is_err());
563 }
564
565 #[tokio::test]
569 async fn definitions_report_the_owning_type_of_a_method() {
570 let dir = tempfile::tempdir().unwrap().keep();
571 let fs = Arc::new(FsStore::new(&dir));
572 let graph_root = dir.join("graphs");
573 {
574 let mut g = SqliteGraphStore::open(view_db_path(&graph_root, "r", "main")).unwrap();
575 g.insert(
576 "beta.rs",
577 build_rust("struct Beta; impl Beta { fn get() {} }"),
578 );
579 g.insert("free.rs", build_rust("fn helper() {}"));
580 }
581 let svc = Service::new(fs.clone(), fs).with_graph_root(graph_root);
582
583 let defs = svc.graph_definitions("r", "main", "get").await.unwrap();
584 assert_eq!(defs[0].item.owner.as_deref(), Some("Beta"));
585
586 let free = svc.graph_definitions("r", "main", "helper").await.unwrap();
589 assert_eq!(free[0].item.owner, None);
590 let json = serde_json::to_string(&free).unwrap();
591 assert!(!json.contains("owner"), "{json}");
592 }
593
594 #[tokio::test]
595 async fn graph_queries_answer_over_an_assembled_view() {
596 let fs = fresh_fs();
597 seed_view(
598 &fs,
599 "r",
600 "main",
601 &[
602 ("src/lib.rs", "fn helper() {}"),
603 ("src/main.rs", "fn main() { helper(); }"),
604 ],
605 )
606 .await;
607 let svc = Service::new(fs.clone(), fs);
608
609 let defs = svc.graph_definitions("r", "main", "helper").await.unwrap();
610 assert_eq!(defs.len(), 1);
611 assert_eq!(defs[0].path, "src/lib.rs");
612
613 assert_eq!(
614 svc.graph_callers_of("r", "main", "helper").await.unwrap(),
615 vec!["main".to_string()]
616 );
617 assert_eq!(
618 svc.graph_callees("r", "main", "main").await.unwrap(),
619 vec!["helper".to_string()]
620 );
621 assert_eq!(
622 svc.graph_impact_names("r", "main", "helper").await.unwrap(),
623 vec!["main".to_string()]
624 );
625 assert_eq!(
626 svc.graph_references_to("r", "main", "helper")
627 .await
628 .unwrap()
629 .len(),
630 1
631 );
632 }
633
634 #[tokio::test]
635 async fn graph_diff_reports_changes_between_two_views() {
636 let fs = fresh_fs();
637 seed_view(&fs, "r", "v1", &[("lib.rs", "fn keep() {}\nfn gone() {}")]).await;
638 seed_view(&fs, "r", "v2", &[("lib.rs", "fn keep() {}\nfn fresh() {}")]).await;
639 let svc = Service::new(fs.clone(), fs);
640
641 let d = svc.graph_diff("r", "v1", "v2").await.unwrap();
642 assert!(d.added_symbols.iter().any(|l| l.item.name == "fresh"));
643 assert!(d.removed_symbols.iter().any(|l| l.item.name == "gone"));
644 assert!(!d.added_symbols.iter().any(|l| l.item.name == "keep"));
645 }
646
647 #[tokio::test]
648 async fn unknown_view_is_an_error_not_an_empty_result() {
649 let fs = fresh_fs();
653 let svc = Service::new(fs.clone(), fs);
654 for res in [
655 svc.graph_definitions("r", "absent", "x").await.err(),
656 svc.graph_impact("r", "absent", "x", None).await.err(),
657 ] {
658 let err = res.expect("an unknown view must error");
659 assert!(
660 matches!(&err, CoreError::NotFound(k) if k.namespace == "r" && k.id == "absent"),
661 "the error must name the selector: {err:?}"
662 );
663 }
664 }
665
666 #[tokio::test]
667 async fn a_missing_symbol_in_a_known_view_is_still_empty() {
668 let fs = fresh_fs();
671 seed_view(&fs, "r", "main", &[("lib.rs", "fn helper() {}")]).await;
672 let svc = Service::new(fs.clone(), fs);
673 assert!(
674 svc.graph_definitions("r", "main", "nonexistent")
675 .await
676 .unwrap()
677 .is_empty()
678 );
679 }
680
681 #[tokio::test]
682 async fn graph_views_lists_indexed_views() {
683 let fs = fresh_fs();
684 seed_view(&fs, "r", "main", &[("lib.rs", "fn helper() {}")]).await;
685 seed_view(&fs, "other", "dev", &[("a.rs", "fn a() {}")]).await;
686 let svc = Service::new(fs.clone(), fs);
687
688 let views = svc.graph_views().await.unwrap();
689 let pairs: Vec<(&str, &str)> = views
690 .iter()
691 .map(|v| (v.repo.as_str(), v.view_id.as_str()))
692 .collect();
693 assert_eq!(pairs, vec![("other", "dev"), ("r", "main")], "sorted");
694 assert_eq!(views[1].files, 1);
695 }
696
697 #[tokio::test]
698 async fn view_exists_distinguishes_known_from_unknown() {
699 let fs = fresh_fs();
700 seed_view(&fs, "r", "main", &[("lib.rs", "fn helper() {}")]).await;
701 let svc = Service::new(fs.clone(), fs);
702 assert!(svc.view_exists("r", "main").await.unwrap());
703 assert!(!svc.view_exists("r", "mian").await.unwrap());
704 assert!(!svc.view_exists("nope", "main").await.unwrap());
705 }
706
707 #[tokio::test]
708 async fn blob_methods_delegate_and_default_size_is_64_mib() {
709 let fs = fresh_fs();
710 let svc = Service::new(fs.clone(), fs);
711 assert_eq!(svc.max_blob_size(), gonzalo_proto::DEFAULT_MAX_BLOB_SIZE);
712
713 let hash = svc.put_blob(b"checkpoint pre-image").await.unwrap();
714 assert_eq!(hash, gonzalo_core::ContentHash::of(b"checkpoint pre-image"));
715 assert_eq!(
716 svc.get_blob(&hash).await.unwrap().as_deref(),
717 Some(&b"checkpoint pre-image"[..])
718 );
719 let listed = svc.list_blobs().await.unwrap();
720 assert_eq!(listed.len(), 1);
721 assert_eq!(listed[0].hash, hash);
722 assert_ne!(
723 listed[0].modified_unix_ms, 0,
724 "a zero timestamp means the substrate never reported one"
725 );
726 svc.delete_blob(&hash).await.unwrap();
727 assert_eq!(svc.get_blob(&hash).await.unwrap(), None);
728
729 let tuned = Service::new(fresh_fs(), fresh_fs()).with_max_blob_size(123);
730 assert_eq!(tuned.max_blob_size(), 123);
731 }
732
733 #[tokio::test]
734 async fn ticket_sync_rejects_unknown_provider() {
735 let dir = tempfile::tempdir().unwrap();
736 let fs = Arc::new(FsStore::new(dir.path()));
737 let svc = Service::new(fs.clone(), fs);
738 #[allow(unsafe_code)]
740 unsafe {
741 std::env::set_var("SVC_TEST_TOKEN", "x")
742 };
743 let conn = Connection {
744 name: "bad".into(),
745 provider: "nope".into(),
746 org: "caliban-ai".into(),
747 project: 1,
748 token_env: "SVC_TEST_TOKEN".into(),
749 state_map: BTreeMap::new(),
750 set_targets: BTreeMap::new(),
751 };
752 let result = svc.ticket_sync(&conn, "tester").await;
753 #[allow(unsafe_code)]
754 unsafe {
755 std::env::remove_var("SVC_TEST_TOKEN");
756 }
757 let err = result.unwrap_err();
758 assert!(matches!(err, TicketSyncError::BadRequest(_)), "got {err:?}");
759 assert!(err.to_string().contains("unknown provider"));
760 }
761
762 fn live_record(key: RecordKey) -> Record {
763 let body = gonzalo_core::Body::Inline(b"{\"v\":1}".to_vec());
764 Record {
765 revision: Revision::initial(body.bytes()),
766 parent: None,
767 body,
768 kind: RecordKind::Topic,
769 meta: Meta {
770 author: Identity::new("tester"),
771 origin_system: "test".into(),
772 created: 0,
773 updated: 0,
774 labels: BTreeMap::new(),
775 },
776 links: Vec::new(),
777 ancestors: Vec::new(),
778 deleted_at: None,
779 deleted_blob: None,
780 key,
781 deleted_kind: None,
782 }
783 }
784
785 #[tokio::test]
786 async fn replication_methods_delegate_to_the_store() {
787 let fs = fresh_fs();
788 let svc = Service::new(fs.clone(), fs);
789 let key = RecordKey::new("ns", "col", "gone");
790 let prefix = KeyPrefix {
791 namespace: Some("ns".into()),
792 collection: None,
793 };
794
795 assert!(matches!(
796 svc.put(live_record(key.clone()), None).await.unwrap(),
797 PutResult::Committed(_)
798 ));
799 assert_eq!(
800 svc.delete_as(&key, None, Some(Identity::new("deleter")))
801 .await
802 .unwrap(),
803 DeleteResult::Deleted
804 );
805
806 assert_eq!(svc.get(&key).await.unwrap(), None);
808 assert!(!svc.list(&prefix).await.unwrap().contains(&key));
809 let tomb = svc.get_raw(&key).await.unwrap().expect("tombstone");
810 assert_eq!(tomb.kind, RecordKind::Tombstone);
811 assert_eq!(tomb.meta.author, Identity::new("deleter"));
812 assert!(svc.list_raw(&prefix).await.unwrap().contains(&key));
813
814 assert_eq!(
816 svc.purge(&key, tomb.revision.clone()).await.unwrap(),
817 DeleteResult::Deleted
818 );
819 assert_eq!(svc.get_raw(&key).await.unwrap(), None);
820
821 let mut replica = live_record(key.clone());
823 replica.meta.author = Identity::new("origin");
824 let rev = replica.revision.clone();
825 assert_eq!(
826 svc.put_raw(replica, None).await.unwrap(),
827 PutResult::Committed(rev)
828 );
829 assert_eq!(
830 svc.get_raw(&key).await.unwrap().unwrap().meta.author,
831 Identity::new("origin")
832 );
833 }
834}