Skip to main content

mneme/watch/
watcher.rs

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/// Estado de un archivo conocido.
9#[derive(Debug, Clone)]
10struct FileState {
11    modified: SystemTime,
12    processed: bool,
13    /// Content hash for change detection
14    content_hash: u64,
15    /// Memory ID if already saved (reserved for future re-index flow).
16    #[allow(dead_code)]
17    memory_id: Option<uuid::Uuid>,
18}
19
20/// Watcher por polling con auto-indexing.
21pub struct DirectoryWatcher {
22    dir: PathBuf,
23    ext: String,
24    interval: Duration,
25    known_files: HashMap<PathBuf, FileState>,
26    store: MemoryStore,
27    project: String,
28    /// Only track new files, don't re-index existing ones
29    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    /// Set whether to only track new files (skip existing).
52    pub fn with_track_new_only(mut self, val: bool) -> Self {
53        self.track_new_only = val;
54        self
55    }
56
57    /// Loop principal — corre hasta Ctrl-C.
58    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    /// Escanea el directorio en busca de archivos nuevos o modificados.
76    /// Público para testing y uso manual desde MCP.
77    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        // Collect all entries first to avoid borrow issues
94        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            // Check file extension
101            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                    // Re-process if file changed
127                    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        // Detect deleted files
169        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    /// Compute a quick content hash for change detection.
190    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    /// Returns current tracked file count.
237    pub fn tracked_count(&self) -> usize {
238        self.known_files.len()
239    }
240
241    /// Returns summary of tracked files.
242    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/// Result of a scan cycle.
253#[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
273/// Parsea un archivo .md o .mneme con frontmatter YAML.
274/// Formato:
275///   ---
276///   title: Mi Memoria
277///   type: decision
278///   importance: high
279///   tags: [rust, auth]
280///   what: What we did
281///   why: Why we did it
282///   ---
283///   Contenido de la memoria...
284fn 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    // Extract block between --- and ---
294    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}