Skip to main content

utils/
resource_path.rs

1use crate::variables::{VarError, Vars};
2use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5
6#[derive(Serialize, Deserialize, JsonSchema)]
7#[serde(untagged)]
8pub enum PathOrObject<O> {
9    Path(ResourcePath),
10    Object(O),
11}
12
13/// A path declared in user-facing settings.
14#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
15#[serde(transparent)]
16pub struct ResourcePath(String);
17
18impl ResourcePath {
19    pub fn new(value: impl Into<String>) -> Self {
20        Self(value.into())
21    }
22
23    /// The user-visible form of the path, suitable for round-trip serialization
24    /// and for inclusion in error messages.
25    pub fn as_authored(&self) -> &str {
26        &self.0
27    }
28
29    /// Expand this path to an absolute [`PathBuf`].
30    pub fn resolve(&self, workspace_root: &Path) -> Result<PathBuf, VarError> {
31        let vars = Vars::new().with("WORKSPACE", workspace_root.to_string_lossy().into_owned());
32        Ok(workspace_root.join(vars.expand(&self.0)?))
33    }
34
35    /// Convert a bare relative path to an absolute one in place.
36    pub fn promote_relative(&mut self, source_root: &Path) {
37        if !Vars::has_reference(&self.0) && !Path::new(&self.0).is_absolute() {
38            self.0 = source_root.join(&self.0).to_string_lossy().into_owned();
39        }
40    }
41}
42
43impl From<&str> for ResourcePath {
44    fn from(value: &str) -> Self {
45        Self::new(value)
46    }
47}
48
49impl From<String> for ResourcePath {
50    fn from(value: String) -> Self {
51        Self::new(value)
52    }
53}
54
55impl JsonSchema for ResourcePath {
56    fn schema_name() -> std::borrow::Cow<'static, str> {
57        "ResourcePath".into()
58    }
59
60    fn json_schema(_: &mut SchemaGenerator) -> Schema {
61        json_schema!({
62            "type": "string",
63            "description": "A path with optional `$VAR` / `${VAR}` expansion. `${WORKSPACE}` resolves to the workspace root; other names fall through to process env. Plain relative paths resolve against the workspace root at use time."
64        })
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    #[test]
73    fn round_trips_string_verbatim() {
74        let cases = ["${WORKSPACE}/AGENTS.md", "AGENTS.md", "/abs/AGENTS.md", "nested/dir/file.md", "$HOME/scratch"];
75        for input in cases {
76            let path: ResourcePath = input.into();
77            assert_eq!(path.as_authored(), input, "round-trip failed for {input}");
78            let json = serde_json::to_string(&path).unwrap();
79            assert_eq!(json, format!("\"{input}\""));
80            let parsed: ResourcePath = serde_json::from_str(&json).unwrap();
81            assert_eq!(parsed, path);
82        }
83    }
84
85    #[test]
86    fn resolve_expands_variables_against_the_workspace_root() {
87        let root = PathBuf::from("/workspace");
88        for (input, expected) in [
89            ("${WORKSPACE}/AGENTS.md", "/workspace/AGENTS.md"),
90            ("$WORKSPACE/a", "/workspace/a"),
91            ("AGENTS.md", "/workspace/AGENTS.md"),
92            ("/abs/AGENTS.md", "/abs/AGENTS.md"),
93            ("$$x/y", "/workspace/$x/y"),
94        ] {
95            assert_eq!(ResourcePath::from(input).resolve(&root).unwrap(), PathBuf::from(expected), "{input}");
96        }
97    }
98
99    #[test]
100    fn resolve_reports_missing_variable() {
101        let root = PathBuf::from("/workspace");
102        let path: ResourcePath = "${DEFINITELY_NOT_SET_VAR}/foo".into();
103        assert!(matches!(path.resolve(&root), Err(VarError::NotFound(name)) if name == "DEFINITELY_NOT_SET_VAR"));
104    }
105
106    #[test]
107    fn promote_relative_joins_plain_relative_paths() {
108        let source_root = PathBuf::from("/user/.aether");
109        let mut path: ResourcePath = "agents/foo.md".into();
110        path.promote_relative(&source_root);
111        assert_eq!(path, ResourcePath::from("/user/.aether/agents/foo.md"));
112    }
113
114    #[test]
115    fn promote_relative_treats_escaped_dollar_paths_as_relative() {
116        let source_root = PathBuf::from("/user/.aether");
117        let mut path: ResourcePath = "$$x/y".into();
118        path.promote_relative(&source_root);
119        assert_eq!(path.resolve(Path::new("/workspace")).unwrap(), PathBuf::from("/user/.aether/$x/y"));
120    }
121
122    #[test]
123    fn promote_relative_leaves_variable_paths_untouched() {
124        let source_root = PathBuf::from("/user/.aether");
125        let mut path: ResourcePath = "${WORKSPACE}/AGENTS.md".into();
126        let original = path.clone();
127        path.promote_relative(&source_root);
128        assert_eq!(path, original);
129    }
130
131    #[test]
132    fn promote_relative_leaves_absolute_paths_untouched() {
133        let source_root = PathBuf::from("/user/.aether");
134        let mut path: ResourcePath = "/abs/foo.md".into();
135        let original = path.clone();
136        path.promote_relative(&source_root);
137        assert_eq!(path, original);
138    }
139}