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)?;
109 let root = Self::find_cargo_toml(¤t).ok_or(ProjectError::CargoTomlNotFound)?;
110
111 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 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 let is_dampen = Self::has_dampen_core(&parsed);
125
126 Ok(Self {
127 root,
128 name,
129 is_dampen,
130 })
131 }
132
133 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct TargetPath {
170 pub absolute: PathBuf,
172
173 pub relative: PathBuf,
175
176 pub project_root: PathBuf,
178}
179
180impl TargetPath {
181 pub fn resolve(project_root: &Path, custom_path: Option<&str>) -> Result<Self, PathError> {
195 let path_str = custom_path.unwrap_or("src/ui");
197
198 let path = Path::new(path_str);
200
201 if path.is_absolute() {
203 return Err(PathError::AbsolutePath(path.to_path_buf()));
204 }
205
206 let normalized = Self::normalize_path(path);
208
209 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 let absolute = project_root.join(&normalized);
223
224 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 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 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 }
267 std::path::Component::Normal(part) => {
268 normalized.push(part);
269 }
270 std::path::Component::ParentDir => {
271 normalized.push(component);
273 }
274 _ => {
275 normalized.push(component);
277 }
278 }
279 }
280
281 normalized
282 }
283
284 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 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 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 #[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 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 #[test]
522 fn test_target_path_resolve_default() {
523 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 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 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 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 let temp = create_test_project(true);
589 let project_root = temp.path();
590
591 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 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 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}