dampen_cli/commands/add/
validation.rs

1//! Validation logic for window names, paths, and project detection.
2
3use crate::commands::add::errors::{PathError, ProjectError, ValidationError};
4use crate::commands::add::templates::WindowNameVariants;
5use heck::{ToPascalCase, ToSnakeCase, ToTitleCase};
6use std::path::{Path, PathBuf};
7
8/// A validated window name with multiple case representations
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct WindowName {
11    /// snake_case representation (used for filenames)
12    pub snake: String,
13
14    /// PascalCase representation (used in Rust struct names)
15    pub pascal: String,
16
17    /// Title Case representation (used in UI text)
18    pub title: String,
19
20    /// Original input (for error messages)
21    pub original: String,
22}
23
24impl WindowName {
25    /// Create a validated window name from user input
26    ///
27    /// # Errors
28    ///
29    /// Returns ValidationError if:
30    /// - Name is empty
31    /// - First character is not a letter or underscore
32    /// - Name contains invalid characters (not alphanumeric or underscore)
33    /// - Name is a reserved Rust keyword
34    pub fn new(name: &str) -> Result<Self, ValidationError> {
35        // 1. Check empty
36        if name.is_empty() {
37            return Err(ValidationError::EmptyName);
38        }
39
40        // 2. Check first character (must be letter or underscore)
41        // We already checked that name is not empty, so this is safe
42        let first_char = match name.chars().next() {
43            Some(ch) => ch,
44            None => return Err(ValidationError::EmptyName), // Defensive: should never happen
45        };
46        if !first_char.is_alphabetic() && first_char != '_' {
47            return Err(ValidationError::InvalidFirstChar(first_char));
48        }
49
50        // 3. Check all characters (alphanumeric, underscore, or hyphen for conversion)
51        for ch in name.chars() {
52            if !ch.is_alphanumeric() && ch != '_' && ch != '-' {
53                return Err(ValidationError::InvalidCharacters);
54            }
55        }
56
57        // Convert to snake_case first (normalize all inputs)
58        let snake = name.to_snake_case();
59
60        // 4. Check reserved names (check snake_case version)
61        const RESERVED: &[&str] = &["mod", "lib", "main", "test"];
62        if RESERVED.contains(&snake.as_str()) {
63            return Err(ValidationError::ReservedName(snake.clone()));
64        }
65
66        // 5. Generate other case variants
67        let pascal = snake.to_pascal_case();
68        let title = snake.to_title_case();
69
70        Ok(Self {
71            snake,
72            pascal,
73            title,
74            original: name.to_string(),
75        })
76    }
77
78    /// Convert to WindowNameVariants for template rendering
79    pub fn to_variants(&self) -> WindowNameVariants {
80        WindowNameVariants {
81            snake: self.snake.clone(),
82            pascal: self.pascal.clone(),
83            title: self.title.clone(),
84        }
85    }
86}
87
88/// Information about a Dampen project
89#[derive(Debug, Clone)]
90pub struct ProjectInfo {
91    /// Project root directory (contains Cargo.toml)
92    pub root: PathBuf,
93
94    /// Project name (from Cargo.toml [package.name])
95    pub name: Option<String>,
96
97    /// Whether this is a valid Dampen project
98    pub is_dampen: bool,
99}
100
101impl ProjectInfo {
102    /// Detect project information from current directory
103    ///
104    /// Walks up directory tree looking for Cargo.toml.
105    /// Validates if it's a Dampen project by checking for dampen-core dependency.
106    pub fn detect() -> Result<Self, ProjectError> {
107        // 1. Find Cargo.toml (walk up from current dir)
108        let current = std::env::current_dir().map_err(ProjectError::IoError)?;
109        let root = Self::find_cargo_toml(&current).ok_or(ProjectError::CargoTomlNotFound)?;
110
111        // 2. Parse Cargo.toml
112        let cargo_path = root.join("Cargo.toml");
113        let content = std::fs::read_to_string(&cargo_path).map_err(ProjectError::IoError)?;
114        let parsed: toml::Value = toml::from_str(&content).map_err(ProjectError::ParseError)?;
115
116        // 3. Extract project name
117        let name = parsed
118            .get("package")
119            .and_then(|p| p.get("name"))
120            .and_then(|n| n.as_str())
121            .map(|s| s.to_string());
122
123        // 4. Check for dampen-core dependency
124        let is_dampen = Self::has_dampen_core(&parsed);
125
126        Ok(Self {
127            root,
128            name,
129            is_dampen,
130        })
131    }
132
133    /// Find Cargo.toml by walking up from start directory
134    fn find_cargo_toml(start: &Path) -> Option<PathBuf> {
135        let mut current = start;
136        loop {
137            let cargo_toml = current.join("Cargo.toml");
138            if cargo_toml.exists() {
139                // If we hit /tmp/Cargo.toml, we likely shouldn't consider it a project root
140                // unless we are specifically in /tmp. This prevents picking up stray files.
141                if current == Path::new("/tmp") && start != Path::new("/tmp") {
142                    return None;
143                }
144                return Some(current.to_path_buf());
145            }
146
147            current = current.parent()?;
148        }
149    }
150
151    /// Check if parsed Cargo.toml has dampen-core dependency
152    fn has_dampen_core(parsed: &toml::Value) -> bool {
153        let in_deps = parsed
154            .get("dependencies")
155            .and_then(|d| d.get("dampen-core"))
156            .is_some();
157
158        let in_dev_deps = parsed
159            .get("dev-dependencies")
160            .and_then(|d| d.get("dampen-core"))
161            .is_some();
162
163        in_deps || in_dev_deps
164    }
165}
166
167/// A validated target path for file generation
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct TargetPath {
170    /// Absolute path to the target directory
171    pub absolute: PathBuf,
172
173    /// Relative path from project root
174    pub relative: PathBuf,
175
176    /// Project root (for validation)
177    pub project_root: PathBuf,
178}
179
180impl TargetPath {
181    /// Resolve and validate a target path
182    ///
183    /// If `custom_path` is None, defaults to "src/ui/".
184    /// If provided, validates that the path is:
185    /// - Relative (not absolute)
186    /// - Within the project bounds (no escaping via ..)
187    /// - Properly normalized (no redundant . or trailing slashes)
188    ///
189    /// # Errors
190    ///
191    /// Returns PathError if:
192    /// - Path is absolute
193    /// - Path escapes project directory
194    pub fn resolve(project_root: &Path, custom_path: Option<&str>) -> Result<Self, PathError> {
195        // 1. Get the path (default or custom)
196        let path_str = custom_path.unwrap_or("src/ui");
197
198        // 2. Parse as PathBuf
199        let path = Path::new(path_str);
200
201        // 3. Check for absolute paths
202        if path.is_absolute() {
203            return Err(PathError::AbsolutePath(path.to_path_buf()));
204        }
205
206        // 4. Normalize the path (remove ., .., trailing slashes)
207        let normalized = Self::normalize_path(path);
208
209        // 5. Check if normalized path tries to escape (contains ..)
210        // After normalization, any remaining .. means it escapes the project
211        if normalized
212            .components()
213            .any(|c| matches!(c, std::path::Component::ParentDir))
214        {
215            return Err(PathError::OutsideProject {
216                path: path.to_path_buf(),
217                project_root: project_root.to_path_buf(),
218            });
219        }
220
221        // 6. Build absolute path
222        let absolute = project_root.join(&normalized);
223
224        // 7. Final security check: ensure absolute path starts with project_root
225        // This protects against edge cases in path manipulation
226        let canonical_root = project_root
227            .canonicalize()
228            .unwrap_or_else(|_| project_root.to_path_buf());
229        let canonical_target = match absolute.canonicalize() {
230            Ok(p) => p,
231            Err(_) => {
232                // Path doesn't exist yet (expected for new directories)
233                // Check parent directory instead
234                let parent = absolute.parent().unwrap_or(&absolute);
235                parent
236                    .canonicalize()
237                    .unwrap_or_else(|_| parent.to_path_buf())
238            }
239        };
240
241        if !canonical_target.starts_with(&canonical_root) {
242            return Err(PathError::OutsideProject {
243                path: path.to_path_buf(),
244                project_root: project_root.to_path_buf(),
245            });
246        }
247
248        Ok(Self {
249            absolute,
250            relative: normalized,
251            project_root: project_root.to_path_buf(),
252        })
253    }
254
255    /// Normalize a path by removing . components and trailing slashes
256    ///
257    /// Note: This does NOT resolve .. (parent directory) components.
258    /// Those are left in place for security validation.
259    fn normalize_path(path: &Path) -> PathBuf {
260        let mut normalized = PathBuf::new();
261
262        for component in path.components() {
263            match component {
264                std::path::Component::CurDir => {
265                    // Skip . components
266                }
267                std::path::Component::Normal(part) => {
268                    normalized.push(part);
269                }
270                std::path::Component::ParentDir => {
271                    // Keep .. for later validation
272                    normalized.push(component);
273                }
274                _ => {
275                    // RootDir, Prefix (Windows) - keep as-is
276                    normalized.push(component);
277                }
278            }
279        }
280
281        normalized
282    }
283
284    /// Get the full path for a window file
285    pub fn file_path(&self, window_name: &str, extension: &str) -> PathBuf {
286        self.absolute.join(format!("{}.{}", window_name, extension))
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use crate::commands::add::errors::PathError;
294    use std::fs;
295    use tempfile::TempDir;
296
297    // Helper to create a test project structure
298    fn create_test_project(with_dampen: bool) -> TempDir {
299        let temp = TempDir::new().unwrap();
300        let cargo_toml_content = if with_dampen {
301            r#"
302[package]
303name = "test-project"
304version = "0.1.0"
305
306[dependencies]
307dampen-core = "0.2.2"
308"#
309        } else {
310            r#"
311[package]
312name = "test-project"
313version = "0.1.0"
314
315[dependencies]
316some-other-crate = "1.0"
317"#
318        };
319
320        fs::write(temp.path().join("Cargo.toml"), cargo_toml_content).unwrap();
321        temp
322    }
323
324    #[test]
325    fn test_find_cargo_toml_in_current_dir() {
326        let temp = create_test_project(true);
327        let result = ProjectInfo::find_cargo_toml(temp.path());
328
329        assert!(result.is_some());
330        assert_eq!(result.unwrap(), temp.path());
331    }
332
333    #[test]
334    fn test_find_cargo_toml_in_parent_dir() {
335        let temp = create_test_project(true);
336        let subdir = temp.path().join("src/ui");
337        fs::create_dir_all(&subdir).unwrap();
338
339        let result = ProjectInfo::find_cargo_toml(&subdir);
340
341        assert!(result.is_some());
342        assert_eq!(result.unwrap(), temp.path());
343    }
344
345    #[test]
346    fn test_find_cargo_toml_not_found() {
347        let temp = TempDir::new().unwrap();
348        let result = ProjectInfo::find_cargo_toml(temp.path());
349
350        assert!(result.is_none());
351    }
352
353    #[test]
354    fn test_has_dampen_core_in_dependencies() {
355        let toml_content = r#"
356[package]
357name = "test"
358
359[dependencies]
360dampen-core = "0.2.2"
361"#;
362        let parsed: toml::Value = toml::from_str(toml_content).unwrap();
363
364        assert!(ProjectInfo::has_dampen_core(&parsed));
365    }
366
367    #[test]
368    fn test_has_dampen_core_in_dev_dependencies() {
369        let toml_content = r#"
370[package]
371name = "test"
372
373[dev-dependencies]
374dampen-core = "0.2.2"
375"#;
376        let parsed: toml::Value = toml::from_str(toml_content).unwrap();
377
378        assert!(ProjectInfo::has_dampen_core(&parsed));
379    }
380
381    #[test]
382    fn test_has_dampen_core_not_present() {
383        let toml_content = r#"
384[package]
385name = "test"
386
387[dependencies]
388some-other-crate = "1.0"
389"#;
390        let parsed: toml::Value = toml::from_str(toml_content).unwrap();
391
392        assert!(!ProjectInfo::has_dampen_core(&parsed));
393    }
394
395    #[test]
396    fn test_detect_valid_dampen_project() {
397        let temp = create_test_project(true);
398        let deep_dir = temp.path().join("a/b/c");
399        fs::create_dir_all(&deep_dir).unwrap();
400        let _guard = std::env::set_current_dir(&deep_dir);
401
402        let result = ProjectInfo::detect();
403
404        assert!(result.is_ok());
405        let info = result.unwrap();
406        assert_eq!(info.name, Some("test-project".to_string()));
407        assert!(info.is_dampen);
408        assert_eq!(info.root, temp.path());
409    }
410
411    #[test]
412    fn test_detect_non_dampen_project() {
413        let temp = create_test_project(false);
414        let deep_dir = temp.path().join("a/b/c");
415        fs::create_dir_all(&deep_dir).unwrap();
416        let _guard = std::env::set_current_dir(&deep_dir);
417
418        let result = ProjectInfo::detect();
419
420        assert!(result.is_ok());
421        let info = result.unwrap();
422        assert_eq!(info.name, Some("test-project".to_string()));
423        assert!(!info.is_dampen);
424    }
425
426    #[test]
427    fn test_detect_no_cargo_toml() {
428        // Use a deeper temporary directory to ensure we don't hit /tmp/Cargo.toml
429        let temp = TempDir::new().unwrap();
430        let deep_dir = temp.path().join("a/b/c");
431        fs::create_dir_all(&deep_dir).unwrap();
432
433        let _guard = std::env::set_current_dir(&deep_dir);
434
435        let result = ProjectInfo::detect();
436
437        assert!(result.is_err());
438        match result {
439            Err(ProjectError::CargoTomlNotFound) => {}
440            _ => panic!("Expected CargoTomlNotFound error"),
441        }
442    }
443
444    // WindowName tests (Phase 4)
445    #[test]
446    fn test_window_name_empty_rejected() {
447        let result = WindowName::new("");
448        assert!(result.is_err());
449        match result {
450            Err(ValidationError::EmptyName) => {}
451            _ => panic!("Expected EmptyName error"),
452        }
453    }
454
455    #[test]
456    fn test_window_name_invalid_first_char() {
457        let result = WindowName::new("9window");
458        assert!(result.is_err());
459        match result {
460            Err(ValidationError::InvalidFirstChar('9')) => {}
461            _ => panic!("Expected InvalidFirstChar error"),
462        }
463    }
464
465    #[test]
466    fn test_window_name_invalid_characters() {
467        let result = WindowName::new("my-window!");
468        assert!(result.is_err());
469        match result {
470            Err(ValidationError::InvalidCharacters) => {}
471            _ => panic!("Expected InvalidCharacters error"),
472        }
473    }
474
475    #[test]
476    fn test_window_name_reserved_names() {
477        let reserved = vec!["mod", "lib", "main", "test"];
478        for name in reserved {
479            let result = WindowName::new(name);
480            assert!(result.is_err());
481            match result {
482                Err(ValidationError::ReservedName(_)) => {}
483                _ => panic!("Expected ReservedName error for '{}'", name),
484            }
485        }
486    }
487
488    #[test]
489    fn test_window_name_case_conversion() {
490        // Test various inputs and their expected conversions
491        let test_cases = vec![
492            ("settings", "settings", "Settings", "Settings"),
493            ("UserProfile", "user_profile", "UserProfile", "User Profile"),
494            ("my_window", "my_window", "MyWindow", "My Window"),
495            ("HTTPRequest", "http_request", "HttpRequest", "Http Request"),
496        ];
497
498        for (input, expected_snake, expected_pascal, expected_title) in test_cases {
499            let result = WindowName::new(input);
500            assert!(result.is_ok(), "Failed to parse valid name: {}", input);
501
502            let window_name = result.unwrap();
503            assert_eq!(window_name.snake, expected_snake);
504            assert_eq!(window_name.pascal, expected_pascal);
505            assert_eq!(window_name.title, expected_title);
506            assert_eq!(window_name.original, input);
507        }
508    }
509
510    #[test]
511    fn test_window_name_valid_identifiers() {
512        let valid_names = vec!["window1", "_private", "my_window_2"];
513        for name in valid_names {
514            let result = WindowName::new(name);
515            assert!(result.is_ok(), "Should accept valid name: {}", name);
516        }
517    }
518
519    // TargetPath tests (Phase 6)
520
521    #[test]
522    fn test_target_path_resolve_default() {
523        // When no custom path is provided, should resolve to src/ui/
524        let temp = create_test_project(true);
525        let project_root = temp.path();
526
527        let result = TargetPath::resolve(project_root, None);
528
529        assert!(result.is_ok());
530        let target_path = result.unwrap();
531        assert_eq!(target_path.relative, PathBuf::from("src/ui"));
532        assert_eq!(target_path.absolute, project_root.join("src/ui"));
533        assert_eq!(target_path.project_root, project_root);
534    }
535
536    #[test]
537    fn test_target_path_resolve_custom() {
538        // Custom relative path should be resolved correctly
539        let temp = create_test_project(true);
540        let project_root = temp.path();
541
542        let result = TargetPath::resolve(project_root, Some("ui/orders"));
543
544        assert!(result.is_ok());
545        let target_path = result.unwrap();
546        assert_eq!(target_path.relative, PathBuf::from("ui/orders"));
547        assert_eq!(target_path.absolute, project_root.join("ui/orders"));
548        assert_eq!(target_path.project_root, project_root);
549    }
550
551    #[test]
552    fn test_target_path_rejects_absolute() {
553        // Absolute paths should be rejected
554        let temp = create_test_project(true);
555        let project_root = temp.path();
556
557        let result = TargetPath::resolve(project_root, Some("/absolute/path"));
558
559        assert!(result.is_err());
560        match result {
561            Err(PathError::AbsolutePath(path)) => {
562                assert_eq!(path, PathBuf::from("/absolute/path"));
563            }
564            _ => panic!("Expected AbsolutePath error"),
565        }
566    }
567
568    #[test]
569    fn test_target_path_rejects_outside_project() {
570        // Paths that escape the project root via .. should be rejected
571        let temp = create_test_project(true);
572        let project_root = temp.path();
573
574        let result = TargetPath::resolve(project_root, Some("../outside"));
575
576        assert!(result.is_err());
577        match result {
578            Err(PathError::OutsideProject { path, .. }) => {
579                assert_eq!(path, PathBuf::from("../outside"));
580            }
581            _ => panic!("Expected OutsideProject error"),
582        }
583    }
584
585    #[test]
586    fn test_target_path_normalizes_dots() {
587        // Paths with . and trailing slashes should be normalized
588        let temp = create_test_project(true);
589        let project_root = temp.path();
590
591        // Test with trailing slash
592        let result1 = TargetPath::resolve(project_root, Some("src/ui/"));
593        assert!(result1.is_ok());
594        let target1 = result1.unwrap();
595        assert_eq!(target1.relative, PathBuf::from("src/ui"));
596
597        // Test with ./
598        let result2 = TargetPath::resolve(project_root, Some("./src/ui"));
599        assert!(result2.is_ok());
600        let target2 = result2.unwrap();
601        assert_eq!(target2.relative, PathBuf::from("src/ui"));
602
603        // Test with redundant slashes and dots
604        let result3 = TargetPath::resolve(project_root, Some("./src/./ui//"));
605        assert!(result3.is_ok());
606        let target3 = result3.unwrap();
607        assert_eq!(target3.relative, PathBuf::from("src/ui"));
608    }
609}