1use gonzalo_core::{
8 BlobStore, ContentHash, CoreError, DeleteResult, KeyPrefix, Manifest, PutResult, Record,
9 RecordKey, Result, Revision, Store,
10};
11use gonzalo_graph::{
12 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<ContentHash>> {
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 fn with_graph_root(mut self, graph_root: impl Into<PathBuf>) -> Self {
93 self.graph_root = Some(graph_root.into());
94 self
95 }
96
97 pub async fn get(&self, key: &RecordKey) -> Result<Option<Record>> {
98 self.store.get(key).await
99 }
100
101 pub async fn ready(&self) -> bool {
107 self.store
108 .get(&RecordKey::new("_gonzalo", "_health", "_probe"))
109 .await
110 .is_ok()
111 }
112
113 pub async fn put(&self, record: Record, expected: Option<Revision>) -> Result<PutResult> {
114 self.store.put(record, expected).await
115 }
116
117 pub async fn list(&self, prefix: &KeyPrefix) -> Result<Vec<RecordKey>> {
118 self.store.list(prefix).await
119 }
120
121 pub async fn delete(
122 &self,
123 key: &RecordKey,
124 expected: Option<Revision>,
125 ) -> Result<DeleteResult> {
126 self.store.delete(key, expected).await
127 }
128
129 pub async fn ticket_sync(
134 &self,
135 conn: &Connection,
136 author: &str,
137 ) -> std::result::Result<IngestSummary, TicketSyncError> {
138 let source = gonzalo_ticket_config::build_source(conn).map_err(classify_config_err)?;
139 gonzalo_ticket::ingest(
142 source.as_ref(),
143 self.store.as_ref(),
144 author,
145 Some(&conn.name),
146 )
147 .await
148 .map_err(|e| TicketSyncError::Internal(e.to_string()))
149 }
150
151 async fn load_manifest(&self, repo: &str, view_id: &str) -> Result<Manifest> {
163 let key = Manifest::key(repo, view_id);
164 match self.store.get(&key).await? {
165 Some(record) => Manifest::from_body(&record.body),
166 None => Err(CoreError::NotFound(key)),
167 }
168 }
169
170 pub async fn view_exists(&self, repo: &str, view_id: &str) -> Result<bool> {
173 if let Some(root) = &self.graph_root
174 && view_db_path(root, repo, view_id).exists()
175 {
176 return Ok(true);
177 }
178 Ok(self
179 .store
180 .get(&Manifest::key(repo, view_id))
181 .await?
182 .is_some())
183 }
184
185 pub async fn graph_views(&self) -> Result<Vec<ViewSummary>> {
189 let prefix = KeyPrefix {
190 namespace: None,
191 collection: Some(Manifest::collection().to_string()),
192 };
193 let mut out = Vec::new();
194 for key in self.store.list(&prefix).await? {
195 let Some(record) = self.store.get(&key).await? else {
196 continue; };
198 let manifest = Manifest::from_body(&record.body)?;
199 out.push(ViewSummary {
200 files: manifest.entries.len(),
201 base_commit: self.recorded_base(&key.namespace, &key.id),
202 repo: key.namespace,
203 view_id: key.id,
204 });
205 }
206 out.sort_by(|a, b| a.repo.cmp(&b.repo).then_with(|| a.view_id.cmp(&b.view_id)));
207 Ok(out)
208 }
209
210 fn recorded_base(&self, repo: &str, view_id: &str) -> Option<String> {
215 let root = self.graph_root.as_ref()?;
216 let base = view_db_path(root, repo, view_id).with_extension("base");
217 let sha = std::fs::read_to_string(base).ok()?;
218 let sha = sha.trim().to_string();
219 (!sha.is_empty()).then_some(sha)
220 }
221
222 async fn view(&self, repo: &str, view_id: &str) -> Result<Box<dyn GraphStore>> {
227 if let Some(root) = &self.graph_root {
228 let db = view_db_path(root, repo, view_id);
229 if db.exists() {
230 let store =
231 SqliteGraphStore::open(&db).map_err(|e| CoreError::Backend(e.to_string()))?;
232 return Ok(Box::new(store));
233 }
234 }
235 let manifest = self.load_manifest(repo, view_id).await?;
236 Ok(Box::new(assemble(&manifest, self.blobs.as_ref()).await?))
237 }
238
239 pub async fn graph_definitions(
241 &self,
242 repo: &str,
243 view_id: &str,
244 name: &str,
245 ) -> Result<Vec<Located<Symbol>>> {
246 Ok(self.view(repo, view_id).await?.definitions(name))
247 }
248
249 pub async fn graph_references_to(
251 &self,
252 repo: &str,
253 view_id: &str,
254 name: &str,
255 ) -> Result<Vec<Located<Reference>>> {
256 Ok(self.view(repo, view_id).await?.references_to(name))
257 }
258
259 pub async fn graph_callers_of(
261 &self,
262 repo: &str,
263 view_id: &str,
264 name: &str,
265 ) -> Result<Vec<String>> {
266 Ok(self.view(repo, view_id).await?.callers_of(name))
267 }
268
269 pub async fn graph_callees(
271 &self,
272 repo: &str,
273 view_id: &str,
274 name: &str,
275 ) -> Result<Vec<String>> {
276 Ok(self.view(repo, view_id).await?.callees(name))
277 }
278
279 pub async fn graph_impact(
288 &self,
289 repo: &str,
290 view_id: &str,
291 name: &str,
292 max_depth: Option<usize>,
293 ) -> Result<ImpactReport> {
294 let view = self.view(repo, view_id).await?;
295 Ok(resolved_impact(view.as_ref(), name, max_depth))
296 }
297
298 pub async fn graph_impact_names(
305 &self,
306 repo: &str,
307 view_id: &str,
308 name: &str,
309 ) -> Result<Vec<String>> {
310 let report = self.graph_impact(repo, view_id, name, None).await?;
311 let mut names: Vec<String> = report.reached.into_iter().map(|n| n.name).collect();
312 names.sort();
313 names.dedup();
314 Ok(names)
315 }
316
317 pub async fn graph_overview(
320 &self,
321 repo: &str,
322 view_id: &str,
323 largest: usize,
324 ) -> Result<ViewOverview> {
325 Ok(self.view(repo, view_id).await?.overview(largest))
326 }
327
328 pub async fn graph_top(
330 &self,
331 repo: &str,
332 view_id: &str,
333 ranking: Ranking,
334 limit: usize,
335 ) -> Result<Page<RankedSymbol>> {
336 Ok(self.view(repo, view_id).await?.top(ranking, limit))
337 }
338
339 pub async fn graph_list(
341 &self,
342 repo: &str,
343 view_id: &str,
344 filter: &SymbolFilter,
345 limit: usize,
346 ) -> Result<Page<Located<Symbol>>> {
347 Ok(self.view(repo, view_id).await?.list(filter, limit))
348 }
349
350 pub async fn graph_unreferenced(
353 &self,
354 repo: &str,
355 view_id: &str,
356 filter: &SymbolFilter,
357 exclude_tests: bool,
358 limit: usize,
359 ) -> Result<Page<Located<Symbol>>> {
360 Ok(self
361 .view(repo, view_id)
362 .await?
363 .unreferenced(filter, exclude_tests, limit))
364 }
365
366 pub async fn graph_diff(
369 &self,
370 repo: &str,
371 view_a: &str,
372 view_b: &str,
373 ) -> Result<gonzalo_graph::GraphDiff> {
374 let a = self.view(repo, view_a).await?;
375 let b = self.view(repo, view_b).await?;
376 Ok(gonzalo_graph::diff(a.as_ref(), b.as_ref()))
377 }
378}
379
380#[derive(Debug, thiserror::Error)]
384pub enum TicketSyncError {
385 #[error("bad request: {0}")]
386 BadRequest(String),
387 #[error("internal: {0}")]
388 Internal(String),
389}
390
391fn classify_config_err(e: gonzalo_ticket_config::ConfigError) -> TicketSyncError {
394 use gonzalo_ticket_config::ConfigError::*;
395 let msg = e.to_string();
396 match e {
397 Read(..) | Parse(..) | MissingEnv { .. } | UnknownProvider { .. } | BadCategory { .. } => {
398 TicketSyncError::BadRequest(msg)
399 }
400 Source(..) => TicketSyncError::Internal(msg),
401 }
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407 use gonzalo_core::{Identity, Meta, RecordKind};
408 use gonzalo_graph::build_rust;
409 use gonzalo_store_fs::FsStore;
410 use gonzalo_ticket_config::Connection;
411 use std::collections::BTreeMap;
412 use std::sync::Arc;
413
414 fn fresh_fs() -> Arc<FsStore> {
415 Arc::new(FsStore::new(tempfile::tempdir().unwrap().keep()))
416 }
417
418 async fn seed_view(fs: &FsStore, repo: &str, view: &str, files: &[(&str, &str)]) {
421 let mut manifest = Manifest::new();
422 for (path, src) in files {
423 let hash = fs
424 .put_blob(&build_rust(src).to_slice_bytes())
425 .await
426 .unwrap();
427 manifest.insert(*path, hash);
428 }
429 let body = manifest.to_body();
430 let record = Record {
431 revision: Revision::initial(body.bytes()),
432 parent: None,
433 body,
434 kind: RecordKind::GraphManifest,
435 meta: Meta {
436 author: Identity::new("tester"),
437 origin_system: "test".into(),
438 created: 0,
439 updated: 0,
440 labels: BTreeMap::new(),
441 },
442 links: Vec::new(),
443 key: Manifest::key(repo, view),
444 };
445 let outcome = fs.put(record, None).await.unwrap();
446 assert!(matches!(outcome, PutResult::Committed(_)));
447 }
448
449 #[tokio::test]
450 async fn graph_queries_prefer_the_persistent_sqlite_graph() {
451 let dir = tempfile::tempdir().unwrap().keep();
452 let fs = Arc::new(FsStore::new(&dir));
453 let graph_root = dir.join("graphs");
454
455 {
458 let mut g = SqliteGraphStore::open(view_db_path(&graph_root, "r", "main")).unwrap();
459 g.insert(
460 "lib.rs",
461 build_rust("fn helper() {}\nfn main() { helper(); }"),
462 );
463 }
464 let svc = Service::new(fs.clone(), fs).with_graph_root(graph_root);
465
466 let defs = svc.graph_definitions("r", "main", "helper").await.unwrap();
467 assert_eq!(defs.len(), 1);
468 assert_eq!(defs[0].path, "lib.rs");
469 assert_eq!(
470 svc.graph_callers_of("r", "main", "helper").await.unwrap(),
471 vec!["main".to_string()]
472 );
473
474 assert!(svc.graph_impact("r", "absent", "x", None).await.is_err());
477 }
478
479 #[tokio::test]
480 async fn graph_queries_answer_over_an_assembled_view() {
481 let fs = fresh_fs();
482 seed_view(
483 &fs,
484 "r",
485 "main",
486 &[
487 ("src/lib.rs", "fn helper() {}"),
488 ("src/main.rs", "fn main() { helper(); }"),
489 ],
490 )
491 .await;
492 let svc = Service::new(fs.clone(), fs);
493
494 let defs = svc.graph_definitions("r", "main", "helper").await.unwrap();
495 assert_eq!(defs.len(), 1);
496 assert_eq!(defs[0].path, "src/lib.rs");
497
498 assert_eq!(
499 svc.graph_callers_of("r", "main", "helper").await.unwrap(),
500 vec!["main".to_string()]
501 );
502 assert_eq!(
503 svc.graph_callees("r", "main", "main").await.unwrap(),
504 vec!["helper".to_string()]
505 );
506 assert_eq!(
507 svc.graph_impact_names("r", "main", "helper").await.unwrap(),
508 vec!["main".to_string()]
509 );
510 assert_eq!(
511 svc.graph_references_to("r", "main", "helper")
512 .await
513 .unwrap()
514 .len(),
515 1
516 );
517 }
518
519 #[tokio::test]
520 async fn graph_diff_reports_changes_between_two_views() {
521 let fs = fresh_fs();
522 seed_view(&fs, "r", "v1", &[("lib.rs", "fn keep() {}\nfn gone() {}")]).await;
523 seed_view(&fs, "r", "v2", &[("lib.rs", "fn keep() {}\nfn fresh() {}")]).await;
524 let svc = Service::new(fs.clone(), fs);
525
526 let d = svc.graph_diff("r", "v1", "v2").await.unwrap();
527 assert!(d.added_symbols.iter().any(|l| l.item.name == "fresh"));
528 assert!(d.removed_symbols.iter().any(|l| l.item.name == "gone"));
529 assert!(!d.added_symbols.iter().any(|l| l.item.name == "keep"));
530 }
531
532 #[tokio::test]
533 async fn unknown_view_is_an_error_not_an_empty_result() {
534 let fs = fresh_fs();
538 let svc = Service::new(fs.clone(), fs);
539 for res in [
540 svc.graph_definitions("r", "absent", "x").await.err(),
541 svc.graph_impact("r", "absent", "x", None).await.err(),
542 ] {
543 let err = res.expect("an unknown view must error");
544 assert!(
545 matches!(&err, CoreError::NotFound(k) if k.namespace == "r" && k.id == "absent"),
546 "the error must name the selector: {err:?}"
547 );
548 }
549 }
550
551 #[tokio::test]
552 async fn a_missing_symbol_in_a_known_view_is_still_empty() {
553 let fs = fresh_fs();
556 seed_view(&fs, "r", "main", &[("lib.rs", "fn helper() {}")]).await;
557 let svc = Service::new(fs.clone(), fs);
558 assert!(
559 svc.graph_definitions("r", "main", "nonexistent")
560 .await
561 .unwrap()
562 .is_empty()
563 );
564 }
565
566 #[tokio::test]
567 async fn graph_views_lists_indexed_views() {
568 let fs = fresh_fs();
569 seed_view(&fs, "r", "main", &[("lib.rs", "fn helper() {}")]).await;
570 seed_view(&fs, "other", "dev", &[("a.rs", "fn a() {}")]).await;
571 let svc = Service::new(fs.clone(), fs);
572
573 let views = svc.graph_views().await.unwrap();
574 let pairs: Vec<(&str, &str)> = views
575 .iter()
576 .map(|v| (v.repo.as_str(), v.view_id.as_str()))
577 .collect();
578 assert_eq!(pairs, vec![("other", "dev"), ("r", "main")], "sorted");
579 assert_eq!(views[1].files, 1);
580 }
581
582 #[tokio::test]
583 async fn view_exists_distinguishes_known_from_unknown() {
584 let fs = fresh_fs();
585 seed_view(&fs, "r", "main", &[("lib.rs", "fn helper() {}")]).await;
586 let svc = Service::new(fs.clone(), fs);
587 assert!(svc.view_exists("r", "main").await.unwrap());
588 assert!(!svc.view_exists("r", "mian").await.unwrap());
589 assert!(!svc.view_exists("nope", "main").await.unwrap());
590 }
591
592 #[tokio::test]
593 async fn blob_methods_delegate_and_default_size_is_64_mib() {
594 let fs = fresh_fs();
595 let svc = Service::new(fs.clone(), fs);
596 assert_eq!(svc.max_blob_size(), gonzalo_proto::DEFAULT_MAX_BLOB_SIZE);
597
598 let hash = svc.put_blob(b"checkpoint pre-image").await.unwrap();
599 assert_eq!(hash, gonzalo_core::ContentHash::of(b"checkpoint pre-image"));
600 assert_eq!(
601 svc.get_blob(&hash).await.unwrap().as_deref(),
602 Some(&b"checkpoint pre-image"[..])
603 );
604 assert_eq!(svc.list_blobs().await.unwrap(), vec![hash.clone()]);
605 svc.delete_blob(&hash).await.unwrap();
606 assert_eq!(svc.get_blob(&hash).await.unwrap(), None);
607
608 let tuned = Service::new(fresh_fs(), fresh_fs()).with_max_blob_size(123);
609 assert_eq!(tuned.max_blob_size(), 123);
610 }
611
612 #[tokio::test]
613 async fn ticket_sync_rejects_unknown_provider() {
614 let dir = tempfile::tempdir().unwrap();
615 let fs = Arc::new(FsStore::new(dir.path()));
616 let svc = Service::new(fs.clone(), fs);
617 #[allow(unsafe_code)]
619 unsafe {
620 std::env::set_var("SVC_TEST_TOKEN", "x")
621 };
622 let conn = Connection {
623 name: "bad".into(),
624 provider: "nope".into(),
625 org: "caliban-ai".into(),
626 project: 1,
627 token_env: "SVC_TEST_TOKEN".into(),
628 state_map: BTreeMap::new(),
629 set_targets: BTreeMap::new(),
630 };
631 let result = svc.ticket_sync(&conn, "tester").await;
632 #[allow(unsafe_code)]
633 unsafe {
634 std::env::remove_var("SVC_TEST_TOKEN");
635 }
636 let err = result.unwrap_err();
637 assert!(matches!(err, TicketSyncError::BadRequest(_)), "got {err:?}");
638 assert!(err.to_string().contains("unknown provider"));
639 }
640}