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