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) {
320 return Ok(false);
321 }
322 }
323 }
324
325 if let Some(parent) = path.parent() {
326 std::fs::create_dir_all(parent).map_err(|source| StoreError::Io {
327 path: parent.to_path_buf(),
328 source,
329 })?;
330 }
331 sidecar::extract_sidecars(r.kind, &path, &mut normalized)?;
332 std::fs::write(&path, format_json(&normalized)).map_err(|source| StoreError::Io {
333 path: path.clone(),
334 source,
335 })?;
336 Ok(true)
337 }
338
339 pub fn write_at(&self, stem: &str, kind: ResourceKind, value: &Value) -> Result<bool> {
354 validate_resource_name(stem).map_err(|e| StoreError::BadName {
355 path: self.kind_dir(kind).join(format!("{stem}.json")),
356 message: e.to_string(),
357 })?;
358 let path = self.kind_dir(kind).join(format!("{stem}.json"));
359 let mut normalized = normalize_for_disk(kind, value);
360 let new_name = normalized
361 .get("name")
362 .and_then(Value::as_str)
363 .unwrap_or(stem)
364 .to_string();
365
366 if path.is_file() {
367 let existing = self.read_path(&path)?;
368 let existing_name = existing
369 .get("name")
370 .and_then(Value::as_str)
371 .unwrap_or(stem)
372 .to_string();
373 if existing_name != new_name {
374 return Err(StoreError::StemOccupiedByDifferentResource {
375 path,
376 existing_name,
377 new_name,
378 });
379 }
380 carry_over_x_rigg(&existing, &mut normalized);
381 carry_over_write_only(kind, &existing, &mut normalized);
382 if crate::normalize::semantic_eq(kind, &existing, &normalized) {
383 return Ok(false);
384 }
385 }
386
387 if let Some(parent) = path.parent() {
388 std::fs::create_dir_all(parent).map_err(|source| StoreError::Io {
389 path: parent.to_path_buf(),
390 source,
391 })?;
392 }
393 sidecar::extract_sidecars(kind, &path, &mut normalized)?;
394 std::fs::write(&path, format_json(&normalized)).map_err(|source| StoreError::Io {
395 path: path.clone(),
396 source,
397 })?;
398 Ok(true)
399 }
400
401 pub fn delete(&self, r: &ResourceRef) -> Result<()> {
406 let Some(path) = self.locate(r)? else {
407 return Ok(());
408 };
409 let stem = path
412 .file_stem()
413 .map(|s| s.to_string_lossy().into_owned())
414 .unwrap_or_else(|| r.name.clone());
415 if path.is_file() {
416 std::fs::remove_file(&path).map_err(|source| StoreError::Io {
417 path: path.clone(),
418 source,
419 })?;
420 }
421 if let Some(dir) = path.parent() {
423 for field in crate::registry::meta(r.kind).sidecar_fields {
424 let sidecar = dir.join(format!("{stem}.{field}.md"));
425 if sidecar.is_file() {
426 let _ = std::fs::remove_file(sidecar);
427 }
428 }
429 }
430 Ok(())
431 }
432}
433
434fn physical_name(path: &Path, fallback_stem: &str) -> Result<String> {
438 let text = std::fs::read_to_string(path).map_err(|source| StoreError::Io {
439 path: path.to_path_buf(),
440 source,
441 })?;
442 let value: Value = serde_json::from_str(&text).map_err(|source| StoreError::Parse {
443 path: path.to_path_buf(),
444 source,
445 })?;
446 Ok(value
447 .get("name")
448 .and_then(Value::as_str)
449 .map(str::to_string)
450 .unwrap_or_else(|| fallback_stem.to_string()))
451}
452
453fn carry_over_write_only(kind: ResourceKind, from: &Value, to: &mut Value) {
456 for spec in crate::registry::meta(kind).write_only_fields {
457 let mut existing_value: Option<Value> = None;
458 crate::registry::collect_path(from, spec, &mut |v| {
459 if !v.is_null() {
460 existing_value = Some(v.clone());
461 }
462 });
463 let Some(existing_value) = existing_value else {
464 continue;
465 };
466 set_path(to, &spec.split('.').collect::<Vec<_>>(), existing_value);
467 }
468}
469
470fn set_path(value: &mut Value, segments: &[&str], new_value: Value) {
473 let Some((head, rest)) = segments.split_first() else {
474 return;
475 };
476 let Value::Object(map) = value else { return };
477 if rest.is_empty() {
478 map.insert((*head).to_string(), new_value);
479 return;
480 }
481 let entry = map
482 .entry((*head).to_string())
483 .or_insert_with(|| Value::Object(serde_json::Map::new()));
484 set_path(entry, rest, new_value);
485}
486
487fn carry_over_x_rigg(from: &Value, to: &mut Value) {
490 match (from, to) {
491 (Value::Object(src), Value::Object(dst)) => {
492 for (k, v) in src {
493 if k.starts_with("x-rigg-") {
494 dst.entry(k.clone()).or_insert_with(|| v.clone());
495 } else if let Some(dv) = dst.get_mut(k) {
496 carry_over_x_rigg(v, dv);
497 }
498 }
499 }
500 (Value::Array(src), Value::Array(dst)) => {
501 for sv in src {
502 let key = sv.get("name").or_else(|| sv.get("type"));
503 if let Some(key) = key {
504 if let Some(dv) = dst
505 .iter_mut()
506 .find(|d| d.get("name").or_else(|| d.get("type")) == Some(key))
507 {
508 carry_over_x_rigg(sv, dv);
509 }
510 }
511 }
512 }
513 _ => {}
514 }
515}
516
517pub fn assert_exclusive_ownership(ws: &Workspace, env: &str) -> Result<()> {
521 let mut seen: BTreeMap<ResourceRef, &str> = BTreeMap::new();
522 for project in &ws.projects {
523 let store = Store::new(project, env);
524 for (r, _) in store.list()? {
525 if let Some(first) = seen.get(&r) {
526 return Err(StoreError::DuplicateOwnership {
527 reference: r.to_string(),
528 first: first.to_string(),
529 second: project.name.clone(),
530 });
531 }
532 seen.insert(r, &project.name);
533 }
534 }
535 Ok(())
536}
537
538#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
540#[serde(rename_all = "kebab-case")]
541pub enum SyncClass {
542 InSync,
544 LocalAhead,
546 RemoteAhead,
548 Conflict,
550 LocalOnly,
552 RemoteOnly,
554 Untracked,
556}
557
558#[derive(Debug, Clone, Serialize, Deserialize)]
564#[serde(untagged)]
565pub enum Baseline {
566 Checksum(String),
569 Doc(Value),
571}
572
573#[derive(Debug, Clone, Default, Serialize, Deserialize)]
575pub struct ProjectState {
576 #[serde(default)]
578 pub baselines: BTreeMap<String, Baseline>,
579}
580
581impl ProjectState {
582 pub fn path(ws: &Workspace, env: &str, project: &str) -> PathBuf {
583 ws.state_dir(env, project).join("state.json")
584 }
585
586 pub fn load(ws: &Workspace, env: &str, project: &str) -> ProjectState {
587 let path = Self::path(ws, env, project);
588 std::fs::read_to_string(&path)
589 .ok()
590 .and_then(|text| serde_json::from_str(&text).ok())
591 .unwrap_or_default()
592 }
593
594 pub fn save(&self, ws: &Workspace, env: &str, project: &str) -> std::io::Result<()> {
595 let path = Self::path(ws, env, project);
596 if let Some(parent) = path.parent() {
597 std::fs::create_dir_all(parent)?;
598 }
599 std::fs::write(&path, format_json(&serde_json::to_value(self).unwrap()))
600 }
601
602 pub fn checksum(kind: ResourceKind, value: &Value) -> String {
609 let normalized = canonical_form(&normalize_for_compare(kind, value));
610 let canonical = serde_json::to_string(&normalized).unwrap_or_default();
611 format!("{:x}", md5_like(&canonical))
612 }
613
614 pub fn has_baseline(&self, r: &ResourceRef) -> bool {
616 self.baselines.contains_key(&r.key())
617 }
618
619 pub fn baseline_checksum(&self, r: &ResourceRef) -> Option<String> {
624 match self.baselines.get(&r.key())? {
625 Baseline::Checksum(s) => Some(s.clone()),
626 Baseline::Doc(v) => Some(Self::checksum(r.kind, v)),
627 }
628 }
629
630 pub fn set_baseline(&mut self, r: &ResourceRef, kind_value: &Value) {
631 let doc = canonical_form(&normalize_for_compare(r.kind, kind_value));
632 self.baselines.insert(r.key(), Baseline::Doc(doc));
633 }
634
635 pub fn clear_baseline(&mut self, r: &ResourceRef) {
636 self.baselines.remove(&r.key());
637 }
638
639 pub fn classify(
641 &self,
642 r: &ResourceRef,
643 local: Option<&Value>,
644 remote: Option<&Value>,
645 ) -> SyncClass {
646 let baseline = self.baseline_checksum(r);
647 match (local, remote) {
648 (None, None) => SyncClass::InSync, (Some(_), None) => SyncClass::LocalOnly,
650 (None, Some(_)) => SyncClass::RemoteOnly,
651 (Some(l), Some(rm)) => {
652 let lsum = Self::checksum(r.kind, l);
653 let rsum = Self::checksum(r.kind, rm);
654 match baseline {
655 None => {
656 if lsum == rsum {
657 SyncClass::InSync
658 } else {
659 SyncClass::Untracked
660 }
661 }
662 Some(base) => {
663 let local_changed = lsum != base;
664 let remote_changed = rsum != base;
665 match (local_changed, remote_changed) {
666 (false, false) => SyncClass::InSync,
667 (true, false) => SyncClass::LocalAhead,
668 (false, true) => SyncClass::RemoteAhead,
669 (true, true) => {
670 if lsum == rsum {
671 SyncClass::InSync
673 } else {
674 SyncClass::Conflict
675 }
676 }
677 }
678 }
679 }
680 }
681 }
682 }
683}
684
685fn canonical_form(value: &Value) -> Value {
688 match value {
689 Value::Object(map) => {
690 let mut sorted: Vec<(String, Value)> = map
693 .iter()
694 .filter(|(_, v)| !v.is_null())
695 .map(|(k, v)| (k.clone(), canonical_form(v)))
696 .collect();
697 sorted.sort_by(|a, b| a.0.cmp(&b.0));
698 Value::Object(sorted.into_iter().collect())
699 }
700 Value::Array(arr) => {
701 let mut items: Vec<Value> = arr.iter().map(canonical_form).collect();
702 if !items.is_empty()
703 && items
704 .iter()
705 .all(|i| i.get("name").and_then(Value::as_str).is_some())
706 {
707 items.sort_by(|a, b| {
708 a["name"]
709 .as_str()
710 .unwrap_or_default()
711 .cmp(b["name"].as_str().unwrap_or_default())
712 });
713 }
714 Value::Array(items)
715 }
716 other => other.clone(),
717 }
718}
719
720fn md5_like(s: &str) -> u128 {
723 let mut h1: u64 = 0xcbf29ce484222325;
724 let mut h2: u64 = 0x9e3779b97f4a7c15;
725 for b in s.as_bytes() {
726 h1 ^= u64::from(*b);
727 h1 = h1.wrapping_mul(0x100000001b3);
728 h2 = h2.rotate_left(5) ^ u64::from(*b);
729 h2 = h2.wrapping_mul(0x2545f4914f6cdd1d);
730 }
731 (u128::from(h1) << 64) | u128::from(h2)
732}
733
734#[cfg(test)]
735mod tests {
736 use super::*;
737 use crate::workspace::{PROJECT_FILE, PROJECTS_DIR, WORKSPACE_FILE};
738 use serde_json::json;
739
740 fn ws_with_projects(dir: &Path, names: &[&str]) -> Workspace {
741 std::fs::write(
742 dir.join(WORKSPACE_FILE),
743 "environments:\n dev:\n default: true\n search: { service: s }\n",
744 )
745 .unwrap();
746 for name in names {
747 let pdir = dir.join(PROJECTS_DIR).join(name);
748 std::fs::create_dir_all(&pdir).unwrap();
749 std::fs::write(pdir.join(PROJECT_FILE), "{}\n").unwrap();
750 }
751 Workspace::load(dir).unwrap()
752 }
753
754 #[test]
755 fn write_list_read_round_trip_with_sidecars() {
756 let tmp = tempfile::tempdir().unwrap();
757 let ws = ws_with_projects(tmp.path(), &["p"]);
758 let store = Store::new(ws.project("p").unwrap(), "dev");
759
760 let agent_ref = ResourceRef::new(ResourceKind::Agent, "helper");
761 let agent = json!({"name": "helper", "model": "gpt-5-mini", "instructions": "Be nice."});
762 assert!(store.write(&agent_ref, &agent).unwrap());
763
764 let sidecar = store
766 .path_for(&agent_ref)
767 .parent()
768 .unwrap()
769 .join("helper.instructions.md");
770 assert!(sidecar.is_file());
771
772 let read = store.read(&agent_ref).unwrap();
774 assert_eq!(read["instructions"], json!("Be nice."));
775
776 let listed = store.list().unwrap();
777 assert_eq!(listed.len(), 1);
778 assert_eq!(listed[0].0, agent_ref);
779
780 assert!(
782 store
783 .path_for(&agent_ref)
784 .to_string_lossy()
785 .contains("envs/dev/")
786 );
787 }
788
789 #[test]
790 fn write_returns_false_when_semantically_unchanged() {
791 let tmp = tempfile::tempdir().unwrap();
792 let ws = ws_with_projects(tmp.path(), &["p"]);
793 let store = Store::new(ws.project("p").unwrap(), "dev");
794 let r = ResourceRef::new(ResourceKind::Index, "idx");
795 assert!(
796 store
797 .write(&r, &json!({"name": "idx", "fields": []}))
798 .unwrap()
799 );
800 let noisy = json!({"@odata.etag": "0x1", "name": "idx", "fields": []});
802 assert!(!store.write(&r, &noisy).unwrap());
803 let changed = json!({"name": "idx", "fields": [{"name": "f"}]});
805 assert!(store.write(&r, &changed).unwrap());
806 }
807
808 #[test]
809 fn write_preserves_local_x_rigg_annotations() {
810 let tmp = tempfile::tempdir().unwrap();
811 let ws = ws_with_projects(tmp.path(), &["p"]);
812 let store = Store::new(ws.project("p").unwrap(), "dev");
813 let r = ResourceRef::new(ResourceKind::Skillset, "sk");
814 let local = json!({
815 "name": "sk",
816 "skills": [{"name": "web", "uri": "https://f", "x-rigg-api": "enrich"}]
817 });
818 store.write(&r, &local).unwrap();
819 let remote = json!({
821 "name": "sk",
822 "skills": [{"name": "web", "uri": "https://f"}]
823 });
824 let rewritten = store.write(&r, &remote).unwrap();
825 let read = store.read(&r).unwrap();
826 assert_eq!(read["skills"][0]["x-rigg-api"], json!("enrich"));
827 assert!(!rewritten, "annotation-only delta is not a semantic change");
828 }
829
830 #[test]
831 fn exclusive_ownership_violation_names_both_projects() {
832 let tmp = tempfile::tempdir().unwrap();
833 let ws = ws_with_projects(tmp.path(), &["alpha", "beta"]);
834 for p in ["alpha", "beta"] {
835 let store = Store::new(ws.project(p).unwrap(), "dev");
836 store
837 .write(
838 &ResourceRef::new(ResourceKind::Index, "shared"),
839 &json!({"name": "shared"}),
840 )
841 .unwrap();
842 }
843 let err = assert_exclusive_ownership(&ws, "dev").unwrap_err();
844 let msg = err.to_string();
845 assert!(msg.contains("alpha") && msg.contains("beta") && msg.contains("indexes/shared"));
846 }
847
848 #[test]
849 fn ownership_is_scoped_per_environment() {
850 let tmp = tempfile::tempdir().unwrap();
853 let ws = ws_with_projects(tmp.path(), &["alpha", "beta"]);
854 Store::new(ws.project("alpha").unwrap(), "dev")
855 .write(
856 &ResourceRef::new(ResourceKind::Index, "shared"),
857 &json!({"name": "shared"}),
858 )
859 .unwrap();
860 Store::new(ws.project("beta").unwrap(), "prod")
861 .write(
862 &ResourceRef::new(ResourceKind::Index, "shared"),
863 &json!({"name": "shared"}),
864 )
865 .unwrap();
866 assert!(assert_exclusive_ownership(&ws, "dev").is_ok());
867 assert!(assert_exclusive_ownership(&ws, "prod").is_ok());
868 }
869
870 #[test]
871 fn envs_of_lists_env_dirs_sorted() {
872 let tmp = tempfile::tempdir().unwrap();
873 let ws = ws_with_projects(tmp.path(), &["p"]);
874 let project = ws.project("p").unwrap();
875 assert_eq!(Store::envs_of(project), Vec::<String>::new());
876 Store::new(project, "prod")
877 .write(
878 &ResourceRef::new(ResourceKind::Index, "idx"),
879 &json!({"name": "idx"}),
880 )
881 .unwrap();
882 Store::new(project, "dev")
883 .write(
884 &ResourceRef::new(ResourceKind::Index, "idx"),
885 &json!({"name": "idx"}),
886 )
887 .unwrap();
888 assert_eq!(Store::envs_of(project), vec!["dev", "prod"]);
889 }
890
891 #[test]
892 fn list_keys_by_physical_name_when_stem_differs() {
893 let tmp = tempfile::tempdir().unwrap();
894 let ws = ws_with_projects(tmp.path(), &["p"]);
895 let store = Store::new(ws.project("p").unwrap(), "dev");
896 let dir = store
897 .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
898 .parent()
899 .unwrap()
900 .to_path_buf();
901 std::fs::create_dir_all(&dir).unwrap();
902 std::fs::write(
903 dir.join("regulus.json"),
904 json!({"name": "Regulus-Prod", "model": "m"}).to_string(),
905 )
906 .unwrap();
907 let listed = store.list().unwrap();
908 assert_eq!(listed.len(), 1);
909 assert_eq!(listed[0].0.name, "Regulus-Prod");
910 assert_eq!(listed[0].1, dir.join("regulus.json"));
911 }
912
913 #[test]
914 fn locate_finds_file_by_physical_name_when_stem_differs() {
915 let tmp = tempfile::tempdir().unwrap();
916 let ws = ws_with_projects(tmp.path(), &["p"]);
917 let store = Store::new(ws.project("p").unwrap(), "dev");
918 let dir = store
919 .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
920 .parent()
921 .unwrap()
922 .to_path_buf();
923 std::fs::create_dir_all(&dir).unwrap();
924 std::fs::write(
925 dir.join("regulus.json"),
926 json!({"name": "Regulus-Prod", "model": "m"}).to_string(),
927 )
928 .unwrap();
929 let found = store
930 .locate(&ResourceRef::new(ResourceKind::Agent, "Regulus-Prod"))
931 .unwrap();
932 assert_eq!(found, Some(dir.join("regulus.json")));
933 assert_eq!(
934 store
935 .locate(&ResourceRef::new(ResourceKind::Agent, "regulus"))
936 .unwrap(),
937 None,
938 "the stem alone is not a physical name once name diverges"
939 );
940 }
941
942 #[test]
943 fn write_updates_the_located_file_not_a_stem_guess() {
944 let tmp = tempfile::tempdir().unwrap();
948 let ws = ws_with_projects(tmp.path(), &["p"]);
949 let store = Store::new(ws.project("p").unwrap(), "dev");
950 let dir = store
951 .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
952 .parent()
953 .unwrap()
954 .to_path_buf();
955 std::fs::create_dir_all(&dir).unwrap();
956 std::fs::write(
957 dir.join("regulus.json"),
958 json!({"name": "Regulus-Prod", "model": "m1"}).to_string(),
959 )
960 .unwrap();
961 let r = ResourceRef::new(ResourceKind::Agent, "Regulus-Prod");
962 store
963 .write(&r, &json!({"name": "Regulus-Prod", "model": "m2"}))
964 .unwrap();
965 assert!(
966 !dir.join("Regulus-Prod.json").exists(),
967 "must not create a second file keyed on the physical name"
968 );
969 let on_disk: Value =
970 serde_json::from_str(&std::fs::read_to_string(dir.join("regulus.json")).unwrap())
971 .unwrap();
972 assert_eq!(on_disk["model"], json!("m2"));
973 }
974
975 #[test]
976 fn write_never_overwrites_a_renamed_resources_stem() {
977 let tmp = tempfile::tempdir().unwrap();
982 let ws = ws_with_projects(tmp.path(), &["p"]);
983 let store = Store::new(ws.project("p").unwrap(), "dev");
984 let dir = store
985 .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
986 .parent()
987 .unwrap()
988 .to_path_buf();
989 std::fs::create_dir_all(&dir).unwrap();
990 std::fs::write(
991 dir.join("regulus.json"),
992 json!({"name": "Regulus-Prod", "model": "m"}).to_string(),
993 )
994 .unwrap();
995
996 let r = ResourceRef::new(ResourceKind::Agent, "regulus");
997 assert!(
998 store
999 .write(&r, &json!({"name": "regulus", "model": "new"}))
1000 .unwrap()
1001 );
1002
1003 let original: Value =
1005 serde_json::from_str(&std::fs::read_to_string(dir.join("regulus.json")).unwrap())
1006 .unwrap();
1007 assert_eq!(
1008 original["name"],
1009 json!("Regulus-Prod"),
1010 "original file untouched"
1011 );
1012
1013 let new_path = store.locate(&r).unwrap().expect("new resource locatable");
1015 assert_ne!(new_path, dir.join("regulus.json"));
1016 assert_eq!(new_path, dir.join("regulus-2.json"));
1017
1018 assert_eq!(
1020 store
1021 .locate(&ResourceRef::new(ResourceKind::Agent, "Regulus-Prod"))
1022 .unwrap(),
1023 Some(dir.join("regulus.json"))
1024 );
1025 assert_eq!(store.list().unwrap().len(), 2);
1026 }
1027
1028 #[test]
1029 fn delete_by_physical_name_removes_located_file_and_stem_sidecars() {
1030 let tmp = tempfile::tempdir().unwrap();
1035 let ws = ws_with_projects(tmp.path(), &["p"]);
1036 let store = Store::new(ws.project("p").unwrap(), "dev");
1037 let dir = store
1038 .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
1039 .parent()
1040 .unwrap()
1041 .to_path_buf();
1042 std::fs::create_dir_all(&dir).unwrap();
1043 std::fs::write(
1044 dir.join("regulus.json"),
1045 json!({
1046 "name": "Regulus-Prod", "model": "m",
1047 "instructions": {"$file": "regulus.instructions.md"}
1048 })
1049 .to_string(),
1050 )
1051 .unwrap();
1052 std::fs::write(dir.join("regulus.instructions.md"), "Be helpful.").unwrap();
1053 std::fs::write(
1055 dir.join("other.json"),
1056 json!({
1057 "name": "other", "model": "m",
1058 "instructions": {"$file": "other.instructions.md"}
1059 })
1060 .to_string(),
1061 )
1062 .unwrap();
1063 std::fs::write(dir.join("other.instructions.md"), "Neighbor.").unwrap();
1064
1065 store
1066 .delete(&ResourceRef::new(ResourceKind::Agent, "Regulus-Prod"))
1067 .unwrap();
1068 assert!(!dir.join("regulus.json").exists(), "located file removed");
1069 assert!(
1070 !dir.join("regulus.instructions.md").exists(),
1071 "stem-named sidecar removed"
1072 );
1073 assert!(dir.join("other.json").exists(), "neighbor untouched");
1074 assert!(
1075 dir.join("other.instructions.md").exists(),
1076 "neighbor sidecar untouched"
1077 );
1078
1079 store
1081 .delete(&ResourceRef::new(ResourceKind::Agent, "ghost"))
1082 .unwrap();
1083 assert!(dir.join("other.json").exists());
1084 }
1085
1086 #[test]
1087 fn list_error_on_corrupt_json_names_the_file() {
1088 let tmp = tempfile::tempdir().unwrap();
1089 let ws = ws_with_projects(tmp.path(), &["p"]);
1090 let store = Store::new(ws.project("p").unwrap(), "dev");
1091 let dir = store
1092 .path_for(&ResourceRef::new(ResourceKind::Index, "a"))
1093 .parent()
1094 .unwrap()
1095 .to_path_buf();
1096 std::fs::create_dir_all(&dir).unwrap();
1097 std::fs::write(dir.join("broken.json"), "{ this is not json").unwrap();
1098 let err = store.list().unwrap_err();
1099 assert!(matches!(err, StoreError::Parse { .. }));
1100 assert!(
1101 err.to_string().contains("broken.json"),
1102 "error names the broken file: {err}"
1103 );
1104 }
1105
1106 #[test]
1107 fn duplicate_physical_name_in_one_kind_dir_errors() {
1108 let tmp = tempfile::tempdir().unwrap();
1109 let ws = ws_with_projects(tmp.path(), &["p"]);
1110 let store = Store::new(ws.project("p").unwrap(), "dev");
1111 let dir = store
1112 .path_for(&ResourceRef::new(ResourceKind::Index, "a"))
1113 .parent()
1114 .unwrap()
1115 .to_path_buf();
1116 std::fs::create_dir_all(&dir).unwrap();
1117 std::fs::write(dir.join("a.json"), json!({"name": "dup"}).to_string()).unwrap();
1118 std::fs::write(dir.join("b.json"), json!({"name": "dup"}).to_string()).unwrap();
1119 let err = store.list().unwrap_err();
1120 assert!(matches!(err, StoreError::DuplicatePhysicalName { .. }));
1121 assert!(err.to_string().contains("dup"));
1122 }
1123
1124 #[test]
1125 fn classify_truth_table() {
1126 let tmp = tempfile::tempdir().unwrap();
1127 let ws = ws_with_projects(tmp.path(), &["p"]);
1128 let r = ResourceRef::new(ResourceKind::Index, "idx");
1129 let a = json!({"name": "idx", "fields": [{"name": "f1"}]});
1130 let b = json!({"name": "idx", "fields": [{"name": "f2"}]});
1131 let c = json!({"name": "idx", "fields": [{"name": "f3"}]});
1132
1133 let mut state = ProjectState::default();
1134 assert_eq!(state.classify(&r, Some(&a), Some(&a)), SyncClass::InSync);
1136 assert_eq!(state.classify(&r, Some(&a), Some(&b)), SyncClass::Untracked);
1137 assert_eq!(state.classify(&r, Some(&a), None), SyncClass::LocalOnly);
1138 assert_eq!(state.classify(&r, None, Some(&a)), SyncClass::RemoteOnly);
1139 assert_eq!(state.classify(&r, None, None), SyncClass::InSync);
1140
1141 state.set_baseline(&r, &a);
1143 assert_eq!(state.classify(&r, Some(&a), Some(&a)), SyncClass::InSync);
1144 assert_eq!(
1145 state.classify(&r, Some(&b), Some(&a)),
1146 SyncClass::LocalAhead
1147 );
1148 assert_eq!(
1149 state.classify(&r, Some(&a), Some(&b)),
1150 SyncClass::RemoteAhead
1151 );
1152 assert_eq!(state.classify(&r, Some(&b), Some(&c)), SyncClass::Conflict);
1153 assert_eq!(state.classify(&r, Some(&b), Some(&b)), SyncClass::InSync);
1154
1155 state.save(&ws, "dev", "p").unwrap();
1157 let loaded = ProjectState::load(&ws, "dev", "p");
1158 assert_eq!(loaded.baseline_checksum(&r), state.baseline_checksum(&r));
1159 }
1160
1161 #[test]
1162 fn legacy_checksum_baseline_still_loads_and_classifies() {
1163 let json = r#"{"baselines": {"agents/a": "deadbeef"}}"#;
1165 let state: ProjectState = serde_json::from_str(json).unwrap();
1166 let r = ResourceRef::new(ResourceKind::Agent, "a".to_string());
1167 assert!(state.has_baseline(&r));
1168 let local = json!({"name": "a", "model": "x"});
1170 let remote = json!({"name": "a", "model": "y"});
1171 assert_eq!(
1172 state.classify(&r, Some(&local), Some(&remote)),
1173 SyncClass::Conflict
1174 );
1175 }
1176
1177 #[test]
1178 fn doc_baseline_self_heals_across_rule_changes() {
1179 let r = ResourceRef::new(ResourceKind::Agent, "a".to_string());
1185 let old_doc = json!({
1186 "name": "a", "model": "x",
1187 "metadata": {"modified_at": "111", "logo": "l.svg"}
1188 });
1189 let mut state = ProjectState::default();
1190 state.baselines.insert(r.key(), Baseline::Doc(old_doc));
1191 let local = json!({
1192 "name": "a", "model": "x", "metadata": {"logo": "l.svg"}
1193 });
1194 let remote = json!({
1195 "name": "a", "model": "CHANGED", "metadata": {"logo": "l.svg"}
1196 });
1197 assert_eq!(
1198 state.classify(&r, Some(&local), Some(&remote)),
1199 SyncClass::RemoteAhead
1200 );
1201 }
1202
1203 #[test]
1204 fn baseline_serde_mixed_roundtrip() {
1205 let r = ResourceRef::new(ResourceKind::Agent, "new".to_string());
1206 let mut state = ProjectState::default();
1207 state.baselines.insert(
1208 "agents/legacy".to_string(),
1209 Baseline::Checksum("abc".to_string()),
1210 );
1211 state.set_baseline(&r, &json!({"name": "new", "model": "m"}));
1212 let text = serde_json::to_string(&state).unwrap();
1213 let back: ProjectState = serde_json::from_str(&text).unwrap();
1214 assert!(
1215 matches!(back.baselines.get("agents/legacy"), Some(Baseline::Checksum(s)) if s == "abc")
1216 );
1217 assert!(matches!(
1218 back.baselines.get("agents/new"),
1219 Some(Baseline::Doc(_))
1220 ));
1221 }
1222
1223 #[test]
1224 fn write_only_fields_survive_server_echo_and_compare() {
1225 let tmp = tempfile::tempdir().unwrap();
1226 let ws = ws_with_projects(tmp.path(), &["p"]);
1227 let store = Store::new(ws.project("p").unwrap(), "dev");
1228 let r = ResourceRef::new(ResourceKind::DataSource, "ds");
1229 let local = json!({
1230 "name": "ds", "type": "azureblob",
1231 "credentials": {"connectionString": "ResourceId=/subscriptions/s/x;"},
1232 "container": {"name": "c"}
1233 });
1234 store.write(&r, &local).unwrap();
1235 let server_echo = json!({
1237 "name": "ds", "type": "azureblob",
1238 "credentials": {"connectionString": null},
1239 "container": {"name": "c"}
1240 });
1241 assert!(!store.write(&r, &server_echo).unwrap());
1243 let read = store.read(&r).unwrap();
1244 assert_eq!(
1245 read["credentials"]["connectionString"],
1246 json!("ResourceId=/subscriptions/s/x;")
1247 );
1248 assert_eq!(
1250 ProjectState::checksum(ResourceKind::DataSource, &local),
1251 ProjectState::checksum(ResourceKind::DataSource, &server_echo)
1252 );
1253 }
1254
1255 #[test]
1256 fn checksum_is_order_canonical() {
1257 let a = serde_json::from_str::<Value>(
1259 r#"{"name": "i", "fields": [{"name": "b"}, {"name": "a"}], "x": 1}"#,
1260 )
1261 .unwrap();
1262 let b = serde_json::from_str::<Value>(
1263 r#"{"x": 1, "name": "i", "fields": [{"name": "a"}, {"name": "b"}]}"#,
1264 )
1265 .unwrap();
1266 assert_eq!(
1267 ProjectState::checksum(ResourceKind::Index, &a),
1268 ProjectState::checksum(ResourceKind::Index, &b)
1269 );
1270 }
1271
1272 #[test]
1273 fn checksum_ignores_volatile_and_annotations() {
1274 let a = json!({"name": "i", "@odata.etag": "1", "x-rigg-note": "hi"});
1275 let b = json!({"name": "i"});
1276 assert_eq!(
1277 ProjectState::checksum(ResourceKind::Index, &a),
1278 ProjectState::checksum(ResourceKind::Index, &b)
1279 );
1280 }
1281
1282 #[test]
1283 fn write_at_creates_file_at_given_stem() {
1284 let tmp = tempfile::tempdir().unwrap();
1285 let ws = ws_with_projects(tmp.path(), &["p"]);
1286 let store = Store::new(ws.project("p").unwrap(), "prod");
1287 let created = store
1288 .write_at(
1289 "regulus",
1290 ResourceKind::Agent,
1291 &json!({"name": "Regulus-Prod", "model": "m"}),
1292 )
1293 .unwrap();
1294 assert!(created);
1295 let dir = store
1296 .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
1297 .parent()
1298 .unwrap()
1299 .to_path_buf();
1300 assert!(
1301 dir.join("regulus.json").is_file(),
1302 "landed at the stem, not the physical name"
1303 );
1304 assert!(!dir.join("Regulus-Prod.json").exists());
1305 let on_disk: Value =
1306 serde_json::from_str(&std::fs::read_to_string(dir.join("regulus.json")).unwrap())
1307 .unwrap();
1308 assert_eq!(on_disk["name"], json!("Regulus-Prod"));
1309 }
1310
1311 #[test]
1312 fn write_at_updates_in_place_when_physical_name_matches() {
1313 let tmp = tempfile::tempdir().unwrap();
1314 let ws = ws_with_projects(tmp.path(), &["p"]);
1315 let store = Store::new(ws.project("p").unwrap(), "prod");
1316 store
1317 .write_at(
1318 "regulus",
1319 ResourceKind::Agent,
1320 &json!({"name": "Regulus-Prod", "model": "m1"}),
1321 )
1322 .unwrap();
1323 let rewritten = store
1324 .write_at(
1325 "regulus",
1326 ResourceKind::Agent,
1327 &json!({"name": "Regulus-Prod", "model": "m2"}),
1328 )
1329 .unwrap();
1330 assert!(rewritten);
1331 let dir = store
1332 .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
1333 .parent()
1334 .unwrap()
1335 .to_path_buf();
1336 let on_disk: Value =
1337 serde_json::from_str(&std::fs::read_to_string(dir.join("regulus.json")).unwrap())
1338 .unwrap();
1339 assert_eq!(on_disk["model"], json!("m2"));
1340 }
1341
1342 #[test]
1343 fn write_at_refuses_to_overwrite_a_different_resources_stem() {
1344 let tmp = tempfile::tempdir().unwrap();
1345 let ws = ws_with_projects(tmp.path(), &["p"]);
1346 let store = Store::new(ws.project("p").unwrap(), "prod");
1347 store
1348 .write_at(
1349 "regulus",
1350 ResourceKind::Agent,
1351 &json!({"name": "Regulus-Prod", "model": "m1"}),
1352 )
1353 .unwrap();
1354 let err = store
1355 .write_at(
1356 "regulus",
1357 ResourceKind::Agent,
1358 &json!({"name": "some-other-name", "model": "m2"}),
1359 )
1360 .unwrap_err();
1361 assert!(matches!(
1362 err,
1363 StoreError::StemOccupiedByDifferentResource { .. }
1364 ));
1365 let dir = store
1367 .path_for(&ResourceRef::new(ResourceKind::Agent, "regulus"))
1368 .parent()
1369 .unwrap()
1370 .to_path_buf();
1371 let on_disk: Value =
1372 serde_json::from_str(&std::fs::read_to_string(dir.join("regulus.json")).unwrap())
1373 .unwrap();
1374 assert_eq!(on_disk["name"], json!("Regulus-Prod"));
1375 }
1376
1377 #[test]
1378 fn write_at_returns_false_when_semantically_unchanged() {
1379 let tmp = tempfile::tempdir().unwrap();
1380 let ws = ws_with_projects(tmp.path(), &["p"]);
1381 let store = Store::new(ws.project("p").unwrap(), "prod");
1382 store
1383 .write_at(
1384 "idx",
1385 ResourceKind::Index,
1386 &json!({"name": "idx", "fields": []}),
1387 )
1388 .unwrap();
1389 let rewritten = store
1390 .write_at(
1391 "idx",
1392 ResourceKind::Index,
1393 &json!({"@odata.etag": "0x1", "name": "idx", "fields": []}),
1394 )
1395 .unwrap();
1396 assert!(!rewritten);
1397 }
1398
1399 #[test]
1400 fn write_at_rejects_path_escaping_stems() {
1401 let tmp = tempfile::tempdir().unwrap();
1402 let ws = ws_with_projects(tmp.path(), &["p"]);
1403 let store = Store::new(ws.project("p").unwrap(), "prod");
1404 let err = store.write_at("../../evil", ResourceKind::Index, &json!({"name": "evil"}));
1405 assert!(matches!(err, Err(StoreError::BadName { .. })), "{err:?}");
1406 }
1407
1408 #[test]
1409 fn write_rejects_path_escaping_names() {
1410 let tmp = tempfile::tempdir().unwrap();
1411 let ws = ws_with_projects(tmp.path(), &["p"]);
1412 let store = Store::new(ws.project("p").unwrap(), "dev");
1413 let r = ResourceRef::new(ResourceKind::Index, "../../evil");
1414 let err = store.write(&r, &json!({"name": "../../evil"}));
1415 assert!(matches!(err, Err(StoreError::BadName { .. })), "{err:?}");
1416 }
1417}