Skip to main content

rigg_core/
store.rs

1//! Project-scoped resource file store and sync-state classification.
2//!
3//! Layout inside a project directory:
4//!
5//! ```text
6//! projects/<name>/
7//!   project.yaml
8//!   envs/<env>/
9//!     search/<kind-dir>/<resource-stem>.json
10//!     foundry/<kind-dir>/<resource-stem>.json
11//! ```
12//!
13//! Each environment gets its own complete resource tree. The **file stem**
14//! (kind dir + filename) is the resource's *logical* identity — the
15//! correlation across environments. The **`name` field inside the file** is
16//! the *physical* Azure name for that environment; by default stem == name,
17//! but they may diverge when a resource is renamed in one environment (see
18//! [`Store::list`] / [`Store::locate`]).
19//!
20//! Files are written via [`crate::normalize::normalize_for_disk`] and long
21//! text fields are extracted to Markdown sidecars ([`crate::sidecar`]).
22//! Baselines (`.rigg/<env>/<project>/state.json`) hold the checksum of each
23//! resource at last sync, enabling local/remote/conflict classification.
24
25use std::collections::BTreeMap;
26use std::path::{Path, PathBuf};
27
28use serde::{Deserialize, Serialize};
29use serde_json::Value;
30use thiserror::Error;
31
32use crate::normalize::{format_json, normalize_for_compare, normalize_for_disk};
33use crate::resources::traits::{ResourceKind, ResourceRef, validate_resource_name};
34use crate::service::ServiceDomain;
35use crate::sidecar::{self, SidecarError};
36use crate::workspace::{ENVS_DIR, Project, Workspace};
37
38#[derive(Debug, Error)]
39pub enum StoreError {
40    #[error("failed to read {path}: {source}")]
41    Io {
42        path: PathBuf,
43        source: std::io::Error,
44    },
45    #[error("invalid JSON in {path}: {source}")]
46    Parse {
47        path: PathBuf,
48        source: serde_json::Error,
49    },
50    #[error(transparent)]
51    Sidecar(#[from] SidecarError),
52    #[error("invalid resource name in {path}: {message}")]
53    BadName { path: PathBuf, message: String },
54    #[error(
55        "duplicate physical name '{name}': both {first} and {second} define a resource named '{name}' — physical (Azure) names must be unique within a kind"
56    )]
57    DuplicatePhysicalName {
58        name: String,
59        first: PathBuf,
60        second: PathBuf,
61    },
62    #[error(
63        "resource {reference} is defined in both project '{first}' and project '{second}' — a resource must belong to exactly one project"
64    )]
65    DuplicateOwnership {
66        reference: String,
67        first: String,
68        second: String,
69    },
70    #[error(
71        "cannot create '{path}': stem is already used by a different resource (existing physical name '{existing_name}', new '{new_name}')"
72    )]
73    StemOccupiedByDifferentResource {
74        path: PathBuf,
75        existing_name: String,
76        new_name: String,
77    },
78}
79
80type Result<T> = std::result::Result<T, StoreError>;
81
82/// File store for one project, rooted at one environment's tree.
83pub struct Store<'w> {
84    project: &'w Project,
85    env: String,
86}
87
88impl<'w> Store<'w> {
89    pub fn new(project: &'w Project, env: &str) -> Self {
90        Store {
91            project,
92            env: env.to_string(),
93        }
94    }
95
96    pub fn project(&self) -> &Project {
97        self.project
98    }
99
100    pub fn env(&self) -> &str {
101        &self.env
102    }
103
104    /// List the environments a project participates in: the sorted names of
105    /// `<project>/envs/*` subdirectories. A project with no env dirs yet
106    /// participates in none (scaffold/adopt/pull materializes them lazily).
107    pub fn envs_of(project: &Project) -> Vec<String> {
108        let dir = project.dir.join(ENVS_DIR);
109        let mut envs: Vec<String> = std::fs::read_dir(&dir)
110            .map(|entries| {
111                entries
112                    .filter_map(|e| e.ok())
113                    .map(|e| e.path())
114                    .filter(|p| p.is_dir())
115                    .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
116                    .collect()
117            })
118            .unwrap_or_default();
119        envs.sort();
120        envs
121    }
122
123    /// Root of this store's environment tree: `<project>/envs/<env>/`.
124    fn root(&self) -> PathBuf {
125        self.project.dir.join(ENVS_DIR).join(&self.env)
126    }
127
128    fn domain_dir(domain: ServiceDomain) -> &'static str {
129        match domain {
130            ServiceDomain::Search => "search",
131            ServiceDomain::Foundry => "foundry",
132        }
133    }
134
135    fn kind_dir(&self, kind: ResourceKind) -> PathBuf {
136        self.root()
137            .join(Self::domain_dir(kind.domain()))
138            .join(kind.directory_name())
139    }
140
141    /// Absolute path for a NEW resource file (used on create — the physical
142    /// name becomes the filename). Existing resources may live at a
143    /// different path when their file stem diverged from the physical name;
144    /// use [`Store::locate`] to find those.
145    pub fn path_for(&self, r: &ResourceRef) -> PathBuf {
146        self.kind_dir(r.kind).join(format!("{}.json", r.name))
147    }
148
149    /// Find the file in this store whose physical name (`name` field, or the
150    /// file stem when absent) equals `r.name`. Scans the kind directory —
151    /// small dirs, correctness over micro-optimization.
152    pub fn locate(&self, r: &ResourceRef) -> Result<Option<PathBuf>> {
153        let dir = self.kind_dir(r.kind);
154        if !dir.is_dir() {
155            return Ok(None);
156        }
157        // Fast path: stem == physical name (the common case).
158        let fast = dir.join(format!("{}.json", r.name));
159        if fast.is_file() && physical_name(&fast, &r.name)? == r.name {
160            return Ok(Some(fast));
161        }
162        let mut entries: Vec<PathBuf> = std::fs::read_dir(&dir)
163            .map_err(|source| StoreError::Io {
164                path: dir.clone(),
165                source,
166            })?
167            .filter_map(|e| e.ok())
168            .map(|e| e.path())
169            .filter(|p| p.extension().is_some_and(|e| e == "json"))
170            .collect();
171        entries.sort();
172        for path in entries {
173            if path == fast {
174                continue; // already checked above
175            }
176            let stem = path
177                .file_stem()
178                .map(|s| s.to_string_lossy().into_owned())
179                .unwrap_or_default();
180            if physical_name(&path, &stem)? == r.name {
181                return Ok(Some(path));
182            }
183        }
184        Ok(None)
185    }
186
187    /// Path for a resource being CREATED: `<physical name>.json`, or — when
188    /// that stem is already occupied by a DIFFERENT resource (a renamed one
189    /// whose stem no longer matches its `name` field) — the first free
190    /// numbered stem (`<name>-2.json`, `<name>-3.json`, …). The create path
191    /// never points at an existing file, so creating can never overwrite a
192    /// renamed resource.
193    fn create_path_for(&self, r: &ResourceRef) -> PathBuf {
194        let dir = self.kind_dir(r.kind);
195        let base = dir.join(format!("{}.json", r.name));
196        if !base.exists() {
197            return base;
198        }
199        for i in 2u64.. {
200            let candidate = dir.join(format!("{}-{i}.json", r.name));
201            if !candidate.exists() {
202                return candidate;
203            }
204        }
205        unreachable!("some numbered stem is always free")
206    }
207
208    /// Scan this store's environment tree for resource files, keyed by
209    /// PHYSICAL name (the file's `name` field, falling back to its stem).
210    ///
211    /// A file with invalid JSON is a HARD error (deliberately): sync
212    /// operations (push/pull/prune/ownership checks) build their world view
213    /// from this listing, and silently skipping a broken file would let them
214    /// act on a partial view — e.g. pruning a resource that still exists
215    /// locally. Fail loud and name the file instead.
216    pub fn list(&self) -> Result<Vec<(ResourceRef, PathBuf)>> {
217        let mut out = Vec::new();
218        for kind in ResourceKind::all() {
219            let dir = self.kind_dir(*kind);
220            if !dir.is_dir() {
221                continue;
222            }
223            let mut entries: Vec<PathBuf> = std::fs::read_dir(&dir)
224                .map_err(|source| StoreError::Io {
225                    path: dir.clone(),
226                    source,
227                })?
228                .filter_map(|e| e.ok())
229                .map(|e| e.path())
230                .filter(|p| p.extension().is_some_and(|e| e == "json"))
231                .collect();
232            entries.sort();
233            let mut seen: BTreeMap<String, PathBuf> = BTreeMap::new();
234            for path in entries {
235                let stem = path
236                    .file_stem()
237                    .map(|s| s.to_string_lossy().into_owned())
238                    .unwrap_or_default();
239                let name = physical_name(&path, &stem)?;
240                validate_resource_name(&name).map_err(|e| StoreError::BadName {
241                    path: path.clone(),
242                    message: e.to_string(),
243                })?;
244                if let Some(first) = seen.get(&name) {
245                    return Err(StoreError::DuplicatePhysicalName {
246                        name,
247                        first: first.clone(),
248                        second: path,
249                    });
250                }
251                seen.insert(name.clone(), path.clone());
252                out.push((ResourceRef::new(*kind, name), path));
253            }
254        }
255        Ok(out)
256    }
257
258    /// Read a resource file with sidecars inlined. Locates the file by
259    /// physical name; when nothing matches, the error keeps the plain `Io`
260    /// not-found shape callers expect (pointing at the stem-guessed path).
261    /// It never falls through to reading a file whose physical name differs
262    /// from `r.name` (a renamed resource occupying the stem).
263    pub fn read(&self, r: &ResourceRef) -> Result<Value> {
264        match self.locate(r)? {
265            Some(path) => self.read_path(&path),
266            None => Err(StoreError::Io {
267                path: self.path_for(r),
268                source: std::io::Error::new(
269                    std::io::ErrorKind::NotFound,
270                    format!("no resource with physical name '{}'", r.name),
271                ),
272            }),
273        }
274    }
275
276    /// Read any resource file (must belong to this project) with sidecars inlined.
277    pub fn read_path(&self, path: &Path) -> Result<Value> {
278        let text = std::fs::read_to_string(path).map_err(|source| StoreError::Io {
279            path: path.to_path_buf(),
280            source,
281        })?;
282        let mut value: Value = serde_json::from_str(&text).map_err(|source| StoreError::Parse {
283            path: path.to_path_buf(),
284            source,
285        })?;
286        sidecar::inline_sidecars(path, &mut value)?;
287        Ok(value)
288    }
289
290    /// Write a resource: normalize for disk, extract sidecars, write only if
291    /// the semantic content changed. Returns true if the file was (re)written.
292    ///
293    /// Updates the located file when one exists (by physical name). When
294    /// none exists (create), the target stem is DISAMBIGUATED, never stolen:
295    /// if `<name>.json` is already occupied by a renamed resource, the new
296    /// file lands at `<name>-2.json` (then `-3`, …). This keeps sync
297    /// operations (pull/adopt capture cloud reality mid-run) robust instead
298    /// of failing, while `locate` keeps lookups correct regardless of stem.
299    pub fn write(&self, r: &ResourceRef, value: &Value) -> Result<bool> {
300        // Defense in depth: a physical name containing '/', '\' or '..' would
301        // otherwise build a path escaping the kind directory — and land where
302        // `list()`'s non-recursive scan never sees it.
303        validate_resource_name(&r.name).map_err(|e| StoreError::BadName {
304            path: self.create_path_for(r),
305            message: e.to_string(),
306        })?;
307        let path = match self.locate(r)? {
308            Some(existing) => existing,
309            None => self.create_path_for(r),
310        };
311        let mut normalized = normalize_for_disk(r.kind, value);
312
313        // Preserve any x-rigg-* annotations the user added locally: they are
314        // Rigg-local and never come back from Azure.
315        if path.is_file() {
316            if let Ok(existing) = self.read_path(&path) {
317                carry_over_x_rigg(&existing, &mut normalized);
318                carry_over_write_only(r.kind, &existing, &mut normalized);
319                if crate::normalize::semantic_eq(r.kind, &existing, &normalized) {
320                    return Ok(false);
321                }
322            }
323        }
324
325        if let Some(parent) = path.parent() {
326            std::fs::create_dir_all(parent).map_err(|source| StoreError::Io {
327                path: parent.to_path_buf(),
328                source,
329            })?;
330        }
331        sidecar::extract_sidecars(r.kind, &path, &mut normalized)?;
332        std::fs::write(&path, format_json(&normalized)).map_err(|source| StoreError::Io {
333            path: path.clone(),
334            source,
335        })?;
336        Ok(true)
337    }
338
339    /// Write a resource at an explicit STEM rather than its physical name —
340    /// used by `rigg promote` when creating a resource in the target
341    /// environment that has no counterpart there yet: the new file must land
342    /// at the SOURCE environment's stem (its logical/correlation id) so the
343    /// two trees keep correlating by path, even when the resource's physical
344    /// `name` differs from that stem (a renamed resource in the source env).
345    ///
346    /// Unlike [`Store::write`] (which locates-or-creates by physical name),
347    /// this never disambiguates: if `<stem>.json` already exists and holds a
348    /// DIFFERENT physical name than `value`, that's a genuine collision (some
349    /// other resource already occupies this stem) and it errors rather than
350    /// overwriting or guessing a new path. When the existing file's physical
351    /// name matches, it behaves like `write` (update in place, same
352    /// x-rigg-*/write-only carry-over and semantic-no-op short circuit).
353    pub fn write_at(&self, stem: &str, kind: ResourceKind, value: &Value) -> Result<bool> {
354        validate_resource_name(stem).map_err(|e| StoreError::BadName {
355            path: self.kind_dir(kind).join(format!("{stem}.json")),
356            message: e.to_string(),
357        })?;
358        let path = self.kind_dir(kind).join(format!("{stem}.json"));
359        let mut normalized = normalize_for_disk(kind, value);
360        let new_name = normalized
361            .get("name")
362            .and_then(Value::as_str)
363            .unwrap_or(stem)
364            .to_string();
365
366        if path.is_file() {
367            let existing = self.read_path(&path)?;
368            let existing_name = existing
369                .get("name")
370                .and_then(Value::as_str)
371                .unwrap_or(stem)
372                .to_string();
373            if existing_name != new_name {
374                return Err(StoreError::StemOccupiedByDifferentResource {
375                    path,
376                    existing_name,
377                    new_name,
378                });
379            }
380            carry_over_x_rigg(&existing, &mut normalized);
381            carry_over_write_only(kind, &existing, &mut normalized);
382            if crate::normalize::semantic_eq(kind, &existing, &normalized) {
383                return Ok(false);
384            }
385        }
386
387        if let Some(parent) = path.parent() {
388            std::fs::create_dir_all(parent).map_err(|source| StoreError::Io {
389                path: parent.to_path_buf(),
390                source,
391            })?;
392        }
393        sidecar::extract_sidecars(kind, &path, &mut normalized)?;
394        std::fs::write(&path, format_json(&normalized)).map_err(|source| StoreError::Io {
395            path: path.clone(),
396            source,
397        })?;
398        Ok(true)
399    }
400
401    /// Delete a resource file (and its default sidecars). Only ever removes
402    /// the file `locate` resolves for this physical name — deleting a name
403    /// that matches nothing is a no-op (never falls through to a stem-guessed
404    /// path that could belong to a renamed resource).
405    pub fn delete(&self, r: &ResourceRef) -> Result<()> {
406        let Some(path) = self.locate(r)? else {
407            return Ok(());
408        };
409        // Sidecars are named after the file's stem (its logical id), which
410        // may differ from the physical name — derive it from `path`, not `r`.
411        let stem = path
412            .file_stem()
413            .map(|s| s.to_string_lossy().into_owned())
414            .unwrap_or_else(|| r.name.clone());
415        if path.is_file() {
416            std::fs::remove_file(&path).map_err(|source| StoreError::Io {
417                path: path.clone(),
418                source,
419            })?;
420        }
421        // Remove default sidecars (e.g. `<stem>.instructions.md`).
422        if let Some(dir) = path.parent() {
423            for field in crate::registry::meta(r.kind).sidecar_fields {
424                let sidecar = dir.join(format!("{stem}.{field}.md"));
425                if sidecar.is_file() {
426                    let _ = std::fs::remove_file(sidecar);
427                }
428            }
429        }
430        Ok(())
431    }
432}
433
434/// Raw (non-sidecar-inlining) read of a resource file's physical name: the
435/// top-level `name` field if it's a string, else `fallback_stem`. Used by
436/// `list`/`locate`, which only need the identity, not the full document.
437fn physical_name(path: &Path, fallback_stem: &str) -> Result<String> {
438    let text = std::fs::read_to_string(path).map_err(|source| StoreError::Io {
439        path: path.to_path_buf(),
440        source,
441    })?;
442    let value: Value = serde_json::from_str(&text).map_err(|source| StoreError::Parse {
443        path: path.to_path_buf(),
444        source,
445    })?;
446    Ok(value
447        .get("name")
448        .and_then(Value::as_str)
449        .map(str::to_string)
450        .unwrap_or_else(|| fallback_stem.to_string()))
451}
452
453/// Preserve write-only fields (server never echoes them) from the existing
454/// local file when the incoming document lacks them or has them as null.
455fn carry_over_write_only(kind: ResourceKind, from: &Value, to: &mut Value) {
456    for spec in crate::registry::meta(kind).write_only_fields {
457        let mut existing_value: Option<Value> = None;
458        crate::registry::collect_path(from, spec, &mut |v| {
459            if !v.is_null() {
460                existing_value = Some(v.clone());
461            }
462        });
463        let Some(existing_value) = existing_value else {
464            continue;
465        };
466        set_path(to, &spec.split('.').collect::<Vec<_>>(), existing_value);
467    }
468}
469
470/// Set a dot-path (no `[]` support — write-only fields are object paths),
471/// creating intermediate objects as needed.
472fn set_path(value: &mut Value, segments: &[&str], new_value: Value) {
473    let Some((head, rest)) = segments.split_first() else {
474        return;
475    };
476    let Value::Object(map) = value else { return };
477    if rest.is_empty() {
478        map.insert((*head).to_string(), new_value);
479        return;
480    }
481    let entry = map
482        .entry((*head).to_string())
483        .or_insert_with(|| Value::Object(serde_json::Map::new()));
484    set_path(entry, rest, new_value);
485}
486
487/// Copy `x-rigg-*` keys from `from` into `to` at the same paths (top-level and
488/// one structural match deep for arrays keyed by `name`/`type`).
489fn carry_over_x_rigg(from: &Value, to: &mut Value) {
490    match (from, to) {
491        (Value::Object(src), Value::Object(dst)) => {
492            for (k, v) in src {
493                if k.starts_with("x-rigg-") {
494                    dst.entry(k.clone()).or_insert_with(|| v.clone());
495                } else if let Some(dv) = dst.get_mut(k) {
496                    carry_over_x_rigg(v, dv);
497                }
498            }
499        }
500        (Value::Array(src), Value::Array(dst)) => {
501            for sv in src {
502                let key = sv.get("name").or_else(|| sv.get("type"));
503                if let Some(key) = key {
504                    if let Some(dv) = dst
505                        .iter_mut()
506                        .find(|d| d.get("name").or_else(|| d.get("type")) == Some(key))
507                    {
508                        carry_over_x_rigg(sv, dv);
509                    }
510                }
511            }
512        }
513        _ => {}
514    }
515}
516
517/// Enforce exclusive ownership: a (kind, name) may appear in only one
518/// project — within one environment (a physical resource named the same in
519/// two envs is normal; it's the same logical resource pushed twice).
520pub fn assert_exclusive_ownership(ws: &Workspace, env: &str) -> Result<()> {
521    let mut seen: BTreeMap<ResourceRef, &str> = BTreeMap::new();
522    for project in &ws.projects {
523        let store = Store::new(project, env);
524        for (r, _) in store.list()? {
525            if let Some(first) = seen.get(&r) {
526                return Err(StoreError::DuplicateOwnership {
527                    reference: r.to_string(),
528                    first: first.to_string(),
529                    second: project.name.clone(),
530                });
531            }
532            seen.insert(r, &project.name);
533        }
534    }
535    Ok(())
536}
537
538/// Sync classification of one resource.
539#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
540#[serde(rename_all = "kebab-case")]
541pub enum SyncClass {
542    /// Local, remote and baseline all agree.
543    InSync,
544    /// Local changed since last sync; remote unchanged.
545    LocalAhead,
546    /// Remote changed since last sync; local unchanged.
547    RemoteAhead,
548    /// Both changed since last sync.
549    Conflict,
550    /// Exists locally, not remotely (new resource or remote-deleted).
551    LocalOnly,
552    /// Exists remotely, not locally (unmanaged or locally-deleted).
553    RemoteOnly,
554    /// No baseline; local and remote both exist but differ (never synced).
555    Untracked,
556}
557
558/// A sync baseline. Newer rigg versions store the compare-normalized
559/// document so the checksum can be recomputed under CURRENT normalization
560/// rules — surviving rule evolution across rigg upgrades. Legacy entries
561/// hold only the frozen checksum and behave as before until the resource
562/// next syncs (every successful pull/push/adopt rewrites its baseline).
563#[derive(Debug, Clone, Serialize, Deserialize)]
564#[serde(untagged)]
565pub enum Baseline {
566    /// Legacy: frozen checksum (string MUST be tried first — `Value`
567    /// deserializes any JSON, including strings).
568    Checksum(String),
569    /// Compare-normalized canonical document.
570    Doc(Value),
571}
572
573/// Per-project, per-environment sync baselines.
574#[derive(Debug, Clone, Default, Serialize, Deserialize)]
575pub struct ProjectState {
576    /// `kind-dir/name` → baseline captured at last sync.
577    #[serde(default)]
578    pub baselines: BTreeMap<String, Baseline>,
579}
580
581impl ProjectState {
582    pub fn path(ws: &Workspace, env: &str, project: &str) -> PathBuf {
583        ws.state_dir(env, project).join("state.json")
584    }
585
586    pub fn load(ws: &Workspace, env: &str, project: &str) -> ProjectState {
587        let path = Self::path(ws, env, project);
588        std::fs::read_to_string(&path)
589            .ok()
590            .and_then(|text| serde_json::from_str(&text).ok())
591            .unwrap_or_default()
592    }
593
594    pub fn save(&self, ws: &Workspace, env: &str, project: &str) -> std::io::Result<()> {
595        let path = Self::path(ws, env, project);
596        if let Some(parent) = path.parent() {
597            std::fs::create_dir_all(parent)?;
598        }
599        std::fs::write(&path, format_json(&serde_json::to_value(self).unwrap()))
600    }
601
602    /// Checksum of the push-normalized form of a document.
603    ///
604    /// The form is canonicalized (object keys sorted recursively, arrays of
605    /// named objects sorted by name) so that server-side reordering between
606    /// GET and PUT responses never reads as a change — matching the semantics
607    /// of the order-insensitive diff.
608    pub fn checksum(kind: ResourceKind, value: &Value) -> String {
609        let normalized = canonical_form(&normalize_for_compare(kind, value));
610        let canonical = serde_json::to_string(&normalized).unwrap_or_default();
611        format!("{:x}", md5_like(&canonical))
612    }
613
614    /// Whether a baseline is recorded for this resource.
615    pub fn has_baseline(&self, r: &ResourceRef) -> bool {
616        self.baselines.contains_key(&r.key())
617    }
618
619    /// Checksum of the recorded baseline, recomputed under CURRENT
620    /// normalization rules for `Doc` entries — this is what lets a resource
621    /// self-heal when a rigg upgrade changes which fields are volatile.
622    /// Legacy `Checksum` entries are frozen and returned as-is.
623    pub fn baseline_checksum(&self, r: &ResourceRef) -> Option<String> {
624        match self.baselines.get(&r.key())? {
625            Baseline::Checksum(s) => Some(s.clone()),
626            Baseline::Doc(v) => Some(Self::checksum(r.kind, v)),
627        }
628    }
629
630    pub fn set_baseline(&mut self, r: &ResourceRef, kind_value: &Value) {
631        let doc = canonical_form(&normalize_for_compare(r.kind, kind_value));
632        self.baselines.insert(r.key(), Baseline::Doc(doc));
633    }
634
635    pub fn clear_baseline(&mut self, r: &ResourceRef) {
636        self.baselines.remove(&r.key());
637    }
638
639    /// Classify a resource given its (optional) local and remote documents.
640    pub fn classify(
641        &self,
642        r: &ResourceRef,
643        local: Option<&Value>,
644        remote: Option<&Value>,
645    ) -> SyncClass {
646        let baseline = self.baseline_checksum(r);
647        match (local, remote) {
648            (None, None) => SyncClass::InSync, // nothing anywhere (only baseline leftover)
649            (Some(_), None) => SyncClass::LocalOnly,
650            (None, Some(_)) => SyncClass::RemoteOnly,
651            (Some(l), Some(rm)) => {
652                let lsum = Self::checksum(r.kind, l);
653                let rsum = Self::checksum(r.kind, rm);
654                match baseline {
655                    None => {
656                        if lsum == rsum {
657                            SyncClass::InSync
658                        } else {
659                            SyncClass::Untracked
660                        }
661                    }
662                    Some(base) => {
663                        let local_changed = lsum != base;
664                        let remote_changed = rsum != base;
665                        match (local_changed, remote_changed) {
666                            (false, false) => SyncClass::InSync,
667                            (true, false) => SyncClass::LocalAhead,
668                            (false, true) => SyncClass::RemoteAhead,
669                            (true, true) => {
670                                if lsum == rsum {
671                                    // Both moved to the same content.
672                                    SyncClass::InSync
673                                } else {
674                                    SyncClass::Conflict
675                                }
676                            }
677                        }
678                    }
679                }
680            }
681        }
682    }
683}
684
685/// Order-canonical JSON: object keys sorted recursively; arrays whose items
686/// all carry a string `name` are sorted by it (identity-keyed arrays).
687fn canonical_form(value: &Value) -> Value {
688    match value {
689        Value::Object(map) => {
690            // null-valued keys are dropped: Azure oscillates between omitting
691            // a field and returning it as null depending on the endpoint.
692            let mut sorted: Vec<(String, Value)> = map
693                .iter()
694                .filter(|(_, v)| !v.is_null())
695                .map(|(k, v)| (k.clone(), canonical_form(v)))
696                .collect();
697            sorted.sort_by(|a, b| a.0.cmp(&b.0));
698            Value::Object(sorted.into_iter().collect())
699        }
700        Value::Array(arr) => {
701            let mut items: Vec<Value> = arr.iter().map(canonical_form).collect();
702            if !items.is_empty()
703                && items
704                    .iter()
705                    .all(|i| i.get("name").and_then(Value::as_str).is_some())
706            {
707                items.sort_by(|a, b| {
708                    a["name"]
709                        .as_str()
710                        .unwrap_or_default()
711                        .cmp(b["name"].as_str().unwrap_or_default())
712                });
713            }
714            Value::Array(items)
715        }
716        other => other.clone(),
717    }
718}
719
720/// Small non-cryptographic checksum (FNV-1a 128-ish via two 64-bit lanes).
721/// Collision resistance is ample for change detection.
722fn md5_like(s: &str) -> u128 {
723    let mut h1: u64 = 0xcbf29ce484222325;
724    let mut h2: u64 = 0x9e3779b97f4a7c15;
725    for b in s.as_bytes() {
726        h1 ^= u64::from(*b);
727        h1 = h1.wrapping_mul(0x100000001b3);
728        h2 = h2.rotate_left(5) ^ u64::from(*b);
729        h2 = h2.wrapping_mul(0x2545f4914f6cdd1d);
730    }
731    (u128::from(h1) << 64) | u128::from(h2)
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737    use crate::workspace::{PROJECT_FILE, PROJECTS_DIR, WORKSPACE_FILE};
738    use serde_json::json;
739
740    fn ws_with_projects(dir: &Path, names: &[&str]) -> Workspace {
741        std::fs::write(
742            dir.join(WORKSPACE_FILE),
743            "environments:\n  dev:\n    default: true\n    search: { service: s }\n",
744        )
745        .unwrap();
746        for name in names {
747            let pdir = dir.join(PROJECTS_DIR).join(name);
748            std::fs::create_dir_all(&pdir).unwrap();
749            std::fs::write(pdir.join(PROJECT_FILE), "{}\n").unwrap();
750        }
751        Workspace::load(dir).unwrap()
752    }
753
754    #[test]
755    fn write_list_read_round_trip_with_sidecars() {
756        let tmp = tempfile::tempdir().unwrap();
757        let ws = ws_with_projects(tmp.path(), &["p"]);
758        let store = Store::new(ws.project("p").unwrap(), "dev");
759
760        let agent_ref = ResourceRef::new(ResourceKind::Agent, "helper");
761        let agent = json!({"name": "helper", "model": "gpt-5-mini", "instructions": "Be nice."});
762        assert!(store.write(&agent_ref, &agent).unwrap());
763
764        // sidecar extracted
765        let sidecar = store
766            .path_for(&agent_ref)
767            .parent()
768            .unwrap()
769            .join("helper.instructions.md");
770        assert!(sidecar.is_file());
771
772        // read inlines it back
773        let read = store.read(&agent_ref).unwrap();
774        assert_eq!(read["instructions"], json!("Be nice."));
775
776        let listed = store.list().unwrap();
777        assert_eq!(listed.len(), 1);
778        assert_eq!(listed[0].0, agent_ref);
779
780        // env-rooted path
781        assert!(
782            store
783                .path_for(&agent_ref)
784                .to_string_lossy()
785                .contains("envs/dev/")
786        );
787    }
788
789    #[test]
790    fn write_returns_false_when_semantically_unchanged() {
791        let tmp = tempfile::tempdir().unwrap();
792        let ws = ws_with_projects(tmp.path(), &["p"]);
793        let store = Store::new(ws.project("p").unwrap(), "dev");
794        let r = ResourceRef::new(ResourceKind::Index, "idx");
795        assert!(
796            store
797                .write(&r, &json!({"name": "idx", "fields": []}))
798                .unwrap()
799        );
800        // same content + volatile noise → no rewrite
801        let noisy = json!({"@odata.etag": "0x1", "name": "idx", "fields": []});
802        assert!(!store.write(&r, &noisy).unwrap());
803        // real change → rewrite
804        let changed = json!({"name": "idx", "fields": [{"name": "f"}]});
805        assert!(store.write(&r, &changed).unwrap());
806    }
807
808    #[test]
809    fn write_preserves_local_x_rigg_annotations() {
810        let tmp = tempfile::tempdir().unwrap();
811        let ws = ws_with_projects(tmp.path(), &["p"]);
812        let store = Store::new(ws.project("p").unwrap(), "dev");
813        let r = ResourceRef::new(ResourceKind::Skillset, "sk");
814        let local = json!({
815            "name": "sk",
816            "skills": [{"name": "web", "uri": "https://f", "x-rigg-api": "enrich"}]
817        });
818        store.write(&r, &local).unwrap();
819        // Azure returns the same thing without the annotation
820        let remote = json!({
821            "name": "sk",
822            "skills": [{"name": "web", "uri": "https://f"}]
823        });
824        let rewritten = store.write(&r, &remote).unwrap();
825        let read = store.read(&r).unwrap();
826        assert_eq!(read["skills"][0]["x-rigg-api"], json!("enrich"));
827        assert!(!rewritten, "annotation-only delta is not a semantic change");
828    }
829
830    #[test]
831    fn exclusive_ownership_violation_names_both_projects() {
832        let tmp = tempfile::tempdir().unwrap();
833        let ws = ws_with_projects(tmp.path(), &["alpha", "beta"]);
834        for p in ["alpha", "beta"] {
835            let store = Store::new(ws.project(p).unwrap(), "dev");
836            store
837                .write(
838                    &ResourceRef::new(ResourceKind::Index, "shared"),
839                    &json!({"name": "shared"}),
840                )
841                .unwrap();
842        }
843        let err = assert_exclusive_ownership(&ws, "dev").unwrap_err();
844        let msg = err.to_string();
845        assert!(msg.contains("alpha") && msg.contains("beta") && msg.contains("indexes/shared"));
846    }
847
848    #[test]
849    fn ownership_is_scoped_per_environment() {
850        // Same physical name in two projects but DIFFERENT envs: not a
851        // violation (ownership is checked per env tree).
852        let tmp = tempfile::tempdir().unwrap();
853        let ws = ws_with_projects(tmp.path(), &["alpha", "beta"]);
854        Store::new(ws.project("alpha").unwrap(), "dev")
855            .write(
856                &ResourceRef::new(ResourceKind::Index, "shared"),
857                &json!({"name": "shared"}),
858            )
859            .unwrap();
860        Store::new(ws.project("beta").unwrap(), "prod")
861            .write(
862                &ResourceRef::new(ResourceKind::Index, "shared"),
863                &json!({"name": "shared"}),
864            )
865            .unwrap();
866        assert!(assert_exclusive_ownership(&ws, "dev").is_ok());
867        assert!(assert_exclusive_ownership(&ws, "prod").is_ok());
868    }
869
870    #[test]
871    fn envs_of_lists_env_dirs_sorted() {
872        let tmp = tempfile::tempdir().unwrap();
873        let ws = ws_with_projects(tmp.path(), &["p"]);
874        let project = ws.project("p").unwrap();
875        assert_eq!(Store::envs_of(project), Vec::<String>::new());
876        Store::new(project, "prod")
877            .write(
878                &ResourceRef::new(ResourceKind::Index, "idx"),
879                &json!({"name": "idx"}),
880            )
881            .unwrap();
882        Store::new(project, "dev")
883            .write(
884                &ResourceRef::new(ResourceKind::Index, "idx"),
885                &json!({"name": "idx"}),
886            )
887            .unwrap();
888        assert_eq!(Store::envs_of(project), vec!["dev", "prod"]);
889    }
890
891    #[test]
892    fn list_keys_by_physical_name_when_stem_differs() {
893        let tmp = tempfile::tempdir().unwrap();
894        let ws = ws_with_projects(tmp.path(), &["p"]);
895        let store = Store::new(ws.project("p").unwrap(), "dev");
896        let dir = store
897            .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
898            .parent()
899            .unwrap()
900            .to_path_buf();
901        std::fs::create_dir_all(&dir).unwrap();
902        std::fs::write(
903            dir.join("regulus.json"),
904            json!({"name": "Regulus-Prod", "model": "m"}).to_string(),
905        )
906        .unwrap();
907        let listed = store.list().unwrap();
908        assert_eq!(listed.len(), 1);
909        assert_eq!(listed[0].0.name, "Regulus-Prod");
910        assert_eq!(listed[0].1, dir.join("regulus.json"));
911    }
912
913    #[test]
914    fn locate_finds_file_by_physical_name_when_stem_differs() {
915        let tmp = tempfile::tempdir().unwrap();
916        let ws = ws_with_projects(tmp.path(), &["p"]);
917        let store = Store::new(ws.project("p").unwrap(), "dev");
918        let dir = store
919            .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
920            .parent()
921            .unwrap()
922            .to_path_buf();
923        std::fs::create_dir_all(&dir).unwrap();
924        std::fs::write(
925            dir.join("regulus.json"),
926            json!({"name": "Regulus-Prod", "model": "m"}).to_string(),
927        )
928        .unwrap();
929        let found = store
930            .locate(&ResourceRef::new(ResourceKind::Agent, "Regulus-Prod"))
931            .unwrap();
932        assert_eq!(found, Some(dir.join("regulus.json")));
933        assert_eq!(
934            store
935                .locate(&ResourceRef::new(ResourceKind::Agent, "regulus"))
936                .unwrap(),
937            None,
938            "the stem alone is not a physical name once name diverges"
939        );
940    }
941
942    #[test]
943    fn write_updates_the_located_file_not_a_stem_guess() {
944        // Physical rename case: file `regulus.json` holds name "Regulus-Prod".
945        // Writing a ResourceRef keyed on the physical name must update THAT
946        // file in place, not create a new `Regulus-Prod.json`.
947        let tmp = tempfile::tempdir().unwrap();
948        let ws = ws_with_projects(tmp.path(), &["p"]);
949        let store = Store::new(ws.project("p").unwrap(), "dev");
950        let dir = store
951            .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
952            .parent()
953            .unwrap()
954            .to_path_buf();
955        std::fs::create_dir_all(&dir).unwrap();
956        std::fs::write(
957            dir.join("regulus.json"),
958            json!({"name": "Regulus-Prod", "model": "m1"}).to_string(),
959        )
960        .unwrap();
961        let r = ResourceRef::new(ResourceKind::Agent, "Regulus-Prod");
962        store
963            .write(&r, &json!({"name": "Regulus-Prod", "model": "m2"}))
964            .unwrap();
965        assert!(
966            !dir.join("Regulus-Prod.json").exists(),
967            "must not create a second file keyed on the physical name"
968        );
969        let on_disk: Value =
970            serde_json::from_str(&std::fs::read_to_string(dir.join("regulus.json")).unwrap())
971                .unwrap();
972        assert_eq!(on_disk["model"], json!("m2"));
973    }
974
975    #[test]
976    fn write_never_overwrites_a_renamed_resources_stem() {
977        // `regulus.json` holds physical name "Regulus-Prod" (a renamed
978        // resource). Creating a NEW resource whose physical name is
979        // "regulus" must NOT clobber it: the create path disambiguates to a
980        // free stem instead.
981        let tmp = tempfile::tempdir().unwrap();
982        let ws = ws_with_projects(tmp.path(), &["p"]);
983        let store = Store::new(ws.project("p").unwrap(), "dev");
984        let dir = store
985            .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
986            .parent()
987            .unwrap()
988            .to_path_buf();
989        std::fs::create_dir_all(&dir).unwrap();
990        std::fs::write(
991            dir.join("regulus.json"),
992            json!({"name": "Regulus-Prod", "model": "m"}).to_string(),
993        )
994        .unwrap();
995
996        let r = ResourceRef::new(ResourceKind::Agent, "regulus");
997        assert!(
998            store
999                .write(&r, &json!({"name": "regulus", "model": "new"}))
1000                .unwrap()
1001        );
1002
1003        // the renamed resource's file is untouched
1004        let original: Value =
1005            serde_json::from_str(&std::fs::read_to_string(dir.join("regulus.json")).unwrap())
1006                .unwrap();
1007        assert_eq!(
1008            original["name"],
1009            json!("Regulus-Prod"),
1010            "original file untouched"
1011        );
1012
1013        // the new resource landed at a different, disambiguated stem
1014        let new_path = store.locate(&r).unwrap().expect("new resource locatable");
1015        assert_ne!(new_path, dir.join("regulus.json"));
1016        assert_eq!(new_path, dir.join("regulus-2.json"));
1017
1018        // both resolve correctly by physical name
1019        assert_eq!(
1020            store
1021                .locate(&ResourceRef::new(ResourceKind::Agent, "Regulus-Prod"))
1022                .unwrap(),
1023            Some(dir.join("regulus.json"))
1024        );
1025        assert_eq!(store.list().unwrap().len(), 2);
1026    }
1027
1028    #[test]
1029    fn delete_by_physical_name_removes_located_file_and_stem_sidecars() {
1030        // File stem "regulus" ≠ physical name "Regulus-Prod", with an
1031        // instructions sidecar named after the STEM. Deleting by physical
1032        // name must remove the located file AND its stem-named sidecar,
1033        // leaving a neighboring resource untouched.
1034        let tmp = tempfile::tempdir().unwrap();
1035        let ws = ws_with_projects(tmp.path(), &["p"]);
1036        let store = Store::new(ws.project("p").unwrap(), "dev");
1037        let dir = store
1038            .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
1039            .parent()
1040            .unwrap()
1041            .to_path_buf();
1042        std::fs::create_dir_all(&dir).unwrap();
1043        std::fs::write(
1044            dir.join("regulus.json"),
1045            json!({
1046                "name": "Regulus-Prod", "model": "m",
1047                "instructions": {"$file": "regulus.instructions.md"}
1048            })
1049            .to_string(),
1050        )
1051        .unwrap();
1052        std::fs::write(dir.join("regulus.instructions.md"), "Be helpful.").unwrap();
1053        // neighbor with its own sidecar
1054        std::fs::write(
1055            dir.join("other.json"),
1056            json!({
1057                "name": "other", "model": "m",
1058                "instructions": {"$file": "other.instructions.md"}
1059            })
1060            .to_string(),
1061        )
1062        .unwrap();
1063        std::fs::write(dir.join("other.instructions.md"), "Neighbor.").unwrap();
1064
1065        store
1066            .delete(&ResourceRef::new(ResourceKind::Agent, "Regulus-Prod"))
1067            .unwrap();
1068        assert!(!dir.join("regulus.json").exists(), "located file removed");
1069        assert!(
1070            !dir.join("regulus.instructions.md").exists(),
1071            "stem-named sidecar removed"
1072        );
1073        assert!(dir.join("other.json").exists(), "neighbor untouched");
1074        assert!(
1075            dir.join("other.instructions.md").exists(),
1076            "neighbor sidecar untouched"
1077        );
1078
1079        // deleting a physical name that matches nothing is a no-op
1080        store
1081            .delete(&ResourceRef::new(ResourceKind::Agent, "ghost"))
1082            .unwrap();
1083        assert!(dir.join("other.json").exists());
1084    }
1085
1086    #[test]
1087    fn list_error_on_corrupt_json_names_the_file() {
1088        let tmp = tempfile::tempdir().unwrap();
1089        let ws = ws_with_projects(tmp.path(), &["p"]);
1090        let store = Store::new(ws.project("p").unwrap(), "dev");
1091        let dir = store
1092            .path_for(&ResourceRef::new(ResourceKind::Index, "a"))
1093            .parent()
1094            .unwrap()
1095            .to_path_buf();
1096        std::fs::create_dir_all(&dir).unwrap();
1097        std::fs::write(dir.join("broken.json"), "{ this is not json").unwrap();
1098        let err = store.list().unwrap_err();
1099        assert!(matches!(err, StoreError::Parse { .. }));
1100        assert!(
1101            err.to_string().contains("broken.json"),
1102            "error names the broken file: {err}"
1103        );
1104    }
1105
1106    #[test]
1107    fn duplicate_physical_name_in_one_kind_dir_errors() {
1108        let tmp = tempfile::tempdir().unwrap();
1109        let ws = ws_with_projects(tmp.path(), &["p"]);
1110        let store = Store::new(ws.project("p").unwrap(), "dev");
1111        let dir = store
1112            .path_for(&ResourceRef::new(ResourceKind::Index, "a"))
1113            .parent()
1114            .unwrap()
1115            .to_path_buf();
1116        std::fs::create_dir_all(&dir).unwrap();
1117        std::fs::write(dir.join("a.json"), json!({"name": "dup"}).to_string()).unwrap();
1118        std::fs::write(dir.join("b.json"), json!({"name": "dup"}).to_string()).unwrap();
1119        let err = store.list().unwrap_err();
1120        assert!(matches!(err, StoreError::DuplicatePhysicalName { .. }));
1121        assert!(err.to_string().contains("dup"));
1122    }
1123
1124    #[test]
1125    fn classify_truth_table() {
1126        let tmp = tempfile::tempdir().unwrap();
1127        let ws = ws_with_projects(tmp.path(), &["p"]);
1128        let r = ResourceRef::new(ResourceKind::Index, "idx");
1129        let a = json!({"name": "idx", "fields": [{"name": "f1"}]});
1130        let b = json!({"name": "idx", "fields": [{"name": "f2"}]});
1131        let c = json!({"name": "idx", "fields": [{"name": "f3"}]});
1132
1133        let mut state = ProjectState::default();
1134        // no baseline
1135        assert_eq!(state.classify(&r, Some(&a), Some(&a)), SyncClass::InSync);
1136        assert_eq!(state.classify(&r, Some(&a), Some(&b)), SyncClass::Untracked);
1137        assert_eq!(state.classify(&r, Some(&a), None), SyncClass::LocalOnly);
1138        assert_eq!(state.classify(&r, None, Some(&a)), SyncClass::RemoteOnly);
1139        assert_eq!(state.classify(&r, None, None), SyncClass::InSync);
1140
1141        // with baseline = a
1142        state.set_baseline(&r, &a);
1143        assert_eq!(state.classify(&r, Some(&a), Some(&a)), SyncClass::InSync);
1144        assert_eq!(
1145            state.classify(&r, Some(&b), Some(&a)),
1146            SyncClass::LocalAhead
1147        );
1148        assert_eq!(
1149            state.classify(&r, Some(&a), Some(&b)),
1150            SyncClass::RemoteAhead
1151        );
1152        assert_eq!(state.classify(&r, Some(&b), Some(&c)), SyncClass::Conflict);
1153        assert_eq!(state.classify(&r, Some(&b), Some(&b)), SyncClass::InSync);
1154
1155        // save/load round trip
1156        state.save(&ws, "dev", "p").unwrap();
1157        let loaded = ProjectState::load(&ws, "dev", "p");
1158        assert_eq!(loaded.baseline_checksum(&r), state.baseline_checksum(&r));
1159    }
1160
1161    #[test]
1162    fn legacy_checksum_baseline_still_loads_and_classifies() {
1163        // A state.json written by an older rigg: baseline is a bare string.
1164        let json = r#"{"baselines": {"agents/a": "deadbeef"}}"#;
1165        let state: ProjectState = serde_json::from_str(json).unwrap();
1166        let r = ResourceRef::new(ResourceKind::Agent, "a".to_string());
1167        assert!(state.has_baseline(&r));
1168        // Stale hash + differing local/remote → Conflict (today's behavior).
1169        let local = json!({"name": "a", "model": "x"});
1170        let remote = json!({"name": "a", "model": "y"});
1171        assert_eq!(
1172            state.classify(&r, Some(&local), Some(&remote)),
1173            SyncClass::Conflict
1174        );
1175    }
1176
1177    #[test]
1178    fn doc_baseline_self_heals_across_rule_changes() {
1179        // Simulate a baseline stored BEFORE metadata.modified_at became
1180        // volatile: the stored doc still carries the field. Under current
1181        // rules the recomputed checksum strips it, so an untouched local
1182        // (without the field) plus a remote-only change classifies as
1183        // RemoteAhead — NOT Conflict.
1184        let r = ResourceRef::new(ResourceKind::Agent, "a".to_string());
1185        let old_doc = json!({
1186            "name": "a", "model": "x",
1187            "metadata": {"modified_at": "111", "logo": "l.svg"}
1188        });
1189        let mut state = ProjectState::default();
1190        state.baselines.insert(r.key(), Baseline::Doc(old_doc));
1191        let local = json!({
1192            "name": "a", "model": "x", "metadata": {"logo": "l.svg"}
1193        });
1194        let remote = json!({
1195            "name": "a", "model": "CHANGED", "metadata": {"logo": "l.svg"}
1196        });
1197        assert_eq!(
1198            state.classify(&r, Some(&local), Some(&remote)),
1199            SyncClass::RemoteAhead
1200        );
1201    }
1202
1203    #[test]
1204    fn baseline_serde_mixed_roundtrip() {
1205        let r = ResourceRef::new(ResourceKind::Agent, "new".to_string());
1206        let mut state = ProjectState::default();
1207        state.baselines.insert(
1208            "agents/legacy".to_string(),
1209            Baseline::Checksum("abc".to_string()),
1210        );
1211        state.set_baseline(&r, &json!({"name": "new", "model": "m"}));
1212        let text = serde_json::to_string(&state).unwrap();
1213        let back: ProjectState = serde_json::from_str(&text).unwrap();
1214        assert!(
1215            matches!(back.baselines.get("agents/legacy"), Some(Baseline::Checksum(s)) if s == "abc")
1216        );
1217        assert!(matches!(
1218            back.baselines.get("agents/new"),
1219            Some(Baseline::Doc(_))
1220        ));
1221    }
1222
1223    #[test]
1224    fn write_only_fields_survive_server_echo_and_compare() {
1225        let tmp = tempfile::tempdir().unwrap();
1226        let ws = ws_with_projects(tmp.path(), &["p"]);
1227        let store = Store::new(ws.project("p").unwrap(), "dev");
1228        let r = ResourceRef::new(ResourceKind::DataSource, "ds");
1229        let local = json!({
1230            "name": "ds", "type": "azureblob",
1231            "credentials": {"connectionString": "ResourceId=/subscriptions/s/x;"},
1232            "container": {"name": "c"}
1233        });
1234        store.write(&r, &local).unwrap();
1235        // Azure's GET echo: connection string redacted to null
1236        let server_echo = json!({
1237            "name": "ds", "type": "azureblob",
1238            "credentials": {"connectionString": null},
1239            "container": {"name": "c"}
1240        });
1241        // no semantic change → no rewrite, and the conn string survives
1242        assert!(!store.write(&r, &server_echo).unwrap());
1243        let read = store.read(&r).unwrap();
1244        assert_eq!(
1245            read["credentials"]["connectionString"],
1246            json!("ResourceId=/subscriptions/s/x;")
1247        );
1248        // checksums ignore the write-only field (local vs redacted remote equal)
1249        assert_eq!(
1250            ProjectState::checksum(ResourceKind::DataSource, &local),
1251            ProjectState::checksum(ResourceKind::DataSource, &server_echo)
1252        );
1253    }
1254
1255    #[test]
1256    fn checksum_is_order_canonical() {
1257        // same content, different key order and array order
1258        let a = serde_json::from_str::<Value>(
1259            r#"{"name": "i", "fields": [{"name": "b"}, {"name": "a"}], "x": 1}"#,
1260        )
1261        .unwrap();
1262        let b = serde_json::from_str::<Value>(
1263            r#"{"x": 1, "name": "i", "fields": [{"name": "a"}, {"name": "b"}]}"#,
1264        )
1265        .unwrap();
1266        assert_eq!(
1267            ProjectState::checksum(ResourceKind::Index, &a),
1268            ProjectState::checksum(ResourceKind::Index, &b)
1269        );
1270    }
1271
1272    #[test]
1273    fn checksum_ignores_volatile_and_annotations() {
1274        let a = json!({"name": "i", "@odata.etag": "1", "x-rigg-note": "hi"});
1275        let b = json!({"name": "i"});
1276        assert_eq!(
1277            ProjectState::checksum(ResourceKind::Index, &a),
1278            ProjectState::checksum(ResourceKind::Index, &b)
1279        );
1280    }
1281
1282    #[test]
1283    fn write_at_creates_file_at_given_stem() {
1284        let tmp = tempfile::tempdir().unwrap();
1285        let ws = ws_with_projects(tmp.path(), &["p"]);
1286        let store = Store::new(ws.project("p").unwrap(), "prod");
1287        let created = store
1288            .write_at(
1289                "regulus",
1290                ResourceKind::Agent,
1291                &json!({"name": "Regulus-Prod", "model": "m"}),
1292            )
1293            .unwrap();
1294        assert!(created);
1295        let dir = store
1296            .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
1297            .parent()
1298            .unwrap()
1299            .to_path_buf();
1300        assert!(
1301            dir.join("regulus.json").is_file(),
1302            "landed at the stem, not the physical name"
1303        );
1304        assert!(!dir.join("Regulus-Prod.json").exists());
1305        let on_disk: Value =
1306            serde_json::from_str(&std::fs::read_to_string(dir.join("regulus.json")).unwrap())
1307                .unwrap();
1308        assert_eq!(on_disk["name"], json!("Regulus-Prod"));
1309    }
1310
1311    #[test]
1312    fn write_at_updates_in_place_when_physical_name_matches() {
1313        let tmp = tempfile::tempdir().unwrap();
1314        let ws = ws_with_projects(tmp.path(), &["p"]);
1315        let store = Store::new(ws.project("p").unwrap(), "prod");
1316        store
1317            .write_at(
1318                "regulus",
1319                ResourceKind::Agent,
1320                &json!({"name": "Regulus-Prod", "model": "m1"}),
1321            )
1322            .unwrap();
1323        let rewritten = store
1324            .write_at(
1325                "regulus",
1326                ResourceKind::Agent,
1327                &json!({"name": "Regulus-Prod", "model": "m2"}),
1328            )
1329            .unwrap();
1330        assert!(rewritten);
1331        let dir = store
1332            .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
1333            .parent()
1334            .unwrap()
1335            .to_path_buf();
1336        let on_disk: Value =
1337            serde_json::from_str(&std::fs::read_to_string(dir.join("regulus.json")).unwrap())
1338                .unwrap();
1339        assert_eq!(on_disk["model"], json!("m2"));
1340    }
1341
1342    #[test]
1343    fn write_at_refuses_to_overwrite_a_different_resources_stem() {
1344        let tmp = tempfile::tempdir().unwrap();
1345        let ws = ws_with_projects(tmp.path(), &["p"]);
1346        let store = Store::new(ws.project("p").unwrap(), "prod");
1347        store
1348            .write_at(
1349                "regulus",
1350                ResourceKind::Agent,
1351                &json!({"name": "Regulus-Prod", "model": "m1"}),
1352            )
1353            .unwrap();
1354        let err = store
1355            .write_at(
1356                "regulus",
1357                ResourceKind::Agent,
1358                &json!({"name": "some-other-name", "model": "m2"}),
1359            )
1360            .unwrap_err();
1361        assert!(matches!(
1362            err,
1363            StoreError::StemOccupiedByDifferentResource { .. }
1364        ));
1365        // original untouched
1366        let dir = store
1367            .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
1368            .parent()
1369            .unwrap()
1370            .to_path_buf();
1371        let on_disk: Value =
1372            serde_json::from_str(&std::fs::read_to_string(dir.join("regulus.json")).unwrap())
1373                .unwrap();
1374        assert_eq!(on_disk["name"], json!("Regulus-Prod"));
1375    }
1376
1377    #[test]
1378    fn write_at_returns_false_when_semantically_unchanged() {
1379        let tmp = tempfile::tempdir().unwrap();
1380        let ws = ws_with_projects(tmp.path(), &["p"]);
1381        let store = Store::new(ws.project("p").unwrap(), "prod");
1382        store
1383            .write_at(
1384                "idx",
1385                ResourceKind::Index,
1386                &json!({"name": "idx", "fields": []}),
1387            )
1388            .unwrap();
1389        let rewritten = store
1390            .write_at(
1391                "idx",
1392                ResourceKind::Index,
1393                &json!({"@odata.etag": "0x1", "name": "idx", "fields": []}),
1394            )
1395            .unwrap();
1396        assert!(!rewritten);
1397    }
1398
1399    #[test]
1400    fn write_at_rejects_path_escaping_stems() {
1401        let tmp = tempfile::tempdir().unwrap();
1402        let ws = ws_with_projects(tmp.path(), &["p"]);
1403        let store = Store::new(ws.project("p").unwrap(), "prod");
1404        let err = store.write_at("../../evil", ResourceKind::Index, &json!({"name": "evil"}));
1405        assert!(matches!(err, Err(StoreError::BadName { .. })), "{err:?}");
1406    }
1407
1408    #[test]
1409    fn write_rejects_path_escaping_names() {
1410        let tmp = tempfile::tempdir().unwrap();
1411        let ws = ws_with_projects(tmp.path(), &["p"]);
1412        let store = Store::new(ws.project("p").unwrap(), "dev");
1413        let r = ResourceRef::new(ResourceKind::Index, "../../evil");
1414        let err = store.write(&r, &json!({"name": "../../evil"}));
1415        assert!(matches!(err, Err(StoreError::BadName { .. })), "{err:?}");
1416    }
1417}