1use crate::{PrayError, PrayResult};
2use std::fs;
3use std::path::{Component, Path, PathBuf};
4
5#[derive(Debug, Clone, PartialEq, Eq, Hash)]
7pub struct ProjectRelativePath(PathBuf);
8
9impl ProjectRelativePath {
10 pub fn parse(value: &str) -> PrayResult<Self> {
11 let trimmed = value.trim();
12 if trimmed.is_empty() {
13 return Err(PrayError::Manifest(
14 "project path must not be empty".to_string(),
15 ));
16 }
17 let path = Path::new(trimmed);
18 if path.is_absolute() {
19 return Err(PrayError::Manifest(format!(
20 "project path must be repository-relative: {trimmed}"
21 )));
22 }
23 let mut relative = PathBuf::new();
24 for component in path.components() {
25 match component {
26 Component::Normal(part) => relative.push(part),
27 Component::CurDir => {}
28 _ => {
29 return Err(PrayError::Manifest(format!(
30 "project path escapes repository root: {trimmed}"
31 )));
32 }
33 }
34 }
35 if relative.as_os_str().is_empty() {
36 return Err(PrayError::Manifest(format!(
37 "project path must be repository-relative: {trimmed}"
38 )));
39 }
40 Ok(Self(relative))
41 }
42
43 pub fn as_path(&self) -> &Path {
44 &self.0
45 }
46
47 pub fn as_str(&self) -> &str {
48 self.0.to_str().unwrap_or("")
49 }
50
51 pub fn join_root(&self, root: &Path) -> PathBuf {
52 root.join(&self.0)
53 }
54}
55
56pub fn validate_project_relative_path(value: &str) -> PrayResult<ProjectRelativePath> {
57 ProjectRelativePath::parse(value)
58}
59
60pub fn find_prayspec_file(root: &Path) -> PrayResult<PathBuf> {
61 let mut prayspec_files = Vec::new();
62 for entry in fs::read_dir(root)? {
63 let entry = entry?;
64 let path = entry.path();
65 if path.extension().and_then(|value| value.to_str()) == Some("prayspec") {
66 prayspec_files.push(path);
67 }
68 }
69 match prayspec_files.len() {
70 1 => Ok(prayspec_files.remove(0)),
71 0 => Err(PrayError::Resolution(format!(
72 "no prayspec file found in {:?}",
73 root
74 ))),
75 _ => Err(PrayError::Resolution(format!(
76 "multiple prayspec files found in {:?}",
77 root
78 ))),
79 }
80}
81
82pub fn validate_package_relative_path(path: &Path) -> PrayResult<()> {
83 if path.is_absolute() {
84 return Err(PrayError::Integrity(format!(
85 "package path must be relative: {}",
86 path.display()
87 )));
88 }
89 for component in path.components() {
90 match component {
91 Component::Normal(_) | Component::CurDir => {}
92 _ => {
93 return Err(PrayError::Integrity(format!(
94 "package path escapes package root: {}",
95 path.display()
96 )));
97 }
98 }
99 }
100 Ok(())
101}
102
103pub fn sanitize_relative_path(path: &str) -> PrayResult<PathBuf> {
104 let path = path.trim_start_matches('/');
105 let mut relative = PathBuf::new();
106 for component in Path::new(path).components() {
107 match component {
108 Component::Normal(part) => relative.push(part),
109 Component::CurDir => {}
110 _ => {
111 return Err(PrayError::Resolution(format!(
112 "invalid relative path: {path}"
113 )));
114 }
115 }
116 }
117 if relative.as_os_str().is_empty() {
118 return Err(PrayError::Resolution(format!(
119 "invalid relative path: {path}"
120 )));
121 }
122 Ok(relative)
123}
124
125pub fn remove_path_if_exists(path: &Path) -> PrayResult<()> {
126 match fs::symlink_metadata(path) {
127 Ok(metadata) if metadata.is_dir() => {
128 fs::remove_dir_all(path)?;
129 Ok(())
130 }
131 Ok(_) => {
132 fs::remove_file(path)?;
133 Ok(())
134 }
135 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
136 Err(error) => Err(error.into()),
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 #[test]
145 fn sanitize_relative_path_rejects_parent_dir() {
146 let error = sanitize_relative_path("../escape.praypkg").expect_err("parent dir");
147 assert!(error.to_string().contains("invalid relative path"));
148 }
149
150 #[test]
151 fn sanitize_relative_path_accepts_nested_artifact() {
152 let path = sanitize_relative_path("v1/artifacts/sample/base/1.0.0/package.praypkg")
153 .expect("nested path");
154 assert_eq!(
155 path,
156 PathBuf::from("v1/artifacts/sample/base/1.0.0/package.praypkg")
157 );
158 }
159
160 #[test]
161 fn validate_package_relative_path_rejects_parent_escape() {
162 let error =
163 validate_package_relative_path(Path::new("../escape.md")).expect_err("parent escape");
164 assert!(error.to_string().contains("escapes package root"));
165 }
166
167 #[test]
168 fn validate_package_relative_path_rejects_absolute() {
169 let absolute = if cfg!(windows) {
170 Path::new(r"C:\escape.md")
171 } else {
172 Path::new("/escape.md")
173 };
174 let error = validate_package_relative_path(absolute).expect_err("absolute");
175 assert!(error.to_string().contains("must be relative"));
176 }
177
178 #[test]
179 fn validate_package_relative_path_accepts_nested_file() {
180 validate_package_relative_path(Path::new("exports/guidance.md")).expect("nested file");
181 }
182
183 #[test]
184 fn project_relative_path_rejects_parent_and_absolute() {
185 let parent = ProjectRelativePath::parse("../escape.md").expect_err("parent");
186 assert!(parent.to_string().contains("escapes repository root"));
187 let absolute = if cfg!(windows) {
188 ProjectRelativePath::parse(r"C:\escape.md")
189 } else {
190 ProjectRelativePath::parse("/tmp/escape.md")
191 }
192 .expect_err("absolute");
193 assert!(absolute.to_string().contains("repository-relative"));
194 }
195
196 #[test]
197 fn project_relative_path_joins_under_root() {
198 let relative = ProjectRelativePath::parse("docs/AGENTS.md").expect("relative");
199 assert_eq!(
200 relative.join_root(Path::new("/repo")),
201 PathBuf::from("/repo/docs/AGENTS.md")
202 );
203 }
204}