1use 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#[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 pub checksums: HashMap<String, String>,
51}
52
53impl LocalState {
54 pub const DIR_NAME: &'static str = ".hoist";
56 pub const STATE_FILE: &'static str = "state.json";
58 pub const CHECKSUMS_FILE: &'static str = "checksums.json";
60
61 pub fn state_dir(project_root: &Path) -> PathBuf {
63 project_root.join(Self::DIR_NAME)
64 }
65
66 pub fn state_file(project_root: &Path) -> PathBuf {
68 Self::state_dir(project_root).join(Self::STATE_FILE)
69 }
70
71 pub fn checksums_file(project_root: &Path) -> PathBuf {
73 Self::state_dir(project_root).join(Self::CHECKSUMS_FILE)
74 }
75
76 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 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 pub fn resource_key(kind: ResourceKind, name: &str) -> String {
100 format!("{}/{}", kind.directory_name(), name)
101 }
102
103 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}