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                // semantic_eq excludes write-only fields (the server never
320                // echoes them) — compare them separately so a credentials
321                // change alone still lands on disk.
322                if crate::normalize::semantic_eq(r.kind, &existing, &normalized)
323                    && write_only_eq(r.kind, &existing, &normalized)
324                {
325                    return Ok(false);
326                }
327            }
328        }
329
330        if let Some(parent) = path.parent() {
331            std::fs::create_dir_all(parent).map_err(|source| StoreError::Io {
332                path: parent.to_path_buf(),
333                source,
334            })?;
335        }
336        sidecar::extract_sidecars(r.kind, &path, &mut normalized)?;
337        std::fs::write(&path, format_json(&normalized)).map_err(|source| StoreError::Io {
338            path: path.clone(),
339            source,
340        })?;
341        Ok(true)
342    }
343
344    /// Write a resource at an explicit STEM rather than its physical name —
345    /// used by `rigg promote` when creating a resource in the target
346    /// environment that has no counterpart there yet: the new file must land
347    /// at the SOURCE environment's stem (its logical/correlation id) so the
348    /// two trees keep correlating by path, even when the resource's physical
349    /// `name` differs from that stem (a renamed resource in the source env).
350    ///
351    /// Unlike [`Store::write`] (which locates-or-creates by physical name),
352    /// this never disambiguates: if `<stem>.json` already exists and holds a
353    /// DIFFERENT physical name than `value`, that's a genuine collision (some
354    /// other resource already occupies this stem) and it errors rather than
355    /// overwriting or guessing a new path. When the existing file's physical
356    /// name matches, it behaves like `write` (update in place, same
357    /// x-rigg-*/write-only carry-over and semantic-no-op short circuit).
358    pub fn write_at(&self, stem: &str, kind: ResourceKind, value: &Value) -> Result<bool> {
359        validate_resource_name(stem).map_err(|e| StoreError::BadName {
360            path: self.kind_dir(kind).join(format!("{stem}.json")),
361            message: e.to_string(),
362        })?;
363        let path = self.kind_dir(kind).join(format!("{stem}.json"));
364        let mut normalized = normalize_for_disk(kind, value);
365        let new_name = normalized
366            .get("name")
367            .and_then(Value::as_str)
368            .unwrap_or(stem)
369            .to_string();
370
371        if path.is_file() {
372            let existing = self.read_path(&path)?;
373            let existing_name = existing
374                .get("name")
375                .and_then(Value::as_str)
376                .unwrap_or(stem)
377                .to_string();
378            if existing_name != new_name {
379                return Err(StoreError::StemOccupiedByDifferentResource {
380                    path,
381                    existing_name,
382                    new_name,
383                });
384            }
385            carry_over_x_rigg(&existing, &mut normalized);
386            carry_over_write_only(kind, &existing, &mut normalized);
387            if crate::normalize::semantic_eq(kind, &existing, &normalized)
388                && write_only_eq(kind, &existing, &normalized)
389            {
390                return Ok(false);
391            }
392        }
393
394        if let Some(parent) = path.parent() {
395            std::fs::create_dir_all(parent).map_err(|source| StoreError::Io {
396                path: parent.to_path_buf(),
397                source,
398            })?;
399        }
400        sidecar::extract_sidecars(kind, &path, &mut normalized)?;
401        std::fs::write(&path, format_json(&normalized)).map_err(|source| StoreError::Io {
402            path: path.clone(),
403            source,
404        })?;
405        Ok(true)
406    }
407
408    /// Delete a resource file (and its default sidecars). Only ever removes
409    /// the file `locate` resolves for this physical name — deleting a name
410    /// that matches nothing is a no-op (never falls through to a stem-guessed
411    /// path that could belong to a renamed resource).
412    pub fn delete(&self, r: &ResourceRef) -> Result<()> {
413        let Some(path) = self.locate(r)? else {
414            return Ok(());
415        };
416        // Sidecars are named after the file's stem (its logical id), which
417        // may differ from the physical name — derive it from `path`, not `r`.
418        let stem = path
419            .file_stem()
420            .map(|s| s.to_string_lossy().into_owned())
421            .unwrap_or_else(|| r.name.clone());
422        if path.is_file() {
423            std::fs::remove_file(&path).map_err(|source| StoreError::Io {
424                path: path.clone(),
425                source,
426            })?;
427        }
428        // Remove default sidecars (e.g. `<stem>.instructions.md`).
429        if let Some(dir) = path.parent() {
430            for field in crate::registry::meta(r.kind).sidecar_fields {
431                let sidecar = dir.join(format!("{stem}.{field}.md"));
432                if sidecar.is_file() {
433                    let _ = std::fs::remove_file(sidecar);
434                }
435            }
436        }
437        Ok(())
438    }
439}
440
441/// Raw (non-sidecar-inlining) read of a resource file's physical name: the
442/// top-level `name` field if it's a string, else `fallback_stem`. Used by
443/// `list`/`locate`, which only need the identity, not the full document.
444fn physical_name(path: &Path, fallback_stem: &str) -> Result<String> {
445    let text = std::fs::read_to_string(path).map_err(|source| StoreError::Io {
446        path: path.to_path_buf(),
447        source,
448    })?;
449    let value: Value = serde_json::from_str(&text).map_err(|source| StoreError::Parse {
450        path: path.to_path_buf(),
451        source,
452    })?;
453    Ok(value
454        .get("name")
455        .and_then(Value::as_str)
456        .map(str::to_string)
457        .unwrap_or_else(|| fallback_stem.to_string()))
458}
459
460/// Whether two documents agree on every write-only field. `semantic_eq`
461/// excludes these fields (the server never echoes them, so including them
462/// would read every canonicalization as drift) — but a LOCAL write that
463/// only changes a credential must still reach the disk.
464fn write_only_eq(kind: ResourceKind, a: &Value, b: &Value) -> bool {
465    fn values_at(doc: &Value, spec: &str) -> Vec<Value> {
466        let mut out = Vec::new();
467        crate::registry::collect_path(doc, spec, &mut |v| out.push(v.clone()));
468        out
469    }
470    crate::registry::meta(kind)
471        .write_only_fields
472        .iter()
473        .all(|spec| values_at(a, spec) == values_at(b, spec))
474}
475
476/// Preserve write-only fields (server never echoes them) from the existing
477/// local file when the incoming document lacks them or has them as null.
478fn carry_over_write_only(kind: ResourceKind, from: &Value, to: &mut Value) {
479    for spec in crate::registry::meta(kind).write_only_fields {
480        let mut existing_value: Option<Value> = None;
481        crate::registry::collect_path(from, spec, &mut |v| {
482            if !v.is_null() {
483                existing_value = Some(v.clone());
484            }
485        });
486        let Some(existing_value) = existing_value else {
487            continue;
488        };
489        set_path(to, &spec.split('.').collect::<Vec<_>>(), existing_value);
490    }
491}
492
493/// Set a dot-path (no `[]` support — write-only fields are object paths),
494/// creating intermediate objects as needed.
495fn set_path(value: &mut Value, segments: &[&str], new_value: Value) {
496    let Some((head, rest)) = segments.split_first() else {
497        return;
498    };
499    let Value::Object(map) = value else { return };
500    if rest.is_empty() {
501        map.insert((*head).to_string(), new_value);
502        return;
503    }
504    let entry = map
505        .entry((*head).to_string())
506        .or_insert_with(|| Value::Object(serde_json::Map::new()));
507    set_path(entry, rest, new_value);
508}
509
510/// Copy `x-rigg-*` keys from `from` into `to` at the same paths (top-level and
511/// one structural match deep for arrays keyed by `name`/`type`).
512fn carry_over_x_rigg(from: &Value, to: &mut Value) {
513    match (from, to) {
514        (Value::Object(src), Value::Object(dst)) => {
515            for (k, v) in src {
516                if k.starts_with("x-rigg-") {
517                    dst.entry(k.clone()).or_insert_with(|| v.clone());
518                } else if let Some(dv) = dst.get_mut(k) {
519                    carry_over_x_rigg(v, dv);
520                }
521            }
522        }
523        (Value::Array(src), Value::Array(dst)) => {
524            for sv in src {
525                let key = sv.get("name").or_else(|| sv.get("type"));
526                if let Some(key) = key {
527                    if let Some(dv) = dst
528                        .iter_mut()
529                        .find(|d| d.get("name").or_else(|| d.get("type")) == Some(key))
530                    {
531                        carry_over_x_rigg(sv, dv);
532                    }
533                }
534            }
535        }
536        _ => {}
537    }
538}
539
540/// Enforce exclusive ownership: a (kind, name) may appear in only one
541/// project — within one environment (a physical resource named the same in
542/// two envs is normal; it's the same logical resource pushed twice).
543pub fn assert_exclusive_ownership(ws: &Workspace, env: &str) -> Result<()> {
544    let mut seen: BTreeMap<ResourceRef, &str> = BTreeMap::new();
545    for project in &ws.projects {
546        let store = Store::new(project, env);
547        for (r, _) in store.list()? {
548            if let Some(first) = seen.get(&r) {
549                return Err(StoreError::DuplicateOwnership {
550                    reference: r.to_string(),
551                    first: first.to_string(),
552                    second: project.name.clone(),
553                });
554            }
555            seen.insert(r, &project.name);
556        }
557    }
558    Ok(())
559}
560
561/// Sync classification of one resource.
562#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
563#[serde(rename_all = "kebab-case")]
564pub enum SyncClass {
565    /// Local, remote and baseline all agree.
566    InSync,
567    /// Local changed since last sync; remote unchanged.
568    LocalAhead,
569    /// Remote changed since last sync; local unchanged.
570    RemoteAhead,
571    /// Both changed since last sync.
572    Conflict,
573    /// Exists locally, not remotely (new resource or remote-deleted).
574    LocalOnly,
575    /// Exists remotely, not locally (unmanaged or locally-deleted).
576    RemoteOnly,
577    /// No baseline; local and remote both exist but differ (never synced).
578    Untracked,
579}
580
581/// A sync baseline. Newer rigg versions store the compare-normalized
582/// document so the checksum can be recomputed under CURRENT normalization
583/// rules — surviving rule evolution across rigg upgrades. Legacy entries
584/// hold only the frozen checksum and behave as before until the resource
585/// next syncs (every successful pull/push/adopt rewrites its baseline).
586#[derive(Debug, Clone, Serialize, Deserialize)]
587#[serde(untagged)]
588pub enum Baseline {
589    /// Legacy: frozen checksum (string MUST be tried first — `Value`
590    /// deserializes any JSON, including strings).
591    Checksum(String),
592    /// Compare-normalized canonical document.
593    Doc(Value),
594}
595
596/// Per-project, per-environment sync baselines.
597#[derive(Debug, Clone, Default, Serialize, Deserialize)]
598pub struct ProjectState {
599    /// `kind-dir/name` → baseline captured at last sync.
600    #[serde(default)]
601    pub baselines: BTreeMap<String, Baseline>,
602}
603
604impl ProjectState {
605    pub fn path(ws: &Workspace, env: &str, project: &str) -> PathBuf {
606        ws.state_dir(env, project).join("state.json")
607    }
608
609    pub fn load(ws: &Workspace, env: &str, project: &str) -> ProjectState {
610        let path = Self::path(ws, env, project);
611        std::fs::read_to_string(&path)
612            .ok()
613            .and_then(|text| serde_json::from_str(&text).ok())
614            .unwrap_or_default()
615    }
616
617    pub fn save(&self, ws: &Workspace, env: &str, project: &str) -> std::io::Result<()> {
618        let path = Self::path(ws, env, project);
619        if let Some(parent) = path.parent() {
620            std::fs::create_dir_all(parent)?;
621        }
622        std::fs::write(&path, format_json(&serde_json::to_value(self).unwrap()))
623    }
624
625    /// Checksum of the push-normalized form of a document.
626    ///
627    /// The form is canonicalized (object keys sorted recursively, arrays of
628    /// named objects sorted by name) so that server-side reordering between
629    /// GET and PUT responses never reads as a change — matching the semantics
630    /// of the order-insensitive diff.
631    pub fn checksum(kind: ResourceKind, value: &Value) -> String {
632        let normalized = canonical_form(&normalize_for_compare(kind, value));
633        let canonical = serde_json::to_string(&normalized).unwrap_or_default();
634        format!("{:x}", md5_like(&canonical))
635    }
636
637    /// Whether a baseline is recorded for this resource.
638    pub fn has_baseline(&self, r: &ResourceRef) -> bool {
639        self.baselines.contains_key(&r.key())
640    }
641
642    /// Checksum of the recorded baseline, recomputed under CURRENT
643    /// normalization rules for `Doc` entries — this is what lets a resource
644    /// self-heal when a rigg upgrade changes which fields are volatile.
645    /// Legacy `Checksum` entries are frozen and returned as-is.
646    pub fn baseline_checksum(&self, r: &ResourceRef) -> Option<String> {
647        match self.baselines.get(&r.key())? {
648            Baseline::Checksum(s) => Some(s.clone()),
649            Baseline::Doc(v) => Some(Self::checksum(r.kind, v)),
650        }
651    }
652
653    pub fn set_baseline(&mut self, r: &ResourceRef, kind_value: &Value) {
654        let doc = canonical_form(&normalize_for_compare(r.kind, kind_value));
655        self.baselines.insert(r.key(), Baseline::Doc(doc));
656    }
657
658    pub fn clear_baseline(&mut self, r: &ResourceRef) {
659        self.baselines.remove(&r.key());
660    }
661
662    /// Classify a resource given its (optional) local and remote documents.
663    pub fn classify(
664        &self,
665        r: &ResourceRef,
666        local: Option<&Value>,
667        remote: Option<&Value>,
668    ) -> SyncClass {
669        let baseline = self.baseline_checksum(r);
670        match (local, remote) {
671            (None, None) => SyncClass::InSync, // nothing anywhere (only baseline leftover)
672            (Some(_), None) => SyncClass::LocalOnly,
673            (None, Some(_)) => SyncClass::RemoteOnly,
674            (Some(l), Some(rm)) => {
675                let lsum = Self::checksum(r.kind, l);
676                let rsum = Self::checksum(r.kind, rm);
677                match baseline {
678                    None => {
679                        if lsum == rsum {
680                            SyncClass::InSync
681                        } else {
682                            SyncClass::Untracked
683                        }
684                    }
685                    Some(base) => {
686                        let local_changed = lsum != base;
687                        let remote_changed = rsum != base;
688                        match (local_changed, remote_changed) {
689                            (false, false) => SyncClass::InSync,
690                            (true, false) => SyncClass::LocalAhead,
691                            (false, true) => SyncClass::RemoteAhead,
692                            (true, true) => {
693                                if lsum == rsum {
694                                    // Both moved to the same content.
695                                    SyncClass::InSync
696                                } else {
697                                    SyncClass::Conflict
698                                }
699                            }
700                        }
701                    }
702                }
703            }
704        }
705    }
706}
707
708/// Order-canonical JSON: object keys sorted recursively; arrays whose items
709/// all carry a string `name` are sorted by it (identity-keyed arrays).
710fn canonical_form(value: &Value) -> Value {
711    match value {
712        Value::Object(map) => {
713            // null-valued keys are dropped: Azure oscillates between omitting
714            // a field and returning it as null depending on the endpoint.
715            let mut sorted: Vec<(String, Value)> = map
716                .iter()
717                .filter(|(_, v)| !v.is_null())
718                .map(|(k, v)| (k.clone(), canonical_form(v)))
719                .collect();
720            sorted.sort_by(|a, b| a.0.cmp(&b.0));
721            Value::Object(sorted.into_iter().collect())
722        }
723        Value::Array(arr) => {
724            let mut items: Vec<Value> = arr.iter().map(canonical_form).collect();
725            if !items.is_empty()
726                && items
727                    .iter()
728                    .all(|i| i.get("name").and_then(Value::as_str).is_some())
729            {
730                items.sort_by(|a, b| {
731                    a["name"]
732                        .as_str()
733                        .unwrap_or_default()
734                        .cmp(b["name"].as_str().unwrap_or_default())
735                });
736            }
737            Value::Array(items)
738        }
739        other => other.clone(),
740    }
741}
742
743/// Small non-cryptographic checksum (FNV-1a 128-ish via two 64-bit lanes).
744/// Collision resistance is ample for change detection.
745fn md5_like(s: &str) -> u128 {
746    let mut h1: u64 = 0xcbf29ce484222325;
747    let mut h2: u64 = 0x9e3779b97f4a7c15;
748    for b in s.as_bytes() {
749        h1 ^= u64::from(*b);
750        h1 = h1.wrapping_mul(0x100000001b3);
751        h2 = h2.rotate_left(5) ^ u64::from(*b);
752        h2 = h2.wrapping_mul(0x2545f4914f6cdd1d);
753    }
754    (u128::from(h1) << 64) | u128::from(h2)
755}
756
757#[cfg(test)]
758mod tests {
759    use super::*;
760    use crate::workspace::{PROJECT_FILE, PROJECTS_DIR, WORKSPACE_FILE};
761    use serde_json::json;
762
763    fn ws_with_projects(dir: &Path, names: &[&str]) -> Workspace {
764        std::fs::write(
765            dir.join(WORKSPACE_FILE),
766            "environments:\n  dev:\n    default: true\n    search: { service: s }\n",
767        )
768        .unwrap();
769        for name in names {
770            let pdir = dir.join(PROJECTS_DIR).join(name);
771            std::fs::create_dir_all(&pdir).unwrap();
772            std::fs::write(pdir.join(PROJECT_FILE), "{}\n").unwrap();
773        }
774        Workspace::load(dir).unwrap()
775    }
776
777    #[test]
778    fn write_list_read_round_trip_with_sidecars() {
779        let tmp = tempfile::tempdir().unwrap();
780        let ws = ws_with_projects(tmp.path(), &["p"]);
781        let store = Store::new(ws.project("p").unwrap(), "dev");
782
783        let agent_ref = ResourceRef::new(ResourceKind::Agent, "helper");
784        let agent = json!({"name": "helper", "model": "gpt-5-mini", "instructions": "Be nice."});
785        assert!(store.write(&agent_ref, &agent).unwrap());
786
787        // sidecar extracted
788        let sidecar = store
789            .path_for(&agent_ref)
790            .parent()
791            .unwrap()
792            .join("helper.instructions.md");
793        assert!(sidecar.is_file());
794
795        // read inlines it back
796        let read = store.read(&agent_ref).unwrap();
797        assert_eq!(read["instructions"], json!("Be nice."));
798
799        let listed = store.list().unwrap();
800        assert_eq!(listed.len(), 1);
801        assert_eq!(listed[0].0, agent_ref);
802
803        // env-rooted path
804        assert!(
805            store
806                .path_for(&agent_ref)
807                .to_string_lossy()
808                .contains("envs/dev/")
809        );
810    }
811
812    #[test]
813    fn write_returns_false_when_semantically_unchanged() {
814        let tmp = tempfile::tempdir().unwrap();
815        let ws = ws_with_projects(tmp.path(), &["p"]);
816        let store = Store::new(ws.project("p").unwrap(), "dev");
817        let r = ResourceRef::new(ResourceKind::Index, "idx");
818        assert!(
819            store
820                .write(&r, &json!({"name": "idx", "fields": []}))
821                .unwrap()
822        );
823        // same content + volatile noise → no rewrite
824        let noisy = json!({"@odata.etag": "0x1", "name": "idx", "fields": []});
825        assert!(!store.write(&r, &noisy).unwrap());
826        // real change → rewrite
827        let changed = json!({"name": "idx", "fields": [{"name": "f"}]});
828        assert!(store.write(&r, &changed).unwrap());
829    }
830
831    #[test]
832    fn write_preserves_local_x_rigg_annotations() {
833        let tmp = tempfile::tempdir().unwrap();
834        let ws = ws_with_projects(tmp.path(), &["p"]);
835        let store = Store::new(ws.project("p").unwrap(), "dev");
836        let r = ResourceRef::new(ResourceKind::Skillset, "sk");
837        let local = json!({
838            "name": "sk",
839            "skills": [{"name": "web", "uri": "https://f", "x-rigg-api": "enrich"}]
840        });
841        store.write(&r, &local).unwrap();
842        // Azure returns the same thing without the annotation
843        let remote = json!({
844            "name": "sk",
845            "skills": [{"name": "web", "uri": "https://f"}]
846        });
847        let rewritten = store.write(&r, &remote).unwrap();
848        let read = store.read(&r).unwrap();
849        assert_eq!(read["skills"][0]["x-rigg-api"], json!("enrich"));
850        assert!(!rewritten, "annotation-only delta is not a semantic change");
851    }
852
853    #[test]
854    fn exclusive_ownership_violation_names_both_projects() {
855        let tmp = tempfile::tempdir().unwrap();
856        let ws = ws_with_projects(tmp.path(), &["alpha", "beta"]);
857        for p in ["alpha", "beta"] {
858            let store = Store::new(ws.project(p).unwrap(), "dev");
859            store
860                .write(
861                    &ResourceRef::new(ResourceKind::Index, "shared"),
862                    &json!({"name": "shared"}),
863                )
864                .unwrap();
865        }
866        let err = assert_exclusive_ownership(&ws, "dev").unwrap_err();
867        let msg = err.to_string();
868        assert!(msg.contains("alpha") && msg.contains("beta") && msg.contains("indexes/shared"));
869    }
870
871    #[test]
872    fn ownership_is_scoped_per_environment() {
873        // Same physical name in two projects but DIFFERENT envs: not a
874        // violation (ownership is checked per env tree).
875        let tmp = tempfile::tempdir().unwrap();
876        let ws = ws_with_projects(tmp.path(), &["alpha", "beta"]);
877        Store::new(ws.project("alpha").unwrap(), "dev")
878            .write(
879                &ResourceRef::new(ResourceKind::Index, "shared"),
880                &json!({"name": "shared"}),
881            )
882            .unwrap();
883        Store::new(ws.project("beta").unwrap(), "prod")
884            .write(
885                &ResourceRef::new(ResourceKind::Index, "shared"),
886                &json!({"name": "shared"}),
887            )
888            .unwrap();
889        assert!(assert_exclusive_ownership(&ws, "dev").is_ok());
890        assert!(assert_exclusive_ownership(&ws, "prod").is_ok());
891    }
892
893    #[test]
894    fn envs_of_lists_env_dirs_sorted() {
895        let tmp = tempfile::tempdir().unwrap();
896        let ws = ws_with_projects(tmp.path(), &["p"]);
897        let project = ws.project("p").unwrap();
898        assert_eq!(Store::envs_of(project), Vec::<String>::new());
899        Store::new(project, "prod")
900            .write(
901                &ResourceRef::new(ResourceKind::Index, "idx"),
902                &json!({"name": "idx"}),
903            )
904            .unwrap();
905        Store::new(project, "dev")
906            .write(
907                &ResourceRef::new(ResourceKind::Index, "idx"),
908                &json!({"name": "idx"}),
909            )
910            .unwrap();
911        assert_eq!(Store::envs_of(project), vec!["dev", "prod"]);
912    }
913
914    #[test]
915    fn list_keys_by_physical_name_when_stem_differs() {
916        let tmp = tempfile::tempdir().unwrap();
917        let ws = ws_with_projects(tmp.path(), &["p"]);
918        let store = Store::new(ws.project("p").unwrap(), "dev");
919        let dir = store
920            .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
921            .parent()
922            .unwrap()
923            .to_path_buf();
924        std::fs::create_dir_all(&dir).unwrap();
925        std::fs::write(
926            dir.join("regulus.json"),
927            json!({"name": "Regulus-Prod", "model": "m"}).to_string(),
928        )
929        .unwrap();
930        let listed = store.list().unwrap();
931        assert_eq!(listed.len(), 1);
932        assert_eq!(listed[0].0.name, "Regulus-Prod");
933        assert_eq!(listed[0].1, dir.join("regulus.json"));
934    }
935
936    #[test]
937    fn locate_finds_file_by_physical_name_when_stem_differs() {
938        let tmp = tempfile::tempdir().unwrap();
939        let ws = ws_with_projects(tmp.path(), &["p"]);
940        let store = Store::new(ws.project("p").unwrap(), "dev");
941        let dir = store
942            .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
943            .parent()
944            .unwrap()
945            .to_path_buf();
946        std::fs::create_dir_all(&dir).unwrap();
947        std::fs::write(
948            dir.join("regulus.json"),
949            json!({"name": "Regulus-Prod", "model": "m"}).to_string(),
950        )
951        .unwrap();
952        let found = store
953            .locate(&ResourceRef::new(ResourceKind::Agent, "Regulus-Prod"))
954            .unwrap();
955        assert_eq!(found, Some(dir.join("regulus.json")));
956        assert_eq!(
957            store
958                .locate(&ResourceRef::new(ResourceKind::Agent, "regulus"))
959                .unwrap(),
960            None,
961            "the stem alone is not a physical name once name diverges"
962        );
963    }
964
965    #[test]
966    fn write_updates_the_located_file_not_a_stem_guess() {
967        // Physical rename case: file `regulus.json` holds name "Regulus-Prod".
968        // Writing a ResourceRef keyed on the physical name must update THAT
969        // file in place, not create a new `Regulus-Prod.json`.
970        let tmp = tempfile::tempdir().unwrap();
971        let ws = ws_with_projects(tmp.path(), &["p"]);
972        let store = Store::new(ws.project("p").unwrap(), "dev");
973        let dir = store
974            .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
975            .parent()
976            .unwrap()
977            .to_path_buf();
978        std::fs::create_dir_all(&dir).unwrap();
979        std::fs::write(
980            dir.join("regulus.json"),
981            json!({"name": "Regulus-Prod", "model": "m1"}).to_string(),
982        )
983        .unwrap();
984        let r = ResourceRef::new(ResourceKind::Agent, "Regulus-Prod");
985        store
986            .write(&r, &json!({"name": "Regulus-Prod", "model": "m2"}))
987            .unwrap();
988        assert!(
989            !dir.join("Regulus-Prod.json").exists(),
990            "must not create a second file keyed on the physical name"
991        );
992        let on_disk: Value =
993            serde_json::from_str(&std::fs::read_to_string(dir.join("regulus.json")).unwrap())
994                .unwrap();
995        assert_eq!(on_disk["model"], json!("m2"));
996    }
997
998    #[test]
999    fn write_never_overwrites_a_renamed_resources_stem() {
1000        // `regulus.json` holds physical name "Regulus-Prod" (a renamed
1001        // resource). Creating a NEW resource whose physical name is
1002        // "regulus" must NOT clobber it: the create path disambiguates to a
1003        // free stem instead.
1004        let tmp = tempfile::tempdir().unwrap();
1005        let ws = ws_with_projects(tmp.path(), &["p"]);
1006        let store = Store::new(ws.project("p").unwrap(), "dev");
1007        let dir = store
1008            .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
1009            .parent()
1010            .unwrap()
1011            .to_path_buf();
1012        std::fs::create_dir_all(&dir).unwrap();
1013        std::fs::write(
1014            dir.join("regulus.json"),
1015            json!({"name": "Regulus-Prod", "model": "m"}).to_string(),
1016        )
1017        .unwrap();
1018
1019        let r = ResourceRef::new(ResourceKind::Agent, "regulus");
1020        assert!(
1021            store
1022                .write(&r, &json!({"name": "regulus", "model": "new"}))
1023                .unwrap()
1024        );
1025
1026        // the renamed resource's file is untouched
1027        let original: Value =
1028            serde_json::from_str(&std::fs::read_to_string(dir.join("regulus.json")).unwrap())
1029                .unwrap();
1030        assert_eq!(
1031            original["name"],
1032            json!("Regulus-Prod"),
1033            "original file untouched"
1034        );
1035
1036        // the new resource landed at a different, disambiguated stem
1037        let new_path = store.locate(&r).unwrap().expect("new resource locatable");
1038        assert_ne!(new_path, dir.join("regulus.json"));
1039        assert_eq!(new_path, dir.join("regulus-2.json"));
1040
1041        // both resolve correctly by physical name
1042        assert_eq!(
1043            store
1044                .locate(&ResourceRef::new(ResourceKind::Agent, "Regulus-Prod"))
1045                .unwrap(),
1046            Some(dir.join("regulus.json"))
1047        );
1048        assert_eq!(store.list().unwrap().len(), 2);
1049    }
1050
1051    #[test]
1052    fn delete_by_physical_name_removes_located_file_and_stem_sidecars() {
1053        // File stem "regulus" ≠ physical name "Regulus-Prod", with an
1054        // instructions sidecar named after the STEM. Deleting by physical
1055        // name must remove the located file AND its stem-named sidecar,
1056        // leaving a neighboring resource untouched.
1057        let tmp = tempfile::tempdir().unwrap();
1058        let ws = ws_with_projects(tmp.path(), &["p"]);
1059        let store = Store::new(ws.project("p").unwrap(), "dev");
1060        let dir = store
1061            .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
1062            .parent()
1063            .unwrap()
1064            .to_path_buf();
1065        std::fs::create_dir_all(&dir).unwrap();
1066        std::fs::write(
1067            dir.join("regulus.json"),
1068            json!({
1069                "name": "Regulus-Prod", "model": "m",
1070                "instructions": {"$file": "regulus.instructions.md"}
1071            })
1072            .to_string(),
1073        )
1074        .unwrap();
1075        std::fs::write(dir.join("regulus.instructions.md"), "Be helpful.").unwrap();
1076        // neighbor with its own sidecar
1077        std::fs::write(
1078            dir.join("other.json"),
1079            json!({
1080                "name": "other", "model": "m",
1081                "instructions": {"$file": "other.instructions.md"}
1082            })
1083            .to_string(),
1084        )
1085        .unwrap();
1086        std::fs::write(dir.join("other.instructions.md"), "Neighbor.").unwrap();
1087
1088        store
1089            .delete(&ResourceRef::new(ResourceKind::Agent, "Regulus-Prod"))
1090            .unwrap();
1091        assert!(!dir.join("regulus.json").exists(), "located file removed");
1092        assert!(
1093            !dir.join("regulus.instructions.md").exists(),
1094            "stem-named sidecar removed"
1095        );
1096        assert!(dir.join("other.json").exists(), "neighbor untouched");
1097        assert!(
1098            dir.join("other.instructions.md").exists(),
1099            "neighbor sidecar untouched"
1100        );
1101
1102        // deleting a physical name that matches nothing is a no-op
1103        store
1104            .delete(&ResourceRef::new(ResourceKind::Agent, "ghost"))
1105            .unwrap();
1106        assert!(dir.join("other.json").exists());
1107    }
1108
1109    #[test]
1110    fn list_error_on_corrupt_json_names_the_file() {
1111        let tmp = tempfile::tempdir().unwrap();
1112        let ws = ws_with_projects(tmp.path(), &["p"]);
1113        let store = Store::new(ws.project("p").unwrap(), "dev");
1114        let dir = store
1115            .path_for(&ResourceRef::new(ResourceKind::Index, "a"))
1116            .parent()
1117            .unwrap()
1118            .to_path_buf();
1119        std::fs::create_dir_all(&dir).unwrap();
1120        std::fs::write(dir.join("broken.json"), "{ this is not json").unwrap();
1121        let err = store.list().unwrap_err();
1122        assert!(matches!(err, StoreError::Parse { .. }));
1123        assert!(
1124            err.to_string().contains("broken.json"),
1125            "error names the broken file: {err}"
1126        );
1127    }
1128
1129    #[test]
1130    fn duplicate_physical_name_in_one_kind_dir_errors() {
1131        let tmp = tempfile::tempdir().unwrap();
1132        let ws = ws_with_projects(tmp.path(), &["p"]);
1133        let store = Store::new(ws.project("p").unwrap(), "dev");
1134        let dir = store
1135            .path_for(&ResourceRef::new(ResourceKind::Index, "a"))
1136            .parent()
1137            .unwrap()
1138            .to_path_buf();
1139        std::fs::create_dir_all(&dir).unwrap();
1140        std::fs::write(dir.join("a.json"), json!({"name": "dup"}).to_string()).unwrap();
1141        std::fs::write(dir.join("b.json"), json!({"name": "dup"}).to_string()).unwrap();
1142        let err = store.list().unwrap_err();
1143        assert!(matches!(err, StoreError::DuplicatePhysicalName { .. }));
1144        assert!(err.to_string().contains("dup"));
1145    }
1146
1147    #[test]
1148    fn classify_truth_table() {
1149        let tmp = tempfile::tempdir().unwrap();
1150        let ws = ws_with_projects(tmp.path(), &["p"]);
1151        let r = ResourceRef::new(ResourceKind::Index, "idx");
1152        let a = json!({"name": "idx", "fields": [{"name": "f1"}]});
1153        let b = json!({"name": "idx", "fields": [{"name": "f2"}]});
1154        let c = json!({"name": "idx", "fields": [{"name": "f3"}]});
1155
1156        let mut state = ProjectState::default();
1157        // no baseline
1158        assert_eq!(state.classify(&r, Some(&a), Some(&a)), SyncClass::InSync);
1159        assert_eq!(state.classify(&r, Some(&a), Some(&b)), SyncClass::Untracked);
1160        assert_eq!(state.classify(&r, Some(&a), None), SyncClass::LocalOnly);
1161        assert_eq!(state.classify(&r, None, Some(&a)), SyncClass::RemoteOnly);
1162        assert_eq!(state.classify(&r, None, None), SyncClass::InSync);
1163
1164        // with baseline = a
1165        state.set_baseline(&r, &a);
1166        assert_eq!(state.classify(&r, Some(&a), Some(&a)), SyncClass::InSync);
1167        assert_eq!(
1168            state.classify(&r, Some(&b), Some(&a)),
1169            SyncClass::LocalAhead
1170        );
1171        assert_eq!(
1172            state.classify(&r, Some(&a), Some(&b)),
1173            SyncClass::RemoteAhead
1174        );
1175        assert_eq!(state.classify(&r, Some(&b), Some(&c)), SyncClass::Conflict);
1176        assert_eq!(state.classify(&r, Some(&b), Some(&b)), SyncClass::InSync);
1177
1178        // save/load round trip
1179        state.save(&ws, "dev", "p").unwrap();
1180        let loaded = ProjectState::load(&ws, "dev", "p");
1181        assert_eq!(loaded.baseline_checksum(&r), state.baseline_checksum(&r));
1182    }
1183
1184    #[test]
1185    fn legacy_checksum_baseline_still_loads_and_classifies() {
1186        // A state.json written by an older rigg: baseline is a bare string.
1187        let json = r#"{"baselines": {"agents/a": "deadbeef"}}"#;
1188        let state: ProjectState = serde_json::from_str(json).unwrap();
1189        let r = ResourceRef::new(ResourceKind::Agent, "a".to_string());
1190        assert!(state.has_baseline(&r));
1191        // Stale hash + differing local/remote → Conflict (today's behavior).
1192        let local = json!({"name": "a", "model": "x"});
1193        let remote = json!({"name": "a", "model": "y"});
1194        assert_eq!(
1195            state.classify(&r, Some(&local), Some(&remote)),
1196            SyncClass::Conflict
1197        );
1198    }
1199
1200    #[test]
1201    fn doc_baseline_self_heals_across_rule_changes() {
1202        // Simulate a baseline stored BEFORE metadata.modified_at became
1203        // volatile: the stored doc still carries the field. Under current
1204        // rules the recomputed checksum strips it, so an untouched local
1205        // (without the field) plus a remote-only change classifies as
1206        // RemoteAhead — NOT Conflict.
1207        let r = ResourceRef::new(ResourceKind::Agent, "a".to_string());
1208        let old_doc = json!({
1209            "name": "a", "model": "x",
1210            "metadata": {"modified_at": "111", "logo": "l.svg"}
1211        });
1212        let mut state = ProjectState::default();
1213        state.baselines.insert(r.key(), Baseline::Doc(old_doc));
1214        let local = json!({
1215            "name": "a", "model": "x", "metadata": {"logo": "l.svg"}
1216        });
1217        let remote = json!({
1218            "name": "a", "model": "CHANGED", "metadata": {"logo": "l.svg"}
1219        });
1220        assert_eq!(
1221            state.classify(&r, Some(&local), Some(&remote)),
1222            SyncClass::RemoteAhead
1223        );
1224    }
1225
1226    #[test]
1227    fn baseline_serde_mixed_roundtrip() {
1228        let r = ResourceRef::new(ResourceKind::Agent, "new".to_string());
1229        let mut state = ProjectState::default();
1230        state.baselines.insert(
1231            "agents/legacy".to_string(),
1232            Baseline::Checksum("abc".to_string()),
1233        );
1234        state.set_baseline(&r, &json!({"name": "new", "model": "m"}));
1235        let text = serde_json::to_string(&state).unwrap();
1236        let back: ProjectState = serde_json::from_str(&text).unwrap();
1237        assert!(
1238            matches!(back.baselines.get("agents/legacy"), Some(Baseline::Checksum(s)) if s == "abc")
1239        );
1240        assert!(matches!(
1241            back.baselines.get("agents/new"),
1242            Some(Baseline::Doc(_))
1243        ));
1244    }
1245
1246    #[test]
1247    fn credential_only_change_still_writes() {
1248        // Regression: semantic_eq excludes write-only fields, so a write
1249        // whose ONLY change is a new credentials.connectionString used to be
1250        // skipped as "no change" — the migrate/push credential fixups then
1251        // never landed on disk.
1252        let tmp = tempfile::tempdir().unwrap();
1253        let ws = ws_with_projects(tmp.path(), &["p"]);
1254        let store = Store::new(ws.project("p").unwrap(), "dev");
1255        let r = ResourceRef::new(ResourceKind::DataSource, "ds");
1256        let without = json!({
1257            "name": "ds", "type": "azureblob",
1258            "credentials": {"connectionString": null},
1259            "container": {"name": "c"}
1260        });
1261        store.write(&r, &without).unwrap();
1262        let mut with = without.clone();
1263        with["credentials"]["connectionString"] = json!("ResourceId=/subscriptions/s/x;");
1264        assert!(
1265            store.write(&r, &with).unwrap(),
1266            "credential change must write"
1267        );
1268        let read = store.read(&r).unwrap();
1269        assert_eq!(
1270            read["credentials"]["connectionString"],
1271            json!("ResourceId=/subscriptions/s/x;")
1272        );
1273    }
1274
1275    #[test]
1276    fn write_only_fields_survive_server_echo_and_compare() {
1277        let tmp = tempfile::tempdir().unwrap();
1278        let ws = ws_with_projects(tmp.path(), &["p"]);
1279        let store = Store::new(ws.project("p").unwrap(), "dev");
1280        let r = ResourceRef::new(ResourceKind::DataSource, "ds");
1281        let local = json!({
1282            "name": "ds", "type": "azureblob",
1283            "credentials": {"connectionString": "ResourceId=/subscriptions/s/x;"},
1284            "container": {"name": "c"}
1285        });
1286        store.write(&r, &local).unwrap();
1287        // Azure's GET echo: connection string redacted to null
1288        let server_echo = json!({
1289            "name": "ds", "type": "azureblob",
1290            "credentials": {"connectionString": null},
1291            "container": {"name": "c"}
1292        });
1293        // no semantic change → no rewrite, and the conn string survives
1294        assert!(!store.write(&r, &server_echo).unwrap());
1295        let read = store.read(&r).unwrap();
1296        assert_eq!(
1297            read["credentials"]["connectionString"],
1298            json!("ResourceId=/subscriptions/s/x;")
1299        );
1300        // checksums ignore the write-only field (local vs redacted remote equal)
1301        assert_eq!(
1302            ProjectState::checksum(ResourceKind::DataSource, &local),
1303            ProjectState::checksum(ResourceKind::DataSource, &server_echo)
1304        );
1305    }
1306
1307    #[test]
1308    fn checksum_is_order_canonical() {
1309        // same content, different key order and array order
1310        let a = serde_json::from_str::<Value>(
1311            r#"{"name": "i", "fields": [{"name": "b"}, {"name": "a"}], "x": 1}"#,
1312        )
1313        .unwrap();
1314        let b = serde_json::from_str::<Value>(
1315            r#"{"x": 1, "name": "i", "fields": [{"name": "a"}, {"name": "b"}]}"#,
1316        )
1317        .unwrap();
1318        assert_eq!(
1319            ProjectState::checksum(ResourceKind::Index, &a),
1320            ProjectState::checksum(ResourceKind::Index, &b)
1321        );
1322    }
1323
1324    #[test]
1325    fn checksum_ignores_volatile_and_annotations() {
1326        let a = json!({"name": "i", "@odata.etag": "1", "x-rigg-note": "hi"});
1327        let b = json!({"name": "i"});
1328        assert_eq!(
1329            ProjectState::checksum(ResourceKind::Index, &a),
1330            ProjectState::checksum(ResourceKind::Index, &b)
1331        );
1332    }
1333
1334    #[test]
1335    fn write_at_creates_file_at_given_stem() {
1336        let tmp = tempfile::tempdir().unwrap();
1337        let ws = ws_with_projects(tmp.path(), &["p"]);
1338        let store = Store::new(ws.project("p").unwrap(), "prod");
1339        let created = store
1340            .write_at(
1341                "regulus",
1342                ResourceKind::Agent,
1343                &json!({"name": "Regulus-Prod", "model": "m"}),
1344            )
1345            .unwrap();
1346        assert!(created);
1347        let dir = store
1348            .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
1349            .parent()
1350            .unwrap()
1351            .to_path_buf();
1352        assert!(
1353            dir.join("regulus.json").is_file(),
1354            "landed at the stem, not the physical name"
1355        );
1356        assert!(!dir.join("Regulus-Prod.json").exists());
1357        let on_disk: Value =
1358            serde_json::from_str(&std::fs::read_to_string(dir.join("regulus.json")).unwrap())
1359                .unwrap();
1360        assert_eq!(on_disk["name"], json!("Regulus-Prod"));
1361    }
1362
1363    #[test]
1364    fn write_at_updates_in_place_when_physical_name_matches() {
1365        let tmp = tempfile::tempdir().unwrap();
1366        let ws = ws_with_projects(tmp.path(), &["p"]);
1367        let store = Store::new(ws.project("p").unwrap(), "prod");
1368        store
1369            .write_at(
1370                "regulus",
1371                ResourceKind::Agent,
1372                &json!({"name": "Regulus-Prod", "model": "m1"}),
1373            )
1374            .unwrap();
1375        let rewritten = store
1376            .write_at(
1377                "regulus",
1378                ResourceKind::Agent,
1379                &json!({"name": "Regulus-Prod", "model": "m2"}),
1380            )
1381            .unwrap();
1382        assert!(rewritten);
1383        let dir = store
1384            .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
1385            .parent()
1386            .unwrap()
1387            .to_path_buf();
1388        let on_disk: Value =
1389            serde_json::from_str(&std::fs::read_to_string(dir.join("regulus.json")).unwrap())
1390                .unwrap();
1391        assert_eq!(on_disk["model"], json!("m2"));
1392    }
1393
1394    #[test]
1395    fn write_at_refuses_to_overwrite_a_different_resources_stem() {
1396        let tmp = tempfile::tempdir().unwrap();
1397        let ws = ws_with_projects(tmp.path(), &["p"]);
1398        let store = Store::new(ws.project("p").unwrap(), "prod");
1399        store
1400            .write_at(
1401                "regulus",
1402                ResourceKind::Agent,
1403                &json!({"name": "Regulus-Prod", "model": "m1"}),
1404            )
1405            .unwrap();
1406        let err = store
1407            .write_at(
1408                "regulus",
1409                ResourceKind::Agent,
1410                &json!({"name": "some-other-name", "model": "m2"}),
1411            )
1412            .unwrap_err();
1413        assert!(matches!(
1414            err,
1415            StoreError::StemOccupiedByDifferentResource { .. }
1416        ));
1417        // original untouched
1418        let dir = store
1419            .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
1420            .parent()
1421            .unwrap()
1422            .to_path_buf();
1423        let on_disk: Value =
1424            serde_json::from_str(&std::fs::read_to_string(dir.join("regulus.json")).unwrap())
1425                .unwrap();
1426        assert_eq!(on_disk["name"], json!("Regulus-Prod"));
1427    }
1428
1429    #[test]
1430    fn write_at_returns_false_when_semantically_unchanged() {
1431        let tmp = tempfile::tempdir().unwrap();
1432        let ws = ws_with_projects(tmp.path(), &["p"]);
1433        let store = Store::new(ws.project("p").unwrap(), "prod");
1434        store
1435            .write_at(
1436                "idx",
1437                ResourceKind::Index,
1438                &json!({"name": "idx", "fields": []}),
1439            )
1440            .unwrap();
1441        let rewritten = store
1442            .write_at(
1443                "idx",
1444                ResourceKind::Index,
1445                &json!({"@odata.etag": "0x1", "name": "idx", "fields": []}),
1446            )
1447            .unwrap();
1448        assert!(!rewritten);
1449    }
1450
1451    #[test]
1452    fn write_at_rejects_path_escaping_stems() {
1453        let tmp = tempfile::tempdir().unwrap();
1454        let ws = ws_with_projects(tmp.path(), &["p"]);
1455        let store = Store::new(ws.project("p").unwrap(), "prod");
1456        let err = store.write_at("../../evil", ResourceKind::Index, &json!({"name": "evil"}));
1457        assert!(matches!(err, Err(StoreError::BadName { .. })), "{err:?}");
1458    }
1459
1460    #[test]
1461    fn write_rejects_path_escaping_names() {
1462        let tmp = tempfile::tempdir().unwrap();
1463        let ws = ws_with_projects(tmp.path(), &["p"]);
1464        let store = Store::new(ws.project("p").unwrap(), "dev");
1465        let r = ResourceRef::new(ResourceKind::Index, "../../evil");
1466        let err = store.write(&r, &json!({"name": "../../evil"}));
1467        assert!(matches!(err, Err(StoreError::BadName { .. })), "{err:?}");
1468    }
1469}