1use std::collections::BTreeMap;
26use std::path::{Path, PathBuf};
27
28use serde::{Deserialize, Serialize};
29use serde_json::Value;
30use thiserror::Error;
31
32use crate::normalize::{format_json, normalize_for_compare, normalize_for_disk};
33use crate::resources::traits::{ResourceKind, ResourceRef, validate_resource_name};
34use crate::service::ServiceDomain;
35use crate::sidecar::{self, SidecarError};
36use crate::workspace::{ENVS_DIR, Project, Workspace};
37
38#[derive(Debug, Error)]
39pub enum StoreError {
40 #[error("failed to read {path}: {source}")]
41 Io {
42 path: PathBuf,
43 source: std::io::Error,
44 },
45 #[error("invalid JSON in {path}: {source}")]
46 Parse {
47 path: PathBuf,
48 source: serde_json::Error,
49 },
50 #[error(transparent)]
51 Sidecar(#[from] SidecarError),
52 #[error("invalid resource name in {path}: {message}")]
53 BadName { path: PathBuf, message: String },
54 #[error(
55 "duplicate physical name '{name}': both {first} and {second} define a resource named '{name}' — physical (Azure) names must be unique within a kind"
56 )]
57 DuplicatePhysicalName {
58 name: String,
59 first: PathBuf,
60 second: PathBuf,
61 },
62 #[error(
63 "resource {reference} is defined in both project '{first}' and project '{second}' — a resource must belong to exactly one project"
64 )]
65 DuplicateOwnership {
66 reference: String,
67 first: String,
68 second: String,
69 },
70 #[error(
71 "cannot create '{path}': stem is already used by a different resource (existing physical name '{existing_name}', new '{new_name}')"
72 )]
73 StemOccupiedByDifferentResource {
74 path: PathBuf,
75 existing_name: String,
76 new_name: String,
77 },
78}
79
80type Result<T> = std::result::Result<T, StoreError>;
81
82pub struct Store<'w> {
84 project: &'w Project,
85 env: String,
86}
87
88impl<'w> Store<'w> {
89 pub fn new(project: &'w Project, env: &str) -> Self {
90 Store {
91 project,
92 env: env.to_string(),
93 }
94 }
95
96 pub fn project(&self) -> &Project {
97 self.project
98 }
99
100 pub fn env(&self) -> &str {
101 &self.env
102 }
103
104 pub fn envs_of(project: &Project) -> Vec<String> {
108 let dir = project.dir.join(ENVS_DIR);
109 let mut envs: Vec<String> = std::fs::read_dir(&dir)
110 .map(|entries| {
111 entries
112 .filter_map(|e| e.ok())
113 .map(|e| e.path())
114 .filter(|p| p.is_dir())
115 .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
116 .collect()
117 })
118 .unwrap_or_default();
119 envs.sort();
120 envs
121 }
122
123 fn root(&self) -> PathBuf {
125 self.project.dir.join(ENVS_DIR).join(&self.env)
126 }
127
128 fn domain_dir(domain: ServiceDomain) -> &'static str {
129 match domain {
130 ServiceDomain::Search => "search",
131 ServiceDomain::Foundry => "foundry",
132 }
133 }
134
135 fn kind_dir(&self, kind: ResourceKind) -> PathBuf {
136 self.root()
137 .join(Self::domain_dir(kind.domain()))
138 .join(kind.directory_name())
139 }
140
141 pub fn path_for(&self, r: &ResourceRef) -> PathBuf {
146 self.kind_dir(r.kind).join(format!("{}.json", r.name))
147 }
148
149 pub fn locate(&self, r: &ResourceRef) -> Result<Option<PathBuf>> {
153 let dir = self.kind_dir(r.kind);
154 if !dir.is_dir() {
155 return Ok(None);
156 }
157 let fast = dir.join(format!("{}.json", r.name));
159 if fast.is_file() && physical_name(&fast, &r.name)? == r.name {
160 return Ok(Some(fast));
161 }
162 let mut entries: Vec<PathBuf> = std::fs::read_dir(&dir)
163 .map_err(|source| StoreError::Io {
164 path: dir.clone(),
165 source,
166 })?
167 .filter_map(|e| e.ok())
168 .map(|e| e.path())
169 .filter(|p| p.extension().is_some_and(|e| e == "json"))
170 .collect();
171 entries.sort();
172 for path in entries {
173 if path == fast {
174 continue; }
176 let stem = path
177 .file_stem()
178 .map(|s| s.to_string_lossy().into_owned())
179 .unwrap_or_default();
180 if physical_name(&path, &stem)? == r.name {
181 return Ok(Some(path));
182 }
183 }
184 Ok(None)
185 }
186
187 fn create_path_for(&self, r: &ResourceRef) -> PathBuf {
194 let dir = self.kind_dir(r.kind);
195 let base = dir.join(format!("{}.json", r.name));
196 if !base.exists() {
197 return base;
198 }
199 for i in 2u64.. {
200 let candidate = dir.join(format!("{}-{i}.json", r.name));
201 if !candidate.exists() {
202 return candidate;
203 }
204 }
205 unreachable!("some numbered stem is always free")
206 }
207
208 pub fn list(&self) -> Result<Vec<(ResourceRef, PathBuf)>> {
217 let mut out = Vec::new();
218 for kind in ResourceKind::all() {
219 let dir = self.kind_dir(*kind);
220 if !dir.is_dir() {
221 continue;
222 }
223 let mut entries: Vec<PathBuf> = std::fs::read_dir(&dir)
224 .map_err(|source| StoreError::Io {
225 path: dir.clone(),
226 source,
227 })?
228 .filter_map(|e| e.ok())
229 .map(|e| e.path())
230 .filter(|p| p.extension().is_some_and(|e| e == "json"))
231 .collect();
232 entries.sort();
233 let mut seen: BTreeMap<String, PathBuf> = BTreeMap::new();
234 for path in entries {
235 let stem = path
236 .file_stem()
237 .map(|s| s.to_string_lossy().into_owned())
238 .unwrap_or_default();
239 let name = physical_name(&path, &stem)?;
240 validate_resource_name(&name).map_err(|e| StoreError::BadName {
241 path: path.clone(),
242 message: e.to_string(),
243 })?;
244 if let Some(first) = seen.get(&name) {
245 return Err(StoreError::DuplicatePhysicalName {
246 name,
247 first: first.clone(),
248 second: path,
249 });
250 }
251 seen.insert(name.clone(), path.clone());
252 out.push((ResourceRef::new(*kind, name), path));
253 }
254 }
255 Ok(out)
256 }
257
258 pub fn read(&self, r: &ResourceRef) -> Result<Value> {
264 match self.locate(r)? {
265 Some(path) => self.read_path(&path),
266 None => Err(StoreError::Io {
267 path: self.path_for(r),
268 source: std::io::Error::new(
269 std::io::ErrorKind::NotFound,
270 format!("no resource with physical name '{}'", r.name),
271 ),
272 }),
273 }
274 }
275
276 pub fn read_path(&self, path: &Path) -> Result<Value> {
278 let text = std::fs::read_to_string(path).map_err(|source| StoreError::Io {
279 path: path.to_path_buf(),
280 source,
281 })?;
282 let mut value: Value = serde_json::from_str(&text).map_err(|source| StoreError::Parse {
283 path: path.to_path_buf(),
284 source,
285 })?;
286 sidecar::inline_sidecars(path, &mut value)?;
287 Ok(value)
288 }
289
290 pub fn write(&self, r: &ResourceRef, value: &Value) -> Result<bool> {
300 validate_resource_name(&r.name).map_err(|e| StoreError::BadName {
304 path: self.create_path_for(r),
305 message: e.to_string(),
306 })?;
307 let path = match self.locate(r)? {
308 Some(existing) => existing,
309 None => self.create_path_for(r),
310 };
311 let mut normalized = normalize_for_disk(r.kind, value);
312
313 if path.is_file() {
316 if let Ok(existing) = self.read_path(&path) {
317 carry_over_x_rigg(&existing, &mut normalized);
318 carry_over_write_only(r.kind, &existing, &mut normalized);
319 if crate::normalize::semantic_eq(r.kind, &existing, &normalized)
323 && write_only_eq(r.kind, &existing, &normalized)
324 {
325 return Ok(false);
326 }
327 }
328 }
329
330 if let Some(parent) = path.parent() {
331 std::fs::create_dir_all(parent).map_err(|source| StoreError::Io {
332 path: parent.to_path_buf(),
333 source,
334 })?;
335 }
336 sidecar::extract_sidecars(r.kind, &path, &mut normalized)?;
337 std::fs::write(&path, format_json(&normalized)).map_err(|source| StoreError::Io {
338 path: path.clone(),
339 source,
340 })?;
341 Ok(true)
342 }
343
344 pub fn write_at(&self, stem: &str, kind: ResourceKind, value: &Value) -> Result<bool> {
359 validate_resource_name(stem).map_err(|e| StoreError::BadName {
360 path: self.kind_dir(kind).join(format!("{stem}.json")),
361 message: e.to_string(),
362 })?;
363 let path = self.kind_dir(kind).join(format!("{stem}.json"));
364 let mut normalized = normalize_for_disk(kind, value);
365 let new_name = normalized
366 .get("name")
367 .and_then(Value::as_str)
368 .unwrap_or(stem)
369 .to_string();
370
371 if path.is_file() {
372 let existing = self.read_path(&path)?;
373 let existing_name = existing
374 .get("name")
375 .and_then(Value::as_str)
376 .unwrap_or(stem)
377 .to_string();
378 if existing_name != new_name {
379 return Err(StoreError::StemOccupiedByDifferentResource {
380 path,
381 existing_name,
382 new_name,
383 });
384 }
385 carry_over_x_rigg(&existing, &mut normalized);
386 carry_over_write_only(kind, &existing, &mut normalized);
387 if crate::normalize::semantic_eq(kind, &existing, &normalized)
388 && write_only_eq(kind, &existing, &normalized)
389 {
390 return Ok(false);
391 }
392 }
393
394 if let Some(parent) = path.parent() {
395 std::fs::create_dir_all(parent).map_err(|source| StoreError::Io {
396 path: parent.to_path_buf(),
397 source,
398 })?;
399 }
400 sidecar::extract_sidecars(kind, &path, &mut normalized)?;
401 std::fs::write(&path, format_json(&normalized)).map_err(|source| StoreError::Io {
402 path: path.clone(),
403 source,
404 })?;
405 Ok(true)
406 }
407
408 pub fn delete(&self, r: &ResourceRef) -> Result<()> {
413 let Some(path) = self.locate(r)? else {
414 return Ok(());
415 };
416 let stem = path
419 .file_stem()
420 .map(|s| s.to_string_lossy().into_owned())
421 .unwrap_or_else(|| r.name.clone());
422 if path.is_file() {
423 std::fs::remove_file(&path).map_err(|source| StoreError::Io {
424 path: path.clone(),
425 source,
426 })?;
427 }
428 if let Some(dir) = path.parent() {
430 for field in crate::registry::meta(r.kind).sidecar_fields {
431 let sidecar = dir.join(format!("{stem}.{field}.md"));
432 if sidecar.is_file() {
433 let _ = std::fs::remove_file(sidecar);
434 }
435 }
436 }
437 Ok(())
438 }
439}
440
441fn physical_name(path: &Path, fallback_stem: &str) -> Result<String> {
445 let text = std::fs::read_to_string(path).map_err(|source| StoreError::Io {
446 path: path.to_path_buf(),
447 source,
448 })?;
449 let value: Value = serde_json::from_str(&text).map_err(|source| StoreError::Parse {
450 path: path.to_path_buf(),
451 source,
452 })?;
453 Ok(value
454 .get("name")
455 .and_then(Value::as_str)
456 .map(str::to_string)
457 .unwrap_or_else(|| fallback_stem.to_string()))
458}
459
460fn write_only_eq(kind: ResourceKind, a: &Value, b: &Value) -> bool {
465 fn values_at(doc: &Value, spec: &str) -> Vec<Value> {
466 let mut out = Vec::new();
467 crate::registry::collect_path(doc, spec, &mut |v| out.push(v.clone()));
468 out
469 }
470 crate::registry::meta(kind)
471 .write_only_fields
472 .iter()
473 .all(|spec| values_at(a, spec) == values_at(b, spec))
474}
475
476fn carry_over_write_only(kind: ResourceKind, from: &Value, to: &mut Value) {
479 for spec in crate::registry::meta(kind).write_only_fields {
480 let mut existing_value: Option<Value> = None;
481 crate::registry::collect_path(from, spec, &mut |v| {
482 if !v.is_null() {
483 existing_value = Some(v.clone());
484 }
485 });
486 let Some(existing_value) = existing_value else {
487 continue;
488 };
489 set_path(to, &spec.split('.').collect::<Vec<_>>(), existing_value);
490 }
491}
492
493fn set_path(value: &mut Value, segments: &[&str], new_value: Value) {
496 let Some((head, rest)) = segments.split_first() else {
497 return;
498 };
499 let Value::Object(map) = value else { return };
500 if rest.is_empty() {
501 map.insert((*head).to_string(), new_value);
502 return;
503 }
504 let entry = map
505 .entry((*head).to_string())
506 .or_insert_with(|| Value::Object(serde_json::Map::new()));
507 set_path(entry, rest, new_value);
508}
509
510fn carry_over_x_rigg(from: &Value, to: &mut Value) {
513 match (from, to) {
514 (Value::Object(src), Value::Object(dst)) => {
515 for (k, v) in src {
516 if k.starts_with("x-rigg-") {
517 dst.entry(k.clone()).or_insert_with(|| v.clone());
518 } else if let Some(dv) = dst.get_mut(k) {
519 carry_over_x_rigg(v, dv);
520 }
521 }
522 }
523 (Value::Array(src), Value::Array(dst)) => {
524 for sv in src {
525 let key = sv.get("name").or_else(|| sv.get("type"));
526 if let Some(key) = key {
527 if let Some(dv) = dst
528 .iter_mut()
529 .find(|d| d.get("name").or_else(|| d.get("type")) == Some(key))
530 {
531 carry_over_x_rigg(sv, dv);
532 }
533 }
534 }
535 }
536 _ => {}
537 }
538}
539
540pub fn assert_exclusive_ownership(ws: &Workspace, env: &str) -> Result<()> {
544 let mut seen: BTreeMap<ResourceRef, &str> = BTreeMap::new();
545 for project in &ws.projects {
546 let store = Store::new(project, env);
547 for (r, _) in store.list()? {
548 if let Some(first) = seen.get(&r) {
549 return Err(StoreError::DuplicateOwnership {
550 reference: r.to_string(),
551 first: first.to_string(),
552 second: project.name.clone(),
553 });
554 }
555 seen.insert(r, &project.name);
556 }
557 }
558 Ok(())
559}
560
561#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
563#[serde(rename_all = "kebab-case")]
564pub enum SyncClass {
565 InSync,
567 LocalAhead,
569 RemoteAhead,
571 Conflict,
573 LocalOnly,
575 RemoteOnly,
577 Untracked,
579}
580
581#[derive(Debug, Clone, Serialize, Deserialize)]
587#[serde(untagged)]
588pub enum Baseline {
589 Checksum(String),
592 Doc(Value),
594}
595
596#[derive(Debug, Clone, Default, Serialize, Deserialize)]
598pub struct ProjectState {
599 #[serde(default)]
601 pub baselines: BTreeMap<String, Baseline>,
602}
603
604impl ProjectState {
605 pub fn path(ws: &Workspace, env: &str, project: &str) -> PathBuf {
606 ws.state_dir(env, project).join("state.json")
607 }
608
609 pub fn load(ws: &Workspace, env: &str, project: &str) -> ProjectState {
610 let path = Self::path(ws, env, project);
611 std::fs::read_to_string(&path)
612 .ok()
613 .and_then(|text| serde_json::from_str(&text).ok())
614 .unwrap_or_default()
615 }
616
617 pub fn save(&self, ws: &Workspace, env: &str, project: &str) -> std::io::Result<()> {
618 let path = Self::path(ws, env, project);
619 if let Some(parent) = path.parent() {
620 std::fs::create_dir_all(parent)?;
621 }
622 std::fs::write(&path, format_json(&serde_json::to_value(self).unwrap()))
623 }
624
625 pub fn checksum(kind: ResourceKind, value: &Value) -> String {
632 let normalized = canonical_form(&normalize_for_compare(kind, value));
633 let canonical = serde_json::to_string(&normalized).unwrap_or_default();
634 format!("{:x}", md5_like(&canonical))
635 }
636
637 pub fn has_baseline(&self, r: &ResourceRef) -> bool {
639 self.baselines.contains_key(&r.key())
640 }
641
642 pub fn baseline_checksum(&self, r: &ResourceRef) -> Option<String> {
647 match self.baselines.get(&r.key())? {
648 Baseline::Checksum(s) => Some(s.clone()),
649 Baseline::Doc(v) => Some(Self::checksum(r.kind, v)),
650 }
651 }
652
653 pub fn set_baseline(&mut self, r: &ResourceRef, kind_value: &Value) {
654 let doc = canonical_form(&normalize_for_compare(r.kind, kind_value));
655 self.baselines.insert(r.key(), Baseline::Doc(doc));
656 }
657
658 pub fn clear_baseline(&mut self, r: &ResourceRef) {
659 self.baselines.remove(&r.key());
660 }
661
662 pub fn classify(
664 &self,
665 r: &ResourceRef,
666 local: Option<&Value>,
667 remote: Option<&Value>,
668 ) -> SyncClass {
669 let baseline = self.baseline_checksum(r);
670 match (local, remote) {
671 (None, None) => SyncClass::InSync, (Some(_), None) => SyncClass::LocalOnly,
673 (None, Some(_)) => SyncClass::RemoteOnly,
674 (Some(l), Some(rm)) => {
675 let lsum = Self::checksum(r.kind, l);
676 let rsum = Self::checksum(r.kind, rm);
677 match baseline {
678 None => {
679 if lsum == rsum {
680 SyncClass::InSync
681 } else {
682 SyncClass::Untracked
683 }
684 }
685 Some(base) => {
686 let local_changed = lsum != base;
687 let remote_changed = rsum != base;
688 match (local_changed, remote_changed) {
689 (false, false) => SyncClass::InSync,
690 (true, false) => SyncClass::LocalAhead,
691 (false, true) => SyncClass::RemoteAhead,
692 (true, true) => {
693 if lsum == rsum {
694 SyncClass::InSync
696 } else {
697 SyncClass::Conflict
698 }
699 }
700 }
701 }
702 }
703 }
704 }
705 }
706}
707
708fn canonical_form(value: &Value) -> Value {
711 match value {
712 Value::Object(map) => {
713 let mut sorted: Vec<(String, Value)> = map
716 .iter()
717 .filter(|(_, v)| !v.is_null())
718 .map(|(k, v)| (k.clone(), canonical_form(v)))
719 .collect();
720 sorted.sort_by(|a, b| a.0.cmp(&b.0));
721 Value::Object(sorted.into_iter().collect())
722 }
723 Value::Array(arr) => {
724 let mut items: Vec<Value> = arr.iter().map(canonical_form).collect();
725 if !items.is_empty()
726 && items
727 .iter()
728 .all(|i| i.get("name").and_then(Value::as_str).is_some())
729 {
730 items.sort_by(|a, b| {
731 a["name"]
732 .as_str()
733 .unwrap_or_default()
734 .cmp(b["name"].as_str().unwrap_or_default())
735 });
736 }
737 Value::Array(items)
738 }
739 other => other.clone(),
740 }
741}
742
743fn md5_like(s: &str) -> u128 {
746 let mut h1: u64 = 0xcbf29ce484222325;
747 let mut h2: u64 = 0x9e3779b97f4a7c15;
748 for b in s.as_bytes() {
749 h1 ^= u64::from(*b);
750 h1 = h1.wrapping_mul(0x100000001b3);
751 h2 = h2.rotate_left(5) ^ u64::from(*b);
752 h2 = h2.wrapping_mul(0x2545f4914f6cdd1d);
753 }
754 (u128::from(h1) << 64) | u128::from(h2)
755}
756
757#[cfg(test)]
758mod tests {
759 use super::*;
760 use crate::workspace::{PROJECT_FILE, PROJECTS_DIR, WORKSPACE_FILE};
761 use serde_json::json;
762
763 fn ws_with_projects(dir: &Path, names: &[&str]) -> Workspace {
764 std::fs::write(
765 dir.join(WORKSPACE_FILE),
766 "environments:\n dev:\n default: true\n search: { service: s }\n",
767 )
768 .unwrap();
769 for name in names {
770 let pdir = dir.join(PROJECTS_DIR).join(name);
771 std::fs::create_dir_all(&pdir).unwrap();
772 std::fs::write(pdir.join(PROJECT_FILE), "{}\n").unwrap();
773 }
774 Workspace::load(dir).unwrap()
775 }
776
777 #[test]
778 fn write_list_read_round_trip_with_sidecars() {
779 let tmp = tempfile::tempdir().unwrap();
780 let ws = ws_with_projects(tmp.path(), &["p"]);
781 let store = Store::new(ws.project("p").unwrap(), "dev");
782
783 let agent_ref = ResourceRef::new(ResourceKind::Agent, "helper");
784 let agent = json!({"name": "helper", "model": "gpt-5-mini", "instructions": "Be nice."});
785 assert!(store.write(&agent_ref, &agent).unwrap());
786
787 let sidecar = store
789 .path_for(&agent_ref)
790 .parent()
791 .unwrap()
792 .join("helper.instructions.md");
793 assert!(sidecar.is_file());
794
795 let read = store.read(&agent_ref).unwrap();
797 assert_eq!(read["instructions"], json!("Be nice."));
798
799 let listed = store.list().unwrap();
800 assert_eq!(listed.len(), 1);
801 assert_eq!(listed[0].0, agent_ref);
802
803 assert!(
805 store
806 .path_for(&agent_ref)
807 .to_string_lossy()
808 .contains("envs/dev/")
809 );
810 }
811
812 #[test]
813 fn write_returns_false_when_semantically_unchanged() {
814 let tmp = tempfile::tempdir().unwrap();
815 let ws = ws_with_projects(tmp.path(), &["p"]);
816 let store = Store::new(ws.project("p").unwrap(), "dev");
817 let r = ResourceRef::new(ResourceKind::Index, "idx");
818 assert!(
819 store
820 .write(&r, &json!({"name": "idx", "fields": []}))
821 .unwrap()
822 );
823 let noisy = json!({"@odata.etag": "0x1", "name": "idx", "fields": []});
825 assert!(!store.write(&r, &noisy).unwrap());
826 let changed = json!({"name": "idx", "fields": [{"name": "f"}]});
828 assert!(store.write(&r, &changed).unwrap());
829 }
830
831 #[test]
832 fn write_preserves_local_x_rigg_annotations() {
833 let tmp = tempfile::tempdir().unwrap();
834 let ws = ws_with_projects(tmp.path(), &["p"]);
835 let store = Store::new(ws.project("p").unwrap(), "dev");
836 let r = ResourceRef::new(ResourceKind::Skillset, "sk");
837 let local = json!({
838 "name": "sk",
839 "skills": [{"name": "web", "uri": "https://f", "x-rigg-api": "enrich"}]
840 });
841 store.write(&r, &local).unwrap();
842 let remote = json!({
844 "name": "sk",
845 "skills": [{"name": "web", "uri": "https://f"}]
846 });
847 let rewritten = store.write(&r, &remote).unwrap();
848 let read = store.read(&r).unwrap();
849 assert_eq!(read["skills"][0]["x-rigg-api"], json!("enrich"));
850 assert!(!rewritten, "annotation-only delta is not a semantic change");
851 }
852
853 #[test]
854 fn exclusive_ownership_violation_names_both_projects() {
855 let tmp = tempfile::tempdir().unwrap();
856 let ws = ws_with_projects(tmp.path(), &["alpha", "beta"]);
857 for p in ["alpha", "beta"] {
858 let store = Store::new(ws.project(p).unwrap(), "dev");
859 store
860 .write(
861 &ResourceRef::new(ResourceKind::Index, "shared"),
862 &json!({"name": "shared"}),
863 )
864 .unwrap();
865 }
866 let err = assert_exclusive_ownership(&ws, "dev").unwrap_err();
867 let msg = err.to_string();
868 assert!(msg.contains("alpha") && msg.contains("beta") && msg.contains("indexes/shared"));
869 }
870
871 #[test]
872 fn ownership_is_scoped_per_environment() {
873 let tmp = tempfile::tempdir().unwrap();
876 let ws = ws_with_projects(tmp.path(), &["alpha", "beta"]);
877 Store::new(ws.project("alpha").unwrap(), "dev")
878 .write(
879 &ResourceRef::new(ResourceKind::Index, "shared"),
880 &json!({"name": "shared"}),
881 )
882 .unwrap();
883 Store::new(ws.project("beta").unwrap(), "prod")
884 .write(
885 &ResourceRef::new(ResourceKind::Index, "shared"),
886 &json!({"name": "shared"}),
887 )
888 .unwrap();
889 assert!(assert_exclusive_ownership(&ws, "dev").is_ok());
890 assert!(assert_exclusive_ownership(&ws, "prod").is_ok());
891 }
892
893 #[test]
894 fn envs_of_lists_env_dirs_sorted() {
895 let tmp = tempfile::tempdir().unwrap();
896 let ws = ws_with_projects(tmp.path(), &["p"]);
897 let project = ws.project("p").unwrap();
898 assert_eq!(Store::envs_of(project), Vec::<String>::new());
899 Store::new(project, "prod")
900 .write(
901 &ResourceRef::new(ResourceKind::Index, "idx"),
902 &json!({"name": "idx"}),
903 )
904 .unwrap();
905 Store::new(project, "dev")
906 .write(
907 &ResourceRef::new(ResourceKind::Index, "idx"),
908 &json!({"name": "idx"}),
909 )
910 .unwrap();
911 assert_eq!(Store::envs_of(project), vec!["dev", "prod"]);
912 }
913
914 #[test]
915 fn list_keys_by_physical_name_when_stem_differs() {
916 let tmp = tempfile::tempdir().unwrap();
917 let ws = ws_with_projects(tmp.path(), &["p"]);
918 let store = Store::new(ws.project("p").unwrap(), "dev");
919 let dir = store
920 .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
921 .parent()
922 .unwrap()
923 .to_path_buf();
924 std::fs::create_dir_all(&dir).unwrap();
925 std::fs::write(
926 dir.join("regulus.json"),
927 json!({"name": "Regulus-Prod", "model": "m"}).to_string(),
928 )
929 .unwrap();
930 let listed = store.list().unwrap();
931 assert_eq!(listed.len(), 1);
932 assert_eq!(listed[0].0.name, "Regulus-Prod");
933 assert_eq!(listed[0].1, dir.join("regulus.json"));
934 }
935
936 #[test]
937 fn locate_finds_file_by_physical_name_when_stem_differs() {
938 let tmp = tempfile::tempdir().unwrap();
939 let ws = ws_with_projects(tmp.path(), &["p"]);
940 let store = Store::new(ws.project("p").unwrap(), "dev");
941 let dir = store
942 .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
943 .parent()
944 .unwrap()
945 .to_path_buf();
946 std::fs::create_dir_all(&dir).unwrap();
947 std::fs::write(
948 dir.join("regulus.json"),
949 json!({"name": "Regulus-Prod", "model": "m"}).to_string(),
950 )
951 .unwrap();
952 let found = store
953 .locate(&ResourceRef::new(ResourceKind::Agent, "Regulus-Prod"))
954 .unwrap();
955 assert_eq!(found, Some(dir.join("regulus.json")));
956 assert_eq!(
957 store
958 .locate(&ResourceRef::new(ResourceKind::Agent, "regulus"))
959 .unwrap(),
960 None,
961 "the stem alone is not a physical name once name diverges"
962 );
963 }
964
965 #[test]
966 fn write_updates_the_located_file_not_a_stem_guess() {
967 let tmp = tempfile::tempdir().unwrap();
971 let ws = ws_with_projects(tmp.path(), &["p"]);
972 let store = Store::new(ws.project("p").unwrap(), "dev");
973 let dir = store
974 .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
975 .parent()
976 .unwrap()
977 .to_path_buf();
978 std::fs::create_dir_all(&dir).unwrap();
979 std::fs::write(
980 dir.join("regulus.json"),
981 json!({"name": "Regulus-Prod", "model": "m1"}).to_string(),
982 )
983 .unwrap();
984 let r = ResourceRef::new(ResourceKind::Agent, "Regulus-Prod");
985 store
986 .write(&r, &json!({"name": "Regulus-Prod", "model": "m2"}))
987 .unwrap();
988 assert!(
989 !dir.join("Regulus-Prod.json").exists(),
990 "must not create a second file keyed on the physical name"
991 );
992 let on_disk: Value =
993 serde_json::from_str(&std::fs::read_to_string(dir.join("regulus.json")).unwrap())
994 .unwrap();
995 assert_eq!(on_disk["model"], json!("m2"));
996 }
997
998 #[test]
999 fn write_never_overwrites_a_renamed_resources_stem() {
1000 let tmp = tempfile::tempdir().unwrap();
1005 let ws = ws_with_projects(tmp.path(), &["p"]);
1006 let store = Store::new(ws.project("p").unwrap(), "dev");
1007 let dir = store
1008 .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
1009 .parent()
1010 .unwrap()
1011 .to_path_buf();
1012 std::fs::create_dir_all(&dir).unwrap();
1013 std::fs::write(
1014 dir.join("regulus.json"),
1015 json!({"name": "Regulus-Prod", "model": "m"}).to_string(),
1016 )
1017 .unwrap();
1018
1019 let r = ResourceRef::new(ResourceKind::Agent, "regulus");
1020 assert!(
1021 store
1022 .write(&r, &json!({"name": "regulus", "model": "new"}))
1023 .unwrap()
1024 );
1025
1026 let original: Value =
1028 serde_json::from_str(&std::fs::read_to_string(dir.join("regulus.json")).unwrap())
1029 .unwrap();
1030 assert_eq!(
1031 original["name"],
1032 json!("Regulus-Prod"),
1033 "original file untouched"
1034 );
1035
1036 let new_path = store.locate(&r).unwrap().expect("new resource locatable");
1038 assert_ne!(new_path, dir.join("regulus.json"));
1039 assert_eq!(new_path, dir.join("regulus-2.json"));
1040
1041 assert_eq!(
1043 store
1044 .locate(&ResourceRef::new(ResourceKind::Agent, "Regulus-Prod"))
1045 .unwrap(),
1046 Some(dir.join("regulus.json"))
1047 );
1048 assert_eq!(store.list().unwrap().len(), 2);
1049 }
1050
1051 #[test]
1052 fn delete_by_physical_name_removes_located_file_and_stem_sidecars() {
1053 let tmp = tempfile::tempdir().unwrap();
1058 let ws = ws_with_projects(tmp.path(), &["p"]);
1059 let store = Store::new(ws.project("p").unwrap(), "dev");
1060 let dir = store
1061 .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
1062 .parent()
1063 .unwrap()
1064 .to_path_buf();
1065 std::fs::create_dir_all(&dir).unwrap();
1066 std::fs::write(
1067 dir.join("regulus.json"),
1068 json!({
1069 "name": "Regulus-Prod", "model": "m",
1070 "instructions": {"$file": "regulus.instructions.md"}
1071 })
1072 .to_string(),
1073 )
1074 .unwrap();
1075 std::fs::write(dir.join("regulus.instructions.md"), "Be helpful.").unwrap();
1076 std::fs::write(
1078 dir.join("other.json"),
1079 json!({
1080 "name": "other", "model": "m",
1081 "instructions": {"$file": "other.instructions.md"}
1082 })
1083 .to_string(),
1084 )
1085 .unwrap();
1086 std::fs::write(dir.join("other.instructions.md"), "Neighbor.").unwrap();
1087
1088 store
1089 .delete(&ResourceRef::new(ResourceKind::Agent, "Regulus-Prod"))
1090 .unwrap();
1091 assert!(!dir.join("regulus.json").exists(), "located file removed");
1092 assert!(
1093 !dir.join("regulus.instructions.md").exists(),
1094 "stem-named sidecar removed"
1095 );
1096 assert!(dir.join("other.json").exists(), "neighbor untouched");
1097 assert!(
1098 dir.join("other.instructions.md").exists(),
1099 "neighbor sidecar untouched"
1100 );
1101
1102 store
1104 .delete(&ResourceRef::new(ResourceKind::Agent, "ghost"))
1105 .unwrap();
1106 assert!(dir.join("other.json").exists());
1107 }
1108
1109 #[test]
1110 fn list_error_on_corrupt_json_names_the_file() {
1111 let tmp = tempfile::tempdir().unwrap();
1112 let ws = ws_with_projects(tmp.path(), &["p"]);
1113 let store = Store::new(ws.project("p").unwrap(), "dev");
1114 let dir = store
1115 .path_for(&ResourceRef::new(ResourceKind::Index, "a"))
1116 .parent()
1117 .unwrap()
1118 .to_path_buf();
1119 std::fs::create_dir_all(&dir).unwrap();
1120 std::fs::write(dir.join("broken.json"), "{ this is not json").unwrap();
1121 let err = store.list().unwrap_err();
1122 assert!(matches!(err, StoreError::Parse { .. }));
1123 assert!(
1124 err.to_string().contains("broken.json"),
1125 "error names the broken file: {err}"
1126 );
1127 }
1128
1129 #[test]
1130 fn duplicate_physical_name_in_one_kind_dir_errors() {
1131 let tmp = tempfile::tempdir().unwrap();
1132 let ws = ws_with_projects(tmp.path(), &["p"]);
1133 let store = Store::new(ws.project("p").unwrap(), "dev");
1134 let dir = store
1135 .path_for(&ResourceRef::new(ResourceKind::Index, "a"))
1136 .parent()
1137 .unwrap()
1138 .to_path_buf();
1139 std::fs::create_dir_all(&dir).unwrap();
1140 std::fs::write(dir.join("a.json"), json!({"name": "dup"}).to_string()).unwrap();
1141 std::fs::write(dir.join("b.json"), json!({"name": "dup"}).to_string()).unwrap();
1142 let err = store.list().unwrap_err();
1143 assert!(matches!(err, StoreError::DuplicatePhysicalName { .. }));
1144 assert!(err.to_string().contains("dup"));
1145 }
1146
1147 #[test]
1148 fn classify_truth_table() {
1149 let tmp = tempfile::tempdir().unwrap();
1150 let ws = ws_with_projects(tmp.path(), &["p"]);
1151 let r = ResourceRef::new(ResourceKind::Index, "idx");
1152 let a = json!({"name": "idx", "fields": [{"name": "f1"}]});
1153 let b = json!({"name": "idx", "fields": [{"name": "f2"}]});
1154 let c = json!({"name": "idx", "fields": [{"name": "f3"}]});
1155
1156 let mut state = ProjectState::default();
1157 assert_eq!(state.classify(&r, Some(&a), Some(&a)), SyncClass::InSync);
1159 assert_eq!(state.classify(&r, Some(&a), Some(&b)), SyncClass::Untracked);
1160 assert_eq!(state.classify(&r, Some(&a), None), SyncClass::LocalOnly);
1161 assert_eq!(state.classify(&r, None, Some(&a)), SyncClass::RemoteOnly);
1162 assert_eq!(state.classify(&r, None, None), SyncClass::InSync);
1163
1164 state.set_baseline(&r, &a);
1166 assert_eq!(state.classify(&r, Some(&a), Some(&a)), SyncClass::InSync);
1167 assert_eq!(
1168 state.classify(&r, Some(&b), Some(&a)),
1169 SyncClass::LocalAhead
1170 );
1171 assert_eq!(
1172 state.classify(&r, Some(&a), Some(&b)),
1173 SyncClass::RemoteAhead
1174 );
1175 assert_eq!(state.classify(&r, Some(&b), Some(&c)), SyncClass::Conflict);
1176 assert_eq!(state.classify(&r, Some(&b), Some(&b)), SyncClass::InSync);
1177
1178 state.save(&ws, "dev", "p").unwrap();
1180 let loaded = ProjectState::load(&ws, "dev", "p");
1181 assert_eq!(loaded.baseline_checksum(&r), state.baseline_checksum(&r));
1182 }
1183
1184 #[test]
1185 fn legacy_checksum_baseline_still_loads_and_classifies() {
1186 let json = r#"{"baselines": {"agents/a": "deadbeef"}}"#;
1188 let state: ProjectState = serde_json::from_str(json).unwrap();
1189 let r = ResourceRef::new(ResourceKind::Agent, "a".to_string());
1190 assert!(state.has_baseline(&r));
1191 let local = json!({"name": "a", "model": "x"});
1193 let remote = json!({"name": "a", "model": "y"});
1194 assert_eq!(
1195 state.classify(&r, Some(&local), Some(&remote)),
1196 SyncClass::Conflict
1197 );
1198 }
1199
1200 #[test]
1201 fn doc_baseline_self_heals_across_rule_changes() {
1202 let r = ResourceRef::new(ResourceKind::Agent, "a".to_string());
1208 let old_doc = json!({
1209 "name": "a", "model": "x",
1210 "metadata": {"modified_at": "111", "logo": "l.svg"}
1211 });
1212 let mut state = ProjectState::default();
1213 state.baselines.insert(r.key(), Baseline::Doc(old_doc));
1214 let local = json!({
1215 "name": "a", "model": "x", "metadata": {"logo": "l.svg"}
1216 });
1217 let remote = json!({
1218 "name": "a", "model": "CHANGED", "metadata": {"logo": "l.svg"}
1219 });
1220 assert_eq!(
1221 state.classify(&r, Some(&local), Some(&remote)),
1222 SyncClass::RemoteAhead
1223 );
1224 }
1225
1226 #[test]
1227 fn baseline_serde_mixed_roundtrip() {
1228 let r = ResourceRef::new(ResourceKind::Agent, "new".to_string());
1229 let mut state = ProjectState::default();
1230 state.baselines.insert(
1231 "agents/legacy".to_string(),
1232 Baseline::Checksum("abc".to_string()),
1233 );
1234 state.set_baseline(&r, &json!({"name": "new", "model": "m"}));
1235 let text = serde_json::to_string(&state).unwrap();
1236 let back: ProjectState = serde_json::from_str(&text).unwrap();
1237 assert!(
1238 matches!(back.baselines.get("agents/legacy"), Some(Baseline::Checksum(s)) if s == "abc")
1239 );
1240 assert!(matches!(
1241 back.baselines.get("agents/new"),
1242 Some(Baseline::Doc(_))
1243 ));
1244 }
1245
1246 #[test]
1247 fn credential_only_change_still_writes() {
1248 let tmp = tempfile::tempdir().unwrap();
1253 let ws = ws_with_projects(tmp.path(), &["p"]);
1254 let store = Store::new(ws.project("p").unwrap(), "dev");
1255 let r = ResourceRef::new(ResourceKind::DataSource, "ds");
1256 let without = json!({
1257 "name": "ds", "type": "azureblob",
1258 "credentials": {"connectionString": null},
1259 "container": {"name": "c"}
1260 });
1261 store.write(&r, &without).unwrap();
1262 let mut with = without.clone();
1263 with["credentials"]["connectionString"] = json!("ResourceId=/subscriptions/s/x;");
1264 assert!(
1265 store.write(&r, &with).unwrap(),
1266 "credential change must write"
1267 );
1268 let read = store.read(&r).unwrap();
1269 assert_eq!(
1270 read["credentials"]["connectionString"],
1271 json!("ResourceId=/subscriptions/s/x;")
1272 );
1273 }
1274
1275 #[test]
1276 fn write_only_fields_survive_server_echo_and_compare() {
1277 let tmp = tempfile::tempdir().unwrap();
1278 let ws = ws_with_projects(tmp.path(), &["p"]);
1279 let store = Store::new(ws.project("p").unwrap(), "dev");
1280 let r = ResourceRef::new(ResourceKind::DataSource, "ds");
1281 let local = json!({
1282 "name": "ds", "type": "azureblob",
1283 "credentials": {"connectionString": "ResourceId=/subscriptions/s/x;"},
1284 "container": {"name": "c"}
1285 });
1286 store.write(&r, &local).unwrap();
1287 let server_echo = json!({
1289 "name": "ds", "type": "azureblob",
1290 "credentials": {"connectionString": null},
1291 "container": {"name": "c"}
1292 });
1293 assert!(!store.write(&r, &server_echo).unwrap());
1295 let read = store.read(&r).unwrap();
1296 assert_eq!(
1297 read["credentials"]["connectionString"],
1298 json!("ResourceId=/subscriptions/s/x;")
1299 );
1300 assert_eq!(
1302 ProjectState::checksum(ResourceKind::DataSource, &local),
1303 ProjectState::checksum(ResourceKind::DataSource, &server_echo)
1304 );
1305 }
1306
1307 #[test]
1308 fn checksum_is_order_canonical() {
1309 let a = serde_json::from_str::<Value>(
1311 r#"{"name": "i", "fields": [{"name": "b"}, {"name": "a"}], "x": 1}"#,
1312 )
1313 .unwrap();
1314 let b = serde_json::from_str::<Value>(
1315 r#"{"x": 1, "name": "i", "fields": [{"name": "a"}, {"name": "b"}]}"#,
1316 )
1317 .unwrap();
1318 assert_eq!(
1319 ProjectState::checksum(ResourceKind::Index, &a),
1320 ProjectState::checksum(ResourceKind::Index, &b)
1321 );
1322 }
1323
1324 #[test]
1325 fn checksum_ignores_volatile_and_annotations() {
1326 let a = json!({"name": "i", "@odata.etag": "1", "x-rigg-note": "hi"});
1327 let b = json!({"name": "i"});
1328 assert_eq!(
1329 ProjectState::checksum(ResourceKind::Index, &a),
1330 ProjectState::checksum(ResourceKind::Index, &b)
1331 );
1332 }
1333
1334 #[test]
1335 fn write_at_creates_file_at_given_stem() {
1336 let tmp = tempfile::tempdir().unwrap();
1337 let ws = ws_with_projects(tmp.path(), &["p"]);
1338 let store = Store::new(ws.project("p").unwrap(), "prod");
1339 let created = store
1340 .write_at(
1341 "regulus",
1342 ResourceKind::Agent,
1343 &json!({"name": "Regulus-Prod", "model": "m"}),
1344 )
1345 .unwrap();
1346 assert!(created);
1347 let dir = store
1348 .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
1349 .parent()
1350 .unwrap()
1351 .to_path_buf();
1352 assert!(
1353 dir.join("regulus.json").is_file(),
1354 "landed at the stem, not the physical name"
1355 );
1356 assert!(!dir.join("Regulus-Prod.json").exists());
1357 let on_disk: Value =
1358 serde_json::from_str(&std::fs::read_to_string(dir.join("regulus.json")).unwrap())
1359 .unwrap();
1360 assert_eq!(on_disk["name"], json!("Regulus-Prod"));
1361 }
1362
1363 #[test]
1364 fn write_at_updates_in_place_when_physical_name_matches() {
1365 let tmp = tempfile::tempdir().unwrap();
1366 let ws = ws_with_projects(tmp.path(), &["p"]);
1367 let store = Store::new(ws.project("p").unwrap(), "prod");
1368 store
1369 .write_at(
1370 "regulus",
1371 ResourceKind::Agent,
1372 &json!({"name": "Regulus-Prod", "model": "m1"}),
1373 )
1374 .unwrap();
1375 let rewritten = store
1376 .write_at(
1377 "regulus",
1378 ResourceKind::Agent,
1379 &json!({"name": "Regulus-Prod", "model": "m2"}),
1380 )
1381 .unwrap();
1382 assert!(rewritten);
1383 let dir = store
1384 .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
1385 .parent()
1386 .unwrap()
1387 .to_path_buf();
1388 let on_disk: Value =
1389 serde_json::from_str(&std::fs::read_to_string(dir.join("regulus.json")).unwrap())
1390 .unwrap();
1391 assert_eq!(on_disk["model"], json!("m2"));
1392 }
1393
1394 #[test]
1395 fn write_at_refuses_to_overwrite_a_different_resources_stem() {
1396 let tmp = tempfile::tempdir().unwrap();
1397 let ws = ws_with_projects(tmp.path(), &["p"]);
1398 let store = Store::new(ws.project("p").unwrap(), "prod");
1399 store
1400 .write_at(
1401 "regulus",
1402 ResourceKind::Agent,
1403 &json!({"name": "Regulus-Prod", "model": "m1"}),
1404 )
1405 .unwrap();
1406 let err = store
1407 .write_at(
1408 "regulus",
1409 ResourceKind::Agent,
1410 &json!({"name": "some-other-name", "model": "m2"}),
1411 )
1412 .unwrap_err();
1413 assert!(matches!(
1414 err,
1415 StoreError::StemOccupiedByDifferentResource { .. }
1416 ));
1417 let dir = store
1419 .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
1420 .parent()
1421 .unwrap()
1422 .to_path_buf();
1423 let on_disk: Value =
1424 serde_json::from_str(&std::fs::read_to_string(dir.join("regulus.json")).unwrap())
1425 .unwrap();
1426 assert_eq!(on_disk["name"], json!("Regulus-Prod"));
1427 }
1428
1429 #[test]
1430 fn write_at_returns_false_when_semantically_unchanged() {
1431 let tmp = tempfile::tempdir().unwrap();
1432 let ws = ws_with_projects(tmp.path(), &["p"]);
1433 let store = Store::new(ws.project("p").unwrap(), "prod");
1434 store
1435 .write_at(
1436 "idx",
1437 ResourceKind::Index,
1438 &json!({"name": "idx", "fields": []}),
1439 )
1440 .unwrap();
1441 let rewritten = store
1442 .write_at(
1443 "idx",
1444 ResourceKind::Index,
1445 &json!({"@odata.etag": "0x1", "name": "idx", "fields": []}),
1446 )
1447 .unwrap();
1448 assert!(!rewritten);
1449 }
1450
1451 #[test]
1452 fn write_at_rejects_path_escaping_stems() {
1453 let tmp = tempfile::tempdir().unwrap();
1454 let ws = ws_with_projects(tmp.path(), &["p"]);
1455 let store = Store::new(ws.project("p").unwrap(), "prod");
1456 let err = store.write_at("../../evil", ResourceKind::Index, &json!({"name": "evil"}));
1457 assert!(matches!(err, Err(StoreError::BadName { .. })), "{err:?}");
1458 }
1459
1460 #[test]
1461 fn write_rejects_path_escaping_names() {
1462 let tmp = tempfile::tempdir().unwrap();
1463 let ws = ws_with_projects(tmp.path(), &["p"]);
1464 let store = Store::new(ws.project("p").unwrap(), "dev");
1465 let r = ResourceRef::new(ResourceKind::Index, "../../evil");
1466 let err = store.write(&r, &json!({"name": "../../evil"}));
1467 assert!(matches!(err, Err(StoreError::BadName { .. })), "{err:?}");
1468 }
1469}