1use std::collections::BTreeSet;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum WorkspacePathKind {
9 WorkspaceRelative,
10 HostAbsolute,
11 Invalid,
12}
13
14#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
15pub struct WorkspacePathInfo {
16 pub input: String,
17 pub kind: WorkspacePathKind,
18 pub normalized: String,
19 pub workspace_path: Option<String>,
20 pub host_path: Option<String>,
21 pub recovered_root_drift: bool,
22 pub reason: Option<String>,
23}
24
25impl WorkspacePathInfo {
26 pub fn normalized_workspace_path(&self) -> Option<&str> {
27 self.workspace_path.as_deref()
28 }
29
30 pub fn display_path(&self) -> &str {
31 self.workspace_path
32 .as_deref()
33 .or(self.host_path.as_deref())
34 .unwrap_or(&self.normalized)
35 }
36
37 pub fn policy_candidates(&self) -> Vec<String> {
38 let mut seen = BTreeSet::new();
39 let mut out = Vec::new();
40 for candidate in [
41 Some(self.input.as_str()),
42 Some(self.normalized.as_str()),
43 self.workspace_path.as_deref(),
44 self.host_path.as_deref(),
45 ]
46 .into_iter()
47 .flatten()
48 {
49 if !candidate.is_empty() && seen.insert(candidate.to_string()) {
50 out.push(candidate.to_string());
51 }
52 }
53 out
54 }
55
56 pub fn resolved_host_path(&self) -> Option<PathBuf> {
57 self.host_path.as_ref().map(PathBuf::from)
58 }
59}
60
61pub fn normalize_workspace_path(path: &str, workspace_root: Option<&Path>) -> Option<String> {
62 classify_workspace_path(path, workspace_root).workspace_path
63}
64
65pub fn classify_workspace_path(path: &str, workspace_root: Option<&Path>) -> WorkspacePathInfo {
66 let input = path.to_string();
67 let trimmed = path.trim();
68 if trimmed.is_empty() {
69 return invalid_info(input, String::new(), "path is empty");
70 }
71 if trimmed.contains('\0') {
72 return invalid_info(input, to_posix(trimmed), "path contains NUL bytes");
73 }
74
75 let normalized_input = normalize_lexical(trimmed);
76 let root_path = workspace_root.map(normalize_workspace_root);
77 let root_norm = root_path
78 .as_ref()
79 .map(|root| normalize_host_path(root))
80 .filter(|root| !root.is_empty());
81
82 if !is_absolute_str(trimmed) {
83 let workspace_path = normalized_input.clone();
84 if escapes_workspace(&workspace_path) {
85 let host_path = root_path.as_ref().map(|root| {
86 normalize_host_path(&root.join(PathBuf::from(workspace_path.as_str())))
87 });
88 return WorkspacePathInfo {
89 input,
90 kind: WorkspacePathKind::Invalid,
91 normalized: workspace_path,
92 workspace_path: None,
93 host_path,
94 recovered_root_drift: false,
95 reason: Some("workspace-relative path escapes the workspace root".to_string()),
96 };
97 }
98 let host_path = root_path
99 .as_ref()
100 .map(|root| normalize_host_path(&root.join(PathBuf::from(workspace_path.as_str()))));
101 return WorkspacePathInfo {
102 input,
103 kind: WorkspacePathKind::WorkspaceRelative,
104 normalized: workspace_path.clone(),
105 workspace_path: Some(workspace_path),
106 host_path,
107 recovered_root_drift: false,
108 reason: None,
109 };
110 }
111
112 let host_path = normalized_input.clone();
113 if let Some(root_norm) = root_norm.as_deref() {
114 if let Some(workspace_path) = workspace_relative_from_absolute(&host_path, root_norm) {
115 return WorkspacePathInfo {
116 input,
117 kind: WorkspacePathKind::HostAbsolute,
118 normalized: host_path.clone(),
119 workspace_path: Some(workspace_path),
120 host_path: Some(host_path),
121 recovered_root_drift: false,
122 reason: None,
123 };
124 }
125
126 if let Some(root_path) = root_path.as_ref() {
127 if let Some(recovered) = recover_root_drift(trimmed, root_path) {
128 return WorkspacePathInfo {
129 input,
130 kind: WorkspacePathKind::WorkspaceRelative,
131 normalized: recovered.clone(),
132 workspace_path: Some(recovered.clone()),
133 host_path: Some(normalize_host_path(
134 &root_path.join(PathBuf::from(recovered.as_str())),
135 )),
136 recovered_root_drift: true,
137 reason: None,
138 };
139 }
140 }
141 }
142
143 WorkspacePathInfo {
144 input,
145 kind: WorkspacePathKind::HostAbsolute,
146 normalized: host_path.clone(),
147 workspace_path: None,
148 host_path: Some(host_path),
149 recovered_root_drift: false,
150 reason: None,
151 }
152}
153
154fn invalid_info(input: String, normalized: String, reason: &str) -> WorkspacePathInfo {
155 WorkspacePathInfo {
156 input,
157 kind: WorkspacePathKind::Invalid,
158 normalized,
159 workspace_path: None,
160 host_path: None,
161 recovered_root_drift: false,
162 reason: Some(reason.to_string()),
163 }
164}
165
166fn normalize_workspace_root(root: &Path) -> PathBuf {
167 if root.is_absolute() {
168 root.to_path_buf()
169 } else {
170 std::env::current_dir()
171 .unwrap_or_else(|_| PathBuf::from("."))
172 .join(root)
173 }
174}
175
176fn to_posix(s: &str) -> String {
177 s.replace('\\', "/")
178}
179
180fn is_absolute_str(path: &str) -> bool {
181 let path = to_posix(path);
182 if path.starts_with('/') {
183 return true;
184 }
185 let bytes = path.as_bytes();
186 bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/'
187}
188
189fn split_segments(path: &str) -> (bool, Option<String>, Vec<String>) {
190 let posix = to_posix(path);
191 let mut drive: Option<String> = None;
192 let mut rest = posix.as_str();
193 let bytes = posix.as_bytes();
194 if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
195 drive = Some(posix[..2].to_string());
196 rest = &posix[2..];
197 }
198 let absolute = rest.starts_with('/');
199 let segments = rest
200 .split('/')
201 .filter(|segment| !segment.is_empty())
202 .map(|segment| segment.to_string())
203 .collect();
204 (absolute, drive, segments)
205}
206
207fn normalize_lexical(path: &str) -> String {
208 let (absolute, drive, segments) = split_segments(path);
209 let mut stack = Vec::new();
210 for segment in segments {
211 match segment.as_str() {
212 "." => {}
213 ".." => {
214 if let Some(top) = stack.last() {
215 if top != ".." {
216 stack.pop();
217 continue;
218 }
219 }
220 if !absolute {
221 stack.push("..".to_string());
222 }
223 }
224 _ => stack.push(segment),
225 }
226 }
227
228 let mut normalized = String::new();
229 if let Some(drive) = drive {
230 normalized.push_str(&drive);
231 }
232 if absolute {
233 normalized.push('/');
234 }
235 normalized.push_str(&stack.join("/"));
236 if normalized.is_empty() {
237 ".".to_string()
238 } else {
239 normalized
240 }
241}
242
243fn normalize_host_path(path: &Path) -> String {
244 normalize_lexical(&path.to_string_lossy())
245}
246
247fn escapes_workspace(path: &str) -> bool {
248 path == ".." || path.starts_with("../")
249}
250
251fn workspace_relative_from_absolute(path: &str, workspace_root: &str) -> Option<String> {
252 let (path_abs, path_drive, path_segments) = split_segments(path);
253 let (root_abs, root_drive, root_segments) = split_segments(workspace_root);
254 if !path_abs || !root_abs || path_drive != root_drive {
255 return None;
256 }
257 if path_segments.len() < root_segments.len()
258 || !path_segments.starts_with(root_segments.as_slice())
259 {
260 return None;
261 }
262 let remainder = &path_segments[root_segments.len()..];
263 if remainder.is_empty() {
264 Some(".".to_string())
265 } else {
266 Some(remainder.join("/"))
267 }
268}
269
270fn recover_root_drift(path: &str, workspace_root: &Path) -> Option<String> {
271 let posix = to_posix(path);
272 if !posix.starts_with('/') {
273 return None;
274 }
275 let trimmed = posix.trim_start_matches('/');
276 if trimmed.is_empty() {
277 return None;
278 }
279 let workspace_path = normalize_lexical(trimmed);
280 if workspace_path == "." || escapes_workspace(&workspace_path) {
281 return None;
282 }
283 if Path::new(path).exists() {
284 return None;
285 }
286 let candidate = workspace_root.join(PathBuf::from(workspace_path.as_str()));
287 if candidate.exists() || candidate.parent().is_some_and(Path::exists) {
288 Some(workspace_path)
289 } else {
290 None
291 }
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 #[test]
299 fn relative_path_is_workspace_relative() {
300 let dir = tempfile::tempdir().unwrap();
301 let info = classify_workspace_path("src/main.rs", Some(dir.path()));
302 assert_eq!(info.kind, WorkspacePathKind::WorkspaceRelative);
303 assert_eq!(info.workspace_path.as_deref(), Some("src/main.rs"));
304 assert_eq!(
305 info.host_path.as_deref(),
306 Some(normalize_host_path(&dir.path().join("src/main.rs")).as_str())
307 );
308 }
309
310 #[test]
311 fn parent_escape_is_invalid() {
312 let dir = tempfile::tempdir().unwrap();
313 let info = classify_workspace_path("../secret.txt", Some(dir.path()));
314 assert_eq!(info.kind, WorkspacePathKind::Invalid);
315 assert_eq!(
316 info.reason.as_deref(),
317 Some("workspace-relative path escapes the workspace root")
318 );
319 }
320
321 #[test]
322 fn windows_drive_relative_path_is_not_host_absolute() {
323 let dir = tempfile::tempdir().unwrap();
324 let info = classify_workspace_path("C:src/main.harn", Some(dir.path()));
325 assert_eq!(info.kind, WorkspacePathKind::WorkspaceRelative);
326 assert_eq!(info.workspace_path.as_deref(), Some("C:src/main.harn"));
327 }
328
329 #[test]
330 fn absolute_path_inside_workspace_gets_relative_projection() {
331 let dir = tempfile::tempdir().unwrap();
332 let file = dir.path().join("packages/app/host.harn");
333 std::fs::create_dir_all(file.parent().unwrap()).unwrap();
334 std::fs::write(&file, "ok").unwrap();
335 let info = classify_workspace_path(file.to_string_lossy().as_ref(), Some(dir.path()));
336 assert_eq!(info.kind, WorkspacePathKind::HostAbsolute);
337 assert_eq!(
338 info.workspace_path.as_deref(),
339 Some("packages/app/host.harn")
340 );
341 assert!(!info.recovered_root_drift);
342 }
343
344 #[test]
345 fn leading_slash_workspace_drift_recovers_when_workspace_candidate_exists() {
346 let dir = tempfile::tempdir().unwrap();
347 let file = dir.path().join("packages/app/host.harn");
348 std::fs::create_dir_all(file.parent().unwrap()).unwrap();
349 std::fs::write(&file, "ok").unwrap();
350 let info = classify_workspace_path("/packages/app/host.harn", Some(dir.path()));
351 assert_eq!(info.kind, WorkspacePathKind::WorkspaceRelative);
352 assert_eq!(
353 info.workspace_path.as_deref(),
354 Some("packages/app/host.harn")
355 );
356 assert!(info.recovered_root_drift);
357 }
358
359 #[test]
360 fn unknown_absolute_path_stays_host_absolute() {
361 let dir = tempfile::tempdir().unwrap();
362 let info = classify_workspace_path("/tmp/harn-issue-125-nope", Some(dir.path()));
363 assert_eq!(info.kind, WorkspacePathKind::HostAbsolute);
364 assert!(info.workspace_path.is_none());
365 assert!(!info.recovered_root_drift);
366 }
367
368 #[test]
369 fn normalize_workspace_path_returns_relative_projection() {
370 let dir = tempfile::tempdir().unwrap();
371 std::fs::create_dir_all(dir.path().join("packages/app")).unwrap();
372 assert_eq!(
373 normalize_workspace_path("/packages/app", Some(dir.path())).as_deref(),
374 Some("packages/app")
375 );
376 }
377}