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, 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/// 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<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    /// Serve code-graph queries from persistent SQLite graphs rooted at
90    /// `graph_root` (matching `gonzalo index`'s `<store_root>/graphs`), falling
91    /// back to slice assembly for views without an indexed db.
92    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    /// Readiness probe: whether the backing store is reachable. Does a cheap
102    /// point lookup of a sentinel key — `Ok` (even `Ok(None)`) means the store
103    /// answered, `Err` means it is unreachable (bad endpoint/bucket, down
104    /// backend), so a load balancer should route around this replica. Backs
105    /// `GET /readyz`; liveness (`/healthz`) needs no store access.
106    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    /// Build a source for `conn` from the registry and ingest its tickets into
130    /// the backing store. The error is typed so each transport can return the
131    /// right status: a misconfigured request is a client error, a
132    /// build/ingest failure is a server error.
133    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        // Scope record keys by connection name so the same issue synced from two
140        // boards produces two distinct records instead of colliding (#159).
141        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    // --- Code graph queries (EPIC C) ---
152    // Each query selects a view by `(repo, view_id)` and answers server-side,
153    // preferring the view's persistent SQLite graph and falling back to
154    // on-the-fly slice assembly.
155
156    /// Load a view's manifest, or [`CoreError::NotFound`] if the view has none.
157    ///
158    /// This used to return an empty manifest for an unknown view, which made a
159    /// misconfigured selector indistinguishable from a query that found nothing
160    /// (#210) — a typo in `view_id` produced a confident, wrong "nothing calls
161    /// this" rather than an error the caller could notice.
162    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    /// Whether `(repo, view_id)` names a view that exists — a persistent graph
171    /// under `graph_root`, or a manifest record in the store.
172    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    /// Every indexed view, for discovery. Cheap by design — this is the call an
186    /// agent makes first, so it reads manifests (and the recorded base commit)
187    /// rather than loading each view's graph. Use `overview` for symbol counts.
188    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; // listed then removed — skip rather than fail discovery
197            };
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    /// The commit a view was last indexed at, recorded by `gonzalo index`
211    /// alongside the view's graph. Lets a caller detect a stale view by
212    /// comparing against the checkout's HEAD — the quieter form of #210, where
213    /// results are plausible but describe code that has moved on.
214    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    /// A queryable graph for `(repo, view_id)`: the persistent SQLite graph if
223    /// one has been indexed under `graph_root`, else assembled from slices.
224    ///
225    /// Errors with [`CoreError::NotFound`] when the selector names no view.
226    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    /// Definitions of `name` in the view, each with its path.
240    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    /// References to `name` in the view, each with its path.
250    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    /// Enclosing functions that call `name` in the view.
260    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    /// Names called from within `name` in the view.
270    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    /// Transitive caller closure of `name` (impact of changing it).
280    /// Symbols transitively affected if `name` changes, following only call
281    /// edges that resolve to a specific definition.
282    ///
283    /// Uses [`resolved_impact`] rather than the name-matched
284    /// [`GraphStore::impact`], which merged unrelated subgraphs through shared
285    /// identifiers (#207). Edges that cannot be attributed are counted in the
286    /// report instead of being traversed or silently dropped.
287    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    /// [`graph_impact`](Self::graph_impact) flattened to distinct caller names.
299    ///
300    /// The daemon transports speak a shared name-list shape for `callers_of`,
301    /// `callees` and `impact`, so they take this projection. They still get the
302    /// resolution-gated walk — only the `ambiguous_edges` and per-node paths are
303    /// dropped, which is why the MCP surface returns the full report.
304    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    /// Aggregate shape of the view: counts, breakdowns by kind and language,
318    /// and the `largest` files by symbol count.
319    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    /// Top `limit` symbol names in the view by `ranking`.
329    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    /// Symbols in the view matching `filter`, bounded by `limit`.
340    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    /// Dead-code candidates in the view: symbols with no inbound reference,
351    /// heuristic — see [`GraphStore::unreferenced`] for the blind spots.
352    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    /// Structural diff of two views of `repo` (`view_a` → `view_b`): symbols and
367    /// references added or removed.
368    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/// Error from a ticket sync, split so transports can return the right status:
381/// a misconfigured request is a client error (400 / invalid_argument), a
382/// build/ingest/transport failure is a server error (500 / internal).
383#[derive(Debug, thiserror::Error)]
384pub enum TicketSyncError {
385    #[error("bad request: {0}")]
386    BadRequest(String),
387    #[error("internal: {0}")]
388    Internal(String),
389}
390
391/// A misconfigured connection is the caller's fault; a failure constructing the
392/// underlying client is ours.
393fn 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    /// Store each file's slice blob and a manifest record for `(repo, view)`,
419    /// exactly as the sync path would, so the service can assemble the view.
420    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        // Write ONLY the SQLite graph — no manifest/slices — so a non-empty
456        // answer proves the query read SQLite rather than assembling.
457        {
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        // A view with neither an indexed db nor a manifest is unresolvable, and
475        // now says so rather than falling back to an empty assembly (#210).
476        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        // Inverted from `unknown_view_yields_empty_results`: returning `[]` for
535        // an unresolvable selector made a typo indistinguishable from a genuine
536        // miss, so callers reported "nothing calls this" as fact (#210).
537        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        // The other half: only the *selector* became an error. A real miss in a
554        // real view must stay an ordinary empty result.
555        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        // Token must exist so we reach the provider match.
618        #[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}