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
51pub 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}