1use std::path::{Component, Path, PathBuf};
9
10use crate::error::FolderError;
11
12pub fn relative(input: &str) -> Result<PathBuf, FolderError> {
18 let trimmed = input.trim();
19 if trimmed.starts_with('/') || trimmed.starts_with('\\') {
20 return Err(FolderError::Absolute(input.to_string()));
21 }
22 let mut out = PathBuf::new();
23 for component in Path::new(trimmed).components() {
24 match component {
25 Component::Normal(part) => out.push(part),
26 Component::CurDir => {}
27 Component::ParentDir => return Err(FolderError::Escape(input.to_string())),
28 Component::RootDir | Component::Prefix(_) => {
29 return Err(FolderError::Absolute(input.to_string()));
30 }
31 }
32 }
33 Ok(out)
34}
35
36pub fn relative_entry(input: &str) -> Result<PathBuf, FolderError> {
39 let rel = relative(input)?;
40 if rel.as_os_str().is_empty() {
41 return Err(FolderError::Invalid(
42 "a file path is required; the root folder itself is not a file".into(),
43 ));
44 }
45 Ok(rel)
46}
47
48pub fn for_dir(rel: &Path) -> &Path {
50 if rel.as_os_str().is_empty() {
51 Path::new(".")
52 } else {
53 rel
54 }
55}
56
57pub fn display(rel: &Path) -> String {
59 let parts: Vec<String> = rel
60 .components()
61 .filter_map(|c| match c {
62 Component::Normal(p) => Some(p.to_string_lossy().into_owned()),
63 _ => None,
64 })
65 .collect();
66 if parts.is_empty() {
67 ".".to_string()
68 } else {
69 parts.join("/")
70 }
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76
77 #[test]
78 fn plain_paths_pass_and_are_normalised() {
79 assert_eq!(relative("a/b.txt").unwrap(), PathBuf::from("a/b.txt"));
80 assert_eq!(relative("./a//b.txt").unwrap(), PathBuf::from("a/b.txt"));
81 assert_eq!(relative("").unwrap(), PathBuf::new());
82 assert_eq!(relative(".").unwrap(), PathBuf::new());
83 assert_eq!(relative(" notes.md ").unwrap(), PathBuf::from("notes.md"));
84 }
85
86 #[test]
87 fn absolute_paths_are_refused() {
88 for p in ["/etc/passwd", "/", "\\windows", "\\\\server\\share"] {
89 assert!(
90 matches!(relative(p), Err(FolderError::Absolute(_))),
91 "{p} must be refused"
92 );
93 }
94 }
95
96 #[test]
97 fn parent_components_are_refused_anywhere() {
98 for p in ["..", "../x", "a/../../x", "a/../b", "a/b/.."] {
99 assert!(
100 matches!(relative(p), Err(FolderError::Escape(_))),
101 "{p} must be refused"
102 );
103 }
104 }
105
106 #[test]
107 fn entry_paths_must_name_something() {
108 assert!(relative_entry(".").is_err());
109 assert!(relative_entry("").is_err());
110 assert!(relative_entry("a.txt").is_ok());
111 }
112
113 #[test]
114 fn display_uses_forward_slashes() {
115 assert_eq!(display(Path::new("a/b/c.rs")), "a/b/c.rs");
116 assert_eq!(display(Path::new("")), ".");
117 assert_eq!(for_dir(Path::new("")), Path::new("."));
118 }
119}