1use crate::config::StoreInfo;
2use crate::types::{DocId, DocType};
3use anyhow::{bail, Context, Result};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
11pub struct DocumentEntry {
12 pub id: DocId,
13 pub path: PathBuf,
14 pub doc_type: DocType,
15 #[serde(default)]
16 pub tags: Vec<String>,
17 #[serde(default)]
18 pub description: String,
19 #[serde(default)]
20 pub agent_name: String,
21}
22
23impl DocumentEntry {
24 pub fn new(path: PathBuf, doc_type: DocType, agent_name: &str) -> Self {
25 Self {
26 id: DocId::new(),
27 path,
28 doc_type,
29 tags: Vec::new(),
30 description: String::new(),
31 agent_name: agent_name.to_string(),
32 }
33 }
34}
35
36#[derive(Debug, Serialize, Deserialize)]
40struct ManifestFile {
41 store: StoreInfo,
42 #[serde(default, rename = "document")]
43 documents: Vec<DocumentEntry>,
44}
45
46pub struct Manifest {
49 pub store: StoreInfo,
50 documents: Vec<DocumentEntry>,
51 by_path: HashMap<PathBuf, usize>,
53 by_id: HashMap<DocId, usize>,
55}
56
57impl Manifest {
58 fn build_indices(
61 documents: &[DocumentEntry],
62 ) -> (HashMap<PathBuf, usize>, HashMap<DocId, usize>) {
63 let mut by_path = HashMap::new();
64 let mut by_id = HashMap::new();
65 for (i, doc) in documents.iter().enumerate() {
66 by_path.insert(doc.path.clone(), i);
67 by_id.insert(doc.id.clone(), i);
68 }
69 (by_path, by_id)
70 }
71
72 pub fn from_parts(store: StoreInfo, documents: Vec<DocumentEntry>) -> Self {
73 let (by_path, by_id) = Self::build_indices(&documents);
74 Self {
75 store,
76 documents,
77 by_path,
78 by_id,
79 }
80 }
81
82 pub fn load(store_root: &Path) -> Result<Self> {
85 let path = manifest_path(store_root);
86
87 let tmp_path = tmp_manifest_path(store_root);
89 if tmp_path.exists() {
90 let _ = std::fs::remove_file(&tmp_path);
91 }
92
93 let contents = std::fs::read_to_string(&path)
94 .with_context(|| format!("Reading manifest: {}", path.display()))?;
95 let file: ManifestFile = toml::from_str(&contents)
96 .with_context(|| format!("Parsing manifest: {}", path.display()))?;
97
98 Ok(Self::from_parts(file.store, file.documents))
99 }
100
101 pub fn save(&self, store_root: &Path) -> Result<()> {
102 let path = manifest_path(store_root);
103 let tmp = tmp_manifest_path(store_root);
104
105 let file = ManifestFile {
106 store: self.store.clone(),
107 documents: self.documents.clone(),
108 };
109 let contents = toml::to_string_pretty(&file)?;
110
111 std::fs::write(&tmp, &contents)
113 .with_context(|| format!("Writing tmp manifest: {}", tmp.display()))?;
114 std::fs::rename(&tmp, &path)
115 .with_context(|| format!("Renaming manifest tmp to final: {}", path.display()))?;
116 Ok(())
117 }
118
119 pub fn create_empty(store_info: StoreInfo, store_root: &Path) -> Result<Self> {
120 let m = Self::from_parts(store_info, Vec::new());
121 m.save(store_root)?;
122 Ok(m)
123 }
124
125 pub fn register(
129 &mut self,
130 path: &Path,
131 doc_type: DocType,
132 agent_name: &str,
133 ) -> Result<&DocumentEntry> {
134 if self.by_path.contains_key(path) {
135 bail!("Path already tracked: {}", path.display());
136 }
137 let entry = DocumentEntry::new(path.to_path_buf(), doc_type, agent_name);
138 let idx = self.documents.len();
139 self.by_path.insert(entry.path.clone(), idx);
140 self.by_id.insert(entry.id.clone(), idx);
141 self.documents.push(entry);
142 Ok(&self.documents[idx])
143 }
144
145 pub fn find_by_path(&self, path: &Path) -> Option<&DocumentEntry> {
146 self.by_path.get(path).map(|&i| &self.documents[i])
147 }
148
149 pub fn find_by_id(&self, id: &DocId) -> Option<&DocumentEntry> {
150 self.by_id.get(id).map(|&i| &self.documents[i])
151 }
152
153 pub fn reclassify(&mut self, path: &Path, new_type: DocType) -> Result<()> {
154 let idx = *self
155 .by_path
156 .get(path)
157 .with_context(|| format!("Path not tracked: {}", path.display()))?;
158 self.documents[idx].doc_type = new_type;
159 Ok(())
160 }
161
162 pub fn update_path(&mut self, old_path: &Path, new_path: &Path) -> Result<()> {
163 let idx = *self
164 .by_path
165 .get(old_path)
166 .with_context(|| format!("Old path not tracked: {}", old_path.display()))?;
167 self.by_path.remove(old_path);
168 self.documents[idx].path = new_path.to_path_buf();
169 self.by_path.insert(new_path.to_path_buf(), idx);
170 Ok(())
171 }
172
173 pub fn untrack(&mut self, path: &Path) -> Result<()> {
174 let idx = *self
175 .by_path
176 .get(path)
177 .with_context(|| format!("Path not tracked: {}", path.display()))?;
178 let id = self.documents[idx].id.clone();
179 self.by_path.remove(path);
180 self.by_id.remove(&id);
181 self.documents.remove(idx);
182 let (by_path, by_id) = Self::build_indices(&self.documents);
184 self.by_path = by_path;
185 self.by_id = by_id;
186 Ok(())
187 }
188
189 pub fn list(&self, type_filter: Option<&DocType>) -> Vec<&DocumentEntry> {
190 self.documents
191 .iter()
192 .filter(|d| type_filter.is_none_or(|t| &d.doc_type == t))
193 .collect()
194 }
195
196 pub fn is_tracked(&self, path: &Path) -> bool {
197 self.by_path.contains_key(path)
198 }
199
200 pub fn update_description(&mut self, path: &Path, description: &str) -> Result<()> {
202 let idx = *self
203 .by_path
204 .get(path)
205 .with_context(|| format!("Path not tracked: {}", path.display()))?;
206 self.documents[idx].description = description.to_string();
207 Ok(())
208 }
209
210 pub fn documents(&self) -> &[DocumentEntry] {
211 &self.documents
212 }
213
214 pub fn len(&self) -> usize {
215 self.documents.len()
216 }
217
218 pub fn is_empty(&self) -> bool {
219 self.documents.is_empty()
220 }
221}
222
223fn manifest_path(store_root: &Path) -> PathBuf {
224 store_root.join(".agent-trace").join("manifest.toml")
225}
226
227fn tmp_manifest_path(store_root: &Path) -> PathBuf {
228 store_root.join(".agent-trace").join(".manifest.toml.tmp")
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use crate::config::StoreInfo;
235 use tempfile::TempDir;
236
237 fn make_store(tmp: &TempDir) -> (PathBuf, StoreInfo) {
238 let root = tmp.path().to_path_buf();
239 std::fs::create_dir_all(root.join(".agent-trace")).unwrap();
240 let info = StoreInfo::new("test".into());
241 (root, info)
242 }
243
244 #[test]
245 fn test_empty_manifest_roundtrip() {
246 let tmp = TempDir::new().unwrap();
247 let (root, info) = make_store(&tmp);
248 let m = Manifest::create_empty(info, &root).unwrap();
249 assert!(m.is_empty());
250
251 let loaded = Manifest::load(&root).unwrap();
252 assert!(loaded.is_empty());
253 assert_eq!(loaded.store.name, "test");
254 }
255
256 #[test]
257 fn test_register_and_lookup() {
258 let tmp = TempDir::new().unwrap();
259 let (root, info) = make_store(&tmp);
260 let mut m = Manifest::create_empty(info, &root).unwrap();
261
262 let path = PathBuf::from("prd.md");
263 m.register(&path, DocType::Plan, "").unwrap();
264
265 let entry = m.find_by_path(&path).unwrap();
266 assert_eq!(entry.doc_type, DocType::Plan);
267 assert!(entry.id.0.parse::<uuid::Uuid>().is_ok());
268
269 let by_id = m.find_by_id(&entry.id.clone()).unwrap();
270 assert_eq!(by_id.path, path);
271 }
272
273 #[test]
274 fn test_register_duplicate_error() {
275 let tmp = TempDir::new().unwrap();
276 let (root, info) = make_store(&tmp);
277 let mut m = Manifest::create_empty(info, &root).unwrap();
278 let path = PathBuf::from("notes.md");
279 m.register(&path, DocType::Scratch, "").unwrap();
280 assert!(m.register(&path, DocType::Scratch, "").is_err());
281 }
282
283 #[test]
284 fn test_reclassify() {
285 let tmp = TempDir::new().unwrap();
286 let (root, info) = make_store(&tmp);
287 let mut m = Manifest::create_empty(info, &root).unwrap();
288 let path = PathBuf::from("notes.md");
289 m.register(&path, DocType::Scratch, "").unwrap();
290 m.reclassify(&path, DocType::Plan).unwrap();
291 assert_eq!(m.find_by_path(&path).unwrap().doc_type, DocType::Plan);
292 }
293
294 #[test]
295 fn test_update_path() {
296 let tmp = TempDir::new().unwrap();
297 let (root, info) = make_store(&tmp);
298 let mut m = Manifest::create_empty(info, &root).unwrap();
299 let old = PathBuf::from("old.md");
300 let new = PathBuf::from("new.md");
301 m.register(&old, DocType::Plan, "").unwrap();
302 m.update_path(&old, &new).unwrap();
303 assert!(m.find_by_path(&old).is_none());
304 assert!(m.find_by_path(&new).is_some());
305 }
306
307 #[test]
308 fn test_untrack() {
309 let tmp = TempDir::new().unwrap();
310 let (root, info) = make_store(&tmp);
311 let mut m = Manifest::create_empty(info, &root).unwrap();
312 let path = PathBuf::from("notes.md");
313 m.register(&path, DocType::Scratch, "").unwrap();
314 m.untrack(&path).unwrap();
315 assert!(!m.is_tracked(&path));
316 assert!(m.is_empty());
317 }
318
319 #[test]
320 fn test_list_filter() {
321 let tmp = TempDir::new().unwrap();
322 let (root, info) = make_store(&tmp);
323 let mut m = Manifest::create_empty(info, &root).unwrap();
324 m.register(&PathBuf::from("prd.md"), DocType::Plan, "")
325 .unwrap();
326 m.register(&PathBuf::from("notes.md"), DocType::Scratch, "")
327 .unwrap();
328 m.register(&PathBuf::from("plan2.md"), DocType::Plan, "")
329 .unwrap();
330
331 assert_eq!(m.list(None).len(), 3);
332 assert_eq!(m.list(Some(&DocType::Plan)).len(), 2);
333 assert_eq!(m.list(Some(&DocType::Scratch)).len(), 1);
334 }
335
336 #[test]
337 fn test_save_load_roundtrip() {
338 let tmp = TempDir::new().unwrap();
339 let (root, info) = make_store(&tmp);
340 let mut m = Manifest::create_empty(info, &root).unwrap();
341 m.register(&PathBuf::from("prd.md"), DocType::Plan, "agent-x")
342 .unwrap();
343 m.save(&root).unwrap();
344
345 let loaded = Manifest::load(&root).unwrap();
346 assert_eq!(loaded.len(), 1);
347 assert_eq!(loaded.documents()[0].doc_type, DocType::Plan);
348 assert_eq!(loaded.documents()[0].agent_name, "agent-x");
349 }
350
351 #[test]
352 fn test_stale_tmp_cleaned_on_load() {
353 let tmp = TempDir::new().unwrap();
354 let (root, info) = make_store(&tmp);
355 let m = Manifest::create_empty(info, &root).unwrap();
356 m.save(&root).unwrap();
357
358 let tmp_path = root.join(".agent-trace").join(".manifest.toml.tmp");
360 std::fs::write(&tmp_path, "garbage").unwrap();
361 assert!(tmp_path.exists());
362
363 Manifest::load(&root).unwrap();
365 assert!(!tmp_path.exists());
366 }
367}