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