1use 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#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct WindowName {
11 pub snake: String,
13
14 pub pascal: String,
16
17 pub title: String,
19
20 pub original: String,
22}
23
24impl WindowName {
25 pub fn new(name: &str) -> Result<Self, ValidationError> {
35 if name.is_empty() {
37 return Err(ValidationError::EmptyName);
38 }
39
40 let first_char = match name.chars().next() {
43 Some(ch) => ch,
44 None => return Err(ValidationError::EmptyName), };
46 if !first_char.is_alphabetic() && first_char != '_' {
47 return Err(ValidationError::InvalidFirstChar(first_char));
48 }
49
50 for ch in name.chars() {
52 if !ch.is_alphanumeric() && ch != '_' && ch != '-' {
53 return Err(ValidationError::InvalidCharacters);
54 }
55 }
56
57 let snake = name.to_snake_case();
59
60 const RESERVED: &[&str] = &["mod", "lib", "main", "test"];
62 if RESERVED.contains(&snake.as_str()) {
63 return Err(ValidationError::ReservedName(snake.clone()));
64 }
65
66 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 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#[derive(Debug, Clone)]
90pub struct ProjectInfo {
91 pub root: PathBuf,
93
94 pub name: Option<String>,
96
97 pub is_dampen: bool,
99}
100
101impl ProjectInfo {
102 pub fn detect() -> Result<Self, ProjectError> {
107 let current = std::env::current_dir().map_err(ProjectError::IoError)?;
108 Self::detect_from(¤t)
109 }
110
111 pub fn detect_from(path: &Path) -> Result<Self, ProjectError> {
113 let root = Self::find_cargo_toml(path).ok_or(ProjectError::CargoTomlNotFound)?;
115
116 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 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 let is_dampen = Self::has_dampen_core(&parsed);
130
131 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, })
142 }
143
144 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct TargetPath {
181 pub absolute: PathBuf,
183
184 pub relative: PathBuf,
186
187 pub project_root: PathBuf,
189}
190
191impl TargetPath {
192 pub fn resolve(project_root: &Path, custom_path: Option<&str>) -> Result<Self, PathError> {
206 let path_str = custom_path.unwrap_or("src/ui");
208
209 let path = Path::new(path_str);
211
212 if path.is_absolute() {
214 return Err(PathError::AbsolutePath(path.to_path_buf()));
215 }
216
217 let normalized = Self::normalize_path(path);
219
220 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 let absolute = project_root.join(&normalized);
234
235 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 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 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 }
278 std::path::Component::Normal(part) => {
279 normalized.push(part);
280 }
281 std::path::Component::ParentDir => {
282 normalized.push(component);
284 }
285 _ => {
286 normalized.push(component);
288 }
289 }
290 }
291
292 normalized
293 }
294
295 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 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 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 #[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 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 #[test]
529 fn test_target_path_resolve_default() {
530 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 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 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 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 let temp = create_test_project(true);
596 let project_root = temp.path();
597
598 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 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 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}