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                return Some(current.to_path_buf());
140            }
141
142            current = current.parent()?;
143        }
144    }
145
146    /// Check if parsed Cargo.toml has dampen-core dependency
147    fn has_dampen_core(parsed: &toml::Value) -> bool {
148        let in_deps = parsed
149            .get("dependencies")
150            .and_then(|d| d.get("dampen-core"))
151            .is_some();
152
153        let in_dev_deps = parsed
154            .get("dev-dependencies")
155            .and_then(|d| d.get("dampen-core"))
156            .is_some();
157
158        in_deps || in_dev_deps
159    }
160}
161
162/// A validated target path for file generation
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct TargetPath {
165    /// Absolute path to the target directory
166    pub absolute: PathBuf,
167
168    /// Relative path from project root
169    pub relative: PathBuf,
170
171    /// Project root (for validation)
172    pub project_root: PathBuf,
173}
174
175impl TargetPath {
176    /// Resolve and validate a target path
177    ///
178    /// If `custom_path` is None, defaults to "src/ui/".
179    /// If provided, validates that the path is:
180    /// - Relative (not absolute)
181    /// - Within the project bounds (no escaping via ..)
182    /// - Properly normalized (no redundant . or trailing slashes)
183    ///
184    /// # Errors
185    ///
186    /// Returns PathError if:
187    /// - Path is absolute
188    /// - Path escapes project directory
189    pub fn resolve(project_root: &Path, custom_path: Option<&str>) -> Result<Self, PathError> {
190        // 1. Get the path (default or custom)
191        let path_str = custom_path.unwrap_or("src/ui");
192
193        // 2. Parse as PathBuf
194        let path = Path::new(path_str);
195
196        // 3. Check for absolute paths
197        if path.is_absolute() {
198            return Err(PathError::AbsolutePath(path.to_path_buf()));
199        }
200
201        // 4. Normalize the path (remove ., .., trailing slashes)
202        let normalized = Self::normalize_path(path);
203
204        // 5. Check if normalized path tries to escape (contains ..)
205        // After normalization, any remaining .. means it escapes the project
206        if normalized
207            .components()
208            .any(|c| matches!(c, std::path::Component::ParentDir))
209        {
210            return Err(PathError::OutsideProject {
211                path: path.to_path_buf(),
212                project_root: project_root.to_path_buf(),
213            });
214        }
215
216        // 6. Build absolute path
217        let absolute = project_root.join(&normalized);
218
219        // 7. Final security check: ensure absolute path starts with project_root
220        // This protects against edge cases in path manipulation
221        let canonical_root = project_root
222            .canonicalize()
223            .unwrap_or_else(|_| project_root.to_path_buf());
224        let canonical_target = match absolute.canonicalize() {
225            Ok(p) => p,
226            Err(_) => {
227                // Path doesn't exist yet (expected for new directories)
228                // Check parent directory instead
229                let parent = absolute.parent().unwrap_or(&absolute);
230                parent
231                    .canonicalize()
232                    .unwrap_or_else(|_| parent.to_path_buf())
233            }
234        };
235
236        if !canonical_target.starts_with(&canonical_root) {
237            return Err(PathError::OutsideProject {
238                path: path.to_path_buf(),
239                project_root: project_root.to_path_buf(),
240            });
241        }
242
243        Ok(Self {
244            absolute,
245            relative: normalized,
246            project_root: project_root.to_path_buf(),
247        })
248    }
249
250    /// Normalize a path by removing . components and trailing slashes
251    ///
252    /// Note: This does NOT resolve .. (parent directory) components.
253    /// Those are left in place for security validation.
254    fn normalize_path(path: &Path) -> PathBuf {
255        let mut normalized = PathBuf::new();
256
257        for component in path.components() {
258            match component {
259                std::path::Component::CurDir => {
260                    // Skip . components
261                }
262                std::path::Component::Normal(part) => {
263                    normalized.push(part);
264                }
265                std::path::Component::ParentDir => {
266                    // Keep .. for later validation
267                    normalized.push(component);
268                }
269                _ => {
270                    // RootDir, Prefix (Windows) - keep as-is
271                    normalized.push(component);
272                }
273            }
274        }
275
276        normalized
277    }
278
279    /// Get the full path for a window file
280    pub fn file_path(&self, window_name: &str, extension: &str) -> PathBuf {
281        self.absolute.join(format!("{}.{}", window_name, extension))
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::commands::add::errors::PathError;
289    use std::fs;
290    use tempfile::TempDir;
291
292    // Helper to create a test project structure
293    fn create_test_project(with_dampen: bool) -> TempDir {
294        let temp = TempDir::new().unwrap();
295        let cargo_toml_content = if with_dampen {
296            r#"
297[package]
298name = "test-project"
299version = "0.1.0"
300
301[dependencies]
302dampen-core = "0.2.2"
303"#
304        } else {
305            r#"
306[package]
307name = "test-project"
308version = "0.1.0"
309
310[dependencies]
311some-other-crate = "1.0"
312"#
313        };
314
315        fs::write(temp.path().join("Cargo.toml"), cargo_toml_content).unwrap();
316        temp
317    }
318
319    #[test]
320    fn test_find_cargo_toml_in_current_dir() {
321        let temp = create_test_project(true);
322        let result = ProjectInfo::find_cargo_toml(temp.path());
323
324        assert!(result.is_some());
325        assert_eq!(result.unwrap(), temp.path());
326    }
327
328    #[test]
329    fn test_find_cargo_toml_in_parent_dir() {
330        let temp = create_test_project(true);
331        let subdir = temp.path().join("src/ui");
332        fs::create_dir_all(&subdir).unwrap();
333
334        let result = ProjectInfo::find_cargo_toml(&subdir);
335
336        assert!(result.is_some());
337        assert_eq!(result.unwrap(), temp.path());
338    }
339
340    #[test]
341    fn test_find_cargo_toml_not_found() {
342        let temp = TempDir::new().unwrap();
343        let result = ProjectInfo::find_cargo_toml(temp.path());
344
345        assert!(result.is_none());
346    }
347
348    #[test]
349    fn test_has_dampen_core_in_dependencies() {
350        let toml_content = r#"
351[package]
352name = "test"
353
354[dependencies]
355dampen-core = "0.2.2"
356"#;
357        let parsed: toml::Value = toml::from_str(toml_content).unwrap();
358
359        assert!(ProjectInfo::has_dampen_core(&parsed));
360    }
361
362    #[test]
363    fn test_has_dampen_core_in_dev_dependencies() {
364        let toml_content = r#"
365[package]
366name = "test"
367
368[dev-dependencies]
369dampen-core = "0.2.2"
370"#;
371        let parsed: toml::Value = toml::from_str(toml_content).unwrap();
372
373        assert!(ProjectInfo::has_dampen_core(&parsed));
374    }
375
376    #[test]
377    fn test_has_dampen_core_not_present() {
378        let toml_content = r#"
379[package]
380name = "test"
381
382[dependencies]
383some-other-crate = "1.0"
384"#;
385        let parsed: toml::Value = toml::from_str(toml_content).unwrap();
386
387        assert!(!ProjectInfo::has_dampen_core(&parsed));
388    }
389
390    #[test]
391    fn test_detect_valid_dampen_project() {
392        let temp = create_test_project(true);
393        let _guard = std::env::set_current_dir(temp.path());
394
395        let result = ProjectInfo::detect();
396
397        assert!(result.is_ok());
398        let info = result.unwrap();
399        assert_eq!(info.name, Some("test-project".to_string()));
400        assert!(info.is_dampen);
401        assert_eq!(info.root, temp.path());
402    }
403
404    #[test]
405    fn test_detect_non_dampen_project() {
406        let temp = create_test_project(false);
407        let _guard = std::env::set_current_dir(temp.path());
408
409        let result = ProjectInfo::detect();
410
411        assert!(result.is_ok());
412        let info = result.unwrap();
413        assert_eq!(info.name, Some("test-project".to_string()));
414        assert!(!info.is_dampen);
415    }
416
417    #[test]
418    fn test_detect_no_cargo_toml() {
419        let temp = TempDir::new().unwrap();
420        let _guard = std::env::set_current_dir(temp.path());
421
422        let result = ProjectInfo::detect();
423
424        assert!(result.is_err());
425        match result {
426            Err(ProjectError::CargoTomlNotFound) => {}
427            _ => panic!("Expected CargoTomlNotFound error"),
428        }
429    }
430
431    // WindowName tests (Phase 4)
432    #[test]
433    fn test_window_name_empty_rejected() {
434        let result = WindowName::new("");
435        assert!(result.is_err());
436        match result {
437            Err(ValidationError::EmptyName) => {}
438            _ => panic!("Expected EmptyName error"),
439        }
440    }
441
442    #[test]
443    fn test_window_name_invalid_first_char() {
444        let result = WindowName::new("9window");
445        assert!(result.is_err());
446        match result {
447            Err(ValidationError::InvalidFirstChar('9')) => {}
448            _ => panic!("Expected InvalidFirstChar error"),
449        }
450    }
451
452    #[test]
453    fn test_window_name_invalid_characters() {
454        let result = WindowName::new("my-window!");
455        assert!(result.is_err());
456        match result {
457            Err(ValidationError::InvalidCharacters) => {}
458            _ => panic!("Expected InvalidCharacters error"),
459        }
460    }
461
462    #[test]
463    fn test_window_name_reserved_names() {
464        let reserved = vec!["mod", "lib", "main", "test"];
465        for name in reserved {
466            let result = WindowName::new(name);
467            assert!(result.is_err());
468            match result {
469                Err(ValidationError::ReservedName(_)) => {}
470                _ => panic!("Expected ReservedName error for '{}'", name),
471            }
472        }
473    }
474
475    #[test]
476    fn test_window_name_case_conversion() {
477        // Test various inputs and their expected conversions
478        let test_cases = vec![
479            ("settings", "settings", "Settings", "Settings"),
480            ("UserProfile", "user_profile", "UserProfile", "User Profile"),
481            ("my_window", "my_window", "MyWindow", "My Window"),
482            ("HTTPRequest", "http_request", "HttpRequest", "Http Request"),
483        ];
484
485        for (input, expected_snake, expected_pascal, expected_title) in test_cases {
486            let result = WindowName::new(input);
487            assert!(result.is_ok(), "Failed to parse valid name: {}", input);
488
489            let window_name = result.unwrap();
490            assert_eq!(window_name.snake, expected_snake);
491            assert_eq!(window_name.pascal, expected_pascal);
492            assert_eq!(window_name.title, expected_title);
493            assert_eq!(window_name.original, input);
494        }
495    }
496
497    #[test]
498    fn test_window_name_valid_identifiers() {
499        let valid_names = vec!["window1", "_private", "my_window_2"];
500        for name in valid_names {
501            let result = WindowName::new(name);
502            assert!(result.is_ok(), "Should accept valid name: {}", name);
503        }
504    }
505
506    // TargetPath tests (Phase 6)
507
508    #[test]
509    fn test_target_path_resolve_default() {
510        // When no custom path is provided, should resolve to src/ui/
511        let temp = create_test_project(true);
512        let project_root = temp.path();
513
514        let result = TargetPath::resolve(project_root, None);
515
516        assert!(result.is_ok());
517        let target_path = result.unwrap();
518        assert_eq!(target_path.relative, PathBuf::from("src/ui"));
519        assert_eq!(target_path.absolute, project_root.join("src/ui"));
520        assert_eq!(target_path.project_root, project_root);
521    }
522
523    #[test]
524    fn test_target_path_resolve_custom() {
525        // Custom relative path should be resolved correctly
526        let temp = create_test_project(true);
527        let project_root = temp.path();
528
529        let result = TargetPath::resolve(project_root, Some("ui/orders"));
530
531        assert!(result.is_ok());
532        let target_path = result.unwrap();
533        assert_eq!(target_path.relative, PathBuf::from("ui/orders"));
534        assert_eq!(target_path.absolute, project_root.join("ui/orders"));
535        assert_eq!(target_path.project_root, project_root);
536    }
537
538    #[test]
539    fn test_target_path_rejects_absolute() {
540        // Absolute paths should be rejected
541        let temp = create_test_project(true);
542        let project_root = temp.path();
543
544        let result = TargetPath::resolve(project_root, Some("/absolute/path"));
545
546        assert!(result.is_err());
547        match result {
548            Err(PathError::AbsolutePath(path)) => {
549                assert_eq!(path, PathBuf::from("/absolute/path"));
550            }
551            _ => panic!("Expected AbsolutePath error"),
552        }
553    }
554
555    #[test]
556    fn test_target_path_rejects_outside_project() {
557        // Paths that escape the project root via .. should be rejected
558        let temp = create_test_project(true);
559        let project_root = temp.path();
560
561        let result = TargetPath::resolve(project_root, Some("../outside"));
562
563        assert!(result.is_err());
564        match result {
565            Err(PathError::OutsideProject { path, .. }) => {
566                assert_eq!(path, PathBuf::from("../outside"));
567            }
568            _ => panic!("Expected OutsideProject error"),
569        }
570    }
571
572    #[test]
573    fn test_target_path_normalizes_dots() {
574        // Paths with . and trailing slashes should be normalized
575        let temp = create_test_project(true);
576        let project_root = temp.path();
577
578        // Test with trailing slash
579        let result1 = TargetPath::resolve(project_root, Some("src/ui/"));
580        assert!(result1.is_ok());
581        let target1 = result1.unwrap();
582        assert_eq!(target1.relative, PathBuf::from("src/ui"));
583
584        // Test with ./
585        let result2 = TargetPath::resolve(project_root, Some("./src/ui"));
586        assert!(result2.is_ok());
587        let target2 = result2.unwrap();
588        assert_eq!(target2.relative, PathBuf::from("src/ui"));
589
590        // Test with redundant slashes and dots
591        let result3 = TargetPath::resolve(project_root, Some("./src/./ui//"));
592        assert!(result3.is_ok());
593        let target3 = result3.unwrap();
594        assert_eq!(target3.relative, PathBuf::from("src/ui"));
595    }
596}