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