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//!   search/<kind-dir>/<resource-name>.json
9//!   foundry/<kind-dir>/<resource-name>.json
10//! ```
11//!
12//! Files are written via [`crate::normalize::normalize_for_disk`] and long
13//! text fields are extracted to Markdown sidecars ([`crate::sidecar`]).
14//! Baselines (`.rigg/<env>/<project>/state.json`) hold the checksum of each
15//! resource at last sync, enabling local/remote/conflict classification.
16
17use std::collections::BTreeMap;
18use std::path::{Path, PathBuf};
19
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22use thiserror::Error;
23
24use crate::normalize::{format_json, normalize_for_compare, normalize_for_disk};
25use crate::resources::traits::{ResourceKind, ResourceRef, validate_resource_name};
26use crate::service::ServiceDomain;
27use crate::sidecar::{self, SidecarError};
28use crate::workspace::{Project, Workspace};
29
30#[derive(Debug, Error)]
31pub enum StoreError {
32    #[error("failed to read {path}: {source}")]
33    Io {
34        path: PathBuf,
35        source: std::io::Error,
36    },
37    #[error("invalid JSON in {path}: {source}")]
38    Parse {
39        path: PathBuf,
40        source: serde_json::Error,
41    },
42    #[error(transparent)]
43    Sidecar(#[from] SidecarError),
44    #[error("invalid resource name in {path}: {message}")]
45    BadName { path: PathBuf, message: String },
46    #[error(
47        "resource {reference} is defined in both project '{first}' and project '{second}' — a resource must belong to exactly one project"
48    )]
49    DuplicateOwnership {
50        reference: String,
51        first: String,
52        second: String,
53    },
54}
55
56type Result<T> = std::result::Result<T, StoreError>;
57
58/// File store for one project.
59pub struct Store<'w> {
60    project: &'w Project,
61}
62
63impl<'w> Store<'w> {
64    pub fn new(project: &'w Project) -> Self {
65        Store { project }
66    }
67
68    pub fn project(&self) -> &Project {
69        self.project
70    }
71
72    fn domain_dir(domain: ServiceDomain) -> &'static str {
73        match domain {
74            ServiceDomain::Search => "search",
75            ServiceDomain::Foundry => "foundry",
76        }
77    }
78
79    /// Absolute path for a resource file.
80    pub fn path_for(&self, r: &ResourceRef) -> PathBuf {
81        self.project
82            .dir
83            .join(Self::domain_dir(r.kind.domain()))
84            .join(r.kind.directory_name())
85            .join(format!("{}.json", r.name))
86    }
87
88    /// Scan the project directory for resource files.
89    pub fn list(&self) -> Result<Vec<(ResourceRef, PathBuf)>> {
90        let mut out = Vec::new();
91        for kind in ResourceKind::all() {
92            let dir = self
93                .project
94                .dir
95                .join(Self::domain_dir(kind.domain()))
96                .join(kind.directory_name());
97            if !dir.is_dir() {
98                continue;
99            }
100            let mut entries: Vec<PathBuf> = std::fs::read_dir(&dir)
101                .map_err(|source| StoreError::Io {
102                    path: dir.clone(),
103                    source,
104                })?
105                .filter_map(|e| e.ok())
106                .map(|e| e.path())
107                .filter(|p| p.extension().is_some_and(|e| e == "json"))
108                .collect();
109            entries.sort();
110            for path in entries {
111                let name = path
112                    .file_stem()
113                    .map(|s| s.to_string_lossy().into_owned())
114                    .unwrap_or_default();
115                validate_resource_name(&name).map_err(|e| StoreError::BadName {
116                    path: path.clone(),
117                    message: e.to_string(),
118                })?;
119                out.push((ResourceRef::new(*kind, name), path));
120            }
121        }
122        Ok(out)
123    }
124
125    /// Read a resource file with sidecars inlined.
126    pub fn read(&self, r: &ResourceRef) -> Result<Value> {
127        let path = self.path_for(r);
128        self.read_path(&path)
129    }
130
131    /// Read any resource file (must belong to this project) with sidecars inlined.
132    pub fn read_path(&self, path: &Path) -> Result<Value> {
133        let text = std::fs::read_to_string(path).map_err(|source| StoreError::Io {
134            path: path.to_path_buf(),
135            source,
136        })?;
137        let mut value: Value = serde_json::from_str(&text).map_err(|source| StoreError::Parse {
138            path: path.to_path_buf(),
139            source,
140        })?;
141        sidecar::inline_sidecars(path, &mut value)?;
142        Ok(value)
143    }
144
145    /// Write a resource: normalize for disk, extract sidecars, write only if
146    /// the semantic content changed. Returns true if the file was (re)written.
147    pub fn write(&self, r: &ResourceRef, value: &Value) -> Result<bool> {
148        let path = self.path_for(r);
149        let mut normalized = normalize_for_disk(r.kind, value);
150
151        // Preserve any x-rigg-* annotations the user added locally: they are
152        // Rigg-local and never come back from Azure.
153        if path.is_file() {
154            if let Ok(existing) = self.read_path(&path) {
155                carry_over_x_rigg(&existing, &mut normalized);
156                carry_over_write_only(r.kind, &existing, &mut normalized);
157                if crate::normalize::semantic_eq(r.kind, &existing, &normalized) {
158                    return Ok(false);
159                }
160            }
161        }
162
163        if let Some(parent) = path.parent() {
164            std::fs::create_dir_all(parent).map_err(|source| StoreError::Io {
165                path: parent.to_path_buf(),
166                source,
167            })?;
168        }
169        sidecar::extract_sidecars(r.kind, &path, &mut normalized)?;
170        std::fs::write(&path, format_json(&normalized)).map_err(|source| StoreError::Io {
171            path: path.clone(),
172            source,
173        })?;
174        Ok(true)
175    }
176
177    /// Delete a resource file (and its default sidecars).
178    pub fn delete(&self, r: &ResourceRef) -> Result<()> {
179        let path = self.path_for(r);
180        if path.is_file() {
181            std::fs::remove_file(&path).map_err(|source| StoreError::Io {
182                path: path.clone(),
183                source,
184            })?;
185        }
186        // Remove default sidecars (e.g. `<name>.instructions.md`).
187        if let Some(dir) = path.parent() {
188            for field in crate::registry::meta(r.kind).sidecar_fields {
189                let sidecar = dir.join(format!("{}.{}.md", r.name, field));
190                if sidecar.is_file() {
191                    let _ = std::fs::remove_file(sidecar);
192                }
193            }
194        }
195        Ok(())
196    }
197}
198
199/// Preserve write-only fields (server never echoes them) from the existing
200/// local file when the incoming document lacks them or has them as null.
201fn carry_over_write_only(kind: ResourceKind, from: &Value, to: &mut Value) {
202    for spec in crate::registry::meta(kind).write_only_fields {
203        let mut existing_value: Option<Value> = None;
204        crate::registry::collect_path(from, spec, &mut |v| {
205            if !v.is_null() {
206                existing_value = Some(v.clone());
207            }
208        });
209        let Some(existing_value) = existing_value else {
210            continue;
211        };
212        set_path(to, &spec.split('.').collect::<Vec<_>>(), existing_value);
213    }
214}
215
216/// Set a dot-path (no `[]` support — write-only fields are object paths),
217/// creating intermediate objects as needed.
218fn set_path(value: &mut Value, segments: &[&str], new_value: Value) {
219    let Some((head, rest)) = segments.split_first() else {
220        return;
221    };
222    let Value::Object(map) = value else { return };
223    if rest.is_empty() {
224        map.insert((*head).to_string(), new_value);
225        return;
226    }
227    let entry = map
228        .entry((*head).to_string())
229        .or_insert_with(|| Value::Object(serde_json::Map::new()));
230    set_path(entry, rest, new_value);
231}
232
233/// Copy `x-rigg-*` keys from `from` into `to` at the same paths (top-level and
234/// one structural match deep for arrays keyed by `name`/`type`).
235fn carry_over_x_rigg(from: &Value, to: &mut Value) {
236    match (from, to) {
237        (Value::Object(src), Value::Object(dst)) => {
238            for (k, v) in src {
239                if k.starts_with("x-rigg-") {
240                    dst.entry(k.clone()).or_insert_with(|| v.clone());
241                } else if let Some(dv) = dst.get_mut(k) {
242                    carry_over_x_rigg(v, dv);
243                }
244            }
245        }
246        (Value::Array(src), Value::Array(dst)) => {
247            for sv in src {
248                let key = sv.get("name").or_else(|| sv.get("type"));
249                if let Some(key) = key {
250                    if let Some(dv) = dst
251                        .iter_mut()
252                        .find(|d| d.get("name").or_else(|| d.get("type")) == Some(key))
253                    {
254                        carry_over_x_rigg(sv, dv);
255                    }
256                }
257            }
258        }
259        _ => {}
260    }
261}
262
263/// Enforce exclusive ownership: a (kind, name) may appear in only one project.
264pub fn assert_exclusive_ownership(ws: &Workspace) -> Result<()> {
265    let mut seen: BTreeMap<ResourceRef, &str> = BTreeMap::new();
266    for project in &ws.projects {
267        let store = Store::new(project);
268        for (r, _) in store.list()? {
269            if let Some(first) = seen.get(&r) {
270                return Err(StoreError::DuplicateOwnership {
271                    reference: r.to_string(),
272                    first: first.to_string(),
273                    second: project.name.clone(),
274                });
275            }
276            seen.insert(r, &project.name);
277        }
278    }
279    Ok(())
280}
281
282/// Sync classification of one resource.
283#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
284#[serde(rename_all = "kebab-case")]
285pub enum SyncClass {
286    /// Local, remote and baseline all agree.
287    InSync,
288    /// Local changed since last sync; remote unchanged.
289    LocalAhead,
290    /// Remote changed since last sync; local unchanged.
291    RemoteAhead,
292    /// Both changed since last sync.
293    Conflict,
294    /// Exists locally, not remotely (new resource or remote-deleted).
295    LocalOnly,
296    /// Exists remotely, not locally (unmanaged or locally-deleted).
297    RemoteOnly,
298    /// No baseline; local and remote both exist but differ (never synced).
299    Untracked,
300}
301
302/// Per-project, per-environment sync baselines.
303#[derive(Debug, Clone, Default, Serialize, Deserialize)]
304pub struct ProjectState {
305    /// `kind-dir/name` → checksum of the push-normalized JSON at last sync.
306    #[serde(default)]
307    pub baselines: BTreeMap<String, String>,
308}
309
310impl ProjectState {
311    pub fn path(ws: &Workspace, env: &str, project: &str) -> PathBuf {
312        ws.state_dir(env, project).join("state.json")
313    }
314
315    pub fn load(ws: &Workspace, env: &str, project: &str) -> ProjectState {
316        let path = Self::path(ws, env, project);
317        std::fs::read_to_string(&path)
318            .ok()
319            .and_then(|text| serde_json::from_str(&text).ok())
320            .unwrap_or_default()
321    }
322
323    pub fn save(&self, ws: &Workspace, env: &str, project: &str) -> std::io::Result<()> {
324        let path = Self::path(ws, env, project);
325        if let Some(parent) = path.parent() {
326            std::fs::create_dir_all(parent)?;
327        }
328        std::fs::write(&path, format_json(&serde_json::to_value(self).unwrap()))
329    }
330
331    /// Checksum of the push-normalized form of a document.
332    ///
333    /// The form is canonicalized (object keys sorted recursively, arrays of
334    /// named objects sorted by name) so that server-side reordering between
335    /// GET and PUT responses never reads as a change — matching the semantics
336    /// of the order-insensitive diff.
337    pub fn checksum(kind: ResourceKind, value: &Value) -> String {
338        let normalized = canonical_form(&normalize_for_compare(kind, value));
339        let canonical = serde_json::to_string(&normalized).unwrap_or_default();
340        format!("{:x}", md5_like(&canonical))
341    }
342
343    pub fn baseline(&self, r: &ResourceRef) -> Option<&str> {
344        self.baselines.get(&r.key()).map(String::as_str)
345    }
346
347    pub fn set_baseline(&mut self, r: &ResourceRef, kind_value: &Value) {
348        self.baselines
349            .insert(r.key(), Self::checksum(r.kind, kind_value));
350    }
351
352    pub fn clear_baseline(&mut self, r: &ResourceRef) {
353        self.baselines.remove(&r.key());
354    }
355
356    /// Classify a resource given its (optional) local and remote documents.
357    pub fn classify(
358        &self,
359        r: &ResourceRef,
360        local: Option<&Value>,
361        remote: Option<&Value>,
362    ) -> SyncClass {
363        let baseline = self.baseline(r);
364        match (local, remote) {
365            (None, None) => SyncClass::InSync, // nothing anywhere (only baseline leftover)
366            (Some(_), None) => SyncClass::LocalOnly,
367            (None, Some(_)) => SyncClass::RemoteOnly,
368            (Some(l), Some(rm)) => {
369                let lsum = Self::checksum(r.kind, l);
370                let rsum = Self::checksum(r.kind, rm);
371                match baseline {
372                    None => {
373                        if lsum == rsum {
374                            SyncClass::InSync
375                        } else {
376                            SyncClass::Untracked
377                        }
378                    }
379                    Some(base) => {
380                        let local_changed = lsum != base;
381                        let remote_changed = rsum != base;
382                        match (local_changed, remote_changed) {
383                            (false, false) => SyncClass::InSync,
384                            (true, false) => SyncClass::LocalAhead,
385                            (false, true) => SyncClass::RemoteAhead,
386                            (true, true) => {
387                                if lsum == rsum {
388                                    // Both moved to the same content.
389                                    SyncClass::InSync
390                                } else {
391                                    SyncClass::Conflict
392                                }
393                            }
394                        }
395                    }
396                }
397            }
398        }
399    }
400}
401
402/// Order-canonical JSON: object keys sorted recursively; arrays whose items
403/// all carry a string `name` are sorted by it (identity-keyed arrays).
404fn canonical_form(value: &Value) -> Value {
405    match value {
406        Value::Object(map) => {
407            // null-valued keys are dropped: Azure oscillates between omitting
408            // a field and returning it as null depending on the endpoint.
409            let mut sorted: Vec<(String, Value)> = map
410                .iter()
411                .filter(|(_, v)| !v.is_null())
412                .map(|(k, v)| (k.clone(), canonical_form(v)))
413                .collect();
414            sorted.sort_by(|a, b| a.0.cmp(&b.0));
415            Value::Object(sorted.into_iter().collect())
416        }
417        Value::Array(arr) => {
418            let mut items: Vec<Value> = arr.iter().map(canonical_form).collect();
419            if !items.is_empty()
420                && items
421                    .iter()
422                    .all(|i| i.get("name").and_then(Value::as_str).is_some())
423            {
424                items.sort_by(|a, b| {
425                    a["name"]
426                        .as_str()
427                        .unwrap_or_default()
428                        .cmp(b["name"].as_str().unwrap_or_default())
429                });
430            }
431            Value::Array(items)
432        }
433        other => other.clone(),
434    }
435}
436
437/// Small non-cryptographic checksum (FNV-1a 128-ish via two 64-bit lanes).
438/// Collision resistance is ample for change detection.
439fn md5_like(s: &str) -> u128 {
440    let mut h1: u64 = 0xcbf29ce484222325;
441    let mut h2: u64 = 0x9e3779b97f4a7c15;
442    for b in s.as_bytes() {
443        h1 ^= u64::from(*b);
444        h1 = h1.wrapping_mul(0x100000001b3);
445        h2 = h2.rotate_left(5) ^ u64::from(*b);
446        h2 = h2.wrapping_mul(0x2545f4914f6cdd1d);
447    }
448    (u128::from(h1) << 64) | u128::from(h2)
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use crate::workspace::{PROJECT_FILE, PROJECTS_DIR, WORKSPACE_FILE};
455    use serde_json::json;
456
457    fn ws_with_projects(dir: &Path, names: &[&str]) -> Workspace {
458        std::fs::write(
459            dir.join(WORKSPACE_FILE),
460            "environments:\n  dev:\n    default: true\n    search: { service: s }\n",
461        )
462        .unwrap();
463        for name in names {
464            let pdir = dir.join(PROJECTS_DIR).join(name);
465            std::fs::create_dir_all(&pdir).unwrap();
466            std::fs::write(pdir.join(PROJECT_FILE), "{}\n").unwrap();
467        }
468        Workspace::load(dir).unwrap()
469    }
470
471    #[test]
472    fn write_list_read_round_trip_with_sidecars() {
473        let tmp = tempfile::tempdir().unwrap();
474        let ws = ws_with_projects(tmp.path(), &["p"]);
475        let store = Store::new(ws.project("p").unwrap());
476
477        let agent_ref = ResourceRef::new(ResourceKind::Agent, "helper");
478        let agent = json!({"name": "helper", "model": "gpt-5-mini", "instructions": "Be nice."});
479        assert!(store.write(&agent_ref, &agent).unwrap());
480
481        // sidecar extracted
482        let sidecar = store
483            .path_for(&agent_ref)
484            .parent()
485            .unwrap()
486            .join("helper.instructions.md");
487        assert!(sidecar.is_file());
488
489        // read inlines it back
490        let read = store.read(&agent_ref).unwrap();
491        assert_eq!(read["instructions"], json!("Be nice."));
492
493        let listed = store.list().unwrap();
494        assert_eq!(listed.len(), 1);
495        assert_eq!(listed[0].0, agent_ref);
496    }
497
498    #[test]
499    fn write_returns_false_when_semantically_unchanged() {
500        let tmp = tempfile::tempdir().unwrap();
501        let ws = ws_with_projects(tmp.path(), &["p"]);
502        let store = Store::new(ws.project("p").unwrap());
503        let r = ResourceRef::new(ResourceKind::Index, "idx");
504        assert!(
505            store
506                .write(&r, &json!({"name": "idx", "fields": []}))
507                .unwrap()
508        );
509        // same content + volatile noise → no rewrite
510        let noisy = json!({"@odata.etag": "0x1", "name": "idx", "fields": []});
511        assert!(!store.write(&r, &noisy).unwrap());
512        // real change → rewrite
513        let changed = json!({"name": "idx", "fields": [{"name": "f"}]});
514        assert!(store.write(&r, &changed).unwrap());
515    }
516
517    #[test]
518    fn write_preserves_local_x_rigg_annotations() {
519        let tmp = tempfile::tempdir().unwrap();
520        let ws = ws_with_projects(tmp.path(), &["p"]);
521        let store = Store::new(ws.project("p").unwrap());
522        let r = ResourceRef::new(ResourceKind::Skillset, "sk");
523        let local = json!({
524            "name": "sk",
525            "skills": [{"name": "web", "uri": "https://f", "x-rigg-api": "enrich"}]
526        });
527        store.write(&r, &local).unwrap();
528        // Azure returns the same thing without the annotation
529        let remote = json!({
530            "name": "sk",
531            "skills": [{"name": "web", "uri": "https://f"}]
532        });
533        let rewritten = store.write(&r, &remote).unwrap();
534        let read = store.read(&r).unwrap();
535        assert_eq!(read["skills"][0]["x-rigg-api"], json!("enrich"));
536        assert!(!rewritten, "annotation-only delta is not a semantic change");
537    }
538
539    #[test]
540    fn exclusive_ownership_violation_names_both_projects() {
541        let tmp = tempfile::tempdir().unwrap();
542        let ws = ws_with_projects(tmp.path(), &["alpha", "beta"]);
543        for p in ["alpha", "beta"] {
544            let store = Store::new(ws.project(p).unwrap());
545            store
546                .write(
547                    &ResourceRef::new(ResourceKind::Index, "shared"),
548                    &json!({"name": "shared"}),
549                )
550                .unwrap();
551        }
552        let err = assert_exclusive_ownership(&ws).unwrap_err();
553        let msg = err.to_string();
554        assert!(msg.contains("alpha") && msg.contains("beta") && msg.contains("indexes/shared"));
555    }
556
557    #[test]
558    fn classify_truth_table() {
559        let tmp = tempfile::tempdir().unwrap();
560        let ws = ws_with_projects(tmp.path(), &["p"]);
561        let r = ResourceRef::new(ResourceKind::Index, "idx");
562        let a = json!({"name": "idx", "fields": [{"name": "f1"}]});
563        let b = json!({"name": "idx", "fields": [{"name": "f2"}]});
564        let c = json!({"name": "idx", "fields": [{"name": "f3"}]});
565
566        let mut state = ProjectState::default();
567        // no baseline
568        assert_eq!(state.classify(&r, Some(&a), Some(&a)), SyncClass::InSync);
569        assert_eq!(state.classify(&r, Some(&a), Some(&b)), SyncClass::Untracked);
570        assert_eq!(state.classify(&r, Some(&a), None), SyncClass::LocalOnly);
571        assert_eq!(state.classify(&r, None, Some(&a)), SyncClass::RemoteOnly);
572        assert_eq!(state.classify(&r, None, None), SyncClass::InSync);
573
574        // with baseline = a
575        state.set_baseline(&r, &a);
576        assert_eq!(state.classify(&r, Some(&a), Some(&a)), SyncClass::InSync);
577        assert_eq!(
578            state.classify(&r, Some(&b), Some(&a)),
579            SyncClass::LocalAhead
580        );
581        assert_eq!(
582            state.classify(&r, Some(&a), Some(&b)),
583            SyncClass::RemoteAhead
584        );
585        assert_eq!(state.classify(&r, Some(&b), Some(&c)), SyncClass::Conflict);
586        assert_eq!(state.classify(&r, Some(&b), Some(&b)), SyncClass::InSync);
587
588        // save/load round trip
589        state.save(&ws, "dev", "p").unwrap();
590        let loaded = ProjectState::load(&ws, "dev", "p");
591        assert_eq!(loaded.baseline(&r), state.baseline(&r));
592    }
593
594    #[test]
595    fn write_only_fields_survive_server_echo_and_compare() {
596        let tmp = tempfile::tempdir().unwrap();
597        let ws = ws_with_projects(tmp.path(), &["p"]);
598        let store = Store::new(ws.project("p").unwrap());
599        let r = ResourceRef::new(ResourceKind::DataSource, "ds");
600        let local = json!({
601            "name": "ds", "type": "azureblob",
602            "credentials": {"connectionString": "ResourceId=/subscriptions/s/x;"},
603            "container": {"name": "c"}
604        });
605        store.write(&r, &local).unwrap();
606        // Azure's GET echo: connection string redacted to null
607        let server_echo = json!({
608            "name": "ds", "type": "azureblob",
609            "credentials": {"connectionString": null},
610            "container": {"name": "c"}
611        });
612        // no semantic change → no rewrite, and the conn string survives
613        assert!(!store.write(&r, &server_echo).unwrap());
614        let read = store.read(&r).unwrap();
615        assert_eq!(
616            read["credentials"]["connectionString"],
617            json!("ResourceId=/subscriptions/s/x;")
618        );
619        // checksums ignore the write-only field (local vs redacted remote equal)
620        assert_eq!(
621            ProjectState::checksum(ResourceKind::DataSource, &local),
622            ProjectState::checksum(ResourceKind::DataSource, &server_echo)
623        );
624    }
625
626    #[test]
627    fn checksum_is_order_canonical() {
628        // same content, different key order and array order
629        let a = serde_json::from_str::<Value>(
630            r#"{"name": "i", "fields": [{"name": "b"}, {"name": "a"}], "x": 1}"#,
631        )
632        .unwrap();
633        let b = serde_json::from_str::<Value>(
634            r#"{"x": 1, "name": "i", "fields": [{"name": "a"}, {"name": "b"}]}"#,
635        )
636        .unwrap();
637        assert_eq!(
638            ProjectState::checksum(ResourceKind::Index, &a),
639            ProjectState::checksum(ResourceKind::Index, &b)
640        );
641    }
642
643    #[test]
644    fn checksum_ignores_volatile_and_annotations() {
645        let a = json!({"name": "i", "@odata.etag": "1", "x-rigg-note": "hi"});
646        let b = json!({"name": "i"});
647        assert_eq!(
648            ProjectState::checksum(ResourceKind::Index, &a),
649            ProjectState::checksum(ResourceKind::Index, &b)
650        );
651    }
652}