Skip to main content

gemini_adk_fluent_rs/spec/
store.rs

1//! Versioned, labelled storage for session specs ("bundles").
2//!
3//! A bundle is a session spec under a name. Every push stores an immutable
4//! **version**, identified by the SHA-256 of the spec's canonical JSON, so
5//! pushing the same spec twice yields the same version. **Labels** such as
6//! `prod` or `staging` are movable pointers to a version. A runtime loads
7//! `booking:prod`, and promoting or rolling back is moving a label; nothing
8//! is rebuilt or redeployed.
9//!
10//! ```text
11//! booking            the latest version
12//! booking@3f2a9c1d   a version, by id or unique prefix
13//! booking:prod       the version a label points to
14//! ```
15//!
16//! [`open_store`] picks a backend from a URI: a directory path (or
17//! `file://`) for local work and tests, or `gs://bucket/prefix` for Cloud
18//! Storage (feature `gcs-store`). Other backends implement [`BundleObjects`],
19//! a three-method object interface, and get versions and labels from
20//! [`ObjectBundleStore`].
21//!
22//! Only valid specs are stored: a push that fails
23//! [`validate`](super::SessionSpec::validate) is refused, so whatever a
24//! runtime loads has already passed validation.
25
26use std::collections::BTreeMap;
27use std::path::PathBuf;
28use std::sync::Arc;
29
30use async_trait::async_trait;
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33use sha2::{Digest, Sha256};
34
35use super::SessionSpec;
36
37/// One stored version of a bundle.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct BundleVersion {
40    /// The bundle's name.
41    pub name: String,
42    /// The version id: the first 12 hex digits of [`digest`](Self::digest).
43    pub version: String,
44    /// SHA-256 of the spec's canonical JSON, in hex.
45    pub digest: String,
46    /// When this version was first pushed (RFC 3339, UTC).
47    pub created_at: String,
48    /// What changed, as given at push time.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub message: Option<String>,
51}
52
53/// Which version of a bundle to load.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum BundleRef {
56    /// The most recently pushed version.
57    Latest,
58    /// A version id, or a unique prefix of one (at least 4 characters).
59    Version(String),
60    /// The version a label points to.
61    Label(String),
62}
63
64impl BundleRef {
65    /// Split `name`, `name@version` or `name:label` into a name and a
66    /// reference.
67    pub fn parse(reference: &str) -> (String, BundleRef) {
68        if let Some((name, version)) = reference.split_once('@') {
69            (name.to_string(), BundleRef::Version(version.to_string()))
70        } else if let Some((name, label)) = reference.split_once(':') {
71            (name.to_string(), BundleRef::Label(label.to_string()))
72        } else {
73            (reference.to_string(), BundleRef::Latest)
74        }
75    }
76}
77
78impl std::fmt::Display for BundleRef {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        match self {
81            BundleRef::Latest => f.write_str("latest"),
82            BundleRef::Version(v) => write!(f, "@{v}"),
83            BundleRef::Label(l) => write!(f, ":{l}"),
84        }
85    }
86}
87
88/// Why a bundle operation failed.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum StoreError {
91    /// No such bundle, version or label.
92    NotFound(String),
93    /// The spec failed validation, or a name was not allowed.
94    Invalid(Vec<String>),
95    /// The backend failed.
96    Backend(String),
97}
98
99impl std::fmt::Display for StoreError {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        match self {
102            StoreError::NotFound(what) => write!(f, "not found: {what}"),
103            StoreError::Invalid(errors) => write!(f, "invalid: {}", errors.join("; ")),
104            StoreError::Backend(message) => write!(f, "bundle store: {message}"),
105        }
106    }
107}
108
109impl std::error::Error for StoreError {}
110
111/// Versioned, labelled storage for session specs. See the module docs.
112#[async_trait]
113pub trait BundleStore: Send + Sync {
114    /// Store `spec` as a version of `name`. Pushing a spec that is already
115    /// stored returns the existing version unchanged.
116    async fn push(
117        &self,
118        name: &str,
119        spec: &SessionSpec,
120        message: Option<&str>,
121    ) -> Result<BundleVersion, StoreError>;
122
123    /// Load a version of `name`.
124    async fn get(
125        &self,
126        name: &str,
127        reference: &BundleRef,
128    ) -> Result<(BundleVersion, SessionSpec), StoreError>;
129
130    /// Every version of `name`, oldest first.
131    async fn versions(&self, name: &str) -> Result<Vec<BundleVersion>, StoreError>;
132
133    /// Every bundle name, sorted.
134    async fn names(&self) -> Result<Vec<String>, StoreError>;
135
136    /// Point `label` of `name` at `version` (an id or unique prefix).
137    /// Returns the full version id.
138    async fn set_label(&self, name: &str, label: &str, version: &str)
139    -> Result<String, StoreError>;
140
141    /// The labels of `name` and the versions they point to.
142    async fn labels(&self, name: &str) -> Result<BTreeMap<String, String>, StoreError>;
143}
144
145/// The object interface a bundle backend implements. Keys are relative
146/// paths with `/` separators, such as `booking/versions/3f2a9c1d4e5f.json`.
147#[async_trait]
148pub trait BundleObjects: Send + Sync {
149    /// The object's bytes, or `None` when it does not exist.
150    async fn read(&self, key: &str) -> Result<Option<Vec<u8>>, StoreError>;
151    /// Create or replace an object, atomically.
152    async fn write(&self, key: &str, bytes: Vec<u8>) -> Result<(), StoreError>;
153    /// Every key that starts with `prefix`.
154    async fn list(&self, prefix: &str) -> Result<Vec<String>, StoreError>;
155}
156
157/// A [`BundleStore`] over any [`BundleObjects`] backend.
158///
159/// Layout, per bundle: `<name>/versions/<id>.json` (the spec),
160/// `<name>/versions/<id>.meta.json` (its [`BundleVersion`], written last so
161/// a version is only listed once complete) and `<name>/labels/<label>` (a
162/// version id).
163pub struct ObjectBundleStore<O> {
164    objects: O,
165}
166
167impl<O: BundleObjects> ObjectBundleStore<O> {
168    /// A store over `objects`.
169    pub fn new(objects: O) -> Self {
170        Self { objects }
171    }
172
173    async fn read_json<T: serde::de::DeserializeOwned>(
174        &self,
175        key: &str,
176    ) -> Result<Option<T>, StoreError> {
177        match self.objects.read(key).await? {
178            Some(bytes) => serde_json::from_slice(&bytes)
179                .map(Some)
180                .map_err(|e| StoreError::Backend(format!("{key}: {e}"))),
181            None => Ok(None),
182        }
183    }
184
185    async fn resolve(&self, name: &str, reference: &BundleRef) -> Result<String, StoreError> {
186        match reference {
187            BundleRef::Latest => self
188                .versions(name)
189                .await?
190                .pop()
191                .map(|v| v.version)
192                .ok_or_else(|| StoreError::NotFound(format!("bundle '{name}'"))),
193            BundleRef::Label(label) => {
194                check_name("label", label)?;
195                let key = format!("{name}/labels/{label}");
196                match self.objects.read(&key).await? {
197                    Some(bytes) => Ok(String::from_utf8_lossy(&bytes).trim().to_string()),
198                    None => Err(StoreError::NotFound(format!("{name}:{label}"))),
199                }
200            }
201            BundleRef::Version(prefix) => {
202                if prefix.len() < 4 || !prefix.chars().all(|c| c.is_ascii_hexdigit()) {
203                    return Err(StoreError::Invalid(vec![format!(
204                        "version '{prefix}': use at least 4 hex digits"
205                    )]));
206                }
207                let matches: Vec<String> = self
208                    .versions(name)
209                    .await?
210                    .into_iter()
211                    .map(|v| v.version)
212                    .filter(|v| v.starts_with(prefix.as_str()))
213                    .collect();
214                match matches.as_slice() {
215                    [one] => Ok(one.clone()),
216                    [] => Err(StoreError::NotFound(format!("{name}@{prefix}"))),
217                    _ => Err(StoreError::Invalid(vec![format!(
218                        "{name}@{prefix} matches {} versions; give more digits",
219                        matches.len()
220                    )])),
221                }
222            }
223        }
224    }
225}
226
227#[async_trait]
228impl<O: BundleObjects> BundleStore for ObjectBundleStore<O> {
229    async fn push(
230        &self,
231        name: &str,
232        spec: &SessionSpec,
233        message: Option<&str>,
234    ) -> Result<BundleVersion, StoreError> {
235        check_name("bundle", name)?;
236        let validation = spec.validate();
237        if !validation.valid {
238            return Err(StoreError::Invalid(validation.errors));
239        }
240        let value = serde_json::to_value(spec).map_err(|e| StoreError::Backend(e.to_string()))?;
241        let digest = hex(&Sha256::digest(canonical(&value).as_bytes()));
242        let version = digest[..12].to_string();
243        let meta_key = format!("{name}/versions/{version}.meta.json");
244        if let Some(existing) = self.read_json::<BundleVersion>(&meta_key).await? {
245            return Ok(existing);
246        }
247        let mut document =
248            serde_json::to_vec_pretty(&value).map_err(|e| StoreError::Backend(e.to_string()))?;
249        document.push(b'\n');
250        self.objects
251            .write(&format!("{name}/versions/{version}.json"), document)
252            .await?;
253        let meta = BundleVersion {
254            name: name.to_string(),
255            version,
256            digest,
257            created_at: now_rfc3339(),
258            message: message.map(str::to_string).filter(|m| !m.trim().is_empty()),
259        };
260        let bytes =
261            serde_json::to_vec_pretty(&meta).map_err(|e| StoreError::Backend(e.to_string()))?;
262        self.objects.write(&meta_key, bytes).await?;
263        Ok(meta)
264    }
265
266    async fn get(
267        &self,
268        name: &str,
269        reference: &BundleRef,
270    ) -> Result<(BundleVersion, SessionSpec), StoreError> {
271        check_name("bundle", name)?;
272        let version = self.resolve(name, reference).await?;
273        let missing = || StoreError::NotFound(format!("{name}@{version}"));
274        let meta: BundleVersion = self
275            .read_json(&format!("{name}/versions/{version}.meta.json"))
276            .await?
277            .ok_or_else(missing)?;
278        let value: Value = self
279            .read_json(&format!("{name}/versions/{version}.json"))
280            .await?
281            .ok_or_else(missing)?;
282        let spec = SessionSpec::from_value(value).map_err(StoreError::Backend)?;
283        Ok((meta, spec))
284    }
285
286    async fn versions(&self, name: &str) -> Result<Vec<BundleVersion>, StoreError> {
287        check_name("bundle", name)?;
288        let mut versions = Vec::new();
289        for key in self.objects.list(&format!("{name}/versions/")).await? {
290            if key.ends_with(".meta.json")
291                && let Some(meta) = self.read_json::<BundleVersion>(&key).await?
292            {
293                versions.push(meta);
294            }
295        }
296        versions.sort_by(|a, b| {
297            (a.created_at.as_str(), a.version.as_str())
298                .cmp(&(b.created_at.as_str(), b.version.as_str()))
299        });
300        Ok(versions)
301    }
302
303    async fn names(&self) -> Result<Vec<String>, StoreError> {
304        let mut names: Vec<String> = self
305            .objects
306            .list("")
307            .await?
308            .iter()
309            .filter(|key| key.ends_with(".meta.json"))
310            .filter_map(|key| {
311                key.split_once("/versions/")
312                    .map(|(name, _)| name.to_string())
313            })
314            .collect();
315        names.sort();
316        names.dedup();
317        Ok(names)
318    }
319
320    async fn set_label(
321        &self,
322        name: &str,
323        label: &str,
324        version: &str,
325    ) -> Result<String, StoreError> {
326        check_name("bundle", name)?;
327        check_name("label", label)?;
328        let version = self
329            .resolve(name, &BundleRef::Version(version.to_string()))
330            .await?;
331        self.objects
332            .write(
333                &format!("{name}/labels/{label}"),
334                version.clone().into_bytes(),
335            )
336            .await?;
337        Ok(version)
338    }
339
340    async fn labels(&self, name: &str) -> Result<BTreeMap<String, String>, StoreError> {
341        check_name("bundle", name)?;
342        let prefix = format!("{name}/labels/");
343        let mut labels = BTreeMap::new();
344        for key in self.objects.list(&prefix).await? {
345            if let Some(bytes) = self.objects.read(&key).await? {
346                let label = key.trim_start_matches(prefix.as_str()).to_string();
347                labels.insert(label, String::from_utf8_lossy(&bytes).trim().to_string());
348            }
349        }
350        Ok(labels)
351    }
352}
353
354/// Open the store a URI names: a directory path or `file://` URI, or
355/// `gs://bucket/prefix` (feature `gcs-store`, credentials from
356/// [`GoogleAccessToken::from_env`](gemini_genai_rs::transport::auth::GoogleAccessToken::from_env)).
357pub fn open_store(uri: &str) -> Result<Arc<dyn BundleStore>, StoreError> {
358    if let Some(rest) = uri.strip_prefix("gs://") {
359        #[cfg(feature = "gcs-store")]
360        {
361            let (bucket, prefix) = rest.split_once('/').unwrap_or((rest, ""));
362            if bucket.is_empty() {
363                return Err(StoreError::Invalid(vec![format!(
364                    "'{uri}' names no bucket"
365                )]));
366            }
367            return Ok(Arc::new(ObjectBundleStore::new(GcsObjects::new(
368                bucket,
369                prefix,
370                Arc::new(gemini_genai_rs::transport::auth::GoogleAccessToken::from_env()),
371            ))));
372        }
373        #[cfg(not(feature = "gcs-store"))]
374        {
375            let _ = rest;
376            return Err(StoreError::Invalid(vec![format!(
377                "'{uri}' needs the `gcs-store` feature"
378            )]));
379        }
380    }
381    if uri.contains("://") && !uri.starts_with("file://") {
382        return Err(StoreError::Invalid(vec![format!(
383            "'{uri}': use a directory path, file://, or gs://"
384        )]));
385    }
386    let path = uri.strip_prefix("file://").unwrap_or(uri);
387    Ok(Arc::new(ObjectBundleStore::new(DirObjects::new(path))))
388}
389
390// ── Directory backend ───────────────────────────────────────────────────────
391
392/// Bundles in a local directory.
393pub struct DirObjects {
394    root: PathBuf,
395}
396
397impl DirObjects {
398    /// Objects under `root`, created on first write.
399    pub fn new(root: impl Into<PathBuf>) -> Self {
400        Self { root: root.into() }
401    }
402
403    fn path(&self, key: &str) -> Result<PathBuf, StoreError> {
404        if key
405            .split('/')
406            .any(|part| part.is_empty() || part == "." || part == "..")
407        {
408            return Err(StoreError::Invalid(vec![format!("object key '{key}'")]));
409        }
410        Ok(self.root.join(key))
411    }
412}
413
414#[async_trait]
415impl BundleObjects for DirObjects {
416    async fn read(&self, key: &str) -> Result<Option<Vec<u8>>, StoreError> {
417        match tokio::fs::read(self.path(key)?).await {
418            Ok(bytes) => Ok(Some(bytes)),
419            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
420            Err(e) => Err(StoreError::Backend(format!("{key}: {e}"))),
421        }
422    }
423
424    async fn write(&self, key: &str, bytes: Vec<u8>) -> Result<(), StoreError> {
425        let path = self.path(key)?;
426        let backend = |e: std::io::Error| StoreError::Backend(format!("{key}: {e}"));
427        if let Some(parent) = path.parent() {
428            tokio::fs::create_dir_all(parent).await.map_err(backend)?;
429        }
430        // Write aside, then rename: readers never see a partial object.
431        let staging = path.with_extension(format!("tmp-{}", std::process::id()));
432        tokio::fs::write(&staging, bytes).await.map_err(backend)?;
433        tokio::fs::rename(&staging, &path).await.map_err(backend)
434    }
435
436    async fn list(&self, prefix: &str) -> Result<Vec<String>, StoreError> {
437        let mut keys = Vec::new();
438        let mut pending = vec![self.root.clone()];
439        while let Some(dir) = pending.pop() {
440            let mut entries = match tokio::fs::read_dir(&dir).await {
441                Ok(entries) => entries,
442                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
443                Err(e) => return Err(StoreError::Backend(format!("{}: {e}", dir.display()))),
444            };
445            while let Some(entry) = entries
446                .next_entry()
447                .await
448                .map_err(|e| StoreError::Backend(e.to_string()))?
449            {
450                let path = entry.path();
451                if path.is_dir() {
452                    pending.push(path);
453                } else if let Ok(relative) = path.strip_prefix(&self.root) {
454                    let key = relative
455                        .components()
456                        .map(|c| c.as_os_str().to_string_lossy())
457                        .collect::<Vec<_>>()
458                        .join("/");
459                    if key.starts_with(prefix) && !key.contains(".tmp-") {
460                        keys.push(key);
461                    }
462                }
463            }
464        }
465        keys.sort();
466        Ok(keys)
467    }
468}
469
470// ── Cloud Storage backend ───────────────────────────────────────────────────
471
472/// Bundles in a Cloud Storage bucket, through the JSON API.
473///
474/// `STORAGE_EMULATOR_HOST` (for example `http://localhost:4443`) points it
475/// at an emulator instead of `https://storage.googleapis.com`.
476#[cfg(feature = "gcs-store")]
477pub struct GcsObjects {
478    bucket: String,
479    prefix: String,
480    endpoint: String,
481    token: Arc<gemini_genai_rs::transport::auth::GoogleAccessToken>,
482    client: reqwest::Client,
483}
484
485#[cfg(feature = "gcs-store")]
486impl GcsObjects {
487    /// Objects in `bucket` under `prefix`, authorized with `token`.
488    pub fn new(
489        bucket: impl Into<String>,
490        prefix: impl Into<String>,
491        token: Arc<gemini_genai_rs::transport::auth::GoogleAccessToken>,
492    ) -> Self {
493        let prefix = prefix.into().trim_matches('/').to_string();
494        Self {
495            bucket: bucket.into(),
496            prefix: if prefix.is_empty() {
497                prefix
498            } else {
499                format!("{prefix}/")
500            },
501            endpoint: std::env::var("STORAGE_EMULATOR_HOST")
502                .ok()
503                .filter(|h| !h.trim().is_empty())
504                .map(|h| h.trim_end_matches('/').to_string())
505                .unwrap_or_else(|| "https://storage.googleapis.com".to_string()),
506            token,
507            client: reqwest::Client::new(),
508        }
509    }
510
511    /// Use `endpoint` instead of the default (or `STORAGE_EMULATOR_HOST`).
512    pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
513        self.endpoint = endpoint.into().trim_end_matches('/').to_string();
514        self
515    }
516
517    fn url(&self, segments: &[&str], query: &[(&str, &str)]) -> Result<url::Url, StoreError> {
518        let mut url =
519            url::Url::parse(&self.endpoint).map_err(|e| StoreError::Backend(e.to_string()))?;
520        url.path_segments_mut()
521            .map_err(|()| StoreError::Backend(format!("endpoint {}", self.endpoint)))?
522            .pop_if_empty()
523            .extend(segments);
524        if !query.is_empty() {
525            url.query_pairs_mut().extend_pairs(query);
526        }
527        Ok(url)
528    }
529
530    async fn send(
531        &self,
532        request: reqwest::RequestBuilder,
533    ) -> Result<reqwest::Response, StoreError> {
534        let token = self
535            .token
536            .token()
537            .await
538            .map_err(|e| StoreError::Backend(e.to_string()))?;
539        let response = request
540            .bearer_auth(token)
541            .send()
542            .await
543            .map_err(|e| StoreError::Backend(e.to_string()))?;
544        if response.status() == reqwest::StatusCode::UNAUTHORIZED {
545            self.token.invalidate().await;
546        }
547        Ok(response)
548    }
549
550    async fn failure(what: &str, response: reqwest::Response) -> StoreError {
551        let status = response.status();
552        let body = response.text().await.unwrap_or_default();
553        StoreError::Backend(format!("{what}: HTTP {status}: {}", body.trim()))
554    }
555}
556
557#[cfg(feature = "gcs-store")]
558#[async_trait]
559impl BundleObjects for GcsObjects {
560    async fn read(&self, key: &str) -> Result<Option<Vec<u8>>, StoreError> {
561        let object = format!("{}{key}", self.prefix);
562        let url = self.url(
563            &["storage", "v1", "b", &self.bucket, "o", &object],
564            &[("alt", "media")],
565        )?;
566        let response = self.send(self.client.get(url)).await?;
567        match response.status() {
568            s if s.is_success() => response
569                .bytes()
570                .await
571                .map(|b| Some(b.to_vec()))
572                .map_err(|e| StoreError::Backend(e.to_string())),
573            reqwest::StatusCode::NOT_FOUND => Ok(None),
574            _ => Err(Self::failure(&object, response).await),
575        }
576    }
577
578    async fn write(&self, key: &str, bytes: Vec<u8>) -> Result<(), StoreError> {
579        let object = format!("{}{key}", self.prefix);
580        let url = self.url(
581            &["upload", "storage", "v1", "b", &self.bucket, "o"],
582            &[("uploadType", "media"), ("name", &object)],
583        )?;
584        let request = self
585            .client
586            .post(url)
587            .header("content-type", "application/json")
588            .body(bytes);
589        let response = self.send(request).await?;
590        if response.status().is_success() {
591            Ok(())
592        } else {
593            Err(Self::failure(&object, response).await)
594        }
595    }
596
597    async fn list(&self, prefix: &str) -> Result<Vec<String>, StoreError> {
598        #[derive(Deserialize)]
599        struct Page {
600            #[serde(default)]
601            items: Vec<Item>,
602            #[serde(default, rename = "nextPageToken")]
603            next_page_token: Option<String>,
604        }
605        #[derive(Deserialize)]
606        struct Item {
607            name: String,
608        }
609        let full = format!("{}{prefix}", self.prefix);
610        let mut keys = Vec::new();
611        let mut page_token: Option<String> = None;
612        loop {
613            let mut query = vec![
614                ("prefix", full.as_str()),
615                ("fields", "items(name),nextPageToken"),
616            ];
617            if let Some(token) = &page_token {
618                query.push(("pageToken", token.as_str()));
619            }
620            let url = self.url(&["storage", "v1", "b", &self.bucket, "o"], &query)?;
621            let response = self.send(self.client.get(url)).await?;
622            if !response.status().is_success() {
623                return Err(Self::failure(&full, response).await);
624            }
625            let page: Page = response
626                .json()
627                .await
628                .map_err(|e| StoreError::Backend(e.to_string()))?;
629            keys.extend(page.items.into_iter().filter_map(|i| {
630                i.name
631                    .strip_prefix(self.prefix.as_str())
632                    .map(str::to_string)
633            }));
634            match page.next_page_token {
635                Some(token) if !token.is_empty() => page_token = Some(token),
636                _ => break,
637            }
638        }
639        keys.sort();
640        Ok(keys)
641    }
642}
643
644// ── Helpers ─────────────────────────────────────────────────────────────────
645
646/// Names and labels: lowercase letters, digits, `-`, `_` and `.`, starting
647/// with a letter or digit, at most 63 characters. They become object keys.
648fn check_name(what: &str, name: &str) -> Result<(), StoreError> {
649    let ok = !name.is_empty()
650        && name.len() <= 63
651        && name.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit())
652        && name
653            .chars()
654            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'))
655        && !name.contains("..");
656    if ok {
657        Ok(())
658    } else {
659        Err(StoreError::Invalid(vec![format!(
660            "{what} name '{name}': use lowercase letters, digits, '-', '_' and '.' (at most 63)"
661        )]))
662    }
663}
664
665/// JSON with object keys sorted at every level, so equal specs hash equally
666/// whatever the map implementation.
667fn canonical(value: &Value) -> String {
668    match value {
669        Value::Object(map) => {
670            let mut keys: Vec<&String> = map.keys().collect();
671            keys.sort();
672            let fields: Vec<String> = keys
673                .into_iter()
674                .map(|k| format!("{}:{}", Value::String(k.clone()), canonical(&map[k])))
675                .collect();
676            format!("{{{}}}", fields.join(","))
677        }
678        Value::Array(items) => {
679            format!(
680                "[{}]",
681                items.iter().map(canonical).collect::<Vec<_>>().join(",")
682            )
683        }
684        other => other.to_string(),
685    }
686}
687
688fn hex(bytes: &[u8]) -> String {
689    bytes.iter().map(|b| format!("{b:02x}")).collect()
690}
691
692/// The current time as RFC 3339 in UTC, to the millisecond.
693fn now_rfc3339() -> String {
694    let now = std::time::SystemTime::now()
695        .duration_since(std::time::UNIX_EPOCH)
696        .unwrap_or_default();
697    let secs = now.as_secs();
698    let (days, rem) = (secs / 86_400, secs % 86_400);
699    // Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
700    let z = days as i64 + 719_468;
701    let era = z.div_euclid(146_097);
702    let doe = z.rem_euclid(146_097);
703    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
704    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
705    let mp = (5 * doy + 2) / 153;
706    let day = doy - (153 * mp + 2) / 5 + 1;
707    let month = if mp < 10 { mp + 3 } else { mp - 9 };
708    let year = yoe + era * 400 + i64::from(month <= 2);
709    format!(
710        "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{:03}Z",
711        rem / 3_600,
712        rem % 3_600 / 60,
713        rem % 60,
714        now.subsec_millis()
715    )
716}
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721    use serde_json::json;
722
723    fn spec(instruction: &str) -> SessionSpec {
724        SessionSpec::from_value(json!({
725            "name": "booking",
726            "instruction": instruction,
727            "flow": { "steps": [{ "id": "s", "terminal": true }] }
728        }))
729        .unwrap()
730    }
731
732    fn temp_dir(tag: &str) -> PathBuf {
733        let dir = std::env::temp_dir().join(format!(
734            "bundles-{tag}-{}-{:?}",
735            std::process::id(),
736            std::time::SystemTime::now()
737        ));
738        let _ = std::fs::remove_dir_all(&dir);
739        dir
740    }
741
742    async fn exercise(store: &dyn BundleStore) {
743        let v1 = store
744            .push("booking", &spec("one"), Some("first"))
745            .await
746            .unwrap();
747        assert_eq!(v1.version.len(), 12);
748        // Pushing the same spec again is the same version.
749        let again = store
750            .push("booking", &spec("one"), Some("dup"))
751            .await
752            .unwrap();
753        assert_eq!(again, v1);
754        // Sleep past the millisecond so versions order by time.
755        tokio::time::sleep(std::time::Duration::from_millis(5)).await;
756        let v2 = store.push("booking", &spec("two"), None).await.unwrap();
757        assert_ne!(v2.version, v1.version);
758
759        let versions = store.versions("booking").await.unwrap();
760        assert_eq!(
761            versions
762                .iter()
763                .map(|v| v.version.as_str())
764                .collect::<Vec<_>>(),
765            [v1.version.as_str(), v2.version.as_str()]
766        );
767        assert_eq!(store.names().await.unwrap(), ["booking"]);
768
769        // Latest, by prefix, by label.
770        let (latest, loaded) = store.get("booking", &BundleRef::Latest).await.unwrap();
771        assert_eq!(latest.version, v2.version);
772        assert_eq!(loaded.instruction, "two");
773        let (by_prefix, _) = store
774            .get("booking", &BundleRef::Version(v1.version[..6].to_string()))
775            .await
776            .unwrap();
777        assert_eq!(by_prefix.version, v1.version);
778        assert_eq!(
779            store
780                .set_label("booking", "prod", &v1.version[..8])
781                .await
782                .unwrap(),
783            v1.version
784        );
785        let (prod, loaded) = store
786            .get("booking", &BundleRef::Label("prod".into()))
787            .await
788            .unwrap();
789        assert_eq!(prod.version, v1.version);
790        assert_eq!(loaded.instruction, "one");
791        // Promotion is moving the label.
792        store
793            .set_label("booking", "prod", &v2.version)
794            .await
795            .unwrap();
796        assert_eq!(store.labels("booking").await.unwrap()["prod"], v2.version);
797
798        // Refusals.
799        assert!(matches!(
800            store
801                .get("booking", &BundleRef::Label("staging".into()))
802                .await,
803            Err(StoreError::NotFound(_))
804        ));
805        assert!(matches!(
806            store.set_label("booking", "prod", "ffffffff").await,
807            Err(StoreError::NotFound(_))
808        ));
809        assert!(matches!(
810            store.push("../etc", &spec("x"), None).await,
811            Err(StoreError::Invalid(_))
812        ));
813        let invalid = SessionSpec::from_value(json!({
814            "name": "bad",
815            "flow": { "steps": [{ "id": "s", "allow": ["nope"] }] }
816        }))
817        .unwrap();
818        assert!(matches!(
819            store.push("bad", &invalid, None).await,
820            Err(StoreError::Invalid(_))
821        ));
822    }
823
824    #[tokio::test]
825    async fn a_directory_store_versions_and_labels_bundles() {
826        let dir = temp_dir("dir");
827        let store = open_store(dir.to_str().unwrap()).unwrap();
828        exercise(store.as_ref()).await;
829        let _ = std::fs::remove_dir_all(dir);
830    }
831
832    #[test]
833    fn references_parse() {
834        assert_eq!(
835            BundleRef::parse("booking"),
836            ("booking".into(), BundleRef::Latest)
837        );
838        assert_eq!(
839            BundleRef::parse("booking@3f2a"),
840            ("booking".into(), BundleRef::Version("3f2a".into()))
841        );
842        assert_eq!(
843            BundleRef::parse("booking:prod"),
844            ("booking".into(), BundleRef::Label("prod".into()))
845        );
846    }
847
848    #[test]
849    fn equal_specs_hash_equally_whatever_the_key_order() {
850        let a = json!({ "b": 1, "a": { "y": [1, { "q": 2, "p": 3 }], "x": null } });
851        let b = json!({ "a": { "x": null, "y": [1, { "p": 3, "q": 2 }] }, "b": 1 });
852        assert_eq!(canonical(&a), canonical(&b));
853        assert_eq!(
854            canonical(&a),
855            r#"{"a":{"x":null,"y":[1,{"p":3,"q":2}]},"b":1}"#
856        );
857    }
858
859    #[test]
860    fn timestamps_are_rfc3339() {
861        let now = now_rfc3339();
862        assert_eq!(now.len(), 24, "{now}");
863        assert!(now.starts_with("20") && now.ends_with('Z'), "{now}");
864    }
865
866    /// A minimal Cloud Storage JSON API: media upload, media download and
867    /// prefix listing, over an in-memory bucket.
868    #[cfg(feature = "gcs-store")]
869    async fn fake_gcs() -> String {
870        use std::sync::Mutex;
871        use tokio::io::{AsyncReadExt, AsyncWriteExt};
872
873        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
874        let endpoint = format!("http://{}", listener.local_addr().unwrap());
875        let objects: Arc<Mutex<BTreeMap<String, Vec<u8>>>> = Arc::default();
876        tokio::spawn(async move {
877            while let Ok((mut socket, _)) = listener.accept().await {
878                let objects = objects.clone();
879                tokio::spawn(async move {
880                    let mut data = Vec::new();
881                    let mut buf = [0u8; 8192];
882                    let (head, body) = loop {
883                        let n = socket.read(&mut buf).await.unwrap_or(0);
884                        if n == 0 {
885                            return;
886                        }
887                        data.extend_from_slice(&buf[..n]);
888                        let Some(end) = data.windows(4).position(|w| w == b"\r\n\r\n") else {
889                            continue;
890                        };
891                        let head = String::from_utf8_lossy(&data[..end]).to_string();
892                        let length = head
893                            .lines()
894                            .find_map(|l| {
895                                l.to_ascii_lowercase()
896                                    .strip_prefix("content-length:")
897                                    .map(|v| v.trim().parse::<usize>().unwrap_or(0))
898                            })
899                            .unwrap_or(0);
900                        if data.len() >= end + 4 + length {
901                            break (head, data[end + 4..end + 4 + length].to_vec());
902                        }
903                    };
904                    assert!(
905                        head.to_ascii_lowercase()
906                            .contains("authorization: bearer test-token")
907                    );
908                    let target = head.split_whitespace().nth(1).unwrap_or("/").to_string();
909                    let url = url::Url::parse(&format!("http://x{target}")).unwrap();
910                    let query: BTreeMap<String, String> = url.query_pairs().into_owned().collect();
911                    let segments: Vec<String> =
912                        url.path_segments().unwrap().map(percent_decode).collect();
913                    let (status, reply) = match segments.as_slice() {
914                        [u, ..] if u == "upload" => {
915                            objects.lock().unwrap().insert(query["name"].clone(), body);
916                            ("200 OK", b"{}".to_vec())
917                        }
918                        [_, _, _, _, o, name] if o == "o" => {
919                            match objects.lock().unwrap().get(name) {
920                                Some(bytes) => ("200 OK", bytes.clone()),
921                                None => ("404 Not Found", b"{}".to_vec()),
922                            }
923                        }
924                        _ => {
925                            let prefix = query.get("prefix").cloned().unwrap_or_default();
926                            let items: Vec<Value> = objects
927                                .lock()
928                                .unwrap()
929                                .keys()
930                                .filter(|k| k.starts_with(&prefix))
931                                .map(|k| json!({ "name": k }))
932                                .collect();
933                            ("200 OK", json!({ "items": items }).to_string().into_bytes())
934                        }
935                    };
936                    let mut response = format!(
937                        "HTTP/1.1 {status}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
938                        reply.len()
939                    )
940                    .into_bytes();
941                    response.extend(reply);
942                    let _ = socket.write_all(&response).await;
943                });
944            }
945        });
946        endpoint
947    }
948
949    #[cfg(feature = "gcs-store")]
950    fn percent_decode(s: &str) -> String {
951        let bytes = s.as_bytes();
952        let mut out = Vec::new();
953        let mut i = 0;
954        while i < bytes.len() {
955            let escaped = (bytes[i] == b'%' && i + 2 < bytes.len())
956                .then(|| u8::from_str_radix(&s[i + 1..i + 3], 16).ok())
957                .flatten();
958            match escaped {
959                Some(b) => {
960                    out.push(b);
961                    i += 3;
962                }
963                None => {
964                    out.push(bytes[i]);
965                    i += 1;
966                }
967            }
968        }
969        String::from_utf8_lossy(&out).into_owned()
970    }
971
972    #[cfg(feature = "gcs-store")]
973    #[tokio::test]
974    async fn a_cloud_storage_store_versions_and_labels_bundles() {
975        let endpoint = fake_gcs().await;
976        let token = Arc::new(gemini_genai_rs::transport::auth::GoogleAccessToken::fixed(
977            "test-token",
978        ));
979        let store = ObjectBundleStore::new(
980            GcsObjects::new("bucket", "team/bundles/", token).with_endpoint(endpoint),
981        );
982        exercise(&store).await;
983    }
984}