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 return Some(current.to_path_buf());
140 }
141
142 current = current.parent()?;
143 }
144 }
145
146 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#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct TargetPath {
165 pub absolute: PathBuf,
167
168 pub relative: PathBuf,
170
171 pub project_root: PathBuf,
173}
174
175impl TargetPath {
176 pub fn resolve(project_root: &Path, custom_path: Option<&str>) -> Result<Self, PathError> {
190 let path_str = custom_path.unwrap_or("src/ui");
192
193 let path = Path::new(path_str);
195
196 if path.is_absolute() {
198 return Err(PathError::AbsolutePath(path.to_path_buf()));
199 }
200
201 let normalized = Self::normalize_path(path);
203
204 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 let absolute = project_root.join(&normalized);
218
219 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 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 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 }
262 std::path::Component::Normal(part) => {
263 normalized.push(part);
264 }
265 std::path::Component::ParentDir => {
266 normalized.push(component);
268 }
269 _ => {
270 normalized.push(component);
272 }
273 }
274 }
275
276 normalized
277 }
278
279 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 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 #[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 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 #[test]
509 fn test_target_path_resolve_default() {
510 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 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 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 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 let temp = create_test_project(true);
576 let project_root = temp.path();
577
578 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 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 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}