Skip to main content

rtango/spec/
io.rs

1use std::collections::HashSet;
2use std::fs;
3use std::path::Path;
4
5use anyhow::Context;
6
7use crate::error::RtangoError;
8
9use super::{Lock, Spec};
10
11const RTANGO_DIR: &str = ".rtango";
12const SPEC_FILE: &str = "spec.yaml";
13const LOCK_FILE: &str = "lock.yaml";
14const GITIGNORE_FILE: &str = ".gitignore";
15const GITIGNORE_START: &str = "# >>> rtango managed targets >>>";
16const GITIGNORE_END: &str = "# <<< rtango managed targets <<<";
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct GitignoreUpdate {
20    pub existed: bool,
21    pub changed: bool,
22    pub content: String,
23}
24
25pub fn rtango_dir(root: &Path) -> std::path::PathBuf {
26    root.join(RTANGO_DIR)
27}
28
29pub fn spec_path(root: &Path) -> std::path::PathBuf {
30    rtango_dir(root).join(SPEC_FILE)
31}
32
33pub fn lock_path(root: &Path) -> std::path::PathBuf {
34    rtango_dir(root).join(LOCK_FILE)
35}
36
37pub fn gitignore_path(root: &Path) -> std::path::PathBuf {
38    root.join(GITIGNORE_FILE)
39}
40
41pub fn load_spec(root: &Path) -> anyhow::Result<Spec> {
42    let path = spec_path(root);
43    let content =
44        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
45    let spec: Spec = serde_yml::from_str(&content)
46        .with_context(|| format!("failed to parse {}", path.display()))?;
47    validate_spec(&spec)?;
48    Ok(spec)
49}
50
51/// Parse a spec from a raw YAML string. Used for loading remote collection specs
52/// that were fetched from GitHub.
53pub fn parse_spec_content(content: &str, source_desc: &str) -> anyhow::Result<Spec> {
54    let spec: Spec = serde_yml::from_str(content)
55        .with_context(|| format!("failed to parse spec from {source_desc}"))?;
56    validate_spec(&spec)?;
57    Ok(spec)
58}
59
60pub fn validate_spec(spec: &Spec) -> anyhow::Result<()> {
61    if spec.version != 1 {
62        anyhow::bail!(RtangoError::InvalidSpec(format!(
63            "unsupported version {}, expected 1",
64            spec.version
65        )));
66    }
67    if spec.agents.is_empty() {
68        anyhow::bail!(RtangoError::InvalidSpec(
69            "agents list must not be empty".into()
70        ));
71    }
72    let mut seen = HashSet::new();
73    for rule in &spec.rules {
74        if !seen.insert(&rule.id) {
75            anyhow::bail!(RtangoError::InvalidSpec(format!(
76                "duplicate rule id '{}'",
77                rule.id
78            )));
79        }
80    }
81    Ok(())
82}
83
84pub fn save_spec(root: &Path, spec: &Spec) -> anyhow::Result<()> {
85    let path = spec_path(root);
86    if let Some(parent) = path.parent() {
87        fs::create_dir_all(parent)
88            .with_context(|| format!("failed to create {}", parent.display()))?;
89    }
90    let yaml = serde_yml::to_string(spec)?;
91    fs::write(&path, yaml).with_context(|| format!("failed to write {}", path.display()))?;
92    Ok(())
93}
94
95pub fn load_lock(root: &Path) -> anyhow::Result<Lock> {
96    let path = lock_path(root);
97    let content =
98        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
99    let lock: Lock = serde_yml::from_str(&content)
100        .with_context(|| format!("failed to parse {}", path.display()))?;
101    Ok(lock)
102}
103
104pub fn load_lock_or_empty(root: &Path) -> anyhow::Result<Lock> {
105    let path = lock_path(root);
106    if !path.exists() {
107        return Ok(Lock {
108            version: 1,
109            tracked_agents: vec![],
110            owners: vec![],
111            deployments: vec![],
112        });
113    }
114    load_lock(root)
115}
116
117pub fn save_lock(root: &Path, lock: &Lock) -> anyhow::Result<()> {
118    let path = lock_path(root);
119    if let Some(parent) = path.parent() {
120        fs::create_dir_all(parent)
121            .with_context(|| format!("failed to create {}", parent.display()))?;
122    }
123    let yaml = serde_yml::to_string(lock)?;
124    fs::write(&path, yaml).with_context(|| format!("failed to write {}", path.display()))?;
125    Ok(())
126}
127
128pub fn gitignore_update(root: &Path, entries: &[String]) -> anyhow::Result<GitignoreUpdate> {
129    let path = gitignore_path(root);
130    let existed = path.exists();
131    let existing = if existed {
132        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?
133    } else {
134        String::new()
135    };
136    let content = render_gitignore(&existing, entries)?;
137    let changed = content != existing;
138    Ok(GitignoreUpdate {
139        existed,
140        changed,
141        content,
142    })
143}
144
145pub fn write_gitignore(root: &Path, content: &str) -> anyhow::Result<()> {
146    let path = gitignore_path(root);
147    fs::write(&path, content).with_context(|| format!("failed to write {}", path.display()))?;
148    Ok(())
149}
150
151fn render_gitignore(existing: &str, entries: &[String]) -> anyhow::Result<String> {
152    let mut preserved = Vec::new();
153    let mut in_managed_block = false;
154    let mut saw_start = false;
155    let mut saw_end = false;
156
157    for line in existing.lines() {
158        if line == GITIGNORE_START {
159            if saw_start {
160                anyhow::bail!("malformed .gitignore: duplicate rtango managed block start marker");
161            }
162            saw_start = true;
163            in_managed_block = true;
164            continue;
165        }
166        if line == GITIGNORE_END {
167            if !in_managed_block {
168                anyhow::bail!(
169                    "malformed .gitignore: rtango managed block end marker without start marker"
170                );
171            }
172            saw_end = true;
173            in_managed_block = false;
174            continue;
175        }
176        if !in_managed_block {
177            preserved.push(line);
178        }
179    }
180
181    if in_managed_block || saw_start != saw_end {
182        anyhow::bail!("malformed .gitignore: unterminated rtango managed block");
183    }
184
185    let mut out = preserved.join("\n").trim_end().to_string();
186    if !entries.is_empty() {
187        if !out.is_empty() {
188            out.push_str("\n\n");
189        }
190        out.push_str(GITIGNORE_START);
191        out.push('\n');
192        out.push_str(&entries.join("\n"));
193        out.push('\n');
194        out.push_str(GITIGNORE_END);
195    }
196
197    if out.is_empty() {
198        Ok(String::new())
199    } else {
200        out.push('\n');
201        Ok(out)
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::render_gitignore;
208
209    #[test]
210    fn appends_managed_block() {
211        let content = render_gitignore("target/\n", &[".pi/skills/foo/".into()]).unwrap();
212        assert_eq!(
213            content,
214            "target/\n\n# >>> rtango managed targets >>>\n.pi/skills/foo/\n# <<< rtango managed targets <<<\n"
215        );
216    }
217
218    #[test]
219    fn replaces_existing_managed_block() {
220        let existing = "target/\n\n# >>> rtango managed targets >>>\n.old/\n# <<< rtango managed targets <<<\n";
221        let content = render_gitignore(existing, &[".pi/skills/foo/".into()]).unwrap();
222        assert_eq!(
223            content,
224            "target/\n\n# >>> rtango managed targets >>>\n.pi/skills/foo/\n# <<< rtango managed targets <<<\n"
225        );
226    }
227
228    #[test]
229    fn removes_managed_block_when_no_entries_remain() {
230        let existing = "target/\n\n# >>> rtango managed targets >>>\n.old/\n# <<< rtango managed targets <<<\n";
231        let content = render_gitignore(existing, &[]).unwrap();
232        assert_eq!(content, "target/\n");
233    }
234
235    #[test]
236    fn replaces_existing_managed_block_in_crlf_file() {
237        let existing = "target/\r\n\r\n# >>> rtango managed targets >>>\r\n.old/\r\n# <<< rtango managed targets <<<\r\n";
238        let content = render_gitignore(existing, &[".pi/skills/foo/".into()]).unwrap();
239        assert_eq!(
240            content,
241            "target/\n\n# >>> rtango managed targets >>>\n.pi/skills/foo/\n# <<< rtango managed targets <<<\n"
242        );
243        assert_eq!(
244            content.matches("# >>> rtango managed targets >>>").count(),
245            1
246        );
247    }
248}