Skip to main content

rigg_core/
state.rs

1//! Local state management for tracking synced resources
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7use thiserror::Error;
8
9use crate::resources::ResourceKind;
10use crate::resources::managed::ManagedMap;
11
12/// State management errors
13#[derive(Debug, Error)]
14pub enum StateError {
15    #[error("Failed to read state: {0}")]
16    ReadError(#[from] std::io::Error),
17    #[error("Failed to parse state: {0}")]
18    ParseError(#[from] serde_json::Error),
19}
20
21/// Local state tracking (.rigg/<env>/state.json)
22#[derive(Debug, Clone, Serialize, Deserialize, Default)]
23pub struct LocalState {
24    /// Last sync timestamp
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub last_sync: Option<DateTime<Utc>>,
27    /// Resources by kind and name
28    #[serde(default)]
29    pub resources: HashMap<String, ResourceState>,
30}
31
32/// State for a single resource
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ResourceState {
35    /// Resource kind
36    pub kind: ResourceKind,
37    /// Last known ETag from Azure
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub etag: Option<String>,
40    /// Checksum of normalized JSON
41    pub checksum: String,
42    /// Last sync timestamp
43    pub synced_at: DateTime<Utc>,
44}
45
46/// Checksums for change detection (.rigg/<env>/checksums.json)
47#[derive(Debug, Clone, Serialize, Deserialize, Default)]
48pub struct Checksums {
49    /// Checksum by resource key (kind/name)
50    #[serde(default)]
51    pub checksums: HashMap<String, String>,
52}
53
54impl LocalState {
55    /// State directory name
56    pub const DIR_NAME: &'static str = ".rigg";
57    /// State file name
58    pub const STATE_FILE: &'static str = "state.json";
59    /// Checksums file name
60    pub const CHECKSUMS_FILE: &'static str = "checksums.json";
61
62    /// Get the environment-specific state directory path
63    pub fn env_dir(project_root: &Path, env_name: &str) -> PathBuf {
64        project_root.join(Self::DIR_NAME).join(env_name)
65    }
66
67    /// Get the environment-specific state file path
68    pub fn state_file_env(project_root: &Path, env_name: &str) -> PathBuf {
69        Self::env_dir(project_root, env_name).join(Self::STATE_FILE)
70    }
71
72    /// Get the environment-specific checksums file path
73    pub fn checksums_file_env(project_root: &Path, env_name: &str) -> PathBuf {
74        Self::env_dir(project_root, env_name).join(Self::CHECKSUMS_FILE)
75    }
76
77    /// Load state for an environment
78    pub fn load_env(project_root: &Path, env_name: &str) -> Result<Self, StateError> {
79        let path = Self::state_file_env(project_root, env_name);
80        if !path.exists() {
81            return Ok(Self::default());
82        }
83        let content = std::fs::read_to_string(&path)?;
84        let state: Self = serde_json::from_str(&content)?;
85        Ok(state)
86    }
87
88    /// Save state for an environment
89    pub fn save_env(&self, project_root: &Path, env_name: &str) -> Result<(), StateError> {
90        let dir = Self::env_dir(project_root, env_name);
91        std::fs::create_dir_all(&dir)?;
92
93        let path = Self::state_file_env(project_root, env_name);
94        let content = serde_json::to_string_pretty(self)?;
95        std::fs::write(&path, content)?;
96        Ok(())
97    }
98
99    /// Get resource key
100    pub fn resource_key(kind: ResourceKind, name: &str) -> String {
101        format!("{}/{}", kind.directory_name(), name)
102    }
103
104    /// Get resource key with managed map awareness.
105    ///
106    /// Managed resources use their KS directory path as the key prefix,
107    /// knowledge sources use their own directory, and standalone resources
108    /// use the default directory.
109    pub fn resource_key_managed(kind: ResourceKind, name: &str, map: &ManagedMap) -> String {
110        use crate::resources::managed::resource_directory;
111        let dir = resource_directory(kind, name, map);
112        format!("{}/{}", dir.display(), name)
113    }
114
115    /// Get resource state
116    pub fn get(&self, kind: ResourceKind, name: &str) -> Option<&ResourceState> {
117        let key = Self::resource_key(kind, name);
118        self.resources.get(&key)
119    }
120
121    /// Set resource state
122    pub fn set(&mut self, kind: ResourceKind, name: &str, state: ResourceState) {
123        let key = Self::resource_key(kind, name);
124        self.resources.insert(key, state);
125    }
126
127    /// Remove resource state
128    pub fn remove(&mut self, kind: ResourceKind, name: &str) {
129        let key = Self::resource_key(kind, name);
130        self.resources.remove(&key);
131    }
132
133    /// Get resource state using managed-aware key
134    pub fn get_managed(
135        &self,
136        kind: ResourceKind,
137        name: &str,
138        map: &ManagedMap,
139    ) -> Option<&ResourceState> {
140        let key = Self::resource_key_managed(kind, name, map);
141        self.resources.get(&key)
142    }
143
144    /// Set resource state using managed-aware key
145    pub fn set_managed(
146        &mut self,
147        kind: ResourceKind,
148        name: &str,
149        state: ResourceState,
150        map: &ManagedMap,
151    ) {
152        let key = Self::resource_key_managed(kind, name, map);
153        self.resources.insert(key, state);
154    }
155
156    /// Remove resource state using managed-aware key
157    pub fn remove_managed(&mut self, kind: ResourceKind, name: &str, map: &ManagedMap) {
158        let key = Self::resource_key_managed(kind, name, map);
159        self.resources.remove(&key);
160    }
161}
162
163impl Checksums {
164    /// Load checksums for an environment
165    pub fn load_env(project_root: &Path, env_name: &str) -> Result<Self, StateError> {
166        let path = LocalState::checksums_file_env(project_root, env_name);
167        if !path.exists() {
168            return Ok(Self::default());
169        }
170        let content = std::fs::read_to_string(&path)?;
171        let checksums: Self = serde_json::from_str(&content)?;
172        Ok(checksums)
173    }
174
175    /// Save checksums for an environment
176    pub fn save_env(&self, project_root: &Path, env_name: &str) -> Result<(), StateError> {
177        let dir = LocalState::env_dir(project_root, env_name);
178        std::fs::create_dir_all(&dir)?;
179
180        let path = LocalState::checksums_file_env(project_root, env_name);
181        let content = serde_json::to_string_pretty(self)?;
182        std::fs::write(&path, content)?;
183        Ok(())
184    }
185
186    /// Calculate checksum for content
187    pub fn calculate(content: &str) -> String {
188        use std::collections::hash_map::DefaultHasher;
189        use std::hash::{Hash, Hasher};
190
191        let mut hasher = DefaultHasher::new();
192        content.hash(&mut hasher);
193        format!("{:016x}", hasher.finish())
194    }
195
196    /// Get checksum for a resource
197    pub fn get(&self, kind: ResourceKind, name: &str) -> Option<&String> {
198        let key = LocalState::resource_key(kind, name);
199        self.checksums.get(&key)
200    }
201
202    /// Set checksum for a resource
203    pub fn set(&mut self, kind: ResourceKind, name: &str, checksum: String) {
204        let key = LocalState::resource_key(kind, name);
205        self.checksums.insert(key, checksum);
206    }
207
208    /// Remove checksum for a resource
209    pub fn remove(&mut self, kind: ResourceKind, name: &str) {
210        let key = LocalState::resource_key(kind, name);
211        self.checksums.remove(&key);
212    }
213
214    /// Get checksum for a resource using managed-aware key
215    pub fn get_managed(&self, kind: ResourceKind, name: &str, map: &ManagedMap) -> Option<&String> {
216        let key = LocalState::resource_key_managed(kind, name, map);
217        self.checksums.get(&key)
218    }
219
220    /// Set checksum for a resource using managed-aware key
221    pub fn set_managed(
222        &mut self,
223        kind: ResourceKind,
224        name: &str,
225        checksum: String,
226        map: &ManagedMap,
227    ) {
228        let key = LocalState::resource_key_managed(kind, name, map);
229        self.checksums.insert(key, checksum);
230    }
231
232    /// Remove checksum for a resource using managed-aware key
233    pub fn remove_managed(&mut self, kind: ResourceKind, name: &str, map: &ManagedMap) {
234        let key = LocalState::resource_key_managed(kind, name, map);
235        self.checksums.remove(&key);
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn test_resource_key_format() {
245        let key = LocalState::resource_key(ResourceKind::Index, "my-index");
246        assert_eq!(key, "search-management/indexes/my-index");
247    }
248
249    #[test]
250    fn test_resource_key_datasource() {
251        let key = LocalState::resource_key(ResourceKind::DataSource, "ds1");
252        assert_eq!(key, "search-management/data-sources/ds1");
253    }
254
255    #[test]
256    fn test_state_get_set() {
257        let mut state = LocalState::default();
258        assert!(state.get(ResourceKind::Index, "idx").is_none());
259
260        state.set(
261            ResourceKind::Index,
262            "idx",
263            ResourceState {
264                kind: ResourceKind::Index,
265                etag: Some("etag1".to_string()),
266                checksum: "abc".to_string(),
267                synced_at: chrono::Utc::now(),
268            },
269        );
270
271        let got = state.get(ResourceKind::Index, "idx").unwrap();
272        assert_eq!(got.checksum, "abc");
273        assert_eq!(got.etag.as_deref(), Some("etag1"));
274    }
275
276    #[test]
277    fn test_state_remove() {
278        let mut state = LocalState::default();
279        state.set(
280            ResourceKind::Index,
281            "idx",
282            ResourceState {
283                kind: ResourceKind::Index,
284                etag: None,
285                checksum: "abc".to_string(),
286                synced_at: chrono::Utc::now(),
287            },
288        );
289
290        state.remove(ResourceKind::Index, "idx");
291        assert!(state.get(ResourceKind::Index, "idx").is_none());
292    }
293
294    #[test]
295    fn test_state_save_and_load_env() {
296        let dir = tempfile::tempdir().unwrap();
297        let mut state = LocalState::default();
298        state.last_sync = Some(chrono::Utc::now());
299        state.set(
300            ResourceKind::Indexer,
301            "my-indexer",
302            ResourceState {
303                kind: ResourceKind::Indexer,
304                etag: None,
305                checksum: "hash123".to_string(),
306                synced_at: chrono::Utc::now(),
307            },
308        );
309
310        state.save_env(dir.path(), "prod").unwrap();
311        let loaded = LocalState::load_env(dir.path(), "prod").unwrap();
312
313        assert!(loaded.last_sync.is_some());
314        let got = loaded.get(ResourceKind::Indexer, "my-indexer").unwrap();
315        assert_eq!(got.checksum, "hash123");
316    }
317
318    #[test]
319    fn test_state_load_missing_returns_default() {
320        let dir = tempfile::tempdir().unwrap();
321        let state = LocalState::load_env(dir.path(), "prod").unwrap();
322        assert!(state.last_sync.is_none());
323        assert!(state.resources.is_empty());
324    }
325
326    #[test]
327    fn test_checksums_calculate_deterministic() {
328        let c1 = Checksums::calculate("hello world");
329        let c2 = Checksums::calculate("hello world");
330        assert_eq!(c1, c2);
331    }
332
333    #[test]
334    fn test_checksums_calculate_different_input() {
335        let c1 = Checksums::calculate("hello");
336        let c2 = Checksums::calculate("world");
337        assert_ne!(c1, c2);
338    }
339
340    #[test]
341    fn test_checksums_get_set() {
342        let mut checksums = Checksums::default();
343        assert!(checksums.get(ResourceKind::Index, "idx").is_none());
344
345        checksums.set(ResourceKind::Index, "idx", "abc123".to_string());
346        assert_eq!(
347            checksums.get(ResourceKind::Index, "idx"),
348            Some(&"abc123".to_string())
349        );
350    }
351
352    #[test]
353    fn test_checksums_remove() {
354        let mut checksums = Checksums::default();
355        checksums.set(ResourceKind::Index, "idx", "abc123".to_string());
356        assert!(checksums.get(ResourceKind::Index, "idx").is_some());
357
358        checksums.remove(ResourceKind::Index, "idx");
359        assert!(checksums.get(ResourceKind::Index, "idx").is_none());
360    }
361
362    #[test]
363    fn test_checksums_save_and_load_env() {
364        let dir = tempfile::tempdir().unwrap();
365        let mut checksums = Checksums::default();
366        checksums.set(ResourceKind::Skillset, "sk1", "hash1".to_string());
367
368        checksums.save_env(dir.path(), "test").unwrap();
369        let loaded = Checksums::load_env(dir.path(), "test").unwrap();
370
371        assert_eq!(
372            loaded.get(ResourceKind::Skillset, "sk1"),
373            Some(&"hash1".to_string())
374        );
375    }
376
377    #[test]
378    fn test_env_dir_path() {
379        let root = Path::new("/my/project");
380        assert_eq!(
381            LocalState::env_dir(root, "prod"),
382            PathBuf::from("/my/project/.rigg/prod")
383        );
384    }
385
386    #[test]
387    fn test_state_file_env_path() {
388        let root = Path::new("/my/project");
389        assert_eq!(
390            LocalState::state_file_env(root, "prod"),
391            PathBuf::from("/my/project/.rigg/prod/state.json")
392        );
393    }
394
395    #[test]
396    fn test_checksums_file_env_path() {
397        let root = Path::new("/my/project");
398        assert_eq!(
399            LocalState::checksums_file_env(root, "test"),
400            PathBuf::from("/my/project/.rigg/test/checksums.json")
401        );
402    }
403
404    #[test]
405    fn test_resource_key_managed_standalone() {
406        let map = ManagedMap::new();
407        let key = LocalState::resource_key_managed(ResourceKind::Index, "my-index", &map);
408        assert_eq!(key, "search-management/indexes/my-index");
409    }
410
411    #[test]
412    fn test_resource_key_managed_ks() {
413        let map = ManagedMap::new();
414        let key = LocalState::resource_key_managed(ResourceKind::KnowledgeSource, "test-ks", &map);
415        assert_eq!(key, "agentic-retrieval/knowledge-sources/test-ks/test-ks");
416    }
417
418    #[test]
419    fn test_resource_key_managed_sub_resource() {
420        let mut map = ManagedMap::new();
421        map.insert(
422            (ResourceKind::Index, "test-ks-index".to_string()),
423            "test-ks".to_string(),
424        );
425        let key = LocalState::resource_key_managed(ResourceKind::Index, "test-ks-index", &map);
426        assert_eq!(
427            key,
428            "agentic-retrieval/knowledge-sources/test-ks/test-ks-index"
429        );
430    }
431
432    #[test]
433    fn test_checksums_managed_get_set() {
434        let mut checksums = Checksums::default();
435        let mut map = ManagedMap::new();
436        map.insert(
437            (ResourceKind::Index, "ks-1-index".to_string()),
438            "ks-1".to_string(),
439        );
440
441        assert!(
442            checksums
443                .get_managed(ResourceKind::Index, "ks-1-index", &map)
444                .is_none()
445        );
446
447        checksums.set_managed(
448            ResourceKind::Index,
449            "ks-1-index",
450            "abc123".to_string(),
451            &map,
452        );
453        assert_eq!(
454            checksums.get_managed(ResourceKind::Index, "ks-1-index", &map),
455            Some(&"abc123".to_string())
456        );
457
458        checksums.remove_managed(ResourceKind::Index, "ks-1-index", &map);
459        assert!(
460            checksums
461                .get_managed(ResourceKind::Index, "ks-1-index", &map)
462                .is_none()
463        );
464    }
465
466    /// Verifies the local modification detection pattern used by pull.
467    /// When a file is modified locally after pull, its disk checksum differs
468    /// from the stored checksum.
469    #[test]
470    fn test_local_modification_detection() {
471        let original_content = r#"{"name": "my-index", "fields": []}"#;
472        let stored_checksum = Checksums::calculate(original_content);
473
474        // User modifies the file locally
475        let modified_content = r#"{"name": "my-index", "fields": [{"name": "id"}]}"#;
476        let disk_checksum = Checksums::calculate(modified_content);
477
478        assert_ne!(
479            stored_checksum, disk_checksum,
480            "Modified file should have different checksum"
481        );
482    }
483
484    /// Verifies that unmodified files match their stored checksum.
485    #[test]
486    fn test_no_local_modification_when_unchanged() {
487        let content = r#"{"name": "my-index", "fields": []}"#;
488        let stored_checksum = Checksums::calculate(content);
489        let disk_checksum = Checksums::calculate(content);
490
491        assert_eq!(
492            stored_checksum, disk_checksum,
493            "Unmodified file should match stored checksum"
494        );
495    }
496
497    /// Verifies the full pull workflow: store checksum, write file, read back.
498    #[test]
499    fn test_checksums_roundtrip_with_file() {
500        let dir = tempfile::tempdir().unwrap();
501        let content = r#"{"name": "test", "fields": []}"#;
502        let checksum = Checksums::calculate(content);
503
504        // Write file and store checksum (simulates pull)
505        let file_path = dir.path().join("test.json");
506        std::fs::write(&file_path, content).unwrap();
507
508        let mut checksums = Checksums::default();
509        checksums.set(ResourceKind::Index, "test", checksum.clone());
510        checksums.save_env(dir.path(), "prod").unwrap();
511
512        // Read back and verify
513        let loaded = Checksums::load_env(dir.path(), "prod").unwrap();
514        let stored = loaded.get(ResourceKind::Index, "test").unwrap();
515
516        let disk_content = std::fs::read_to_string(&file_path).unwrap();
517        let disk_checksum = Checksums::calculate(&disk_content);
518
519        assert_eq!(*stored, disk_checksum, "Stored checksum should match disk");
520
521        // Now modify the file
522        std::fs::write(
523            &file_path,
524            r#"{"name": "test", "fields": [{"name": "id"}]}"#,
525        )
526        .unwrap();
527        let modified_disk = std::fs::read_to_string(&file_path).unwrap();
528        let modified_checksum = Checksums::calculate(&modified_disk);
529
530        assert_ne!(
531            *stored, modified_checksum,
532            "Modified file should differ from stored checksum"
533        );
534    }
535
536    #[test]
537    fn test_checksums_load_bare_empty_json() {
538        let dir = tempfile::tempdir().unwrap();
539        let env_dir = dir.path().join(".rigg").join("test");
540        std::fs::create_dir_all(&env_dir).unwrap();
541        std::fs::write(env_dir.join("checksums.json"), "{}").unwrap();
542
543        let checksums = Checksums::load_env(dir.path(), "test").unwrap();
544        assert!(checksums.checksums.is_empty());
545    }
546
547    #[test]
548    fn test_separate_envs_dont_interfere() {
549        let dir = tempfile::tempdir().unwrap();
550
551        let mut state_prod = LocalState::default();
552        state_prod.set(
553            ResourceKind::Index,
554            "idx",
555            ResourceState {
556                kind: ResourceKind::Index,
557                etag: None,
558                checksum: "prod-hash".to_string(),
559                synced_at: chrono::Utc::now(),
560            },
561        );
562        state_prod.save_env(dir.path(), "prod").unwrap();
563
564        let mut state_test = LocalState::default();
565        state_test.set(
566            ResourceKind::Index,
567            "idx",
568            ResourceState {
569                kind: ResourceKind::Index,
570                etag: None,
571                checksum: "test-hash".to_string(),
572                synced_at: chrono::Utc::now(),
573            },
574        );
575        state_test.save_env(dir.path(), "test").unwrap();
576
577        let loaded_prod = LocalState::load_env(dir.path(), "prod").unwrap();
578        let loaded_test = LocalState::load_env(dir.path(), "test").unwrap();
579
580        assert_eq!(
581            loaded_prod
582                .get(ResourceKind::Index, "idx")
583                .unwrap()
584                .checksum,
585            "prod-hash"
586        );
587        assert_eq!(
588            loaded_test
589                .get(ResourceKind::Index, "idx")
590                .unwrap()
591                .checksum,
592            "test-hash"
593        );
594    }
595}