baobao_codegen/generation/
handlers.rs

1//! Handler file path management utilities.
2
3use std::{
4    collections::HashSet,
5    path::{Path, PathBuf},
6};
7
8use eyre::Result;
9
10/// Manages handler file paths for a code generator.
11///
12/// Provides utilities for:
13/// - Computing handler file paths from command paths
14/// - Finding orphaned handler files
15/// - Checking if handlers exist
16#[derive(Debug, Clone)]
17pub struct HandlerPaths {
18    /// Base directory for handlers (e.g., "src/handlers")
19    base_dir: PathBuf,
20    /// File extension without dot (e.g., "rs", "ts")
21    extension: String,
22    /// Marker string that indicates an unmodified handler stub.
23    /// Files containing this marker are considered safe to delete.
24    stub_marker: String,
25}
26
27impl HandlerPaths {
28    /// Create a new HandlerPaths instance.
29    ///
30    /// # Arguments
31    /// * `base_dir` - Base directory for handlers (e.g., "src/handlers")
32    /// * `extension` - File extension without dot (e.g., "rs", "ts")
33    /// * `stub_marker` - Marker string that indicates an unmodified handler stub
34    ///
35    /// # Example
36    /// ```ignore
37    /// // For Rust handlers
38    /// let paths = HandlerPaths::new("src/handlers", "rs", "todo!(\"implement");
39    /// // For TypeScript handlers
40    /// let paths = HandlerPaths::new("src/handlers", "ts", "// TODO: implement");
41    /// ```
42    pub fn new(
43        base_dir: impl Into<PathBuf>,
44        extension: impl Into<String>,
45        stub_marker: impl Into<String>,
46    ) -> Self {
47        Self {
48            base_dir: base_dir.into(),
49            extension: extension.into(),
50            stub_marker: stub_marker.into(),
51        }
52    }
53
54    /// Get the handler file path for a command.
55    ///
56    /// # Example
57    ///
58    /// ```ignore
59    /// let paths = HandlerPaths::new("src/handlers", "rs");
60    /// assert_eq!(
61    ///     paths.handler_path(&["db", "migrate"]),
62    ///     PathBuf::from("src/handlers/db/migrate.rs")
63    /// );
64    /// ```
65    pub fn handler_path(&self, command_path: &[&str]) -> PathBuf {
66        let mut path = self.base_dir.clone();
67        if command_path.len() > 1 {
68            // Add parent directories
69            for segment in &command_path[..command_path.len() - 1] {
70                path.push(segment);
71            }
72        }
73        // Add the file with extension
74        if let Some(last) = command_path.last() {
75            path.push(format!("{}.{}", last, self.extension));
76        }
77        path
78    }
79
80    /// Get the module file path for a parent command.
81    ///
82    /// # Example
83    ///
84    /// ```ignore
85    /// let paths = HandlerPaths::new("src/handlers", "rs");
86    /// assert_eq!(
87    ///     paths.mod_path(&["db"]),
88    ///     PathBuf::from("src/handlers/db/mod.rs")
89    /// );
90    /// ```
91    pub fn mod_path(&self, command_path: &[&str]) -> PathBuf {
92        let mut path = self.base_dir.clone();
93        for segment in command_path {
94            path.push(segment);
95        }
96        path.push(format!("mod.{}", self.extension));
97        path
98    }
99
100    /// Check if a handler file exists.
101    pub fn exists(&self, command_path: &[&str]) -> bool {
102        self.handler_path(command_path).exists()
103    }
104
105    /// Find orphaned handler files that are no longer in the schema.
106    ///
107    /// Returns paths relative to the base directory.
108    pub fn find_orphans(&self, expected_paths: &HashSet<String>) -> Result<Vec<String>> {
109        let mut orphans = Vec::new();
110        self.scan_for_orphans(&self.base_dir, "", expected_paths, &mut orphans)?;
111        Ok(orphans)
112    }
113
114    fn scan_for_orphans(
115        &self,
116        dir: &Path,
117        prefix: &str,
118        expected: &HashSet<String>,
119        orphans: &mut Vec<String>,
120    ) -> Result<()> {
121        if !dir.exists() {
122            return Ok(());
123        }
124
125        for entry in std::fs::read_dir(dir)? {
126            let entry = entry?;
127            let path = entry.path();
128            let file_name = path.file_name().unwrap().to_string_lossy();
129
130            // Skip mod files
131            if file_name == format!("mod.{}", self.extension) {
132                continue;
133            }
134
135            if path.is_dir() {
136                let new_prefix = if prefix.is_empty() {
137                    file_name.to_string()
138                } else {
139                    format!("{}/{}", prefix, file_name)
140                };
141
142                // Check if this directory is expected
143                if !expected.contains(&new_prefix) {
144                    orphans.push(new_prefix.clone());
145                } else {
146                    self.scan_for_orphans(&path, &new_prefix, expected, orphans)?;
147                }
148            } else if path
149                .extension()
150                .is_some_and(|ext| ext == self.extension.as_str())
151            {
152                let stem = path.file_stem().unwrap().to_string_lossy();
153                let handler_path = if prefix.is_empty() {
154                    stem.to_string()
155                } else {
156                    format!("{}/{}", prefix, stem)
157                };
158
159                if !expected.contains(&handler_path) {
160                    orphans.push(handler_path);
161                }
162            }
163        }
164
165        Ok(())
166    }
167
168    /// Find orphaned handler files with their full paths and modification status.
169    ///
170    /// Returns tuples of (relative_path, full_path, is_unmodified).
171    /// A handler is considered unmodified if it contains the `todo!("implement` marker.
172    pub fn find_orphans_with_status(
173        &self,
174        expected_paths: &HashSet<String>,
175    ) -> Result<Vec<OrphanHandler>> {
176        let mut orphans = Vec::new();
177        self.scan_for_orphans_with_status(&self.base_dir, "", expected_paths, &mut orphans)?;
178        Ok(orphans)
179    }
180
181    fn scan_for_orphans_with_status(
182        &self,
183        dir: &Path,
184        prefix: &str,
185        expected: &HashSet<String>,
186        orphans: &mut Vec<OrphanHandler>,
187    ) -> Result<()> {
188        if !dir.exists() {
189            return Ok(());
190        }
191
192        for entry in std::fs::read_dir(dir)? {
193            let entry = entry?;
194            let path = entry.path();
195            let file_name = path.file_name().unwrap().to_string_lossy();
196
197            // Skip mod files
198            if file_name == format!("mod.{}", self.extension) {
199                continue;
200            }
201
202            if path.is_dir() {
203                let new_prefix = if prefix.is_empty() {
204                    file_name.to_string()
205                } else {
206                    format!("{}/{}", prefix, file_name)
207                };
208
209                // Check if this directory is expected
210                if !expected.contains(&new_prefix) {
211                    // Recursively collect all files in this orphaned directory
212                    self.collect_all_files(&path, &new_prefix, orphans)?;
213                } else {
214                    self.scan_for_orphans_with_status(&path, &new_prefix, expected, orphans)?;
215                }
216            } else if path
217                .extension()
218                .is_some_and(|ext| ext == self.extension.as_str())
219            {
220                let stem = path.file_stem().unwrap().to_string_lossy();
221                let relative_path = if prefix.is_empty() {
222                    stem.to_string()
223                } else {
224                    format!("{}/{}", prefix, stem)
225                };
226
227                if !expected.contains(&relative_path) {
228                    let is_unmodified = self.is_handler_unmodified(&path);
229                    orphans.push(OrphanHandler {
230                        relative_path,
231                        full_path: path,
232                        is_unmodified,
233                    });
234                }
235            }
236        }
237
238        Ok(())
239    }
240
241    /// Collect all files in an orphaned directory (recursively).
242    fn collect_all_files(
243        &self,
244        dir: &Path,
245        prefix: &str,
246        orphans: &mut Vec<OrphanHandler>,
247    ) -> Result<()> {
248        if !dir.exists() {
249            return Ok(());
250        }
251
252        for entry in std::fs::read_dir(dir)? {
253            let entry = entry?;
254            let path = entry.path();
255            let file_name = path.file_name().unwrap().to_string_lossy();
256
257            if path.is_dir() {
258                let new_prefix = format!("{}/{}", prefix, file_name);
259                self.collect_all_files(&path, &new_prefix, orphans)?;
260            } else if path
261                .extension()
262                .is_some_and(|ext| ext == self.extension.as_str())
263            {
264                let stem = path.file_stem().unwrap().to_string_lossy();
265                let relative_path = format!("{}/{}", prefix, stem);
266                let is_unmodified = self.is_handler_unmodified(&path);
267                orphans.push(OrphanHandler {
268                    relative_path,
269                    full_path: path,
270                    is_unmodified,
271                });
272            }
273        }
274
275        Ok(())
276    }
277
278    /// Check if a handler file is unmodified (still contains the stub marker).
279    fn is_handler_unmodified(&self, path: &Path) -> bool {
280        std::fs::read_to_string(path)
281            .map(|content| content.contains(&self.stub_marker))
282            .unwrap_or(false)
283    }
284}
285
286/// Information about an orphaned handler file.
287#[derive(Debug, Clone)]
288pub struct OrphanHandler {
289    /// Path relative to the handlers directory (e.g., "db/migrate")
290    pub relative_path: String,
291    /// Full filesystem path
292    pub full_path: PathBuf,
293    /// Whether the handler is unmodified (still contains `todo!` marker)
294    pub is_unmodified: bool,
295}
296
297/// Find orphaned generated command files in a directory.
298///
299/// Scans the given directory for files with the specified extension that are
300/// not in the expected set of command names.
301pub fn find_orphan_commands(
302    commands_dir: &Path,
303    extension: &str,
304    expected_commands: &HashSet<String>,
305) -> Result<Vec<PathBuf>> {
306    let mut orphans = Vec::new();
307
308    if !commands_dir.exists() {
309        return Ok(orphans);
310    }
311
312    for entry in std::fs::read_dir(commands_dir)? {
313        let entry = entry?;
314        let path = entry.path();
315
316        // Skip directories and mod files
317        if path.is_dir() {
318            continue;
319        }
320
321        let file_name = path.file_name().unwrap().to_string_lossy();
322        if file_name == format!("mod.{}", extension) {
323            continue;
324        }
325
326        // Check if this is a source file
327        if path.extension().is_none_or(|ext| ext != extension) {
328            continue;
329        }
330
331        // Get the command name (file stem)
332        let stem = path.file_stem().unwrap().to_string_lossy();
333
334        // Check if this command is expected
335        if !expected_commands.contains(stem.as_ref()) {
336            orphans.push(path);
337        }
338    }
339
340    Ok(orphans)
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    const RUST_STUB_MARKER: &str = "todo!(\"implement";
348    const TS_STUB_MARKER: &str = "// TODO: implement";
349
350    #[test]
351    fn test_handler_path_simple() {
352        let paths = HandlerPaths::new("src/handlers", "rs", RUST_STUB_MARKER);
353        assert_eq!(
354            paths.handler_path(&["hello"]),
355            PathBuf::from("src/handlers/hello.rs")
356        );
357    }
358
359    #[test]
360    fn test_handler_path_nested() {
361        let paths = HandlerPaths::new("src/handlers", "rs", RUST_STUB_MARKER);
362        assert_eq!(
363            paths.handler_path(&["db", "migrate"]),
364            PathBuf::from("src/handlers/db/migrate.rs")
365        );
366    }
367
368    #[test]
369    fn test_handler_path_deeply_nested() {
370        let paths = HandlerPaths::new("src/handlers", "rs", RUST_STUB_MARKER);
371        assert_eq!(
372            paths.handler_path(&["config", "user", "set"]),
373            PathBuf::from("src/handlers/config/user/set.rs")
374        );
375    }
376
377    #[test]
378    fn test_mod_path() {
379        let paths = HandlerPaths::new("src/handlers", "rs", RUST_STUB_MARKER);
380        assert_eq!(
381            paths.mod_path(&["db"]),
382            PathBuf::from("src/handlers/db/mod.rs")
383        );
384    }
385
386    #[test]
387    fn test_typescript_extension() {
388        let paths = HandlerPaths::new("src/handlers", "ts", TS_STUB_MARKER);
389        assert_eq!(
390            paths.handler_path(&["hello"]),
391            PathBuf::from("src/handlers/hello.ts")
392        );
393    }
394}