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_disk, normalize_for_push};
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                if crate::normalize::semantic_eq(r.kind, &existing, &normalized) {
157                    return Ok(false);
158                }
159            }
160        }
161
162        if let Some(parent) = path.parent() {
163            std::fs::create_dir_all(parent).map_err(|source| StoreError::Io {
164                path: parent.to_path_buf(),
165                source,
166            })?;
167        }
168        sidecar::extract_sidecars(r.kind, &path, &mut normalized)?;
169        std::fs::write(&path, format_json(&normalized)).map_err(|source| StoreError::Io {
170            path: path.clone(),
171            source,
172        })?;
173        Ok(true)
174    }
175
176    /// Delete a resource file (and its default sidecars).
177    pub fn delete(&self, r: &ResourceRef) -> Result<()> {
178        let path = self.path_for(r);
179        if path.is_file() {
180            std::fs::remove_file(&path).map_err(|source| StoreError::Io {
181                path: path.clone(),
182                source,
183            })?;
184        }
185        // Remove default sidecars (e.g. `<name>.instructions.md`).
186        if let Some(dir) = path.parent() {
187            for field in crate::registry::meta(r.kind).sidecar_fields {
188                let sidecar = dir.join(format!("{}.{}.md", r.name, field));
189                if sidecar.is_file() {
190                    let _ = std::fs::remove_file(sidecar);
191                }
192            }
193        }
194        Ok(())
195    }
196}
197
198/// Copy `x-rigg-*` keys from `from` into `to` at the same paths (top-level and
199/// one structural match deep for arrays keyed by `name`/`type`).
200fn carry_over_x_rigg(from: &Value, to: &mut Value) {
201    match (from, to) {
202        (Value::Object(src), Value::Object(dst)) => {
203            for (k, v) in src {
204                if k.starts_with("x-rigg-") {
205                    dst.entry(k.clone()).or_insert_with(|| v.clone());
206                } else if let Some(dv) = dst.get_mut(k) {
207                    carry_over_x_rigg(v, dv);
208                }
209            }
210        }
211        (Value::Array(src), Value::Array(dst)) => {
212            for sv in src {
213                let key = sv.get("name").or_else(|| sv.get("type"));
214                if let Some(key) = key {
215                    if let Some(dv) = dst
216                        .iter_mut()
217                        .find(|d| d.get("name").or_else(|| d.get("type")) == Some(key))
218                    {
219                        carry_over_x_rigg(sv, dv);
220                    }
221                }
222            }
223        }
224        _ => {}
225    }
226}
227
228/// Enforce exclusive ownership: a (kind, name) may appear in only one project.
229pub fn assert_exclusive_ownership(ws: &Workspace) -> Result<()> {
230    let mut seen: BTreeMap<ResourceRef, &str> = BTreeMap::new();
231    for project in &ws.projects {
232        let store = Store::new(project);
233        for (r, _) in store.list()? {
234            if let Some(first) = seen.get(&r) {
235                return Err(StoreError::DuplicateOwnership {
236                    reference: r.to_string(),
237                    first: first.to_string(),
238                    second: project.name.clone(),
239                });
240            }
241            seen.insert(r, &project.name);
242        }
243    }
244    Ok(())
245}
246
247/// Sync classification of one resource.
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
249#[serde(rename_all = "kebab-case")]
250pub enum SyncClass {
251    /// Local, remote and baseline all agree.
252    InSync,
253    /// Local changed since last sync; remote unchanged.
254    LocalAhead,
255    /// Remote changed since last sync; local unchanged.
256    RemoteAhead,
257    /// Both changed since last sync.
258    Conflict,
259    /// Exists locally, not remotely (new resource or remote-deleted).
260    LocalOnly,
261    /// Exists remotely, not locally (unmanaged or locally-deleted).
262    RemoteOnly,
263    /// No baseline; local and remote both exist but differ (never synced).
264    Untracked,
265}
266
267/// Per-project, per-environment sync baselines.
268#[derive(Debug, Clone, Default, Serialize, Deserialize)]
269pub struct ProjectState {
270    /// `kind-dir/name` → checksum of the push-normalized JSON at last sync.
271    #[serde(default)]
272    pub baselines: BTreeMap<String, String>,
273}
274
275impl ProjectState {
276    pub fn path(ws: &Workspace, env: &str, project: &str) -> PathBuf {
277        ws.state_dir(env, project).join("state.json")
278    }
279
280    pub fn load(ws: &Workspace, env: &str, project: &str) -> ProjectState {
281        let path = Self::path(ws, env, project);
282        std::fs::read_to_string(&path)
283            .ok()
284            .and_then(|text| serde_json::from_str(&text).ok())
285            .unwrap_or_default()
286    }
287
288    pub fn save(&self, ws: &Workspace, env: &str, project: &str) -> std::io::Result<()> {
289        let path = Self::path(ws, env, project);
290        if let Some(parent) = path.parent() {
291            std::fs::create_dir_all(parent)?;
292        }
293        std::fs::write(&path, format_json(&serde_json::to_value(self).unwrap()))
294    }
295
296    /// Checksum of the push-normalized form of a document.
297    ///
298    /// The form is canonicalized (object keys sorted recursively, arrays of
299    /// named objects sorted by name) so that server-side reordering between
300    /// GET and PUT responses never reads as a change — matching the semantics
301    /// of the order-insensitive diff.
302    pub fn checksum(kind: ResourceKind, value: &Value) -> String {
303        let normalized = canonical_form(&normalize_for_push(kind, value));
304        let canonical = serde_json::to_string(&normalized).unwrap_or_default();
305        format!("{:x}", md5_like(&canonical))
306    }
307
308    pub fn baseline(&self, r: &ResourceRef) -> Option<&str> {
309        self.baselines.get(&r.key()).map(String::as_str)
310    }
311
312    pub fn set_baseline(&mut self, r: &ResourceRef, kind_value: &Value) {
313        self.baselines
314            .insert(r.key(), Self::checksum(r.kind, kind_value));
315    }
316
317    pub fn clear_baseline(&mut self, r: &ResourceRef) {
318        self.baselines.remove(&r.key());
319    }
320
321    /// Classify a resource given its (optional) local and remote documents.
322    pub fn classify(
323        &self,
324        r: &ResourceRef,
325        local: Option<&Value>,
326        remote: Option<&Value>,
327    ) -> SyncClass {
328        let baseline = self.baseline(r);
329        match (local, remote) {
330            (None, None) => SyncClass::InSync, // nothing anywhere (only baseline leftover)
331            (Some(_), None) => SyncClass::LocalOnly,
332            (None, Some(_)) => SyncClass::RemoteOnly,
333            (Some(l), Some(rm)) => {
334                let lsum = Self::checksum(r.kind, l);
335                let rsum = Self::checksum(r.kind, rm);
336                match baseline {
337                    None => {
338                        if lsum == rsum {
339                            SyncClass::InSync
340                        } else {
341                            SyncClass::Untracked
342                        }
343                    }
344                    Some(base) => {
345                        let local_changed = lsum != base;
346                        let remote_changed = rsum != base;
347                        match (local_changed, remote_changed) {
348                            (false, false) => SyncClass::InSync,
349                            (true, false) => SyncClass::LocalAhead,
350                            (false, true) => SyncClass::RemoteAhead,
351                            (true, true) => {
352                                if lsum == rsum {
353                                    // Both moved to the same content.
354                                    SyncClass::InSync
355                                } else {
356                                    SyncClass::Conflict
357                                }
358                            }
359                        }
360                    }
361                }
362            }
363        }
364    }
365}
366
367/// Order-canonical JSON: object keys sorted recursively; arrays whose items
368/// all carry a string `name` are sorted by it (identity-keyed arrays).
369fn canonical_form(value: &Value) -> Value {
370    match value {
371        Value::Object(map) => {
372            // null-valued keys are dropped: Azure oscillates between omitting
373            // a field and returning it as null depending on the endpoint.
374            let mut sorted: Vec<(String, Value)> = map
375                .iter()
376                .filter(|(_, v)| !v.is_null())
377                .map(|(k, v)| (k.clone(), canonical_form(v)))
378                .collect();
379            sorted.sort_by(|a, b| a.0.cmp(&b.0));
380            Value::Object(sorted.into_iter().collect())
381        }
382        Value::Array(arr) => {
383            let mut items: Vec<Value> = arr.iter().map(canonical_form).collect();
384            if !items.is_empty()
385                && items
386                    .iter()
387                    .all(|i| i.get("name").and_then(Value::as_str).is_some())
388            {
389                items.sort_by(|a, b| {
390                    a["name"]
391                        .as_str()
392                        .unwrap_or_default()
393                        .cmp(b["name"].as_str().unwrap_or_default())
394                });
395            }
396            Value::Array(items)
397        }
398        other => other.clone(),
399    }
400}
401
402/// Small non-cryptographic checksum (FNV-1a 128-ish via two 64-bit lanes).
403/// Collision resistance is ample for change detection.
404fn md5_like(s: &str) -> u128 {
405    let mut h1: u64 = 0xcbf29ce484222325;
406    let mut h2: u64 = 0x9e3779b97f4a7c15;
407    for b in s.as_bytes() {
408        h1 ^= u64::from(*b);
409        h1 = h1.wrapping_mul(0x100000001b3);
410        h2 = h2.rotate_left(5) ^ u64::from(*b);
411        h2 = h2.wrapping_mul(0x2545f4914f6cdd1d);
412    }
413    (u128::from(h1) << 64) | u128::from(h2)
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use crate::workspace::{PROJECT_FILE, PROJECTS_DIR, WORKSPACE_FILE};
420    use serde_json::json;
421
422    fn ws_with_projects(dir: &Path, names: &[&str]) -> Workspace {
423        std::fs::write(
424            dir.join(WORKSPACE_FILE),
425            "environments:\n  dev:\n    default: true\n    search: { service: s }\n",
426        )
427        .unwrap();
428        for name in names {
429            let pdir = dir.join(PROJECTS_DIR).join(name);
430            std::fs::create_dir_all(&pdir).unwrap();
431            std::fs::write(pdir.join(PROJECT_FILE), "{}\n").unwrap();
432        }
433        Workspace::load(dir).unwrap()
434    }
435
436    #[test]
437    fn write_list_read_round_trip_with_sidecars() {
438        let tmp = tempfile::tempdir().unwrap();
439        let ws = ws_with_projects(tmp.path(), &["p"]);
440        let store = Store::new(ws.project("p").unwrap());
441
442        let agent_ref = ResourceRef::new(ResourceKind::Agent, "helper");
443        let agent = json!({"name": "helper", "model": "gpt-5-mini", "instructions": "Be nice."});
444        assert!(store.write(&agent_ref, &agent).unwrap());
445
446        // sidecar extracted
447        let sidecar = store
448            .path_for(&agent_ref)
449            .parent()
450            .unwrap()
451            .join("helper.instructions.md");
452        assert!(sidecar.is_file());
453
454        // read inlines it back
455        let read = store.read(&agent_ref).unwrap();
456        assert_eq!(read["instructions"], json!("Be nice."));
457
458        let listed = store.list().unwrap();
459        assert_eq!(listed.len(), 1);
460        assert_eq!(listed[0].0, agent_ref);
461    }
462
463    #[test]
464    fn write_returns_false_when_semantically_unchanged() {
465        let tmp = tempfile::tempdir().unwrap();
466        let ws = ws_with_projects(tmp.path(), &["p"]);
467        let store = Store::new(ws.project("p").unwrap());
468        let r = ResourceRef::new(ResourceKind::Index, "idx");
469        assert!(
470            store
471                .write(&r, &json!({"name": "idx", "fields": []}))
472                .unwrap()
473        );
474        // same content + volatile noise → no rewrite
475        let noisy = json!({"@odata.etag": "0x1", "name": "idx", "fields": []});
476        assert!(!store.write(&r, &noisy).unwrap());
477        // real change → rewrite
478        let changed = json!({"name": "idx", "fields": [{"name": "f"}]});
479        assert!(store.write(&r, &changed).unwrap());
480    }
481
482    #[test]
483    fn write_preserves_local_x_rigg_annotations() {
484        let tmp = tempfile::tempdir().unwrap();
485        let ws = ws_with_projects(tmp.path(), &["p"]);
486        let store = Store::new(ws.project("p").unwrap());
487        let r = ResourceRef::new(ResourceKind::Skillset, "sk");
488        let local = json!({
489            "name": "sk",
490            "skills": [{"name": "web", "uri": "https://f", "x-rigg-api": "enrich"}]
491        });
492        store.write(&r, &local).unwrap();
493        // Azure returns the same thing without the annotation
494        let remote = json!({
495            "name": "sk",
496            "skills": [{"name": "web", "uri": "https://f"}]
497        });
498        let rewritten = store.write(&r, &remote).unwrap();
499        let read = store.read(&r).unwrap();
500        assert_eq!(read["skills"][0]["x-rigg-api"], json!("enrich"));
501        assert!(!rewritten, "annotation-only delta is not a semantic change");
502    }
503
504    #[test]
505    fn exclusive_ownership_violation_names_both_projects() {
506        let tmp = tempfile::tempdir().unwrap();
507        let ws = ws_with_projects(tmp.path(), &["alpha", "beta"]);
508        for p in ["alpha", "beta"] {
509            let store = Store::new(ws.project(p).unwrap());
510            store
511                .write(
512                    &ResourceRef::new(ResourceKind::Index, "shared"),
513                    &json!({"name": "shared"}),
514                )
515                .unwrap();
516        }
517        let err = assert_exclusive_ownership(&ws).unwrap_err();
518        let msg = err.to_string();
519        assert!(msg.contains("alpha") && msg.contains("beta") && msg.contains("indexes/shared"));
520    }
521
522    #[test]
523    fn classify_truth_table() {
524        let tmp = tempfile::tempdir().unwrap();
525        let ws = ws_with_projects(tmp.path(), &["p"]);
526        let r = ResourceRef::new(ResourceKind::Index, "idx");
527        let a = json!({"name": "idx", "fields": [{"name": "f1"}]});
528        let b = json!({"name": "idx", "fields": [{"name": "f2"}]});
529        let c = json!({"name": "idx", "fields": [{"name": "f3"}]});
530
531        let mut state = ProjectState::default();
532        // no baseline
533        assert_eq!(state.classify(&r, Some(&a), Some(&a)), SyncClass::InSync);
534        assert_eq!(state.classify(&r, Some(&a), Some(&b)), SyncClass::Untracked);
535        assert_eq!(state.classify(&r, Some(&a), None), SyncClass::LocalOnly);
536        assert_eq!(state.classify(&r, None, Some(&a)), SyncClass::RemoteOnly);
537        assert_eq!(state.classify(&r, None, None), SyncClass::InSync);
538
539        // with baseline = a
540        state.set_baseline(&r, &a);
541        assert_eq!(state.classify(&r, Some(&a), Some(&a)), SyncClass::InSync);
542        assert_eq!(
543            state.classify(&r, Some(&b), Some(&a)),
544            SyncClass::LocalAhead
545        );
546        assert_eq!(
547            state.classify(&r, Some(&a), Some(&b)),
548            SyncClass::RemoteAhead
549        );
550        assert_eq!(state.classify(&r, Some(&b), Some(&c)), SyncClass::Conflict);
551        assert_eq!(state.classify(&r, Some(&b), Some(&b)), SyncClass::InSync);
552
553        // save/load round trip
554        state.save(&ws, "dev", "p").unwrap();
555        let loaded = ProjectState::load(&ws, "dev", "p");
556        assert_eq!(loaded.baseline(&r), state.baseline(&r));
557    }
558
559    #[test]
560    fn checksum_is_order_canonical() {
561        // same content, different key order and array order
562        let a = serde_json::from_str::<Value>(
563            r#"{"name": "i", "fields": [{"name": "b"}, {"name": "a"}], "x": 1}"#,
564        )
565        .unwrap();
566        let b = serde_json::from_str::<Value>(
567            r#"{"x": 1, "name": "i", "fields": [{"name": "a"}, {"name": "b"}]}"#,
568        )
569        .unwrap();
570        assert_eq!(
571            ProjectState::checksum(ResourceKind::Index, &a),
572            ProjectState::checksum(ResourceKind::Index, &b)
573        );
574    }
575
576    #[test]
577    fn checksum_ignores_volatile_and_annotations() {
578        let a = json!({"name": "i", "@odata.etag": "1", "x-rigg-note": "hi"});
579        let b = json!({"name": "i"});
580        assert_eq!(
581            ProjectState::checksum(ResourceKind::Index, &a),
582            ProjectState::checksum(ResourceKind::Index, &b)
583        );
584    }
585}