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 stdlib_prompt_asset_path(path: &str) -> Option<&str> {
45 let rel = path.strip_prefix("std/")?;
46 (!rel.is_empty()).then_some(rel)
47}
48
49pub fn parse(path: &str) -> Option<AssetRef<'_>> {
55 let stripped = path.strip_prefix(ASSET_PREFIX)?;
56 if let Some(rel) = stripped.strip_prefix('/') {
57 return Some(AssetRef::ProjectRoot { rel });
58 }
59 let (alias, rel) = stripped.split_once('/')?;
60 Some(AssetRef::Alias { alias, rel })
61}
62
63pub fn find_project_root(base: &Path) -> Option<PathBuf> {
67 let mut dir = base.to_path_buf();
68 loop {
69 if dir.join("harn.toml").exists() {
70 return Some(dir);
71 }
72 if !dir.pop() {
73 return None;
74 }
75 }
76}
77
78pub fn resolve(asset_ref: &AssetRef<'_>, anchor: &Path) -> Result<PathBuf, String> {
93 let project_root = find_project_root(anchor).ok_or_else(|| {
94 format!(
95 "package-root prompt path '{}' has no project root: no harn.toml found above {}",
96 display_asset(asset_ref),
97 anchor.display()
98 )
99 })?;
100 match asset_ref {
101 AssetRef::ProjectRoot { rel } => {
102 let safe = safe_relative(rel)
103 .ok_or_else(|| format!("invalid project-root asset path '@/{rel}'"))?;
104 Ok(project_root.join(safe))
105 }
106 AssetRef::Alias { alias, rel } => {
107 let safe =
108 safe_relative(rel).ok_or_else(|| format!("invalid asset path '@{alias}/{rel}'"))?;
109 let asset_root = lookup_alias(&project_root, alias).ok_or_else(|| {
110 format!(
111 "asset alias '{alias}' is not defined in [asset_roots] of {}",
112 project_root.join("harn.toml").display()
113 )
114 })?;
115 let safe_root = safe_relative(&asset_root).ok_or_else(|| {
116 format!(
117 "asset alias '{alias}' resolves to an unsafe path '{asset_root}' \
118 (must be a project-relative directory without `..` segments)"
119 )
120 })?;
121 Ok(project_root.join(safe_root).join(safe))
122 }
123 }
124}
125
126pub fn resolve_or<F>(path: &str, anchor: &Path, fallback: F) -> Result<PathBuf, String>
130where
131 F: FnOnce(&str) -> PathBuf,
132{
133 if let Some(asset_ref) = parse(path) {
134 return resolve(&asset_ref, anchor);
135 }
136 Ok(fallback(path))
137}
138
139fn safe_relative(raw: &str) -> Option<PathBuf> {
144 if raw.is_empty() || raw.contains('\\') {
145 return None;
146 }
147 let mut out = PathBuf::new();
148 let mut saw_component = false;
149 for component in Path::new(raw).components() {
150 match component {
151 Component::Normal(part) => {
152 saw_component = true;
153 out.push(part);
154 }
155 Component::CurDir => {}
156 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
157 }
158 }
159 saw_component.then_some(out)
160}
161
162fn display_asset(asset_ref: &AssetRef<'_>) -> String {
163 match asset_ref {
164 AssetRef::ProjectRoot { rel } => format!("@/{rel}"),
165 AssetRef::Alias { alias, rel } => format!("@{alias}/{rel}"),
166 }
167}
168
169fn lookup_alias(project_root: &Path, alias: &str) -> Option<String> {
173 let manifest = std::fs::read_to_string(project_root.join("harn.toml")).ok()?;
174 let parsed: toml::Value = toml::from_str(&manifest).ok()?;
175 let table = parsed.get("asset_roots")?.as_table()?;
176 table.get(alias)?.as_str().map(str::to_string)
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use std::fs;
183 use tempfile::TempDir;
184
185 #[test]
186 fn parses_project_root_form() {
187 assert_eq!(
188 parse("@/partials/foo.harn.prompt"),
189 Some(AssetRef::ProjectRoot {
190 rel: "partials/foo.harn.prompt"
191 })
192 );
193 }
194
195 #[test]
196 fn parses_alias_form() {
197 assert_eq!(
198 parse("@partials/foo.harn.prompt"),
199 Some(AssetRef::Alias {
200 alias: "partials",
201 rel: "foo.harn.prompt"
202 })
203 );
204 }
205
206 #[test]
207 fn plain_paths_pass_through() {
208 assert!(parse("relative/path").is_none());
209 assert!(parse("/absolute/path").is_none());
210 assert!(parse("../sibling").is_none());
211 }
212
213 #[test]
214 fn stdlib_prompt_paths_are_classified_without_filesystem_resolution() {
215 assert_eq!(
216 stdlib_prompt_asset_path("std/agent/prompts/tool_contract_text.harn.prompt"),
217 Some("agent/prompts/tool_contract_text.harn.prompt")
218 );
219 assert_eq!(
220 stdlib_prompt_asset_path("agent/prompts/foo.harn.prompt"),
221 None
222 );
223 }
224
225 #[test]
226 fn parent_traversal_rejected() {
227 assert!(safe_relative("foo/../bar").is_none());
228 assert!(safe_relative("/abs").is_none());
229 assert!(safe_relative("").is_none());
230 }
231
232 #[test]
233 fn resolves_project_root_path_anchored_at_caller_root() {
234 let temp = TempDir::new().unwrap();
235 let root = temp.path();
236 fs::write(root.join("harn.toml"), "[package]\nname = \"x\"\n").unwrap();
237 fs::create_dir_all(root.join("a/b/c")).unwrap();
238 let resolved = resolve(
239 &parse("@/prompts/foo.harn.prompt").unwrap(),
240 &root.join("a/b/c"),
241 )
242 .unwrap();
243 assert_eq!(resolved, root.join("prompts/foo.harn.prompt"));
244 }
245
246 #[test]
247 fn resolves_alias_path_via_asset_roots() {
248 let temp = TempDir::new().unwrap();
249 let root = temp.path();
250 fs::write(
251 root.join("harn.toml"),
252 "[package]\nname = \"x\"\n[asset_roots]\npartials = \"src/prompts\"\n",
253 )
254 .unwrap();
255 fs::create_dir_all(root.join("a/b")).unwrap();
256 let resolved = resolve(
257 &parse("@partials/foo.harn.prompt").unwrap(),
258 &root.join("a/b"),
259 )
260 .unwrap();
261 assert_eq!(resolved, root.join("src/prompts/foo.harn.prompt"));
262 }
263
264 #[test]
265 fn missing_alias_produces_clear_error() {
266 let temp = TempDir::new().unwrap();
267 let root = temp.path();
268 fs::write(root.join("harn.toml"), "[package]\nname = \"x\"\n").unwrap();
269 let err = resolve(&parse("@unknown/foo.harn.prompt").unwrap(), root).unwrap_err();
270 assert!(err.contains("[asset_roots]"));
271 assert!(err.contains("unknown"));
272 }
273
274 #[test]
275 fn no_project_root_produces_error() {
276 let temp = TempDir::new().unwrap();
277 let err = resolve(&parse("@/foo.harn.prompt").unwrap(), temp.path()).unwrap_err();
278 assert!(err.contains("no harn.toml"));
279 }
280}