Skip to main content

harn_modules/
asset_paths.rs

1//! Package-root prompt asset addressing (issue #742).
2//!
3//! Pipelines that move files around want stable, refactor-safe paths to
4//! `.harn.prompt` assets. Source-relative `../../partials/foo.harn.prompt`
5//! paths break the moment a caller is renamed or relocated. This module
6//! resolves a small URI scheme that anchors prompt assets at the
7//! project root or a project-defined alias instead:
8//!
9//! - `@/<rel>` → resolved from the calling module's project root
10//!   (the nearest `harn.toml` ancestor of the *calling file*, not the
11//!   workspace cwd).
12//! - `@<alias>/<rel>` → resolved from a `[asset_roots]` entry in the
13//!   project's `harn.toml`, e.g. `[asset_roots] partials = "..."`.
14//!
15//! Plain (non-`@`) paths fall through to the caller's existing
16//! source-relative resolver — back-compat is exact.
17//!
18//! Lives in `harn-modules` so the VM, the LSP, and the CLI's preflight
19//! checker can share one resolver and produce identical errors.
20
21use std::path::{Component, Path, PathBuf};
22
23const ASSET_PREFIX: char = '@';
24
25/// A parsed `@`-prefixed asset reference.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum AssetRef<'a> {
28    /// `@/<rel>` — anchored at the project root.
29    ProjectRoot { rel: &'a str },
30    /// `@<alias>/<rel>` — anchored at a `[asset_roots]` alias.
31    Alias { alias: &'a str, rel: &'a str },
32}
33
34/// Returns true when `path` starts with the `@`-asset prefix.
35pub fn is_asset_path(path: &str) -> bool {
36    path.starts_with(ASSET_PREFIX)
37}
38
39/// Return the stdlib prompt-asset path without its `std/` prefix.
40///
41/// `std/...` prompt assets are embedded, not filesystem paths, but they
42/// share this module so runtime, CLI, and editor tooling classify prompt
43/// targets the same way.
44pub 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
49/// Parse an `@`-prefixed path. Returns `None` for plain paths so callers
50/// can fall back to source-relative resolution. Malformed `@`-paths
51/// (e.g. `@foo` with no slash) also return `None`; the resolver wraps
52/// `None` cases into a parse-time error when the caller knows it has
53/// an `@` prefix.
54pub 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
63/// Walk up from `base` looking for the nearest ancestor containing
64/// `harn.toml`, via the shared [`manifest_walk`](crate::manifest_walk) walk.
65pub fn find_project_root(base: &Path) -> Option<PathBuf> {
66    crate::manifest_walk::find_project_root(base)
67}
68
69/// Resolve an `@`-prefixed asset path to an absolute filesystem path.
70///
71/// `anchor` is the directory the project-root walk starts from — the
72/// caller's choice depends on context:
73///
74/// - **VM runtime**: pass the thread-local source dir
75///   (`source_root_path()`), which always reflects the file currently
76///   executing.
77/// - **LSP / preflight**: pass the calling source file's parent, so the
78///   project root is derived from the file under analysis.
79///
80/// In every case the project root is derived from the *call site*, not
81/// the user's cwd, so an imported pipeline resolves prompts the same
82/// way regardless of who called it.
83pub 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
117/// Convenience for the common case in `render_prompt(path, ...)`:
118/// resolve `@`-prefixed paths against the project root, otherwise apply
119/// the caller's source-relative fallback.
120pub 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
130/// Reject paths that would escape the anchor or contain shell-relative
131/// shenanigans. Mirrors the safety check in
132/// `safe_package_relative_path` so package-rooted prompts can't reach
133/// outside the project root via `..` traversal.
134fn 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
160/// Look up `[asset_roots] <alias> = "..."` in the project's harn.toml.
161/// Missing manifest, missing table, or missing key all return `None`
162/// so the caller can produce one uniform diagnostic.
163fn 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}