Skip to main content

lit/commands/
watch.rs

1use crate::core::find_repo_root;
2use crate::response::{WatchEvent, WatchResponse};
3use std::collections::HashMap;
4use std::fs;
5use std::path::Path;
6use std::time::SystemTime;
7use walkdir::WalkDir;
8
9pub fn execute(
10    debounce_ms: u64,
11    filter: Option<String>,
12) -> Result<WatchResponse, crate::errors::LitError> {
13    let repo_root = find_repo_root()?;
14
15    // Build initial file state snapshot
16    let mut last_state = scan_files(&repo_root, filter.as_deref())?;
17
18    let debounce = std::time::Duration::from_millis(debounce_ms);
19
20    eprintln!(
21        "Watching {} for changes (Ctrl+C to stop)...",
22        repo_root.display()
23    );
24
25    // Emit a start event as JSON on stdout
26    let start_event = WatchEvent {
27        event_type: "start".to_string(),
28        path: repo_root.to_string_lossy().to_string(),
29        timestamp: chrono::Utc::now().timestamp(),
30    };
31    println!(
32        "{}",
33        serde_json::to_string(&start_event).unwrap_or_default()
34    );
35
36    loop {
37        std::thread::sleep(debounce);
38
39        let current_state = scan_files(&repo_root, filter.as_deref())?;
40        let mut events = Vec::new();
41
42        // Detect modifications and creations
43        for (path, mtime) in &current_state {
44            match last_state.get(path) {
45                Some(old_mtime) if old_mtime != mtime => {
46                    events.push(WatchEvent {
47                        event_type: "modified".to_string(),
48                        path: path.clone(),
49                        timestamp: chrono::Utc::now().timestamp(),
50                    });
51                }
52                None => {
53                    events.push(WatchEvent {
54                        event_type: "created".to_string(),
55                        path: path.clone(),
56                        timestamp: chrono::Utc::now().timestamp(),
57                    });
58                }
59                _ => {}
60            }
61        }
62
63        // Detect deletions
64        for path in last_state.keys() {
65            if !current_state.contains_key(path) {
66                events.push(WatchEvent {
67                    event_type: "deleted".to_string(),
68                    path: path.clone(),
69                    timestamp: chrono::Utc::now().timestamp(),
70                });
71            }
72        }
73
74        // Emit events as JSONL
75        for event in &events {
76            println!("{}", serde_json::to_string(event).unwrap_or_default());
77        }
78
79        last_state = current_state;
80    }
81}
82
83/// Scan files and return map of relative_path -> last_modified_timestamp
84fn scan_files(repo_root: &Path, filter: Option<&str>) -> Result<HashMap<String, u64>, String> {
85    let mut files = HashMap::new();
86
87    for entry in WalkDir::new(repo_root).into_iter().filter_entry(|e| {
88        let name = e.file_name().to_string_lossy();
89        !name.starts_with('.') && name != "target" && name != "node_modules"
90    }) {
91        let entry = entry.map_err(|e| format!("Walk error: {}", e))?;
92        if !entry.file_type().is_file() {
93            continue;
94        }
95        let path = entry.path();
96        if path.starts_with(repo_root.join(".lit")) {
97            continue;
98        }
99
100        let rel_path = path
101            .strip_prefix(repo_root)
102            .unwrap_or(path)
103            .to_string_lossy()
104            .replace('\\', "/");
105
106        // Apply filter if present
107        if let Some(f) = filter {
108            if !rel_path.contains(f) {
109                continue;
110            }
111        }
112
113        let mtime = fs::metadata(path)
114            .and_then(|m| m.modified())
115            .unwrap_or(SystemTime::UNIX_EPOCH)
116            .duration_since(SystemTime::UNIX_EPOCH)
117            .unwrap_or_default()
118            .as_secs();
119
120        files.insert(rel_path, mtime);
121    }
122
123    Ok(files)
124}