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::{
33 format_json, normalize_for_compare, normalize_for_disk, normalize_for_push,
34};
35use crate::resources::traits::{ResourceKind, ResourceRef, validate_resource_name};
36use crate::service::ServiceDomain;
37use crate::sidecar::{self, SidecarError};
38use crate::workspace::{ENVS_DIR, Project, Workspace};
39
40#[derive(Debug, Error)]
41pub enum StoreError {
42 #[error("failed to read {path}: {source}")]
43 Io {
44 path: PathBuf,
45 source: std::io::Error,
46 },
47 #[error("invalid JSON in {path}: {source}")]
48 Parse {
49 path: PathBuf,
50 source: serde_json::Error,
51 },
52 #[error(transparent)]
53 Sidecar(#[from] SidecarError),
54 #[error("invalid resource name in {path}: {message}")]
55 BadName { path: PathBuf, message: String },
56 #[error(
57 "duplicate physical name '{name}': both {first} and {second} define a resource named '{name}' — physical (Azure) names must be unique within a kind"
58 )]
59 DuplicatePhysicalName {
60 name: String,
61 first: PathBuf,
62 second: PathBuf,
63 },
64 #[error(
65 "resource {reference} is defined in both project '{first}' and project '{second}' — a resource must belong to exactly one project"
66 )]
67 DuplicateOwnership {
68 reference: String,
69 first: String,
70 second: String,
71 },
72 #[error(
73 "cannot create '{path}': stem is already used by a different resource (existing physical name '{existing_name}', new '{new_name}')"
74 )]
75 StemOccupiedByDifferentResource {
76 path: PathBuf,
77 existing_name: String,
78 new_name: String,
79 },
80}
81
82type Result<T> = std::result::Result<T, StoreError>;
83
84pub struct Store<'w> {
86 project: &'w Project,
87 env: String,
88}
89
90impl<'w> Store<'w> {
91 pub fn new(project: &'w Project, env: &str) -> Self {
92 Store {
93 project,
94 env: env.to_string(),
95 }
96 }
97
98 pub fn project(&self) -> &Project {
99 self.project
100 }
101
102 pub fn env(&self) -> &str {
103 &self.env
104 }
105
106 pub fn envs_of(project: &Project) -> Vec<String> {
110 let dir = project.dir.join(ENVS_DIR);
111 let mut envs: Vec<String> = std::fs::read_dir(&dir)
112 .map(|entries| {
113 entries
114 .filter_map(|e| e.ok())
115 .map(|e| e.path())
116 .filter(|p| p.is_dir())
117 .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
118 .collect()
119 })
120 .unwrap_or_default();
121 envs.sort();
122 envs
123 }
124
125 fn root(&self) -> PathBuf {
127 self.project.dir.join(ENVS_DIR).join(&self.env)
128 }
129
130 fn domain_dir(domain: ServiceDomain) -> &'static str {
131 match domain {
132 ServiceDomain::Search => "search",
133 ServiceDomain::Foundry => "foundry",
134 }
135 }
136
137 fn kind_dir(&self, kind: ResourceKind) -> PathBuf {
138 self.root()
139 .join(Self::domain_dir(kind.domain()))
140 .join(kind.directory_name())
141 }
142
143 pub fn path_for(&self, r: &ResourceRef) -> PathBuf {
148 self.kind_dir(r.kind).join(format!("{}.json", r.name))
149 }
150
151 pub fn locate(&self, r: &ResourceRef) -> Result<Option<PathBuf>> {
155 let dir = self.kind_dir(r.kind);
156 if !dir.is_dir() {
157 return Ok(None);
158 }
159 let fast = dir.join(format!("{}.json", r.name));
161 if fast.is_file() && physical_name(&fast, &r.name)? == r.name {
162 return Ok(Some(fast));
163 }
164 let mut entries: Vec<PathBuf> = std::fs::read_dir(&dir)
165 .map_err(|source| StoreError::Io {
166 path: dir.clone(),
167 source,
168 })?
169 .filter_map(|e| e.ok())
170 .map(|e| e.path())
171 .filter(|p| p.extension().is_some_and(|e| e == "json"))
172 .collect();
173 entries.sort();
174 for path in entries {
175 if path == fast {
176 continue; }
178 let stem = path
179 .file_stem()
180 .map(|s| s.to_string_lossy().into_owned())
181 .unwrap_or_default();
182 if physical_name(&path, &stem)? == r.name {
183 return Ok(Some(path));
184 }
185 }
186 Ok(None)
187 }
188
189 fn create_path_for(&self, r: &ResourceRef) -> PathBuf {
196 let dir = self.kind_dir(r.kind);
197 let base = dir.join(format!("{}.json", r.name));
198 if !base.exists() {
199 return base;
200 }
201 for i in 2u64.. {
202 let candidate = dir.join(format!("{}-{i}.json", r.name));
203 if !candidate.exists() {
204 return candidate;
205 }
206 }
207 unreachable!("some numbered stem is always free")
208 }
209
210 pub fn list(&self) -> Result<Vec<(ResourceRef, PathBuf)>> {
219 let mut out = Vec::new();
220 for kind in ResourceKind::all() {
221 let dir = self.kind_dir(*kind);
222 if !dir.is_dir() {
223 continue;
224 }
225 let mut entries: Vec<PathBuf> = std::fs::read_dir(&dir)
226 .map_err(|source| StoreError::Io {
227 path: dir.clone(),
228 source,
229 })?
230 .filter_map(|e| e.ok())
231 .map(|e| e.path())
232 .filter(|p| p.extension().is_some_and(|e| e == "json"))
233 .collect();
234 entries.sort();
235 let mut seen: BTreeMap<String, PathBuf> = BTreeMap::new();
236 for path in entries {
237 let stem = path
238 .file_stem()
239 .map(|s| s.to_string_lossy().into_owned())
240 .unwrap_or_default();
241 let name = physical_name(&path, &stem)?;
242 validate_resource_name(&name).map_err(|e| StoreError::BadName {
243 path: path.clone(),
244 message: e.to_string(),
245 })?;
246 if let Some(first) = seen.get(&name) {
247 return Err(StoreError::DuplicatePhysicalName {
248 name,
249 first: first.clone(),
250 second: path,
251 });
252 }
253 seen.insert(name.clone(), path.clone());
254 out.push((ResourceRef::new(*kind, name), path));
255 }
256 }
257 Ok(out)
258 }
259
260 pub fn read(&self, r: &ResourceRef) -> Result<Value> {
266 match self.locate(r)? {
267 Some(path) => self.read_path(&path),
268 None => Err(StoreError::Io {
269 path: self.path_for(r),
270 source: std::io::Error::new(
271 std::io::ErrorKind::NotFound,
272 format!("no resource with physical name '{}'", r.name),
273 ),
274 }),
275 }
276 }
277
278 pub fn read_path(&self, path: &Path) -> Result<Value> {
280 let text = std::fs::read_to_string(path).map_err(|source| StoreError::Io {
281 path: path.to_path_buf(),
282 source,
283 })?;
284 let mut value: Value = serde_json::from_str(&text).map_err(|source| StoreError::Parse {
285 path: path.to_path_buf(),
286 source,
287 })?;
288 sidecar::inline_sidecars(path, &mut value)?;
289 Ok(value)
290 }
291
292 pub fn write(&self, r: &ResourceRef, value: &Value) -> Result<bool> {
302 self.write_inner(r, value, CarryOver::Yes)
303 }
304
305 pub fn write_exact(&self, r: &ResourceRef, value: &Value) -> Result<bool> {
316 self.write_inner(r, value, CarryOver::No)
317 }
318
319 fn write_inner(&self, r: &ResourceRef, value: &Value, carry: CarryOver) -> Result<bool> {
320 validate_resource_name(&r.name).map_err(|e| StoreError::BadName {
324 path: self.create_path_for(r),
325 message: e.to_string(),
326 })?;
327 let path = match self.locate(r)? {
328 Some(existing) => existing,
329 None => self.create_path_for(r),
330 };
331 let mut normalized = normalize_for_disk(r.kind, value);
332
333 if path.is_file()
336 && let Ok(existing) = self.read_path(&path)
337 {
338 if carry == CarryOver::Yes {
339 carry_over_x_rigg(&existing, &mut normalized);
340 carry_over_write_only(r.kind, &existing, &mut normalized);
341 }
342 if crate::normalize::semantic_eq(r.kind, &existing, &normalized)
346 && write_only_eq(r.kind, &existing, &normalized)
347 {
348 return Ok(false);
349 }
350 }
351
352 if let Some(parent) = path.parent() {
353 std::fs::create_dir_all(parent).map_err(|source| StoreError::Io {
354 path: parent.to_path_buf(),
355 source,
356 })?;
357 }
358 sidecar::extract_sidecars(r.kind, &path, &mut normalized)?;
359 std::fs::write(&path, format_json(&normalized)).map_err(|source| StoreError::Io {
360 path: path.clone(),
361 source,
362 })?;
363 Ok(true)
364 }
365
366 pub fn write_at(&self, stem: &str, kind: ResourceKind, value: &Value) -> Result<bool> {
381 self.write_at_inner(stem, kind, value, CarryOver::Yes)
382 }
383
384 pub fn write_at_exact(&self, stem: &str, kind: ResourceKind, value: &Value) -> Result<bool> {
387 self.write_at_inner(stem, kind, value, CarryOver::No)
388 }
389
390 fn write_at_inner(
391 &self,
392 stem: &str,
393 kind: ResourceKind,
394 value: &Value,
395 carry: CarryOver,
396 ) -> Result<bool> {
397 validate_resource_name(stem).map_err(|e| StoreError::BadName {
398 path: self.kind_dir(kind).join(format!("{stem}.json")),
399 message: e.to_string(),
400 })?;
401 let path = self.kind_dir(kind).join(format!("{stem}.json"));
402 let mut normalized = normalize_for_disk(kind, value);
403 let new_name = normalized
404 .get("name")
405 .and_then(Value::as_str)
406 .unwrap_or(stem)
407 .to_string();
408
409 if path.is_file() {
410 let existing = self.read_path(&path)?;
411 let existing_name = existing
412 .get("name")
413 .and_then(Value::as_str)
414 .unwrap_or(stem)
415 .to_string();
416 if existing_name != new_name {
417 return Err(StoreError::StemOccupiedByDifferentResource {
418 path,
419 existing_name,
420 new_name,
421 });
422 }
423 if carry == CarryOver::Yes {
424 carry_over_x_rigg(&existing, &mut normalized);
425 carry_over_write_only(kind, &existing, &mut normalized);
426 }
427 if crate::normalize::semantic_eq(kind, &existing, &normalized)
428 && write_only_eq(kind, &existing, &normalized)
429 {
430 return Ok(false);
431 }
432 }
433
434 if let Some(parent) = path.parent() {
435 std::fs::create_dir_all(parent).map_err(|source| StoreError::Io {
436 path: parent.to_path_buf(),
437 source,
438 })?;
439 }
440 sidecar::extract_sidecars(kind, &path, &mut normalized)?;
441 std::fs::write(&path, format_json(&normalized)).map_err(|source| StoreError::Io {
442 path: path.clone(),
443 source,
444 })?;
445 Ok(true)
446 }
447
448 pub fn delete(&self, r: &ResourceRef) -> Result<()> {
453 let Some(path) = self.locate(r)? else {
454 return Ok(());
455 };
456 let stem = path
459 .file_stem()
460 .map(|s| s.to_string_lossy().into_owned())
461 .unwrap_or_else(|| r.name.clone());
462 if path.is_file() {
463 std::fs::remove_file(&path).map_err(|source| StoreError::Io {
464 path: path.clone(),
465 source,
466 })?;
467 }
468 if let Some(dir) = path.parent() {
470 for field in crate::registry::meta(r.kind).sidecar_fields {
471 let sidecar = dir.join(format!("{stem}.{field}.md"));
472 if sidecar.is_file() {
473 let _ = std::fs::remove_file(sidecar);
474 }
475 }
476 }
477 Ok(())
478 }
479}
480
481fn physical_name(path: &Path, fallback_stem: &str) -> Result<String> {
485 let text = std::fs::read_to_string(path).map_err(|source| StoreError::Io {
486 path: path.to_path_buf(),
487 source,
488 })?;
489 let value: Value = serde_json::from_str(&text).map_err(|source| StoreError::Parse {
490 path: path.to_path_buf(),
491 source,
492 })?;
493 Ok(value
494 .get("name")
495 .and_then(Value::as_str)
496 .map(str::to_string)
497 .unwrap_or_else(|| fallback_stem.to_string()))
498}
499
500fn write_only_eq(kind: ResourceKind, a: &Value, b: &Value) -> bool {
505 crate::registry::meta(kind)
506 .write_only_fields
507 .iter()
508 .all(|spec| write_only_values(a, spec) == write_only_values(b, spec))
509}
510
511fn write_only_values(doc: &Value, spec: &str) -> Vec<Value> {
516 let mut out = Vec::new();
517 crate::registry::collect_path(doc, spec, &mut |v| {
518 if !v.is_null() {
519 out.push(v.clone());
520 }
521 });
522 out
523}
524
525#[derive(Clone, Copy, PartialEq, Eq)]
530enum CarryOver {
531 Yes,
532 No,
533}
534
535pub fn baseline_doc(kind: ResourceKind, doc: &Value, local: Option<&Value>) -> Value {
544 let mut out = doc.clone();
545 if let Some(local) = local {
546 carry_over_write_only(kind, local, &mut out);
547 }
548 out
549}
550
551pub fn carry_over_write_only(kind: ResourceKind, from: &Value, to: &mut Value) {
554 for spec in crate::registry::meta(kind).write_only_fields {
555 let mut existing_value: Option<Value> = None;
556 crate::registry::collect_path(from, spec, &mut |v| {
557 if !v.is_null() {
558 existing_value = Some(v.clone());
559 }
560 });
561 let Some(existing_value) = existing_value else {
562 continue;
563 };
564 set_path(to, &spec.split('.').collect::<Vec<_>>(), existing_value);
565 }
566}
567
568fn set_path(value: &mut Value, segments: &[&str], new_value: Value) {
571 let Some((head, rest)) = segments.split_first() else {
572 return;
573 };
574 let Value::Object(map) = value else { return };
575 if rest.is_empty() {
576 map.insert((*head).to_string(), new_value);
577 return;
578 }
579 let entry = map
580 .entry((*head).to_string())
581 .or_insert_with(|| Value::Object(serde_json::Map::new()));
582 set_path(entry, rest, new_value);
583}
584
585fn carry_over_x_rigg(from: &Value, to: &mut Value) {
588 match (from, to) {
589 (Value::Object(src), Value::Object(dst)) => {
590 for (k, v) in src {
591 if k.starts_with("x-rigg-") {
592 dst.entry(k.clone()).or_insert_with(|| v.clone());
593 } else if let Some(dv) = dst.get_mut(k) {
594 carry_over_x_rigg(v, dv);
595 }
596 }
597 }
598 (Value::Array(src), Value::Array(dst)) => {
599 for sv in src {
600 let key = sv.get("name").or_else(|| sv.get("type"));
601 if let Some(key) = key
602 && let Some(dv) = dst
603 .iter_mut()
604 .find(|d| d.get("name").or_else(|| d.get("type")) == Some(key))
605 {
606 carry_over_x_rigg(sv, dv);
607 }
608 }
609 }
610 _ => {}
611 }
612}
613
614pub fn assert_exclusive_ownership(ws: &Workspace, env: &str) -> Result<()> {
618 let mut seen: BTreeMap<ResourceRef, &str> = BTreeMap::new();
619 for project in &ws.projects {
620 let store = Store::new(project, env);
621 for (r, _) in store.list()? {
622 if let Some(first) = seen.get(&r) {
623 return Err(StoreError::DuplicateOwnership {
624 reference: r.to_string(),
625 first: first.to_string(),
626 second: project.name.clone(),
627 });
628 }
629 seen.insert(r, &project.name);
630 }
631 }
632 Ok(())
633}
634
635#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
637#[serde(rename_all = "kebab-case")]
638pub enum SyncClass {
639 InSync,
641 LocalAhead,
643 RemoteAhead,
645 Conflict,
647 LocalOnly,
649 RemoteOnly,
651 Untracked,
653}
654
655#[derive(Debug, Clone, Serialize, Deserialize)]
663#[serde(untagged)]
664pub enum Baseline {
665 Checksum(String),
668 Doc(Value),
670}
671
672#[derive(Debug, Clone, Default, Serialize, Deserialize)]
674pub struct ProjectState {
675 #[serde(default)]
677 pub baselines: BTreeMap<String, Baseline>,
678}
679
680impl ProjectState {
681 pub fn path(ws: &Workspace, env: &str, project: &str) -> PathBuf {
682 ws.state_dir(env, project).join("state.json")
683 }
684
685 pub fn load(ws: &Workspace, env: &str, project: &str) -> ProjectState {
686 let path = Self::path(ws, env, project);
687 std::fs::read_to_string(&path)
688 .ok()
689 .and_then(|text| serde_json::from_str(&text).ok())
690 .unwrap_or_default()
691 }
692
693 pub fn save(&self, ws: &Workspace, env: &str, project: &str) -> std::io::Result<()> {
694 let path = Self::path(ws, env, project);
695 if let Some(parent) = path.parent() {
696 std::fs::create_dir_all(parent)?;
697 }
698 std::fs::write(&path, format_json(&serde_json::to_value(self).unwrap()))
699 }
700
701 pub fn checksum(kind: ResourceKind, value: &Value) -> String {
708 let normalized = canonical_form(&normalize_for_compare(kind, value));
709 let canonical = serde_json::to_string(&normalized).unwrap_or_default();
710 format!("{:x}", md5_like(&canonical))
711 }
712
713 pub fn has_baseline(&self, r: &ResourceRef) -> bool {
715 self.baselines.contains_key(&r.key())
716 }
717
718 pub fn baseline_checksum(&self, r: &ResourceRef) -> Option<String> {
723 match self.baselines.get(&r.key())? {
724 Baseline::Checksum(s) => Some(s.clone()),
725 Baseline::Doc(v) => Some(Self::checksum(r.kind, v)),
726 }
727 }
728
729 pub fn baseline_document(&self, r: &ResourceRef) -> Option<&Value> {
732 match self.baselines.get(&r.key())? {
733 Baseline::Checksum(_) => None,
734 Baseline::Doc(v) => Some(v),
735 }
736 }
737
738 pub fn set_baseline(&mut self, r: &ResourceRef, kind_value: &Value) {
742 let doc = canonical_form(&normalize_for_push(r.kind, kind_value));
745 self.baselines.insert(r.key(), Baseline::Doc(doc));
746 }
747
748 pub fn clear_baseline(&mut self, r: &ResourceRef) {
749 self.baselines.remove(&r.key());
750 }
751
752 fn write_only_drifted(&self, r: &ResourceRef, local: &Value) -> bool {
760 if crate::registry::meta(r.kind).write_only_fields.is_empty() {
761 return false;
762 }
763 let Some(base) = self.baseline_document(r) else {
764 return false;
765 };
766 !write_only_eq(r.kind, base, local)
767 }
768
769 pub fn classify(
771 &self,
772 r: &ResourceRef,
773 local: Option<&Value>,
774 remote: Option<&Value>,
775 ) -> SyncClass {
776 let baseline = self.baseline_checksum(r);
777 match (local, remote) {
778 (None, None) => SyncClass::InSync, (Some(_), None) => SyncClass::LocalOnly,
780 (None, Some(_)) => SyncClass::RemoteOnly,
781 (Some(l), Some(rm)) => {
782 let lsum = Self::checksum(r.kind, l);
783 let rsum = Self::checksum(r.kind, rm);
784 match baseline {
785 None => {
786 if lsum == rsum {
787 SyncClass::InSync
788 } else {
789 SyncClass::Untracked
790 }
791 }
792 Some(base) => {
793 let write_only_drift = self.write_only_drifted(r, l);
800 let local_changed = lsum != base || write_only_drift;
801 let remote_changed = rsum != base;
802 match (local_changed, remote_changed) {
803 (false, false) => SyncClass::InSync,
804 (true, false) => SyncClass::LocalAhead,
805 (false, true) => SyncClass::RemoteAhead,
806 (true, true) => {
807 if lsum != rsum {
808 SyncClass::Conflict
809 } else if write_only_drift {
810 SyncClass::LocalAhead
813 } else {
814 SyncClass::InSync
816 }
817 }
818 }
819 }
820 }
821 }
822 }
823 }
824}
825
826fn canonical_form(value: &Value) -> Value {
829 match value {
830 Value::Object(map) => {
831 let mut sorted: Vec<(String, Value)> = map
834 .iter()
835 .filter(|(_, v)| !v.is_null())
836 .map(|(k, v)| (k.clone(), canonical_form(v)))
837 .collect();
838 sorted.sort_by(|a, b| a.0.cmp(&b.0));
839 Value::Object(sorted.into_iter().collect())
840 }
841 Value::Array(arr) => {
842 let mut items: Vec<Value> = arr.iter().map(canonical_form).collect();
843 if !items.is_empty()
844 && items
845 .iter()
846 .all(|i| i.get("name").and_then(Value::as_str).is_some())
847 {
848 items.sort_by(|a, b| {
849 a["name"]
850 .as_str()
851 .unwrap_or_default()
852 .cmp(b["name"].as_str().unwrap_or_default())
853 });
854 }
855 Value::Array(items)
856 }
857 other => other.clone(),
858 }
859}
860
861fn md5_like(s: &str) -> u128 {
864 let mut h1: u64 = 0xcbf29ce484222325;
865 let mut h2: u64 = 0x9e3779b97f4a7c15;
866 for b in s.as_bytes() {
867 h1 ^= u64::from(*b);
868 h1 = h1.wrapping_mul(0x100000001b3);
869 h2 = h2.rotate_left(5) ^ u64::from(*b);
870 h2 = h2.wrapping_mul(0x2545f4914f6cdd1d);
871 }
872 (u128::from(h1) << 64) | u128::from(h2)
873}
874
875#[cfg(test)]
876mod tests {
877 use super::*;
878 use crate::workspace::{PROJECT_FILE, PROJECTS_DIR, WORKSPACE_FILE};
879 use serde_json::json;
880
881 fn ws_with_projects(dir: &Path, names: &[&str]) -> Workspace {
882 std::fs::write(
883 dir.join(WORKSPACE_FILE),
884 "environments:\n dev:\n default: true\n search: { service: s }\n",
885 )
886 .unwrap();
887 for name in names {
888 let pdir = dir.join(PROJECTS_DIR).join(name);
889 std::fs::create_dir_all(&pdir).unwrap();
890 std::fs::write(pdir.join(PROJECT_FILE), "{}\n").unwrap();
891 }
892 Workspace::load(dir).unwrap()
893 }
894
895 #[test]
896 fn write_list_read_round_trip_with_sidecars() {
897 let tmp = tempfile::tempdir().unwrap();
898 let ws = ws_with_projects(tmp.path(), &["p"]);
899 let store = Store::new(ws.project("p").unwrap(), "dev");
900
901 let agent_ref = ResourceRef::new(ResourceKind::Agent, "helper");
902 let agent = json!({"name": "helper", "model": "gpt-5-mini", "instructions": "Be nice."});
903 assert!(store.write(&agent_ref, &agent).unwrap());
904
905 let sidecar = store
907 .path_for(&agent_ref)
908 .parent()
909 .unwrap()
910 .join("helper.instructions.md");
911 assert!(sidecar.is_file());
912
913 let read = store.read(&agent_ref).unwrap();
915 assert_eq!(read["instructions"], json!("Be nice."));
916
917 let listed = store.list().unwrap();
918 assert_eq!(listed.len(), 1);
919 assert_eq!(listed[0].0, agent_ref);
920
921 assert!(
923 store
924 .path_for(&agent_ref)
925 .to_string_lossy()
926 .contains("envs/dev/")
927 );
928 }
929
930 #[test]
931 fn write_returns_false_when_semantically_unchanged() {
932 let tmp = tempfile::tempdir().unwrap();
933 let ws = ws_with_projects(tmp.path(), &["p"]);
934 let store = Store::new(ws.project("p").unwrap(), "dev");
935 let r = ResourceRef::new(ResourceKind::Index, "idx");
936 assert!(
937 store
938 .write(&r, &json!({"name": "idx", "fields": []}))
939 .unwrap()
940 );
941 let noisy = json!({"@odata.etag": "0x1", "name": "idx", "fields": []});
943 assert!(!store.write(&r, &noisy).unwrap());
944 let changed = json!({"name": "idx", "fields": [{"name": "f"}]});
946 assert!(store.write(&r, &changed).unwrap());
947 }
948
949 #[test]
950 fn write_preserves_local_x_rigg_annotations() {
951 let tmp = tempfile::tempdir().unwrap();
952 let ws = ws_with_projects(tmp.path(), &["p"]);
953 let store = Store::new(ws.project("p").unwrap(), "dev");
954 let r = ResourceRef::new(ResourceKind::Skillset, "sk");
955 let local = json!({
956 "name": "sk",
957 "skills": [{"name": "web", "uri": "https://f", "x-rigg-api": "enrich"}]
958 });
959 store.write(&r, &local).unwrap();
960 let remote = json!({
962 "name": "sk",
963 "skills": [{"name": "web", "uri": "https://f"}]
964 });
965 let rewritten = store.write(&r, &remote).unwrap();
966 let read = store.read(&r).unwrap();
967 assert_eq!(read["skills"][0]["x-rigg-api"], json!("enrich"));
968 assert!(!rewritten, "annotation-only delta is not a semantic change");
969 }
970
971 #[test]
972 fn write_exact_replaces_a_write_only_field_instead_of_carrying_it_over() {
973 let tmp = tempfile::tempdir().unwrap();
978 let ws = ws_with_projects(tmp.path(), &["p"]);
979 let store = Store::new(ws.project("p").unwrap(), "prod");
980 let r = ResourceRef::new(ResourceKind::DataSource, "ds");
981 let ds = |account: &str| {
982 json!({
983 "name": "ds",
984 "type": "azureblob",
985 "credentials": {"connectionString": format!("ResourceId=/{account};")},
986 "container": {"name": "c"},
987 })
988 };
989 assert!(store.write(&r, &ds("devacct")).unwrap());
990 assert!(
991 store.write_exact(&r, &ds("prodacct")).unwrap(),
992 "the rewired connection string is a change"
993 );
994 let read = store.read(&r).unwrap();
995 assert_eq!(
996 read["credentials"]["connectionString"],
997 json!("ResourceId=/prodacct;"),
998 "write_exact must not resurrect the previous write-only value"
999 );
1000 assert!(
1001 !store.write_exact(&r, &ds("prodacct")).unwrap(),
1002 "write_exact is still a no-op when nothing changed"
1003 );
1004 }
1005
1006 #[test]
1007 fn write_exact_does_not_carry_over_x_rigg_annotations() {
1008 let tmp = tempfile::tempdir().unwrap();
1009 let ws = ws_with_projects(tmp.path(), &["p"]);
1010 let store = Store::new(ws.project("p").unwrap(), "dev");
1011 let r = ResourceRef::new(ResourceKind::Skillset, "sk");
1012 store
1013 .write(
1014 &r,
1015 &json!({"name": "sk", "description": "old", "x-rigg-pin": ["a"], "skills": []}),
1016 )
1017 .unwrap();
1018 assert!(
1019 store
1020 .write_exact(
1021 &r,
1022 &json!({"name": "sk", "description": "new", "skills": []})
1023 )
1024 .unwrap()
1025 );
1026 let read = store.read(&r).unwrap();
1027 assert!(
1028 read.get("x-rigg-pin").is_none(),
1029 "write_exact writes the document as given: {read}"
1030 );
1031 }
1032
1033 #[test]
1034 fn write_at_exact_replaces_a_write_only_field_instead_of_carrying_it_over() {
1035 let tmp = tempfile::tempdir().unwrap();
1036 let ws = ws_with_projects(tmp.path(), &["p"]);
1037 let store = Store::new(ws.project("p").unwrap(), "prod");
1038 let ds = |account: &str| {
1039 json!({
1040 "name": "ds",
1041 "type": "azureblob",
1042 "credentials": {"connectionString": format!("ResourceId=/{account};")},
1043 "container": {"name": "c"},
1044 })
1045 };
1046 store
1047 .write_at("docs", ResourceKind::DataSource, &ds("devacct"))
1048 .unwrap();
1049 assert!(
1050 store
1051 .write_at_exact("docs", ResourceKind::DataSource, &ds("prodacct"))
1052 .unwrap()
1053 );
1054 let dir = store.kind_dir(ResourceKind::DataSource);
1055 let on_disk: Value =
1056 serde_json::from_str(&std::fs::read_to_string(dir.join("docs.json")).unwrap()).unwrap();
1057 assert_eq!(
1058 on_disk["credentials"]["connectionString"],
1059 json!("ResourceId=/prodacct;")
1060 );
1061 }
1062
1063 #[test]
1064 fn exclusive_ownership_violation_names_both_projects() {
1065 let tmp = tempfile::tempdir().unwrap();
1066 let ws = ws_with_projects(tmp.path(), &["alpha", "beta"]);
1067 for p in ["alpha", "beta"] {
1068 let store = Store::new(ws.project(p).unwrap(), "dev");
1069 store
1070 .write(
1071 &ResourceRef::new(ResourceKind::Index, "shared"),
1072 &json!({"name": "shared"}),
1073 )
1074 .unwrap();
1075 }
1076 let err = assert_exclusive_ownership(&ws, "dev").unwrap_err();
1077 let msg = err.to_string();
1078 assert!(msg.contains("alpha") && msg.contains("beta") && msg.contains("indexes/shared"));
1079 }
1080
1081 #[test]
1082 fn ownership_is_scoped_per_environment() {
1083 let tmp = tempfile::tempdir().unwrap();
1086 let ws = ws_with_projects(tmp.path(), &["alpha", "beta"]);
1087 Store::new(ws.project("alpha").unwrap(), "dev")
1088 .write(
1089 &ResourceRef::new(ResourceKind::Index, "shared"),
1090 &json!({"name": "shared"}),
1091 )
1092 .unwrap();
1093 Store::new(ws.project("beta").unwrap(), "prod")
1094 .write(
1095 &ResourceRef::new(ResourceKind::Index, "shared"),
1096 &json!({"name": "shared"}),
1097 )
1098 .unwrap();
1099 assert!(assert_exclusive_ownership(&ws, "dev").is_ok());
1100 assert!(assert_exclusive_ownership(&ws, "prod").is_ok());
1101 }
1102
1103 #[test]
1104 fn envs_of_lists_env_dirs_sorted() {
1105 let tmp = tempfile::tempdir().unwrap();
1106 let ws = ws_with_projects(tmp.path(), &["p"]);
1107 let project = ws.project("p").unwrap();
1108 assert_eq!(Store::envs_of(project), Vec::<String>::new());
1109 Store::new(project, "prod")
1110 .write(
1111 &ResourceRef::new(ResourceKind::Index, "idx"),
1112 &json!({"name": "idx"}),
1113 )
1114 .unwrap();
1115 Store::new(project, "dev")
1116 .write(
1117 &ResourceRef::new(ResourceKind::Index, "idx"),
1118 &json!({"name": "idx"}),
1119 )
1120 .unwrap();
1121 assert_eq!(Store::envs_of(project), vec!["dev", "prod"]);
1122 }
1123
1124 #[test]
1125 fn list_keys_by_physical_name_when_stem_differs() {
1126 let tmp = tempfile::tempdir().unwrap();
1127 let ws = ws_with_projects(tmp.path(), &["p"]);
1128 let store = Store::new(ws.project("p").unwrap(), "dev");
1129 let dir = store
1130 .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
1131 .parent()
1132 .unwrap()
1133 .to_path_buf();
1134 std::fs::create_dir_all(&dir).unwrap();
1135 std::fs::write(
1136 dir.join("regulus.json"),
1137 json!({"name": "Regulus-Prod", "model": "m"}).to_string(),
1138 )
1139 .unwrap();
1140 let listed = store.list().unwrap();
1141 assert_eq!(listed.len(), 1);
1142 assert_eq!(listed[0].0.name, "Regulus-Prod");
1143 assert_eq!(listed[0].1, dir.join("regulus.json"));
1144 }
1145
1146 #[test]
1147 fn locate_finds_file_by_physical_name_when_stem_differs() {
1148 let tmp = tempfile::tempdir().unwrap();
1149 let ws = ws_with_projects(tmp.path(), &["p"]);
1150 let store = Store::new(ws.project("p").unwrap(), "dev");
1151 let dir = store
1152 .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
1153 .parent()
1154 .unwrap()
1155 .to_path_buf();
1156 std::fs::create_dir_all(&dir).unwrap();
1157 std::fs::write(
1158 dir.join("regulus.json"),
1159 json!({"name": "Regulus-Prod", "model": "m"}).to_string(),
1160 )
1161 .unwrap();
1162 let found = store
1163 .locate(&ResourceRef::new(ResourceKind::Agent, "Regulus-Prod"))
1164 .unwrap();
1165 assert_eq!(found, Some(dir.join("regulus.json")));
1166 assert_eq!(
1167 store
1168 .locate(&ResourceRef::new(ResourceKind::Agent, "regulus"))
1169 .unwrap(),
1170 None,
1171 "the stem alone is not a physical name once name diverges"
1172 );
1173 }
1174
1175 #[test]
1176 fn write_updates_the_located_file_not_a_stem_guess() {
1177 let tmp = tempfile::tempdir().unwrap();
1181 let ws = ws_with_projects(tmp.path(), &["p"]);
1182 let store = Store::new(ws.project("p").unwrap(), "dev");
1183 let dir = store
1184 .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
1185 .parent()
1186 .unwrap()
1187 .to_path_buf();
1188 std::fs::create_dir_all(&dir).unwrap();
1189 std::fs::write(
1190 dir.join("regulus.json"),
1191 json!({"name": "Regulus-Prod", "model": "m1"}).to_string(),
1192 )
1193 .unwrap();
1194 let r = ResourceRef::new(ResourceKind::Agent, "Regulus-Prod");
1195 store
1196 .write(&r, &json!({"name": "Regulus-Prod", "model": "m2"}))
1197 .unwrap();
1198 assert!(
1199 !dir.join("Regulus-Prod.json").exists(),
1200 "must not create a second file keyed on the physical name"
1201 );
1202 let on_disk: Value =
1203 serde_json::from_str(&std::fs::read_to_string(dir.join("regulus.json")).unwrap())
1204 .unwrap();
1205 assert_eq!(on_disk["model"], json!("m2"));
1206 }
1207
1208 #[test]
1209 fn write_never_overwrites_a_renamed_resources_stem() {
1210 let tmp = tempfile::tempdir().unwrap();
1215 let ws = ws_with_projects(tmp.path(), &["p"]);
1216 let store = Store::new(ws.project("p").unwrap(), "dev");
1217 let dir = store
1218 .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
1219 .parent()
1220 .unwrap()
1221 .to_path_buf();
1222 std::fs::create_dir_all(&dir).unwrap();
1223 std::fs::write(
1224 dir.join("regulus.json"),
1225 json!({"name": "Regulus-Prod", "model": "m"}).to_string(),
1226 )
1227 .unwrap();
1228
1229 let r = ResourceRef::new(ResourceKind::Agent, "regulus");
1230 assert!(
1231 store
1232 .write(&r, &json!({"name": "regulus", "model": "new"}))
1233 .unwrap()
1234 );
1235
1236 let original: Value =
1238 serde_json::from_str(&std::fs::read_to_string(dir.join("regulus.json")).unwrap())
1239 .unwrap();
1240 assert_eq!(
1241 original["name"],
1242 json!("Regulus-Prod"),
1243 "original file untouched"
1244 );
1245
1246 let new_path = store.locate(&r).unwrap().expect("new resource locatable");
1248 assert_ne!(new_path, dir.join("regulus.json"));
1249 assert_eq!(new_path, dir.join("regulus-2.json"));
1250
1251 assert_eq!(
1253 store
1254 .locate(&ResourceRef::new(ResourceKind::Agent, "Regulus-Prod"))
1255 .unwrap(),
1256 Some(dir.join("regulus.json"))
1257 );
1258 assert_eq!(store.list().unwrap().len(), 2);
1259 }
1260
1261 #[test]
1262 fn delete_by_physical_name_removes_located_file_and_stem_sidecars() {
1263 let tmp = tempfile::tempdir().unwrap();
1268 let ws = ws_with_projects(tmp.path(), &["p"]);
1269 let store = Store::new(ws.project("p").unwrap(), "dev");
1270 let dir = store
1271 .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
1272 .parent()
1273 .unwrap()
1274 .to_path_buf();
1275 std::fs::create_dir_all(&dir).unwrap();
1276 std::fs::write(
1277 dir.join("regulus.json"),
1278 json!({
1279 "name": "Regulus-Prod", "model": "m",
1280 "instructions": {"$file": "regulus.instructions.md"}
1281 })
1282 .to_string(),
1283 )
1284 .unwrap();
1285 std::fs::write(dir.join("regulus.instructions.md"), "Be helpful.").unwrap();
1286 std::fs::write(
1288 dir.join("other.json"),
1289 json!({
1290 "name": "other", "model": "m",
1291 "instructions": {"$file": "other.instructions.md"}
1292 })
1293 .to_string(),
1294 )
1295 .unwrap();
1296 std::fs::write(dir.join("other.instructions.md"), "Neighbor.").unwrap();
1297
1298 store
1299 .delete(&ResourceRef::new(ResourceKind::Agent, "Regulus-Prod"))
1300 .unwrap();
1301 assert!(!dir.join("regulus.json").exists(), "located file removed");
1302 assert!(
1303 !dir.join("regulus.instructions.md").exists(),
1304 "stem-named sidecar removed"
1305 );
1306 assert!(dir.join("other.json").exists(), "neighbor untouched");
1307 assert!(
1308 dir.join("other.instructions.md").exists(),
1309 "neighbor sidecar untouched"
1310 );
1311
1312 store
1314 .delete(&ResourceRef::new(ResourceKind::Agent, "ghost"))
1315 .unwrap();
1316 assert!(dir.join("other.json").exists());
1317 }
1318
1319 #[test]
1320 fn list_error_on_corrupt_json_names_the_file() {
1321 let tmp = tempfile::tempdir().unwrap();
1322 let ws = ws_with_projects(tmp.path(), &["p"]);
1323 let store = Store::new(ws.project("p").unwrap(), "dev");
1324 let dir = store
1325 .path_for(&ResourceRef::new(ResourceKind::Index, "a"))
1326 .parent()
1327 .unwrap()
1328 .to_path_buf();
1329 std::fs::create_dir_all(&dir).unwrap();
1330 std::fs::write(dir.join("broken.json"), "{ this is not json").unwrap();
1331 let err = store.list().unwrap_err();
1332 assert!(matches!(err, StoreError::Parse { .. }));
1333 assert!(
1334 err.to_string().contains("broken.json"),
1335 "error names the broken file: {err}"
1336 );
1337 }
1338
1339 #[test]
1340 fn duplicate_physical_name_in_one_kind_dir_errors() {
1341 let tmp = tempfile::tempdir().unwrap();
1342 let ws = ws_with_projects(tmp.path(), &["p"]);
1343 let store = Store::new(ws.project("p").unwrap(), "dev");
1344 let dir = store
1345 .path_for(&ResourceRef::new(ResourceKind::Index, "a"))
1346 .parent()
1347 .unwrap()
1348 .to_path_buf();
1349 std::fs::create_dir_all(&dir).unwrap();
1350 std::fs::write(dir.join("a.json"), json!({"name": "dup"}).to_string()).unwrap();
1351 std::fs::write(dir.join("b.json"), json!({"name": "dup"}).to_string()).unwrap();
1352 let err = store.list().unwrap_err();
1353 assert!(matches!(err, StoreError::DuplicatePhysicalName { .. }));
1354 assert!(err.to_string().contains("dup"));
1355 }
1356
1357 #[test]
1358 fn classify_truth_table() {
1359 let tmp = tempfile::tempdir().unwrap();
1360 let ws = ws_with_projects(tmp.path(), &["p"]);
1361 let r = ResourceRef::new(ResourceKind::Index, "idx");
1362 let a = json!({"name": "idx", "fields": [{"name": "f1"}]});
1363 let b = json!({"name": "idx", "fields": [{"name": "f2"}]});
1364 let c = json!({"name": "idx", "fields": [{"name": "f3"}]});
1365
1366 let mut state = ProjectState::default();
1367 assert_eq!(state.classify(&r, Some(&a), Some(&a)), SyncClass::InSync);
1369 assert_eq!(state.classify(&r, Some(&a), Some(&b)), SyncClass::Untracked);
1370 assert_eq!(state.classify(&r, Some(&a), None), SyncClass::LocalOnly);
1371 assert_eq!(state.classify(&r, None, Some(&a)), SyncClass::RemoteOnly);
1372 assert_eq!(state.classify(&r, None, None), SyncClass::InSync);
1373
1374 state.set_baseline(&r, &a);
1376 assert_eq!(state.classify(&r, Some(&a), Some(&a)), SyncClass::InSync);
1377 assert_eq!(
1378 state.classify(&r, Some(&b), Some(&a)),
1379 SyncClass::LocalAhead
1380 );
1381 assert_eq!(
1382 state.classify(&r, Some(&a), Some(&b)),
1383 SyncClass::RemoteAhead
1384 );
1385 assert_eq!(state.classify(&r, Some(&b), Some(&c)), SyncClass::Conflict);
1386 assert_eq!(state.classify(&r, Some(&b), Some(&b)), SyncClass::InSync);
1387
1388 state.save(&ws, "dev", "p").unwrap();
1390 let loaded = ProjectState::load(&ws, "dev", "p");
1391 assert_eq!(loaded.baseline_checksum(&r), state.baseline_checksum(&r));
1392 }
1393
1394 #[test]
1395 fn legacy_checksum_baseline_still_loads_and_classifies() {
1396 let json = r#"{"baselines": {"agents/a": "deadbeef"}}"#;
1398 let state: ProjectState = serde_json::from_str(json).unwrap();
1399 let r = ResourceRef::new(ResourceKind::Agent, "a".to_string());
1400 assert!(state.has_baseline(&r));
1401 let local = json!({"name": "a", "model": "x"});
1403 let remote = json!({"name": "a", "model": "y"});
1404 assert_eq!(
1405 state.classify(&r, Some(&local), Some(&remote)),
1406 SyncClass::Conflict
1407 );
1408 }
1409
1410 #[test]
1411 fn doc_baseline_self_heals_across_rule_changes() {
1412 let r = ResourceRef::new(ResourceKind::Agent, "a".to_string());
1418 let old_doc = json!({
1419 "name": "a", "model": "x",
1420 "metadata": {"modified_at": "111", "logo": "l.svg"}
1421 });
1422 let mut state = ProjectState::default();
1423 state.baselines.insert(r.key(), Baseline::Doc(old_doc));
1424 let local = json!({
1425 "name": "a", "model": "x", "metadata": {"logo": "l.svg"}
1426 });
1427 let remote = json!({
1428 "name": "a", "model": "CHANGED", "metadata": {"logo": "l.svg"}
1429 });
1430 assert_eq!(
1431 state.classify(&r, Some(&local), Some(&remote)),
1432 SyncClass::RemoteAhead
1433 );
1434 }
1435
1436 #[test]
1437 fn baseline_serde_mixed_roundtrip() {
1438 let r = ResourceRef::new(ResourceKind::Agent, "new".to_string());
1439 let mut state = ProjectState::default();
1440 state.baselines.insert(
1441 "agents/legacy".to_string(),
1442 Baseline::Checksum("abc".to_string()),
1443 );
1444 state.set_baseline(&r, &json!({"name": "new", "model": "m"}));
1445 let text = serde_json::to_string(&state).unwrap();
1446 let back: ProjectState = serde_json::from_str(&text).unwrap();
1447 assert!(
1448 matches!(back.baselines.get("agents/legacy"), Some(Baseline::Checksum(s)) if s == "abc")
1449 );
1450 assert!(matches!(
1451 back.baselines.get("agents/new"),
1452 Some(Baseline::Doc(_))
1453 ));
1454 }
1455
1456 #[test]
1457 fn a_credential_only_local_edit_classifies_as_local_ahead() {
1458 let r = ResourceRef::new(ResourceKind::DataSource, "ds");
1462 let ds = |conn: &str| {
1463 json!({
1464 "name": "ds", "type": "azureblob",
1465 "credentials": {"connectionString": conn},
1466 "container": {"name": "c"}
1467 })
1468 };
1469 let local_a = ds("ResourceId=/subscriptions/s/…/storageAccounts/acct-a;");
1470 let local_b = ds("ResourceId=/subscriptions/s/…/storageAccounts/acct-b;");
1471 let remote = json!({
1473 "name": "ds", "type": "azureblob",
1474 "credentials": {"connectionString": null},
1475 "container": {"name": "c"}
1476 });
1477
1478 let mut state = ProjectState::default();
1479 state.set_baseline(&r, &local_a);
1480 assert_eq!(
1481 state.classify(&r, Some(&local_a), Some(&remote)),
1482 SyncClass::InSync,
1483 "unchanged credentials against a redacted remote stay in sync"
1484 );
1485 assert_eq!(
1486 state.classify(&r, Some(&local_b), Some(&remote)),
1487 SyncClass::LocalAhead,
1488 "a new connection string is a pending local change"
1489 );
1490
1491 state.baselines.insert(
1494 r.key(),
1495 Baseline::Checksum(ProjectState::checksum(r.kind, &local_a)),
1496 );
1497 assert_eq!(
1498 state.classify(&r, Some(&local_b), Some(&remote)),
1499 SyncClass::InSync
1500 );
1501 }
1502
1503 #[test]
1504 fn credential_only_change_still_writes() {
1505 let tmp = tempfile::tempdir().unwrap();
1510 let ws = ws_with_projects(tmp.path(), &["p"]);
1511 let store = Store::new(ws.project("p").unwrap(), "dev");
1512 let r = ResourceRef::new(ResourceKind::DataSource, "ds");
1513 let without = json!({
1514 "name": "ds", "type": "azureblob",
1515 "credentials": {"connectionString": null},
1516 "container": {"name": "c"}
1517 });
1518 store.write(&r, &without).unwrap();
1519 let mut with = without.clone();
1520 with["credentials"]["connectionString"] = json!("ResourceId=/subscriptions/s/x;");
1521 assert!(
1522 store.write(&r, &with).unwrap(),
1523 "credential change must write"
1524 );
1525 let read = store.read(&r).unwrap();
1526 assert_eq!(
1527 read["credentials"]["connectionString"],
1528 json!("ResourceId=/subscriptions/s/x;")
1529 );
1530 }
1531
1532 #[test]
1533 fn write_only_fields_survive_server_echo_and_compare() {
1534 let tmp = tempfile::tempdir().unwrap();
1535 let ws = ws_with_projects(tmp.path(), &["p"]);
1536 let store = Store::new(ws.project("p").unwrap(), "dev");
1537 let r = ResourceRef::new(ResourceKind::DataSource, "ds");
1538 let local = json!({
1539 "name": "ds", "type": "azureblob",
1540 "credentials": {"connectionString": "ResourceId=/subscriptions/s/x;"},
1541 "container": {"name": "c"}
1542 });
1543 store.write(&r, &local).unwrap();
1544 let server_echo = json!({
1546 "name": "ds", "type": "azureblob",
1547 "credentials": {"connectionString": null},
1548 "container": {"name": "c"}
1549 });
1550 assert!(!store.write(&r, &server_echo).unwrap());
1552 let read = store.read(&r).unwrap();
1553 assert_eq!(
1554 read["credentials"]["connectionString"],
1555 json!("ResourceId=/subscriptions/s/x;")
1556 );
1557 assert_eq!(
1559 ProjectState::checksum(ResourceKind::DataSource, &local),
1560 ProjectState::checksum(ResourceKind::DataSource, &server_echo)
1561 );
1562 }
1563
1564 #[test]
1565 fn checksum_is_order_canonical() {
1566 let a = serde_json::from_str::<Value>(
1568 r#"{"name": "i", "fields": [{"name": "b"}, {"name": "a"}], "x": 1}"#,
1569 )
1570 .unwrap();
1571 let b = serde_json::from_str::<Value>(
1572 r#"{"x": 1, "name": "i", "fields": [{"name": "a"}, {"name": "b"}]}"#,
1573 )
1574 .unwrap();
1575 assert_eq!(
1576 ProjectState::checksum(ResourceKind::Index, &a),
1577 ProjectState::checksum(ResourceKind::Index, &b)
1578 );
1579 }
1580
1581 #[test]
1582 fn checksum_ignores_volatile_and_annotations() {
1583 let a = json!({"name": "i", "@odata.etag": "1", "x-rigg-note": "hi"});
1584 let b = json!({"name": "i"});
1585 assert_eq!(
1586 ProjectState::checksum(ResourceKind::Index, &a),
1587 ProjectState::checksum(ResourceKind::Index, &b)
1588 );
1589 }
1590
1591 #[test]
1592 fn write_at_creates_file_at_given_stem() {
1593 let tmp = tempfile::tempdir().unwrap();
1594 let ws = ws_with_projects(tmp.path(), &["p"]);
1595 let store = Store::new(ws.project("p").unwrap(), "prod");
1596 let created = store
1597 .write_at(
1598 "regulus",
1599 ResourceKind::Agent,
1600 &json!({"name": "Regulus-Prod", "model": "m"}),
1601 )
1602 .unwrap();
1603 assert!(created);
1604 let dir = store
1605 .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
1606 .parent()
1607 .unwrap()
1608 .to_path_buf();
1609 assert!(
1610 dir.join("regulus.json").is_file(),
1611 "landed at the stem, not the physical name"
1612 );
1613 assert!(!dir.join("Regulus-Prod.json").exists());
1614 let on_disk: Value =
1615 serde_json::from_str(&std::fs::read_to_string(dir.join("regulus.json")).unwrap())
1616 .unwrap();
1617 assert_eq!(on_disk["name"], json!("Regulus-Prod"));
1618 }
1619
1620 #[test]
1621 fn write_at_updates_in_place_when_physical_name_matches() {
1622 let tmp = tempfile::tempdir().unwrap();
1623 let ws = ws_with_projects(tmp.path(), &["p"]);
1624 let store = Store::new(ws.project("p").unwrap(), "prod");
1625 store
1626 .write_at(
1627 "regulus",
1628 ResourceKind::Agent,
1629 &json!({"name": "Regulus-Prod", "model": "m1"}),
1630 )
1631 .unwrap();
1632 let rewritten = store
1633 .write_at(
1634 "regulus",
1635 ResourceKind::Agent,
1636 &json!({"name": "Regulus-Prod", "model": "m2"}),
1637 )
1638 .unwrap();
1639 assert!(rewritten);
1640 let dir = store
1641 .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
1642 .parent()
1643 .unwrap()
1644 .to_path_buf();
1645 let on_disk: Value =
1646 serde_json::from_str(&std::fs::read_to_string(dir.join("regulus.json")).unwrap())
1647 .unwrap();
1648 assert_eq!(on_disk["model"], json!("m2"));
1649 }
1650
1651 #[test]
1652 fn write_at_refuses_to_overwrite_a_different_resources_stem() {
1653 let tmp = tempfile::tempdir().unwrap();
1654 let ws = ws_with_projects(tmp.path(), &["p"]);
1655 let store = Store::new(ws.project("p").unwrap(), "prod");
1656 store
1657 .write_at(
1658 "regulus",
1659 ResourceKind::Agent,
1660 &json!({"name": "Regulus-Prod", "model": "m1"}),
1661 )
1662 .unwrap();
1663 let err = store
1664 .write_at(
1665 "regulus",
1666 ResourceKind::Agent,
1667 &json!({"name": "some-other-name", "model": "m2"}),
1668 )
1669 .unwrap_err();
1670 assert!(matches!(
1671 err,
1672 StoreError::StemOccupiedByDifferentResource { .. }
1673 ));
1674 let dir = store
1676 .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
1677 .parent()
1678 .unwrap()
1679 .to_path_buf();
1680 let on_disk: Value =
1681 serde_json::from_str(&std::fs::read_to_string(dir.join("regulus.json")).unwrap())
1682 .unwrap();
1683 assert_eq!(on_disk["name"], json!("Regulus-Prod"));
1684 }
1685
1686 #[test]
1687 fn write_at_returns_false_when_semantically_unchanged() {
1688 let tmp = tempfile::tempdir().unwrap();
1689 let ws = ws_with_projects(tmp.path(), &["p"]);
1690 let store = Store::new(ws.project("p").unwrap(), "prod");
1691 store
1692 .write_at(
1693 "idx",
1694 ResourceKind::Index,
1695 &json!({"name": "idx", "fields": []}),
1696 )
1697 .unwrap();
1698 let rewritten = store
1699 .write_at(
1700 "idx",
1701 ResourceKind::Index,
1702 &json!({"@odata.etag": "0x1", "name": "idx", "fields": []}),
1703 )
1704 .unwrap();
1705 assert!(!rewritten);
1706 }
1707
1708 #[test]
1709 fn write_at_rejects_path_escaping_stems() {
1710 let tmp = tempfile::tempdir().unwrap();
1711 let ws = ws_with_projects(tmp.path(), &["p"]);
1712 let store = Store::new(ws.project("p").unwrap(), "prod");
1713 let err = store.write_at("../../evil", ResourceKind::Index, &json!({"name": "evil"}));
1714 assert!(matches!(err, Err(StoreError::BadName { .. })), "{err:?}");
1715 }
1716
1717 #[test]
1718 fn write_rejects_path_escaping_names() {
1719 let tmp = tempfile::tempdir().unwrap();
1720 let ws = ws_with_projects(tmp.path(), &["p"]);
1721 let store = Store::new(ws.project("p").unwrap(), "dev");
1722 let r = ResourceRef::new(ResourceKind::Index, "../../evil");
1723 let err = store.write(&r, &json!({"name": "../../evil"}));
1724 assert!(matches!(err, Err(StoreError::BadName { .. })), "{err:?}");
1725 }
1726}