Skip to main content

hoist_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::managed::ManagedMap;
10use crate::resources::ResourceKind;
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 (.hoist/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 (.hoist/checksums.json)
47#[derive(Debug, Clone, Serialize, Deserialize, Default)]
48pub struct Checksums {
49    /// Checksum by resource key (kind/name)
50    pub checksums: HashMap<String, String>,
51}
52
53impl LocalState {
54    /// State directory name
55    pub const DIR_NAME: &'static str = ".hoist";
56    /// State file name
57    pub const STATE_FILE: &'static str = "state.json";
58    /// Checksums file name
59    pub const CHECKSUMS_FILE: &'static str = "checksums.json";
60
61    /// Get the state directory path
62    pub fn state_dir(project_root: &Path) -> PathBuf {
63        project_root.join(Self::DIR_NAME)
64    }
65
66    /// Get the state file path
67    pub fn state_file(project_root: &Path) -> PathBuf {
68        Self::state_dir(project_root).join(Self::STATE_FILE)
69    }
70
71    /// Get the checksums file path
72    pub fn checksums_file(project_root: &Path) -> PathBuf {
73        Self::state_dir(project_root).join(Self::CHECKSUMS_FILE)
74    }
75
76    /// Load state from disk
77    pub fn load(project_root: &Path) -> Result<Self, StateError> {
78        let path = Self::state_file(project_root);
79        if !path.exists() {
80            return Ok(Self::default());
81        }
82        let content = std::fs::read_to_string(&path)?;
83        let state: Self = serde_json::from_str(&content)?;
84        Ok(state)
85    }
86
87    /// Save state to disk
88    pub fn save(&self, project_root: &Path) -> Result<(), StateError> {
89        let dir = Self::state_dir(project_root);
90        std::fs::create_dir_all(&dir)?;
91
92        let path = Self::state_file(project_root);
93        let content = serde_json::to_string_pretty(self)?;
94        std::fs::write(&path, content)?;
95        Ok(())
96    }
97
98    /// Get resource key
99    pub fn resource_key(kind: ResourceKind, name: &str) -> String {
100        format!("{}/{}", kind.directory_name(), name)
101    }
102
103    /// Get resource key with managed map awareness.
104    ///
105    /// Managed resources use their KS directory path as the key prefix,
106    /// knowledge sources use their own directory, and standalone resources
107    /// use the default directory.
108    pub fn resource_key_managed(kind: ResourceKind, name: &str, map: &ManagedMap) -> String {
109        use crate::resources::managed::resource_directory;
110        let dir = resource_directory(kind, name, map);
111        format!("{}/{}", dir.display(), name)
112    }
113
114    /// Get resource state
115    pub fn get(&self, kind: ResourceKind, name: &str) -> Option<&ResourceState> {
116        let key = Self::resource_key(kind, name);
117        self.resources.get(&key)
118    }
119
120    /// Set resource state
121    pub fn set(&mut self, kind: ResourceKind, name: &str, state: ResourceState) {
122        let key = Self::resource_key(kind, name);
123        self.resources.insert(key, state);
124    }
125
126    /// Remove resource state
127    pub fn remove(&mut self, kind: ResourceKind, name: &str) {
128        let key = Self::resource_key(kind, name);
129        self.resources.remove(&key);
130    }
131
132    /// Get resource state using managed-aware key
133    pub fn get_managed(
134        &self,
135        kind: ResourceKind,
136        name: &str,
137        map: &ManagedMap,
138    ) -> Option<&ResourceState> {
139        let key = Self::resource_key_managed(kind, name, map);
140        self.resources.get(&key)
141    }
142
143    /// Set resource state using managed-aware key
144    pub fn set_managed(
145        &mut self,
146        kind: ResourceKind,
147        name: &str,
148        state: ResourceState,
149        map: &ManagedMap,
150    ) {
151        let key = Self::resource_key_managed(kind, name, map);
152        self.resources.insert(key, state);
153    }
154
155    /// Remove resource state using managed-aware key
156    pub fn remove_managed(&mut self, kind: ResourceKind, name: &str, map: &ManagedMap) {
157        let key = Self::resource_key_managed(kind, name, map);
158        self.resources.remove(&key);
159    }
160}
161
162impl Checksums {
163    /// Load checksums from disk
164    pub fn load(project_root: &Path) -> Result<Self, StateError> {
165        let path = LocalState::checksums_file(project_root);
166        if !path.exists() {
167            return Ok(Self::default());
168        }
169        let content = std::fs::read_to_string(&path)?;
170        let checksums: Self = serde_json::from_str(&content)?;
171        Ok(checksums)
172    }
173
174    /// Save checksums to disk
175    pub fn save(&self, project_root: &Path) -> Result<(), StateError> {
176        let dir = LocalState::state_dir(project_root);
177        std::fs::create_dir_all(&dir)?;
178
179        let path = LocalState::checksums_file(project_root);
180        let content = serde_json::to_string_pretty(self)?;
181        std::fs::write(&path, content)?;
182        Ok(())
183    }
184
185    /// Calculate checksum for content
186    pub fn calculate(content: &str) -> String {
187        use std::collections::hash_map::DefaultHasher;
188        use std::hash::{Hash, Hasher};
189
190        let mut hasher = DefaultHasher::new();
191        content.hash(&mut hasher);
192        format!("{:016x}", hasher.finish())
193    }
194
195    /// Get checksum for a resource
196    pub fn get(&self, kind: ResourceKind, name: &str) -> Option<&String> {
197        let key = LocalState::resource_key(kind, name);
198        self.checksums.get(&key)
199    }
200
201    /// Set checksum for a resource
202    pub fn set(&mut self, kind: ResourceKind, name: &str, checksum: String) {
203        let key = LocalState::resource_key(kind, name);
204        self.checksums.insert(key, checksum);
205    }
206
207    /// Remove checksum for a resource
208    pub fn remove(&mut self, kind: ResourceKind, name: &str) {
209        let key = LocalState::resource_key(kind, name);
210        self.checksums.remove(&key);
211    }
212
213    /// Get checksum for a resource using managed-aware key
214    pub fn get_managed(&self, kind: ResourceKind, name: &str, map: &ManagedMap) -> Option<&String> {
215        let key = LocalState::resource_key_managed(kind, name, map);
216        self.checksums.get(&key)
217    }
218
219    /// Set checksum for a resource using managed-aware key
220    pub fn set_managed(
221        &mut self,
222        kind: ResourceKind,
223        name: &str,
224        checksum: String,
225        map: &ManagedMap,
226    ) {
227        let key = LocalState::resource_key_managed(kind, name, map);
228        self.checksums.insert(key, checksum);
229    }
230
231    /// Remove checksum for a resource using managed-aware key
232    pub fn remove_managed(&mut self, kind: ResourceKind, name: &str, map: &ManagedMap) {
233        let key = LocalState::resource_key_managed(kind, name, map);
234        self.checksums.remove(&key);
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn test_resource_key_format() {
244        let key = LocalState::resource_key(ResourceKind::Index, "my-index");
245        assert_eq!(key, "search-management/indexes/my-index");
246    }
247
248    #[test]
249    fn test_resource_key_datasource() {
250        let key = LocalState::resource_key(ResourceKind::DataSource, "ds1");
251        assert_eq!(key, "search-management/data-sources/ds1");
252    }
253
254    #[test]
255    fn test_state_get_set() {
256        let mut state = LocalState::default();
257        assert!(state.get(ResourceKind::Index, "idx").is_none());
258
259        state.set(
260            ResourceKind::Index,
261            "idx",
262            ResourceState {
263                kind: ResourceKind::Index,
264                etag: Some("etag1".to_string()),
265                checksum: "abc".to_string(),
266                synced_at: chrono::Utc::now(),
267            },
268        );
269
270        let got = state.get(ResourceKind::Index, "idx").unwrap();
271        assert_eq!(got.checksum, "abc");
272        assert_eq!(got.etag.as_deref(), Some("etag1"));
273    }
274
275    #[test]
276    fn test_state_remove() {
277        let mut state = LocalState::default();
278        state.set(
279            ResourceKind::Index,
280            "idx",
281            ResourceState {
282                kind: ResourceKind::Index,
283                etag: None,
284                checksum: "abc".to_string(),
285                synced_at: chrono::Utc::now(),
286            },
287        );
288
289        state.remove(ResourceKind::Index, "idx");
290        assert!(state.get(ResourceKind::Index, "idx").is_none());
291    }
292
293    #[test]
294    fn test_state_save_and_load() {
295        let dir = tempfile::tempdir().unwrap();
296        let mut state = LocalState::default();
297        state.last_sync = Some(chrono::Utc::now());
298        state.set(
299            ResourceKind::Indexer,
300            "my-indexer",
301            ResourceState {
302                kind: ResourceKind::Indexer,
303                etag: None,
304                checksum: "hash123".to_string(),
305                synced_at: chrono::Utc::now(),
306            },
307        );
308
309        state.save(dir.path()).unwrap();
310        let loaded = LocalState::load(dir.path()).unwrap();
311
312        assert!(loaded.last_sync.is_some());
313        let got = loaded.get(ResourceKind::Indexer, "my-indexer").unwrap();
314        assert_eq!(got.checksum, "hash123");
315    }
316
317    #[test]
318    fn test_state_load_missing_returns_default() {
319        let dir = tempfile::tempdir().unwrap();
320        let state = LocalState::load(dir.path()).unwrap();
321        assert!(state.last_sync.is_none());
322        assert!(state.resources.is_empty());
323    }
324
325    #[test]
326    fn test_checksums_calculate_deterministic() {
327        let c1 = Checksums::calculate("hello world");
328        let c2 = Checksums::calculate("hello world");
329        assert_eq!(c1, c2);
330    }
331
332    #[test]
333    fn test_checksums_calculate_different_input() {
334        let c1 = Checksums::calculate("hello");
335        let c2 = Checksums::calculate("world");
336        assert_ne!(c1, c2);
337    }
338
339    #[test]
340    fn test_checksums_get_set() {
341        let mut checksums = Checksums::default();
342        assert!(checksums.get(ResourceKind::Index, "idx").is_none());
343
344        checksums.set(ResourceKind::Index, "idx", "abc123".to_string());
345        assert_eq!(
346            checksums.get(ResourceKind::Index, "idx"),
347            Some(&"abc123".to_string())
348        );
349    }
350
351    #[test]
352    fn test_checksums_remove() {
353        let mut checksums = Checksums::default();
354        checksums.set(ResourceKind::Index, "idx", "abc123".to_string());
355        assert!(checksums.get(ResourceKind::Index, "idx").is_some());
356
357        checksums.remove(ResourceKind::Index, "idx");
358        assert!(checksums.get(ResourceKind::Index, "idx").is_none());
359    }
360
361    #[test]
362    fn test_checksums_save_and_load() {
363        let dir = tempfile::tempdir().unwrap();
364        let mut checksums = Checksums::default();
365        checksums.set(ResourceKind::Skillset, "sk1", "hash1".to_string());
366
367        checksums.save(dir.path()).unwrap();
368        let loaded = Checksums::load(dir.path()).unwrap();
369
370        assert_eq!(
371            loaded.get(ResourceKind::Skillset, "sk1"),
372            Some(&"hash1".to_string())
373        );
374    }
375
376    #[test]
377    fn test_state_dir_path() {
378        let root = Path::new("/my/project");
379        assert_eq!(
380            LocalState::state_dir(root),
381            PathBuf::from("/my/project/.hoist")
382        );
383    }
384
385    #[test]
386    fn test_state_file_path() {
387        let root = Path::new("/my/project");
388        assert_eq!(
389            LocalState::state_file(root),
390            PathBuf::from("/my/project/.hoist/state.json")
391        );
392    }
393
394    #[test]
395    fn test_checksums_file_path() {
396        let root = Path::new("/my/project");
397        assert_eq!(
398            LocalState::checksums_file(root),
399            PathBuf::from("/my/project/.hoist/checksums.json")
400        );
401    }
402
403    #[test]
404    fn test_resource_key_managed_standalone() {
405        let map = ManagedMap::new();
406        let key = LocalState::resource_key_managed(ResourceKind::Index, "my-index", &map);
407        assert_eq!(key, "search-management/indexes/my-index");
408    }
409
410    #[test]
411    fn test_resource_key_managed_ks() {
412        let map = ManagedMap::new();
413        let key = LocalState::resource_key_managed(ResourceKind::KnowledgeSource, "test-ks", &map);
414        assert_eq!(key, "agentic-retrieval/knowledge-sources/test-ks/test-ks");
415    }
416
417    #[test]
418    fn test_resource_key_managed_sub_resource() {
419        let mut map = ManagedMap::new();
420        map.insert(
421            (ResourceKind::Index, "test-ks-index".to_string()),
422            "test-ks".to_string(),
423        );
424        let key = LocalState::resource_key_managed(ResourceKind::Index, "test-ks-index", &map);
425        assert_eq!(
426            key,
427            "agentic-retrieval/knowledge-sources/test-ks/test-ks-index"
428        );
429    }
430
431    #[test]
432    fn test_checksums_managed_get_set() {
433        let mut checksums = Checksums::default();
434        let mut map = ManagedMap::new();
435        map.insert(
436            (ResourceKind::Index, "ks-1-index".to_string()),
437            "ks-1".to_string(),
438        );
439
440        assert!(checksums
441            .get_managed(ResourceKind::Index, "ks-1-index", &map)
442            .is_none());
443
444        checksums.set_managed(
445            ResourceKind::Index,
446            "ks-1-index",
447            "abc123".to_string(),
448            &map,
449        );
450        assert_eq!(
451            checksums.get_managed(ResourceKind::Index, "ks-1-index", &map),
452            Some(&"abc123".to_string())
453        );
454
455        checksums.remove_managed(ResourceKind::Index, "ks-1-index", &map);
456        assert!(checksums
457            .get_managed(ResourceKind::Index, "ks-1-index", &map)
458            .is_none());
459    }
460}