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_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
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 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 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 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
199fn 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
216fn 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
233fn 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
263pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
284#[serde(rename_all = "kebab-case")]
285pub enum SyncClass {
286 InSync,
288 LocalAhead,
290 RemoteAhead,
292 Conflict,
294 LocalOnly,
296 RemoteOnly,
298 Untracked,
300}
301
302#[derive(Debug, Clone, Default, Serialize, Deserialize)]
304pub struct ProjectState {
305 #[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 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 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, (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 SyncClass::InSync
390 } else {
391 SyncClass::Conflict
392 }
393 }
394 }
395 }
396 }
397 }
398 }
399 }
400}
401
402fn canonical_form(value: &Value) -> Value {
405 match value {
406 Value::Object(map) => {
407 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
437fn 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 let sidecar = store
483 .path_for(&agent_ref)
484 .parent()
485 .unwrap()
486 .join("helper.instructions.md");
487 assert!(sidecar.is_file());
488
489 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 let noisy = json!({"@odata.etag": "0x1", "name": "idx", "fields": []});
511 assert!(!store.write(&r, &noisy).unwrap());
512 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 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 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 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 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 let server_echo = json!({
608 "name": "ds", "type": "azureblob",
609 "credentials": {"connectionString": null},
610 "container": {"name": "c"}
611 });
612 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 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 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}