use crate::state::SessionState; use std::path::{Path, PathBuf}; /// Resolve a path argument supplied on the CLI against the given working /// directory. Relative paths (including `.`) are joined with `cwd` and /// then canonicalized when possible. Absolute paths are returned /// canonicalized when possible, unchanged otherwise. pub fn resolve_cli_path(cwd: &Path, path_arg: &str) -> PathBuf { let p = PathBuf::from(path_arg); let joined = if p.is_absolute() { p } else { cwd.join(p) }; // Canonicalize resolves `.`, `..` and symlinks. We fall back to the // joined (non-canonical) path when the target does not yet exist so // that callers can still create it later. std::fs::canonicalize(&joined).unwrap_or(joined) } /// Walk from `start` up through parent directories, returning the first /// directory that contains a `.storkit/` subdirectory, or `None`. pub fn find_story_kit_root(start: &Path) -> Option { let mut current = start.to_path_buf(); loop { if current.join(".storkit").is_dir() { return Some(current); } if !current.pop() { return None; } } } pub fn get_home_directory() -> Result { let home = homedir::my_home() .map_err(|e| format!("Failed to resolve home directory: {e}"))? .ok_or_else(|| "Home directory not found".to_string())?; Ok(home.to_string_lossy().to_string()) } /// Resolves a relative path against the active project root (pure function for testing). /// Returns error if path attempts traversal (..). pub(crate) fn resolve_path_impl(root: PathBuf, relative_path: &str) -> Result { if relative_path.contains("..") { return Err("Security Violation: Directory traversal ('..') is not allowed.".to_string()); } Ok(root.join(relative_path)) } /// Resolves a relative path against the active project root. /// Returns error if no project is open or if path attempts traversal (..). pub(crate) fn resolve_path(state: &SessionState, relative_path: &str) -> Result { let root = state.get_project_root()?; resolve_path_impl(root, relative_path) } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; // --- resolve_path_impl --- #[test] fn resolve_path_joins_relative_to_root() { let root = PathBuf::from("/projects/myapp"); let result = resolve_path_impl(root, "src/main.rs").unwrap(); assert_eq!(result, PathBuf::from("/projects/myapp/src/main.rs")); } #[test] fn resolve_path_rejects_traversal() { let root = PathBuf::from("/projects/myapp"); let result = resolve_path_impl(root, "../etc/passwd"); assert!(result.is_err()); assert!(result.unwrap_err().contains("traversal")); } // --- find_story_kit_root --- #[test] fn find_story_kit_root_returns_cwd_when_story_kit_in_cwd() { let tmp = tempfile::tempdir().unwrap(); std::fs::create_dir_all(tmp.path().join(".storkit")).unwrap(); let result = find_story_kit_root(tmp.path()); assert_eq!(result, Some(tmp.path().to_path_buf())); } #[test] fn find_story_kit_root_returns_parent_when_story_kit_in_parent() { let tmp = tempfile::tempdir().unwrap(); std::fs::create_dir_all(tmp.path().join(".storkit")).unwrap(); let child = tmp.path().join("subdir").join("nested"); std::fs::create_dir_all(&child).unwrap(); let result = find_story_kit_root(&child); assert_eq!(result, Some(tmp.path().to_path_buf())); } #[test] fn find_story_kit_root_returns_none_when_no_story_kit() { let tmp = tempfile::tempdir().unwrap(); let result = find_story_kit_root(tmp.path()); assert_eq!(result, None); } #[test] fn find_story_kit_root_prefers_nearest_ancestor() { let tmp = tempfile::tempdir().unwrap(); std::fs::create_dir_all(tmp.path().join(".storkit")).unwrap(); let child = tmp.path().join("inner"); std::fs::create_dir_all(child.join(".storkit")).unwrap(); let result = find_story_kit_root(&child); assert_eq!(result, Some(child)); } // --- resolve_cli_path --- #[test] fn resolve_cli_path_absolute_returned_unchanged_when_nonexistent() { let cwd = PathBuf::from("/some/cwd"); let result = resolve_cli_path(&cwd, "/nonexistent/absolute/path"); assert_eq!(result, PathBuf::from("/nonexistent/absolute/path")); } #[test] fn resolve_cli_path_dot_resolves_to_cwd() { let tmp = tempdir().unwrap(); let cwd = tmp.path().to_path_buf(); let result = resolve_cli_path(&cwd, "."); // Canonicalize should resolve "." in an existing dir to the canonical cwd assert_eq!(result, cwd.canonicalize().unwrap_or(cwd)); } #[test] fn resolve_cli_path_relative_resolves_against_cwd() { let tmp = tempdir().unwrap(); let cwd = tmp.path().to_path_buf(); let subdir = cwd.join("sub"); std::fs::create_dir_all(&subdir).unwrap(); let result = resolve_cli_path(&cwd, "sub"); assert_eq!(result, subdir.canonicalize().unwrap_or(subdir)); } #[test] fn resolve_cli_path_nonexistent_relative_falls_back_to_joined() { let tmp = tempdir().unwrap(); let cwd = tmp.path().to_path_buf(); let result = resolve_cli_path(&cwd, "newproject"); // Path doesn't exist yet — canonicalize fails, fallback is cwd/newproject assert_eq!(result, cwd.join("newproject")); } }