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#[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 pub fn as_authored(&self) -> &str {
26 &self.0
27 }
28
29 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 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}