1use 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#[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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
23pub struct LocalState {
24 #[serde(skip_serializing_if = "Option::is_none")]
26 pub last_sync: Option<DateTime<Utc>>,
27 #[serde(default)]
29 pub resources: HashMap<String, ResourceState>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ResourceState {
35 pub kind: ResourceKind,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub etag: Option<String>,
40 pub checksum: String,
42 pub synced_at: DateTime<Utc>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, Default)]
48pub struct Checksums {
49 #[serde(default)]
51 pub checksums: HashMap<String, String>,
52}
53
54impl LocalState {
55 pub const DIR_NAME: &'static str = ".rigg";
57 pub const STATE_FILE: &'static str = "state.json";
59 pub const CHECKSUMS_FILE: &'static str = "checksums.json";
61
62 pub fn env_dir(project_root: &Path, env_name: &str) -> PathBuf {
64 project_root.join(Self::DIR_NAME).join(env_name)
65 }
66
67 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 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 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 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 pub fn resource_key(kind: ResourceKind, name: &str) -> String {
101 format!("{}/{}", kind.directory_name(), name)
102 }
103
104 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 #[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 #[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 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 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 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}