ggen_core/codegen/
watch.rs

1//! Watch mode - File system monitoring for auto-regeneration
2//!
3//! This module implements the `--watch` flag functionality, monitoring ontology
4//! and manifest files for changes and triggering automatic regeneration.
5//!
6//! ## Features
7//!
8//! - 300ms debounce to avoid duplicate events
9//! - Bounded queue (10 items) to prevent memory exhaustion
10//! - Monitors: ggen.toml, *.ttl ontology files, *.sparql queries
11//! - Real-time regeneration on file changes
12//!
13//! ## Architecture
14//!
15//! ```text
16//! File Change → Debouncer (300ms) → Queue (bounded) → Regeneration
17//! ```
18
19use ggen_utils::error::{Error, Result};
20use std::path::{Path, PathBuf};
21use std::sync::mpsc::{channel, Receiver, RecvTimeoutError};
22use std::time::Duration;
23
24/// File system watcher for auto-regeneration
25pub struct FileWatcher {
26    /// Paths to watch
27    watch_paths: Vec<PathBuf>,
28    /// Debounce duration (milliseconds)
29    pub debounce_ms: u64,
30    /// Queue capacity
31    pub queue_capacity: usize,
32}
33
34/// Watch event indicating file change
35#[derive(Debug, Clone)]
36pub struct WatchEvent {
37    /// Changed file path
38    pub path: PathBuf,
39    /// Event timestamp
40    pub timestamp: std::time::Instant,
41}
42
43impl FileWatcher {
44    /// Create a new FileWatcher with default settings
45    ///
46    /// - Debounce: 300ms
47    /// - Queue capacity: 10 items
48    pub fn new<P: AsRef<Path>>(watch_paths: Vec<P>) -> Self {
49        Self {
50            watch_paths: watch_paths
51                .iter()
52                .map(|p| p.as_ref().to_path_buf())
53                .collect(),
54            debounce_ms: 300,
55            queue_capacity: 10,
56        }
57    }
58
59    /// Set debounce duration in milliseconds
60    pub fn with_debounce_ms(mut self, debounce_ms: u64) -> Self {
61        self.debounce_ms = debounce_ms;
62        self
63    }
64
65    /// Set queue capacity
66    pub fn with_queue_capacity(mut self, capacity: usize) -> Self {
67        self.queue_capacity = capacity;
68        self
69    }
70
71    /// Start watching and return event receiver
72    ///
73    /// This is a placeholder implementation. Real implementation would use
74    /// the `notify` crate for cross-platform file watching.
75    ///
76    /// ## Returns
77    ///
78    /// A `Receiver<WatchEvent>` that yields file change events after debouncing.
79    pub fn start(self) -> Result<Receiver<WatchEvent>> {
80        let (_tx, rx) = channel();
81
82        // Validate watch paths exist
83        for path in &self.watch_paths {
84            if !path.exists() {
85                return Err(Error::new(&format!(
86                    "Watch path does not exist: {}",
87                    path.display()
88                )));
89            }
90        }
91
92        // TODO: Implement actual file watching using notify crate
93        // For now, this is a stub implementation that returns the receiver
94        //
95        // Real implementation would:
96        // 1. Create notify::Watcher
97        // 2. Add watch_paths to watcher
98        // 3. Spawn thread to handle events
99        // 4. Implement debouncing logic
100        // 5. Send WatchEvent through _tx channel
101
102        std::thread::spawn(move || {
103            // Placeholder: In real implementation, this would receive from notify::Watcher
104            // and apply debouncing before sending to _tx
105            loop {
106                std::thread::sleep(Duration::from_millis(1000));
107                // Real implementation would receive from notify and send WatchEvent via _tx
108            }
109        });
110
111        Ok(rx)
112    }
113
114    /// Wait for next change event with timeout
115    ///
116    /// ## Arguments
117    ///
118    /// * `rx` - Event receiver from `start()`
119    /// * `timeout` - Maximum wait duration
120    ///
121    /// ## Returns
122    ///
123    /// - `Ok(Some(event))` if change detected
124    /// - `Ok(None)` if timeout reached
125    /// - `Err` if channel closed
126    pub fn wait_for_change(
127        rx: &Receiver<WatchEvent>, timeout: Duration,
128    ) -> Result<Option<WatchEvent>> {
129        match rx.recv_timeout(timeout) {
130            Ok(event) => Ok(Some(event)),
131            Err(RecvTimeoutError::Timeout) => Ok(None),
132            Err(RecvTimeoutError::Disconnected) => Err(Error::new("Watch channel disconnected")),
133        }
134    }
135}
136
137/// Collect watch paths from manifest
138///
139/// Returns all paths that should be monitored for changes:
140/// - ggen.toml (manifest)
141/// - ontology.source (main ontology file)
142/// - ontology.imports (imported ontology files)
143/// - generation.rules[].query files
144/// - generation.rules[].template files
145pub fn collect_watch_paths(
146    manifest_path: &Path, manifest: &crate::manifest::GgenManifest, base_path: &Path,
147) -> Vec<PathBuf> {
148    use crate::manifest::{QuerySource, TemplateSource};
149
150    let mut paths = Vec::new();
151
152    // Watch manifest itself
153    paths.push(manifest_path.to_path_buf());
154
155    // Watch ontology source
156    paths.push(base_path.join(&manifest.ontology.source));
157
158    // Watch ontology imports
159    for import in &manifest.ontology.imports {
160        paths.push(base_path.join(import));
161    }
162
163    // Watch query files
164    for rule in &manifest.generation.rules {
165        if let QuerySource::File { file } = &rule.query {
166            paths.push(base_path.join(file));
167        }
168    }
169
170    // Watch template files
171    for rule in &manifest.generation.rules {
172        if let TemplateSource::File { file } = &rule.template {
173            paths.push(base_path.join(file));
174        }
175    }
176
177    paths
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn test_file_watcher_creation() {
186        let paths = vec![PathBuf::from(".")];
187        let watcher = FileWatcher::new(paths);
188        assert_eq!(watcher.debounce_ms, 300);
189        assert_eq!(watcher.queue_capacity, 10);
190    }
191
192    #[test]
193    fn test_file_watcher_configuration() {
194        let paths = vec![PathBuf::from(".")];
195        let watcher = FileWatcher::new(paths)
196            .with_debounce_ms(500)
197            .with_queue_capacity(20);
198
199        assert_eq!(watcher.debounce_ms, 500);
200        assert_eq!(watcher.queue_capacity, 20);
201    }
202
203    #[test]
204    fn test_collect_watch_paths_empty() {
205        use crate::manifest::{
206            GenerationConfig, GgenManifest, InferenceConfig, OntologyConfig, ProjectConfig,
207            ValidationConfig,
208        };
209        use std::collections::BTreeMap;
210        use std::path::PathBuf;
211
212        let manifest = GgenManifest {
213            project: ProjectConfig {
214                name: "test".to_string(),
215                version: "1.0.0".to_string(),
216                description: None,
217            },
218            ontology: OntologyConfig {
219                source: PathBuf::from("ontology.ttl"),
220                imports: vec![],
221                base_iri: None,
222                prefixes: BTreeMap::new(),
223            },
224            inference: InferenceConfig {
225                rules: vec![],
226                max_reasoning_timeout_ms: 5000,
227            },
228            generation: GenerationConfig {
229                rules: vec![],
230                max_sparql_timeout_ms: 5000,
231                require_audit_trail: false,
232                determinism_salt: None,
233                output_dir: PathBuf::from("generated"),
234            },
235            validation: ValidationConfig::default(),
236        };
237
238        let manifest_path = Path::new("ggen.toml");
239        let base_path = Path::new(".");
240        let paths = collect_watch_paths(manifest_path, &manifest, base_path);
241
242        // Should have at least manifest and ontology source
243        assert!(paths.len() >= 2);
244        assert!(paths.contains(&PathBuf::from("ggen.toml")));
245        // Ontology path might be joined with base_path, check if any path ends with ontology.ttl
246        assert!(
247            paths
248                .iter()
249                .any(|p| p.to_string_lossy().ends_with("ontology.ttl")),
250            "Should contain ontology.ttl path (possibly joined with base_path)"
251        );
252    }
253}