harn_modules/
asset_paths.rs1use std::path::{Component, Path, PathBuf};
22
23const ASSET_PREFIX: char = '@';
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum AssetRef<'a> {
28 ProjectRoot { rel: &'a str },
30 Alias { alias: &'a str, rel: &'a str },
32}
33
34pub fn is_asset_path(path: &str) -> bool {
36 path.starts_with(ASSET_PREFIX)
37}
38
39pub fn parse(path: &str) -> Option<AssetRef<'_>> {
45 let stripped = path.strip_prefix(ASSET_PREFIX)?;
46 if let Some(rel) = stripped.strip_prefix('/') {
47 return Some(AssetRef::ProjectRoot { rel });
48 }
49 let (alias, rel) = stripped.split_once('/')?;
50 Some(AssetRef::Alias { alias, rel })
51}
52
53pub fn find_project_root(base: &Path) -> Option<PathBuf> {
57 let mut dir = base.to_path_buf();
58 loop {
59 if dir.join("harn.toml").exists() {
60 return Some(dir);
61 }
62 if !dir.pop() {
63 return None;
64 }
65 }
66}
67
68pub fn resolve(asset_ref: &AssetRef<'_>, anchor: &Path) -> Result<PathBuf, String> {
83 let project_root = find_project_root(anchor).ok_or_else(|| {
84 format!(
85 "package-root prompt path '{}' has no project root: no harn.toml found above {}",
86 display_asset(asset_ref),
87 anchor.display()
88 )
89 })?;
90 match asset_ref {
91 AssetRef::ProjectRoot { rel } => {
92 let safe = safe_relative(rel)
93 .ok_or_else(|| format!("invalid project-root asset path '@/{rel}'"))?;
94 Ok(project_root.join(safe))
95 }
96 AssetRef::Alias { alias, rel } => {
97 let safe =
98 safe_relative(rel).ok_or_else(|| format!("invalid asset path '@{alias}/{rel}'"))?;
99 let asset_root = lookup_alias(&project_root, alias).ok_or_else(|| {
100 format!(
101 "asset alias '{alias}' is not defined in [asset_roots] of {}",
102 project_root.join("harn.toml").display()
103 )
104 })?;
105 let safe_root = safe_relative(&asset_root).ok_or_else(|| {
106 format!(
107 "asset alias '{alias}' resolves to an unsafe path '{asset_root}' \
108 (must be a project-relative directory without `..` segments)"
109 )
110 })?;
111 Ok(project_root.join(safe_root).join(safe))
112 }
113 }
114}
115
116pub fn resolve_or<F>(path: &str, anchor: &Path, fallback: F) -> Result<PathBuf, String>
120where
121 F: FnOnce(&str) -> PathBuf,
122{
123 if let Some(asset_ref) = parse(path) {
124 return resolve(&asset_ref, anchor);
125 }
126 Ok(fallback(path))
127}
128
129fn safe_relative(raw: &str) -> Option<PathBuf> {
134 if raw.is_empty() || raw.contains('\\') {
135 return None;
136 }
137 let mut out = PathBuf::new();
138 let mut saw_component = false;
139 for component in Path::new(raw).components() {
140 match component {
141 Component::Normal(part) => {
142 saw_component = true;
143 out.push(part);
144 }
145 Component::CurDir => {}
146 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
147 }
148 }
149 saw_component.then_some(out)
150}
151
152fn display_asset(asset_ref: &AssetRef<'_>) -> String {
153 match asset_ref {
154 AssetRef::ProjectRoot { rel } => format!("@/{rel}"),
155 AssetRef::Alias { alias, rel } => format!("@{alias}/{rel}"),
156 }
157}
158
159fn lookup_alias(project_root: &Path, alias: &str) -> Option<String> {
163 let manifest = std::fs::read_to_string(project_root.join("harn.toml")).ok()?;
164 let parsed: toml::Value = toml::from_str(&manifest).ok()?;
165 let table = parsed.get("asset_roots")?.as_table()?;
166 table.get(alias)?.as_str().map(str::to_string)
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use std::fs;
173 use tempfile::TempDir;
174
175 #[test]
176 fn parses_project_root_form() {
177 assert_eq!(
178 parse("@/partials/foo.harn.prompt"),
179 Some(AssetRef::ProjectRoot {
180 rel: "partials/foo.harn.prompt"
181 })
182 );
183 }
184
185 #[test]
186 fn parses_alias_form() {
187 assert_eq!(
188 parse("@partials/foo.harn.prompt"),
189 Some(AssetRef::Alias {
190 alias: "partials",
191 rel: "foo.harn.prompt"
192 })
193 );
194 }
195
196 #[test]
197 fn plain_paths_pass_through() {
198 assert!(parse("relative/path").is_none());
199 assert!(parse("/absolute/path").is_none());
200 assert!(parse("../sibling").is_none());
201 }
202
203 #[test]
204 fn parent_traversal_rejected() {
205 assert!(safe_relative("foo/../bar").is_none());
206 assert!(safe_relative("/abs").is_none());
207 assert!(safe_relative("").is_none());
208 }
209
210 #[test]
211 fn resolves_project_root_path_anchored_at_caller_root() {
212 let temp = TempDir::new().unwrap();
213 let root = temp.path();
214 fs::write(root.join("harn.toml"), "[package]\nname = \"x\"\n").unwrap();
215 fs::create_dir_all(root.join("a/b/c")).unwrap();
216 let resolved = resolve(
217 &parse("@/prompts/foo.harn.prompt").unwrap(),
218 &root.join("a/b/c"),
219 )
220 .unwrap();
221 assert_eq!(resolved, root.join("prompts/foo.harn.prompt"));
222 }
223
224 #[test]
225 fn resolves_alias_path_via_asset_roots() {
226 let temp = TempDir::new().unwrap();
227 let root = temp.path();
228 fs::write(
229 root.join("harn.toml"),
230 "[package]\nname = \"x\"\n[asset_roots]\npartials = \"src/prompts\"\n",
231 )
232 .unwrap();
233 fs::create_dir_all(root.join("a/b")).unwrap();
234 let resolved = resolve(
235 &parse("@partials/foo.harn.prompt").unwrap(),
236 &root.join("a/b"),
237 )
238 .unwrap();
239 assert_eq!(resolved, root.join("src/prompts/foo.harn.prompt"));
240 }
241
242 #[test]
243 fn missing_alias_produces_clear_error() {
244 let temp = TempDir::new().unwrap();
245 let root = temp.path();
246 fs::write(root.join("harn.toml"), "[package]\nname = \"x\"\n").unwrap();
247 let err = resolve(&parse("@unknown/foo.harn.prompt").unwrap(), root).unwrap_err();
248 assert!(err.contains("[asset_roots]"));
249 assert!(err.contains("unknown"));
250 }
251
252 #[test]
253 fn no_project_root_produces_error() {
254 let temp = TempDir::new().unwrap();
255 let err = resolve(&parse("@/foo.harn.prompt").unwrap(), temp.path()).unwrap_err();
256 assert!(err.contains("no harn.toml"));
257 }
258}