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 _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 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 #[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 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 #[test]
518 fn test_target_path_resolve_default() {
519 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 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 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 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 let temp = create_test_project(true);
585 let project_root = temp.path();
586
587 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 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 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}