Skip to main content

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