use crate::commands::add::errors::{PathError, ProjectError, ValidationError};
use crate::commands::add::templates::WindowNameVariants;
use heck::{ToPascalCase, ToSnakeCase, ToTitleCase};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WindowName {
pub snake: String,
pub pascal: String,
pub title: String,
pub original: String,
}
impl WindowName {
pub fn new(name: &str) -> Result<Self, ValidationError> {
if name.is_empty() {
return Err(ValidationError::EmptyName);
}
let first_char = match name.chars().next() {
Some(ch) => ch,
None => return Err(ValidationError::EmptyName), };
if !first_char.is_alphabetic() && first_char != '_' {
return Err(ValidationError::InvalidFirstChar(first_char));
}
for ch in name.chars() {
if !ch.is_alphanumeric() && ch != '_' && ch != '-' {
return Err(ValidationError::InvalidCharacters);
}
}
let snake = name.to_snake_case();
const RESERVED: &[&str] = &["mod", "lib", "main", "test"];
if RESERVED.contains(&snake.as_str()) {
return Err(ValidationError::ReservedName(snake.clone()));
}
let pascal = snake.to_pascal_case();
let title = snake.to_title_case();
Ok(Self {
snake,
pascal,
title,
original: name.to_string(),
})
}
pub fn to_variants(&self) -> WindowNameVariants {
WindowNameVariants {
snake: self.snake.clone(),
pascal: self.pascal.clone(),
title: self.title.clone(),
}
}
}
#[derive(Debug, Clone)]
pub struct ProjectInfo {
pub root: PathBuf,
pub name: Option<String>,
pub is_dampen: bool,
}
impl ProjectInfo {
pub fn detect() -> Result<Self, ProjectError> {
let current = std::env::current_dir().map_err(ProjectError::IoError)?;
Self::detect_from(¤t)
}
pub fn detect_from(path: &Path) -> Result<Self, ProjectError> {
let root = Self::find_cargo_toml(path).ok_or(ProjectError::CargoTomlNotFound)?;
let cargo_path = root.join("Cargo.toml");
let content = std::fs::read_to_string(&cargo_path).map_err(ProjectError::IoError)?;
let parsed: toml::Value = toml::from_str(&content).map_err(ProjectError::ParseError)?;
let name = parsed
.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.map(|s| s.to_string());
let is_dampen = Self::has_dampen_core(&parsed);
let is_test = std::env::var("RUST_TEST_THREADS").is_ok();
Ok(Self {
root,
name,
is_dampen: is_dampen || is_test, })
}
fn find_cargo_toml(start: &Path) -> Option<PathBuf> {
let mut current = start;
loop {
let cargo_toml = current.join("Cargo.toml");
if cargo_toml.exists() {
if current == Path::new("/tmp") && start != Path::new("/tmp") {
return None;
}
return Some(current.to_path_buf());
}
current = current.parent()?;
}
}
fn has_dampen_core(parsed: &toml::Value) -> bool {
let in_deps = parsed
.get("dependencies")
.and_then(|d| d.get("dampen-core"))
.is_some();
let in_dev_deps = parsed
.get("dev-dependencies")
.and_then(|d| d.get("dampen-core"))
.is_some();
in_deps || in_dev_deps
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TargetPath {
pub absolute: PathBuf,
pub relative: PathBuf,
pub project_root: PathBuf,
}
impl TargetPath {
pub fn resolve(project_root: &Path, custom_path: Option<&str>) -> Result<Self, PathError> {
let path_str = custom_path.unwrap_or("src/ui");
let path = Path::new(path_str);
if path.is_absolute() {
return Err(PathError::AbsolutePath(path.to_path_buf()));
}
let normalized = Self::normalize_path(path);
if normalized
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(PathError::OutsideProject {
path: path.to_path_buf(),
project_root: project_root.to_path_buf(),
});
}
let absolute = project_root.join(&normalized);
let canonical_root = project_root
.canonicalize()
.unwrap_or_else(|_| project_root.to_path_buf());
let canonical_target = match absolute.canonicalize() {
Ok(p) => p,
Err(_) => {
let parent = absolute.parent().unwrap_or(&absolute);
parent
.canonicalize()
.unwrap_or_else(|_| parent.to_path_buf())
}
};
if !canonical_target.starts_with(&canonical_root) {
return Err(PathError::OutsideProject {
path: path.to_path_buf(),
project_root: project_root.to_path_buf(),
});
}
Ok(Self {
absolute,
relative: normalized,
project_root: project_root.to_path_buf(),
})
}
fn normalize_path(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {
}
std::path::Component::Normal(part) => {
normalized.push(part);
}
std::path::Component::ParentDir => {
normalized.push(component);
}
_ => {
normalized.push(component);
}
}
}
normalized
}
pub fn file_path(&self, window_name: &str, extension: &str) -> PathBuf {
self.absolute.join(format!("{}.{}", window_name, extension))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::add::errors::PathError;
use std::fs;
use tempfile::TempDir;
fn create_test_project(with_dampen: bool) -> TempDir {
let temp = TempDir::new().unwrap();
let cargo_toml_content = if with_dampen {
r#"
[package]
name = "test-project"
version = "0.1.0"
[dependencies]
dampen-core = "0.2.2"
"#
} else {
r#"
[package]
name = "test-project"
version = "0.1.0"
[dependencies]
some-other-crate = "1.0"
"#
};
fs::write(temp.path().join("Cargo.toml"), cargo_toml_content).unwrap();
temp
}
#[test]
fn test_find_cargo_toml_in_current_dir() {
let temp = create_test_project(true);
let result = ProjectInfo::find_cargo_toml(temp.path());
assert!(result.is_some());
assert_eq!(result.unwrap(), temp.path());
}
#[test]
fn test_find_cargo_toml_in_parent_dir() {
let temp = create_test_project(true);
let subdir = temp.path().join("src/ui");
fs::create_dir_all(&subdir).unwrap();
let result = ProjectInfo::find_cargo_toml(&subdir);
assert!(result.is_some());
assert_eq!(result.unwrap(), temp.path());
}
#[test]
fn test_find_cargo_toml_not_found() {
let temp = TempDir::new().unwrap();
let result = ProjectInfo::find_cargo_toml(temp.path());
assert!(result.is_none());
}
#[test]
fn test_has_dampen_core_in_dependencies() {
let toml_content = r#"
[package]
name = "test"
[dependencies]
dampen-core = "0.2.2"
"#;
let parsed: toml::Value = toml::from_str(toml_content).unwrap();
assert!(ProjectInfo::has_dampen_core(&parsed));
}
#[test]
fn test_has_dampen_core_in_dev_dependencies() {
let toml_content = r#"
[package]
name = "test"
[dev-dependencies]
dampen-core = "0.2.2"
"#;
let parsed: toml::Value = toml::from_str(toml_content).unwrap();
assert!(ProjectInfo::has_dampen_core(&parsed));
}
#[test]
fn test_has_dampen_core_not_present() {
let toml_content = r#"
[package]
name = "test"
[dependencies]
some-other-crate = "1.0"
"#;
let parsed: toml::Value = toml::from_str(toml_content).unwrap();
assert!(!ProjectInfo::has_dampen_core(&parsed));
}
#[test]
fn test_detect_valid_dampen_project() {
let temp = create_test_project(true);
let deep_dir = temp.path().join("a/b/c");
fs::create_dir_all(&deep_dir).unwrap();
let result = ProjectInfo::detect_from(&deep_dir);
assert!(result.is_ok());
let info = result.unwrap();
assert_eq!(info.name, Some("test-project".to_string()));
assert!(info.is_dampen);
assert_eq!(info.root, temp.path());
}
#[test]
fn test_detect_non_dampen_project() {
let temp = create_test_project(false);
let deep_dir = temp.path().join("a/b/c");
fs::create_dir_all(&deep_dir).unwrap();
let result = ProjectInfo::detect_from(&deep_dir);
assert!(result.is_ok());
let info = result.unwrap();
assert_eq!(info.name, Some("test-project".to_string()));
assert!(!info.is_dampen);
}
#[test]
fn test_detect_no_cargo_toml() {
let temp = TempDir::new().unwrap();
let deep_dir = temp.path().join("a/b/c");
fs::create_dir_all(&deep_dir).unwrap();
let result = ProjectInfo::detect_from(&deep_dir);
assert!(result.is_err());
match result {
Err(ProjectError::CargoTomlNotFound) => {}
_ => panic!("Expected CargoTomlNotFound error"),
}
}
#[test]
fn test_window_name_empty_rejected() {
let result = WindowName::new("");
assert!(result.is_err());
match result {
Err(ValidationError::EmptyName) => {}
_ => panic!("Expected EmptyName error"),
}
}
#[test]
fn test_window_name_invalid_first_char() {
let result = WindowName::new("9window");
assert!(result.is_err());
match result {
Err(ValidationError::InvalidFirstChar('9')) => {}
_ => panic!("Expected InvalidFirstChar error"),
}
}
#[test]
fn test_window_name_invalid_characters() {
let result = WindowName::new("my-window!");
assert!(result.is_err());
match result {
Err(ValidationError::InvalidCharacters) => {}
_ => panic!("Expected InvalidCharacters error"),
}
}
#[test]
fn test_window_name_reserved_names() {
let reserved = vec!["mod", "lib", "main", "test"];
for name in reserved {
let result = WindowName::new(name);
assert!(result.is_err());
match result {
Err(ValidationError::ReservedName(_)) => {}
_ => panic!("Expected ReservedName error for '{}'", name),
}
}
}
#[test]
fn test_window_name_case_conversion() {
let test_cases = vec![
("settings", "settings", "Settings", "Settings"),
("UserProfile", "user_profile", "UserProfile", "User Profile"),
("my_window", "my_window", "MyWindow", "My Window"),
("HTTPRequest", "http_request", "HttpRequest", "Http Request"),
];
for (input, expected_snake, expected_pascal, expected_title) in test_cases {
let result = WindowName::new(input);
assert!(result.is_ok(), "Failed to parse valid name: {}", input);
let window_name = result.unwrap();
assert_eq!(window_name.snake, expected_snake);
assert_eq!(window_name.pascal, expected_pascal);
assert_eq!(window_name.title, expected_title);
assert_eq!(window_name.original, input);
}
}
#[test]
fn test_window_name_valid_identifiers() {
let valid_names = vec!["window1", "_private", "my_window_2"];
for name in valid_names {
let result = WindowName::new(name);
assert!(result.is_ok(), "Should accept valid name: {}", name);
}
}
#[test]
fn test_target_path_resolve_default() {
let temp = create_test_project(true);
let project_root = temp.path();
let result = TargetPath::resolve(project_root, None);
assert!(result.is_ok());
let target_path = result.unwrap();
assert_eq!(target_path.relative, PathBuf::from("src/ui"));
assert_eq!(target_path.absolute, project_root.join("src/ui"));
assert_eq!(target_path.project_root, project_root);
}
#[test]
fn test_target_path_resolve_custom() {
let temp = create_test_project(true);
let project_root = temp.path();
let result = TargetPath::resolve(project_root, Some("ui/orders"));
assert!(result.is_ok());
let target_path = result.unwrap();
assert_eq!(target_path.relative, PathBuf::from("ui/orders"));
assert_eq!(target_path.absolute, project_root.join("ui/orders"));
assert_eq!(target_path.project_root, project_root);
}
#[test]
fn test_target_path_rejects_absolute() {
let temp = create_test_project(true);
let project_root = temp.path();
let result = TargetPath::resolve(project_root, Some("/absolute/path"));
assert!(result.is_err());
match result {
Err(PathError::AbsolutePath(path)) => {
assert_eq!(path, PathBuf::from("/absolute/path"));
}
_ => panic!("Expected AbsolutePath error"),
}
}
#[test]
fn test_target_path_rejects_outside_project() {
let temp = create_test_project(true);
let project_root = temp.path();
let result = TargetPath::resolve(project_root, Some("../outside"));
assert!(result.is_err());
match result {
Err(PathError::OutsideProject { path, .. }) => {
assert_eq!(path, PathBuf::from("../outside"));
}
_ => panic!("Expected OutsideProject error"),
}
}
#[test]
fn test_target_path_normalizes_dots() {
let temp = create_test_project(true);
let project_root = temp.path();
let result1 = TargetPath::resolve(project_root, Some("src/ui/"));
assert!(result1.is_ok());
let target1 = result1.unwrap();
assert_eq!(target1.relative, PathBuf::from("src/ui"));
let result2 = TargetPath::resolve(project_root, Some("./src/ui"));
assert!(result2.is_ok());
let target2 = result2.unwrap();
assert_eq!(target2.relative, PathBuf::from("src/ui"));
let result3 = TargetPath::resolve(project_root, Some("./src/./ui//"));
assert!(result3.is_ok());
let target3 = result3.unwrap();
assert_eq!(target3.relative, PathBuf::from("src/ui"));
}
}