1use std::path::{Path, PathBuf};
13
14use serde_json::{Map, Value};
15use thiserror::Error;
16
17use crate::registry;
18use crate::resources::traits::ResourceKind;
19
20pub const FILE_KEY: &str = "$file";
21
22#[derive(Debug, Error)]
23pub enum SidecarError {
24 #[error("sidecar file not found: {path} (referenced from {referrer})")]
25 NotFound { path: PathBuf, referrer: PathBuf },
26 #[error("failed to read sidecar {path}: {source}")]
27 Read {
28 path: PathBuf,
29 source: std::io::Error,
30 },
31 #[error("failed to write sidecar {path}: {source}")]
32 Write {
33 path: PathBuf,
34 source: std::io::Error,
35 },
36 #[error("invalid $file reference in {referrer}: value must be a relative path string")]
37 InvalidRef { referrer: PathBuf },
38}
39
40pub fn inline_sidecars(json_path: &Path, value: &mut Value) -> Result<Vec<PathBuf>, SidecarError> {
43 let base = json_path.parent().unwrap_or(Path::new("."));
44 let mut inlined = Vec::new();
45 inline_walk(json_path, base, value, &mut inlined)?;
46 Ok(inlined)
47}
48
49fn inline_walk(
50 referrer: &Path,
51 base: &Path,
52 value: &mut Value,
53 inlined: &mut Vec<PathBuf>,
54) -> Result<(), SidecarError> {
55 match value {
56 Value::Object(map) => {
57 if let Some(file_ref) = file_ref(map) {
58 let rel = file_ref.map_err(|_| SidecarError::InvalidRef {
59 referrer: referrer.to_path_buf(),
60 })?;
61 let path = base.join(&rel);
62 if !path.is_file() {
63 return Err(SidecarError::NotFound {
64 path,
65 referrer: referrer.to_path_buf(),
66 });
67 }
68 let content =
69 std::fs::read_to_string(&path).map_err(|source| SidecarError::Read {
70 path: path.clone(),
71 source,
72 })?;
73 inlined.push(path);
74 *value = Value::String(content);
75 return Ok(());
76 }
77 for (_, v) in map.iter_mut() {
78 inline_walk(referrer, base, v, inlined)?;
79 }
80 }
81 Value::Array(arr) => {
82 for item in arr {
83 inline_walk(referrer, base, item, inlined)?;
84 }
85 }
86 _ => {}
87 }
88 Ok(())
89}
90
91fn file_ref(map: &Map<String, Value>) -> Option<Result<String, ()>> {
93 let v = map.get(FILE_KEY)?;
94 if map.len() != 1 {
95 return Some(Err(()));
96 }
97 match v.as_str() {
98 Some(s) if !s.is_empty() && !Path::new(s).is_absolute() => Some(Ok(s.to_string())),
99 _ => Some(Err(())),
100 }
101}
102
103pub fn extract_sidecars(
113 kind: ResourceKind,
114 json_path: &Path,
115 value: &mut Value,
116) -> Result<(), SidecarError> {
117 let base = json_path.parent().unwrap_or(Path::new("."));
118 let stem = json_path
119 .file_stem()
120 .map(|s| s.to_string_lossy().into_owned())
121 .unwrap_or_default();
122 let default_fields = registry::meta(kind).sidecar_fields;
123
124 let Some(map) = value.as_object_mut() else {
125 return Ok(());
126 };
127 let field_names: Vec<String> = map.keys().cloned().collect();
128 for field in field_names {
129 let sidecar_name = format!("{stem}.{field}.md");
130 let sidecar_path = base.join(&sidecar_name);
131 let is_default = default_fields.contains(&field.as_str());
132 let exists = sidecar_path.is_file();
133 if !is_default && !exists {
134 continue;
135 }
136 let Some(text) = map.get(&field).and_then(Value::as_str) else {
137 continue;
138 };
139 std::fs::write(&sidecar_path, text).map_err(|source| SidecarError::Write {
140 path: sidecar_path.clone(),
141 source,
142 })?;
143 let mut ref_obj = Map::new();
144 ref_obj.insert(FILE_KEY.to_string(), Value::String(sidecar_name));
145 map.insert(field, Value::Object(ref_obj));
146 }
147 Ok(())
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use serde_json::json;
154
155 #[test]
156 fn inlines_file_reference() {
157 let tmp = tempfile::tempdir().unwrap();
158 let json_path = tmp.path().join("agent.json");
159 std::fs::write(tmp.path().join("agent.instructions.md"), "Be helpful.").unwrap();
160 let mut v = json!({
161 "name": "agent",
162 "instructions": {"$file": "agent.instructions.md"}
163 });
164 let inlined = inline_sidecars(&json_path, &mut v).unwrap();
165 assert_eq!(v["instructions"], json!("Be helpful."));
166 assert_eq!(inlined.len(), 1);
167 }
168
169 #[test]
170 fn inline_missing_file_errors() {
171 let tmp = tempfile::tempdir().unwrap();
172 let json_path = tmp.path().join("agent.json");
173 let mut v = json!({"instructions": {"$file": "missing.md"}});
174 let err = inline_sidecars(&json_path, &mut v).unwrap_err();
175 assert!(matches!(err, SidecarError::NotFound { .. }));
176 }
177
178 #[test]
179 fn inline_rejects_absolute_paths() {
180 let tmp = tempfile::tempdir().unwrap();
181 let json_path = tmp.path().join("agent.json");
182 let mut v = json!({"instructions": {"$file": "/etc/passwd"}});
183 let err = inline_sidecars(&json_path, &mut v).unwrap_err();
184 assert!(matches!(err, SidecarError::InvalidRef { .. }));
185 }
186
187 #[test]
188 fn extracts_default_sidecar_field_for_agent() {
189 let tmp = tempfile::tempdir().unwrap();
190 let json_path = tmp.path().join("support-agent.json");
191 let mut v = json!({"name": "support-agent", "instructions": "Long prose here."});
192 extract_sidecars(ResourceKind::Agent, &json_path, &mut v).unwrap();
193 assert_eq!(
194 v["instructions"],
195 json!({"$file": "support-agent.instructions.md"})
196 );
197 let md = std::fs::read_to_string(tmp.path().join("support-agent.instructions.md")).unwrap();
198 assert_eq!(md, "Long prose here.");
199 }
200
201 #[test]
202 fn extracts_opt_in_field_when_sidecar_exists() {
203 let tmp = tempfile::tempdir().unwrap();
204 let json_path = tmp.path().join("skill.json");
205 std::fs::write(tmp.path().join("skill.description.md"), "old").unwrap();
207 let mut v = json!({"name": "skill", "description": "new text"});
208 extract_sidecars(ResourceKind::Skillset, &json_path, &mut v).unwrap();
209 assert_eq!(v["description"], json!({"$file": "skill.description.md"}));
210 let md = std::fs::read_to_string(tmp.path().join("skill.description.md")).unwrap();
211 assert_eq!(md, "new text");
212 }
213
214 #[test]
215 fn round_trip_is_identity() {
216 let tmp = tempfile::tempdir().unwrap();
217 let json_path = tmp.path().join("a.json");
218 let mut v = json!({"name": "a", "instructions": "text body"});
219 extract_sidecars(ResourceKind::Agent, &json_path, &mut v).unwrap();
220 inline_sidecars(&json_path, &mut v).unwrap();
221 assert_eq!(v, json!({"name": "a", "instructions": "text body"}));
222 }
223
224 #[test]
225 fn non_default_field_without_sidecar_untouched() {
226 let tmp = tempfile::tempdir().unwrap();
227 let json_path = tmp.path().join("i.json");
228 let mut v = json!({"name": "i", "description": "short"});
229 extract_sidecars(ResourceKind::Index, &json_path, &mut v).unwrap();
230 assert_eq!(v["description"], json!("short"));
231 }
232}