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 _guard = std::env::set_current_dir(temp.path());
399
400        let result = ProjectInfo::detect();
401
402        assert!(result.is_ok());
403        let info = result.unwrap();
404        assert_eq!(info.name, Some("test-project".to_string()));
405        assert!(info.is_dampen);
406        assert_eq!(info.root, temp.path());
407    }
408
409    #[test]
410    fn test_detect_non_dampen_project() {
411        let temp = create_test_project(false);
412        let _guard = std::env::set_current_dir(temp.path());
413
414        let result = ProjectInfo::detect();
415
416        assert!(result.is_ok());
417        let info = result.unwrap();
418        assert_eq!(info.name, Some("test-project".to_string()));
419        assert!(!info.is_dampen);
420    }
421
422    #[test]
423    fn test_detect_no_cargo_toml() {
424        // Use a deeper temporary directory to ensure we don't hit /tmp/Cargo.toml
425        let temp = TempDir::new().unwrap();
426        let deep_dir = temp.path().join("a/b/c");
427        fs::create_dir_all(&deep_dir).unwrap();
428
429        let _guard = std::env::set_current_dir(&deep_dir);
430
431        let result = ProjectInfo::detect();
432
433        assert!(result.is_err());
434        match result {
435            Err(ProjectError::CargoTomlNotFound) => {}
436            _ => panic!("Expected CargoTomlNotFound error"),
437        }
438    }
439
440    // WindowName tests (Phase 4)
441    #[test]
442    fn test_window_name_empty_rejected() {
443        let result = WindowName::new("");
444        assert!(result.is_err());
445        match result {
446            Err(ValidationError::EmptyName) => {}
447            _ => panic!("Expected EmptyName error"),
448        }
449    }
450
451    #[test]
452    fn test_window_name_invalid_first_char() {
453        let result = WindowName::new("9window");
454        assert!(result.is_err());
455        match result {
456            Err(ValidationError::InvalidFirstChar('9')) => {}
457            _ => panic!("Expected InvalidFirstChar error"),
458        }
459    }
460
461    #[test]
462    fn test_window_name_invalid_characters() {
463        let result = WindowName::new("my-window!");
464        assert!(result.is_err());
465        match result {
466            Err(ValidationError::InvalidCharacters) => {}
467            _ => panic!("Expected InvalidCharacters error"),
468        }
469    }
470
471    #[test]
472    fn test_window_name_reserved_names() {
473        let reserved = vec!["mod", "lib", "main", "test"];
474        for name in reserved {
475            let result = WindowName::new(name);
476            assert!(result.is_err());
477            match result {
478                Err(ValidationError::ReservedName(_)) => {}
479                _ => panic!("Expected ReservedName error for '{}'", name),
480            }
481        }
482    }
483
484    #[test]
485    fn test_window_name_case_conversion() {
486        // Test various inputs and their expected conversions
487        let test_cases = vec![
488            ("settings", "settings", "Settings", "Settings"),
489            ("UserProfile", "user_profile", "UserProfile", "User Profile"),
490            ("my_window", "my_window", "MyWindow", "My Window"),
491            ("HTTPRequest", "http_request", "HttpRequest", "Http Request"),
492        ];
493
494        for (input, expected_snake, expected_pascal, expected_title) in test_cases {
495            let result = WindowName::new(input);
496            assert!(result.is_ok(), "Failed to parse valid name: {}", input);
497
498            let window_name = result.unwrap();
499            assert_eq!(window_name.snake, expected_snake);
500            assert_eq!(window_name.pascal, expected_pascal);
501            assert_eq!(window_name.title, expected_title);
502            assert_eq!(window_name.original, input);
503        }
504    }
505
506    #[test]
507    fn test_window_name_valid_identifiers() {
508        let valid_names = vec!["window1", "_private", "my_window_2"];
509        for name in valid_names {
510            let result = WindowName::new(name);
511            assert!(result.is_ok(), "Should accept valid name: {}", name);
512        }
513    }
514
515    // TargetPath tests (Phase 6)
516
517    #[test]
518    fn test_target_path_resolve_default() {
519        // When no custom path is provided, should resolve to src/ui/
520        let temp = create_test_project(true);
521        let project_root = temp.path();
522
523        let result = TargetPath::resolve(project_root, None);
524
525        assert!(result.is_ok());
526        let target_path = result.unwrap();
527        assert_eq!(target_path.relative, PathBuf::from("src/ui"));
528        assert_eq!(target_path.absolute, project_root.join("src/ui"));
529        assert_eq!(target_path.project_root, project_root);
530    }
531
532    #[test]
533    fn test_target_path_resolve_custom() {
534        // Custom relative path should be resolved correctly
535        let temp = create_test_project(true);
536        let project_root = temp.path();
537
538        let result = TargetPath::resolve(project_root, Some("ui/orders"));
539
540        assert!(result.is_ok());
541        let target_path = result.unwrap();
542        assert_eq!(target_path.relative, PathBuf::from("ui/orders"));
543        assert_eq!(target_path.absolute, project_root.join("ui/orders"));
544        assert_eq!(target_path.project_root, project_root);
545    }
546
547    #[test]
548    fn test_target_path_rejects_absolute() {
549        // Absolute paths should be rejected
550        let temp = create_test_project(true);
551        let project_root = temp.path();
552
553        let result = TargetPath::resolve(project_root, Some("/absolute/path"));
554
555        assert!(result.is_err());
556        match result {
557            Err(PathError::AbsolutePath(path)) => {
558                assert_eq!(path, PathBuf::from("/absolute/path"));
559            }
560            _ => panic!("Expected AbsolutePath error"),
561        }
562    }
563
564    #[test]
565    fn test_target_path_rejects_outside_project() {
566        // Paths that escape the project root via .. should be rejected
567        let temp = create_test_project(true);
568        let project_root = temp.path();
569
570        let result = TargetPath::resolve(project_root, Some("../outside"));
571
572        assert!(result.is_err());
573        match result {
574            Err(PathError::OutsideProject { path, .. }) => {
575                assert_eq!(path, PathBuf::from("../outside"));
576            }
577            _ => panic!("Expected OutsideProject error"),
578        }
579    }
580
581    #[test]
582    fn test_target_path_normalizes_dots() {
583        // Paths with . and trailing slashes should be normalized
584        let temp = create_test_project(true);
585        let project_root = temp.path();
586
587        // Test with trailing slash
588        let result1 = TargetPath::resolve(project_root, Some("src/ui/"));
589        assert!(result1.is_ok());
590        let target1 = result1.unwrap();
591        assert_eq!(target1.relative, PathBuf::from("src/ui"));
592
593        // Test with ./
594        let result2 = TargetPath::resolve(project_root, Some("./src/ui"));
595        assert!(result2.is_ok());
596        let target2 = result2.unwrap();
597        assert_eq!(target2.relative, PathBuf::from("src/ui"));
598
599        // Test with redundant slashes and dots
600        let result3 = TargetPath::resolve(project_root, Some("./src/./ui//"));
601        assert!(result3.is_ok());
602        let target3 = result3.unwrap();
603        assert_eq!(target3.relative, PathBuf::from("src/ui"));
604    }
605}