1use 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
58pub 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 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 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 pub fn read(&self, r: &ResourceRef) -> Result<Value> {
127 let path = self.path_for(r);
128 self.read_path(&path)
129 }
130
131 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 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 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 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 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
198fn 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
228pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
249#[serde(rename_all = "kebab-case")]
250pub enum SyncClass {
251 InSync,
253 LocalAhead,
255 RemoteAhead,
257 Conflict,
259 LocalOnly,
261 RemoteOnly,
263 Untracked,
265}
266
267#[derive(Debug, Clone, Default, Serialize, Deserialize)]
269pub struct ProjectState {
270 #[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 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 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, (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 SyncClass::InSync
355 } else {
356 SyncClass::Conflict
357 }
358 }
359 }
360 }
361 }
362 }
363 }
364 }
365}
366
367fn canonical_form(value: &Value) -> Value {
370 match value {
371 Value::Object(map) => {
372 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
402fn 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 let sidecar = store
448 .path_for(&agent_ref)
449 .parent()
450 .unwrap()
451 .join("helper.instructions.md");
452 assert!(sidecar.is_file());
453
454 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 let noisy = json!({"@odata.etag": "0x1", "name": "idx", "fields": []});
476 assert!(!store.write(&r, &noisy).unwrap());
477 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 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 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 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 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 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}