Skip to main content

gonzalo_server/
service.rs

1//! The transport-agnostic service layer. Both the gRPC and HTTP transports
2//! delegate to this; it forwards record ops to the backing `Store` and answers
3//! code-graph queries. A view is served from its persistent SQLite graph when
4//! one has been indexed (under `graph_root`); otherwise it is assembled from the
5//! content-addressed slices on the fly.
6
7use 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/// One indexed view, as reported by [`Service::graph_views`].
22#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
23pub struct ViewSummary {
24    pub repo: String,
25    pub view_id: String,
26    /// Paths in the view's manifest.
27    pub files: usize,
28    /// The commit this view was last indexed at, when `gonzalo index` recorded
29    /// one. Compare against the checkout's HEAD to detect a stale view.
30    pub base_commit: Option<String>,
31}
32
33/// Wraps a `Store` (records) and a `BlobStore` (content-addressed slices) and
34/// exposes their operations to the daemon transports. The daemon backs both
35/// with the same `FsStore`.
36#[derive(Clone)]
37pub struct Service {
38    store: Arc<dyn Store>,
39    blobs: Arc<dyn BlobStore>,
40    /// Root under which per-view SQLite graphs live (`gonzalo index` writes
41    /// them). When set and a view's db exists, queries read it instead of
42    /// assembling from slices.
43    graph_root: Option<PathBuf>,
44    /// Ceiling for a single blob over the transports (bytes). Defaults to
45    /// `DEFAULT_MAX_BLOB_SIZE`; the daemon may raise it from the environment.
46    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    /// Override the per-blob size ceiling (bytes) used by the HTTP body limit
60    /// and the gRPC decode limit.
61    pub fn with_max_blob_size(mut self, n: usize) -> Self {
62        self.max_blob_size = n;
63        self
64    }
65
66    /// The per-blob size ceiling (bytes).
67    pub fn max_blob_size(&self) -> usize {
68        self.max_blob_size
69    }
70
71    // --- Content-addressed blobs (BlobStore over the daemon, gonzalo#184) ---
72
73    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    /// Whether `hash` is stored, without reading it. Substrates answer with a
90    /// cheap existence check (`try_exists` on a filesystem, `HeadObject` on
91    /// S3), which is what lets `HEAD /v1/blobs/{hash}` and the `HasBlob` RPC
92    /// cost a stat rather than a read (#329).
93    pub async fn has_blob(&self, hash: &ContentHash) -> Result<bool> {
94        self.blobs.has_blob(hash).await
95    }
96
97    /// Serve code-graph queries from persistent SQLite graphs rooted at
98    /// `graph_root` (matching `gonzalo index`'s `<store_root>/graphs`), falling
99    /// back to slice assembly for views without an indexed db.
100    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    /// Readiness probe: whether the backing store is reachable. Does a cheap
110    /// point lookup of a sentinel key — `Ok` (even `Ok(None)`) means the store
111    /// answered, `Err` means it is unreachable (bad endpoint/bucket, down
112    /// backend), so a load balancer should route around this replica. Backs
113    /// `GET /readyz`; liveness (`/healthz`) needs no store access.
114    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    /// Delete `key` by writing a tombstone. `author`, when set, becomes the
130    /// tombstone's `meta.author`; transports compute it with
131    /// `Principal::delete_author` (ADR 0015, gonzalo#203).
132    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    // --- Replication surface (gonzalo#203): tombstones visible ---
142
143    /// Like [`get`](Self::get), but returns tombstones. Replication only.
144    pub async fn get_raw(&self, key: &RecordKey) -> Result<Option<Record>> {
145        self.store.get_raw(key).await
146    }
147
148    /// Like [`list`](Self::list), but includes tombstoned keys. Replication only.
149    pub async fn list_raw(&self, prefix: &KeyPrefix) -> Result<Vec<RecordKey>> {
150        self.store.list_raw(prefix).await
151    }
152
153    /// Replication write: stores `record` verbatim (the revision is never
154    /// re-stamped). Authorship policy belongs to the transport (see
155    /// `put_raw_record`).
156    pub async fn put_raw(&self, record: Record, expected: Option<Revision>) -> Result<PutResult> {
157        self.store.put_raw(record, expected).await
158    }
159
160    /// Physically remove the record at `key` iff its current revision is
161    /// `expected`. The only physical removal in the system.
162    ///
163    /// **Refuses to remove a live record (gonzalo#341)**, as `gonzalo purge`
164    /// does, so the daemon and the CLI — the system's two operator surfaces —
165    /// agree. `plan_purge` is kind-blind and removes whatever matches the
166    /// revision, and purging a *live* record leaves no tombstone behind, so a
167    /// peer that has not synced since would resurrect it: the exact failure
168    /// tombstones exist to prevent (ADR 0021).
169    ///
170    /// The refusal is deliberately narrow — only a revision that *matches* a
171    /// live record, which is the only call that would actually remove one. Two
172    /// neighbouring cases must keep their existing answers, because tombstone
173    /// collection depends on both:
174    ///
175    /// - A **mismatched** revision over a live record is `collect` losing an OCC
176    ///   race to a recreate between listing and purging. `plan_purge` answers
177    ///   `Conflict` and removes nothing, which `collect` records in
178    ///   `CollectReport::conflicts`; refusing here instead would fail the whole
179    ///   run (`conformance::purge_conflicts_after_recreation`).
180    /// - A key that is **already gone** still reports `Deleted`, so re-running
181    ///   collection after a partial run stays safe.
182    pub async fn purge(&self, key: &RecordKey, expected: Revision) -> Result<DeleteResult> {
183        let Some(current) = self.store.get_raw(key).await? else {
184            // Nothing stored: `plan_purge` answers `Deleted`, which is what
185            // keeps a re-run of collection safe. Let it.
186            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    /// Build a source for `conn` from the registry and ingest its tickets into
201    /// the backing store. The error is typed so each transport can return the
202    /// right status: a misconfigured request is a client error, a
203    /// build/ingest failure is a server error.
204    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        // Scope record keys by connection name so the same issue synced from two
211        // boards produces two distinct records instead of colliding (#159).
212        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    // --- Code graph queries (EPIC C) ---
223    // Each query selects a view by `(repo, view_id)` and answers server-side,
224    // preferring the view's persistent SQLite graph and falling back to
225    // on-the-fly slice assembly.
226
227    /// Load a view's manifest, or [`CoreError::NotFound`] if the view has none.
228    ///
229    /// This used to return an empty manifest for an unknown view, which made a
230    /// misconfigured selector indistinguishable from a query that found nothing
231    /// (#210) — a typo in `view_id` produced a confident, wrong "nothing calls
232    /// this" rather than an error the caller could notice.
233    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    /// Whether `(repo, view_id)` names a view that exists — a persistent graph
242    /// under `graph_root`, or a manifest record in the store.
243    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    /// Every indexed view, for discovery. Cheap by design — this is the call an
257    /// agent makes first, so it reads manifests (and the recorded base commit)
258    /// rather than loading each view's graph. Use `overview` for symbol counts.
259    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; // listed then removed — skip rather than fail discovery
268            };
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    /// The commit a view was last indexed at, recorded by `gonzalo index`
282    /// alongside the view's graph. Lets a caller detect a stale view by
283    /// comparing against the checkout's HEAD — the quieter form of #210, where
284    /// results are plausible but describe code that has moved on.
285    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    /// A queryable graph for `(repo, view_id)`: the persistent SQLite graph if
294    /// one has been indexed under `graph_root`, else assembled from slices.
295    ///
296    /// Errors with [`CoreError::NotFound`] when the selector names no view.
297    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    /// Definitions of `name` in the view, each with its path.
311    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    /// References to `name` in the view, each with its path.
321    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    /// Enclosing functions that call `name` in the view.
331    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    /// Callers of `name`, each with whether it is a function or a module-level
341    /// binding (#268).
342    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    /// Names called from within `name` in the view.
352    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    /// Transitive caller closure of `name` (impact of changing it).
362    /// Symbols transitively affected if `name` changes, following only call
363    /// edges that resolve to a specific definition.
364    ///
365    /// Uses [`resolved_impact`] rather than the name-matched
366    /// [`GraphStore::impact`], which merged unrelated subgraphs through shared
367    /// identifiers (#207). Edges that cannot be attributed are counted in the
368    /// report instead of being traversed or silently dropped.
369    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    /// [`graph_impact`](Self::graph_impact) flattened to distinct caller names.
381    ///
382    /// The daemon transports speak a shared name-list shape for `callers_of`,
383    /// `callees` and `impact`, so they take this projection. They still get the
384    /// resolution-gated walk — only the `ambiguous_edges` and per-node paths are
385    /// dropped, which is why the MCP surface returns the full report.
386    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    /// Aggregate shape of the view: counts, breakdowns by kind and language,
400    /// and the `largest` files by symbol count.
401    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    /// Top `limit` symbol names in the view by `ranking`.
411    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    /// Symbols in the view matching `filter`, bounded by `limit`.
422    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    /// Dead-code candidates in the view: symbols with no inbound reference,
433    /// heuristic — see [`GraphStore::unreferenced`] for the blind spots.
434    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    /// Structural diff of two views of `repo` (`view_a` → `view_b`): symbols and
449    /// references added or removed.
450    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/// Error from a ticket sync, split so transports can return the right status:
463/// a misconfigured request is a client error (400 / invalid_argument), a
464/// build/ingest/transport failure is a server error (500 / internal).
465#[derive(Debug, thiserror::Error)]
466pub enum TicketSyncError {
467    #[error("bad request: {0}")]
468    BadRequest(String),
469    #[error("internal: {0}")]
470    Internal(String),
471}
472
473/// A misconfigured connection is the caller's fault; a failure constructing the
474/// underlying client is ours.
475fn 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    /// Store each file's slice blob and a manifest record for `(repo, view)`,
501    /// exactly as the sync path would, so the service can assemble the view.
502    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        // Write ONLY the SQLite graph — no manifest/slices — so a non-empty
542        // answer proves the query read SQLite rather than assembling.
543        {
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        // A view with neither an indexed db nor a manifest is unresolvable, and
561        // now says so rather than falling back to an empty assembly (#210).
562        assert!(svc.graph_impact("r", "absent", "x", None).await.is_err());
563    }
564
565    /// The owning type has to survive the whole path — extraction, the
566    /// persistent graph, and the service query — or `search` answers with a
567    /// bare name again and nothing says the owner was dropped (#248).
568    #[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        // A free function has none, and the field is omitted from the answer
587        // rather than sent as an explicit null.
588        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        // Inverted from `unknown_view_yields_empty_results`: returning `[]` for
650        // an unresolvable selector made a typo indistinguishable from a genuine
651        // miss, so callers reported "nothing calls this" as fact (#210).
652        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        // The other half: only the *selector* became an error. A real miss in a
669        // real view must stay an ordinary empty result.
670        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        // Token must exist so we reach the provider match.
739        #[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        // Consumer surface hides the tombstone; raw surface shows it, stamped.
807        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        // Purge physically removes it.
815        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        // put_raw writes verbatim, author included.
822        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}