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#[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 pub fn as_authored(&self) -> &str {
34 &self.0
35 }
36
37 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 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}