1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::time::{Duration, SystemTime};
4use tokio::time;
5
6use crate::store::memory::{CreateMemoryInput, Importance, MemoryStore, MemoryType, Scope};
7
8#[derive(Debug, Clone)]
10struct FileState {
11 modified: SystemTime,
12 processed: bool,
13 content_hash: u64,
15 #[allow(dead_code)]
17 memory_id: Option<uuid::Uuid>,
18}
19
20pub struct DirectoryWatcher {
22 dir: PathBuf,
23 ext: String,
24 interval: Duration,
25 known_files: HashMap<PathBuf, FileState>,
26 store: MemoryStore,
27 project: String,
28 track_new_only: bool,
30}
31
32impl DirectoryWatcher {
33 pub fn new(
34 dir: PathBuf,
35 ext: String,
36 interval_secs: u64,
37 store: MemoryStore,
38 project: String,
39 ) -> Self {
40 Self {
41 dir,
42 ext,
43 interval: Duration::from_secs(interval_secs),
44 known_files: HashMap::new(),
45 store,
46 project,
47 track_new_only: false,
48 }
49 }
50
51 pub fn with_track_new_only(mut self, val: bool) -> Self {
53 self.track_new_only = val;
54 self
55 }
56
57 pub async fn run(&mut self) -> crate::error::Result<()> {
59 tracing::info!(dir = %self.dir.display(), ext = %self.ext, "watch iniciado");
60 println!(
61 "👁 Watching {} for *{} files. Ctrl-C to stop.",
62 self.dir.display(),
63 self.ext
64 );
65
66 let mut ticker = time::interval(self.interval);
67 loop {
68 ticker.tick().await;
69 if let Err(e) = self.scan().await {
70 tracing::warn!(error = %e, "scan error");
71 }
72 }
73 }
74
75 pub async fn scan(&mut self) -> crate::error::Result<ScanResult> {
78 let mut result = ScanResult::default();
79
80 if !self.dir.exists() {
81 tracing::warn!(dir = %self.dir.display(), "watch directory does not exist");
82 return Ok(result);
83 }
84
85 let entries = match std::fs::read_dir(&self.dir) {
86 Ok(e) => e,
87 Err(e) => {
88 tracing::warn!(error = %e, "failed to read watch directory");
89 return Ok(result);
90 }
91 };
92
93 let all_entries: Vec<_> = entries.filter_map(|e| e.ok()).collect();
95
96 for entry in &all_entries {
97 let path = entry.path();
98 let file_name = path.to_string_lossy();
99
100 let is_match = self.ext == ".*"
102 || file_name.ends_with(&self.ext)
103 || (self.ext == ".md" && file_name.ends_with(".md"))
104 || (self.ext == ".mneme" && file_name.ends_with(".mneme"));
105
106 if !is_match {
107 continue;
108 }
109
110 let meta = match std::fs::metadata(&path) {
111 Ok(m) => m,
112 Err(_) => continue,
113 };
114
115 if !meta.is_file() {
116 continue;
117 }
118
119 let modified = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
120 let content_hash = Self::quick_hash(&path);
121
122 let state = self.known_files.get(&path);
123 let should_process = match state {
124 None => !self.track_new_only,
125 Some(st) => {
126 modified > st.modified || content_hash != st.content_hash
128 }
129 };
130
131 if should_process {
132 match self.process_file(&path, content_hash).await {
133 Ok(processed) => {
134 if processed {
135 result.indexed += 1;
136 } else {
137 result.skipped += 1;
138 }
139 self.known_files.insert(
140 path.clone(),
141 FileState {
142 modified,
143 processed: true,
144 content_hash,
145 memory_id: None,
146 },
147 );
148 }
149 Err(e) => {
150 tracing::warn!(path = %path.display(), error = %e, "error procesando archivo");
151 result.errors += 1;
152 self.known_files.insert(
153 path.clone(),
154 FileState {
155 modified,
156 processed: false,
157 content_hash,
158 memory_id: None,
159 },
160 );
161 }
162 }
163 } else {
164 result.skipped += 1;
165 }
166 }
167
168 let mut to_remove = Vec::new();
170 let current_paths: std::collections::HashSet<PathBuf> = all_entries
171 .iter()
172 .map(|e| e.path())
173 .collect();
174
175 for tracked_path in self.known_files.keys() {
176 if !current_paths.contains(tracked_path) {
177 to_remove.push(tracked_path.clone());
178 }
179 }
180
181 result.removed = to_remove.len() as u32;
182 for path in to_remove {
183 self.known_files.remove(&path);
184 }
185
186 Ok(result)
187 }
188
189 fn quick_hash(path: &Path) -> u64 {
191 use std::hash::{Hash, Hasher};
192 let content = std::fs::read_to_string(path).unwrap_or_default();
193 let mut hasher = std::collections::hash_map::DefaultHasher::new();
194 content.hash(&mut hasher);
195 hasher.finish()
196 }
197
198 async fn process_file(&self, path: &Path, _content_hash: u64) -> crate::error::Result<bool> {
199 let content = std::fs::read_to_string(path)?;
200 if content.trim().is_empty() {
201 return Ok(false);
202 }
203
204 let parsed = parse_mneme_file(&content);
205
206 let input = CreateMemoryInput {
207 project: self.project.clone(),
208 scope: Some(Scope::Project),
209 title: parsed.title,
210 content: parsed.content,
211 what: parsed.what,
212 why: parsed.why,
213 context: parsed.context,
214 learned: parsed.learned,
215 memory_type: parsed.memory_type,
216 importance: parsed.importance,
217 tags: parsed.tags,
218 topic_key: Some(format!(
219 "watch/{}",
220 path.file_stem()
221 .and_then(|s| s.to_str())
222 .unwrap_or("unknown")
223 )),
224 capture_prompt: None,
225 encrypt: false,
226 valid_from: None,
227 valid_until: None,
228 provenance: Some(format!("file://{}", path.to_string_lossy())),
229 };
230
231 let memory = self.store.save(input, None, None)?;
232 tracing::info!(memory_id = %memory.id, title = %memory.title, "auto-indexed file");
233 Ok(true)
234 }
235
236 pub fn tracked_count(&self) -> usize {
238 self.known_files.len()
239 }
240
241 pub fn tracked_summary(&self) -> Vec<(String, bool)> {
243 self.known_files
244 .iter()
245 .map(|(path, state)| {
246 (path.to_string_lossy().to_string(), state.processed)
247 })
248 .collect()
249 }
250}
251
252#[derive(Debug, Clone, Default)]
254pub struct ScanResult {
255 pub indexed: u32,
256 pub skipped: u32,
257 pub errors: u32,
258 pub removed: u32,
259}
260
261struct ParsedFile {
262 title: String,
263 content: String,
264 memory_type: MemoryType,
265 importance: Importance,
266 tags: Vec<String>,
267 what: Option<String>,
268 why: Option<String>,
269 context: Option<String>,
270 learned: Option<String>,
271}
272
273fn parse_mneme_file(content: &str) -> ParsedFile {
285 if content.starts_with("---") {
286 parse_with_frontmatter(content)
287 } else {
288 parse_simple(content)
289 }
290}
291
292fn parse_with_frontmatter(content: &str) -> ParsedFile {
293 let parts: Vec<&str> = content.splitn(3, "---").collect();
295 let (frontmatter, body) = if parts.len() >= 3 {
296 (parts[1].trim(), parts[2].trim())
297 } else {
298 ("", content)
299 };
300
301 let mut title = String::new();
302 let mut memory_type = MemoryType::Note;
303 let mut importance = Importance::Medium;
304 let mut tags = vec![];
305 let mut what = None;
306 let mut why = None;
307 let mut context = None;
308 let mut learned = None;
309
310 for line in frontmatter.lines() {
311 if let Some((k, v)) = line.split_once(':') {
312 let k = k.trim();
313 let v = v.trim();
314 match k {
315 "title" => title = v.to_string(),
316 "type" => memory_type = v.parse().unwrap_or(MemoryType::Note),
317 "importance" => importance = v.parse().unwrap_or(Importance::Medium),
318 "tags" => {
319 let clean = v
320 .trim_start_matches('[')
321 .trim_end_matches(']');
322 tags = clean
323 .split(',')
324 .map(|t| t.trim().trim_matches('"').trim_matches('\'').to_string())
325 .filter(|t| !t.is_empty())
326 .collect()
327 }
328 "what" => what = Some(v.to_string()),
329 "why" => why = Some(v.to_string()),
330 "context" => context = Some(v.to_string()),
331 "learned" => learned = Some(v.to_string()),
332 _ => {}
333 }
334 }
335 }
336
337 if title.is_empty() {
338 title = body.lines().next().unwrap_or("untitled").to_string();
339 }
340
341 ParsedFile {
342 title,
343 content: body.to_string(),
344 memory_type,
345 importance,
346 tags,
347 what,
348 why,
349 context,
350 learned,
351 }
352}
353
354fn parse_simple(content: &str) -> ParsedFile {
355 let mut lines = content.lines();
356 let title = lines.next().unwrap_or("untitled").to_string();
357 let body = lines.collect::<Vec<_>>().join("\n").trim().to_string();
358 ParsedFile {
359 title: title.clone(),
360 content: if body.is_empty() { title } else { body },
361 memory_type: MemoryType::Note,
362 importance: Importance::Medium,
363 tags: vec![],
364 what: None,
365 why: None,
366 context: None,
367 learned: None,
368 }
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 #[test]
376 fn test_parse_frontmatter_extracts_title() {
377 let content = "---\ntitle: Test Title\ntype: note\n---\ncontent here";
378 let parsed = parse_mneme_file(content);
379 assert_eq!(parsed.title, "Test Title");
380 }
381
382 #[test]
383 fn test_parse_frontmatter_extracts_type() {
384 let content = "---\ntitle: T\ntype: decision\n---\nbody";
385 let parsed = parse_mneme_file(content);
386 assert!(matches!(parsed.memory_type, MemoryType::Decision));
387 }
388
389 #[test]
390 fn test_parse_frontmatter_extracts_tags_array() {
391 let content = "---\ntitle: T\ntags: [rust, auth, jwt]\n---\nbody";
392 let parsed = parse_mneme_file(content);
393 assert_eq!(parsed.tags, vec!["rust", "auth", "jwt"]);
394 }
395
396 #[test]
397 fn test_parse_frontmatter_extracts_tags_csv() {
398 let content = "---\ntitle: T\ntags: rust, auth, jwt\n---\nbody";
399 let parsed = parse_mneme_file(content);
400 assert_eq!(parsed.tags, vec!["rust", "auth", "jwt"]);
401 }
402
403 #[test]
404 fn test_parse_frontmatter_extracts_structured_fields() {
405 let content = "---\ntitle: My Decision\ntype: decision\nwhat: Chose Rust over Go\nwhy: Better ecosystem for this project\ncontext: Team meeting Q2\nlearned: Rust's type system caught several bugs early\n---\nWe decided to use Rust for the new service.";
406 let parsed = parse_mneme_file(content);
407 assert_eq!(parsed.what.unwrap(), "Chose Rust over Go");
408 assert_eq!(parsed.why.unwrap(), "Better ecosystem for this project");
409 assert_eq!(parsed.context.unwrap(), "Team meeting Q2");
410 assert!(parsed.learned.unwrap().contains("Rust's"));
411 }
412
413 #[test]
414 fn test_parse_simple_uses_first_line_as_title() {
415 let content = "First Line Title\nRest of content\nMore content";
416 let parsed = parse_mneme_file(content);
417 assert_eq!(parsed.title, "First Line Title");
418 }
419
420 #[test]
421 fn test_parse_simple_empty_string() {
422 let content = "";
423 let parsed = parse_mneme_file(content);
424 assert_eq!(parsed.title, "untitled");
425 }
426
427 #[test]
428 fn test_parse_frontmatter_all_types_roundtrip() {
429 for (type_str, expected) in [
430 ("architecture", MemoryType::Architecture),
431 ("decision", MemoryType::Decision),
432 ("bugfix", MemoryType::Bugfix),
433 ("pattern", MemoryType::Pattern),
434 ("convention", MemoryType::Convention),
435 ("dependency", MemoryType::Dependency),
436 ("workflow", MemoryType::Workflow),
437 ("note", MemoryType::Note),
438 ("config", MemoryType::Config),
439 ("discovery", MemoryType::Discovery),
440 ("learning", MemoryType::Learning),
441 ("agent_fact", MemoryType::AgentFact),
442 ] {
443 let content = format!("---\ntitle: T\ntype: {type_str}\n---\nbody");
444 let parsed = parse_mneme_file(&content);
445 assert_eq!(
446 parsed.memory_type, expected,
447 "type '{type_str}' should parse to {expected:?}"
448 );
449 }
450 }
451}