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