Skip to main content

rigg_core/
sidecar.rs

1//! `$file` sidecar handling.
2//!
3//! Any string field in a resource JSON file may instead be an object
4//! `{"$file": "relative/path.md"}`. On push (and whenever Rigg loads the
5//! file), the referenced file's content is inlined as the string value. On
6//! pull, fields listed in the kind's registry `sidecar_fields` — or fields
7//! that already have a sidecar file on disk — are extracted back out to the
8//! sidecar and replaced with the `$file` reference.
9//!
10//! Sidecar paths are relative to the resource JSON file's directory.
11
12use 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
40/// Replace every `{"$file": "path"}` object with the referenced file content.
41/// Returns the list of sidecar paths that were inlined.
42pub 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
91/// If `map` is exactly a `$file` reference object, return its path.
92fn 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
103/// Extract long-text fields to Markdown sidecars.
104///
105/// A top-level string field is extracted when:
106/// - it is listed in the kind's registry `sidecar_fields`, or
107/// - a sidecar file named `<resource-stem>.<field>.md` already exists next to
108///   the JSON file (the user opted in by creating it).
109///
110/// The default sidecar filename is `<resource-stem>.<field>.md` — for agent
111/// instructions this yields e.g. `support-agent.instructions.md`.
112pub 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        // user opted in by creating the sidecar file previously
206        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}