Skip to main content

openapi_to_rust/server/
edit.rs

1//! Formatting-preserving edits to the `[server].operations` array
2//! in a project's `openapi-to-rust.toml`. Backs the
3//! `server add` and `server remove` CLI subcommands.
4
5use std::path::Path;
6use toml_edit::{Array, DocumentMut, Item, Table, Value};
7
8#[derive(Debug, thiserror::Error)]
9pub enum EditError {
10    #[error("failed to read {path}: {source}")]
11    Read {
12        path: String,
13        #[source]
14        source: std::io::Error,
15    },
16    #[error("failed to write {path}: {source}")]
17    Write {
18        path: String,
19        #[source]
20        source: std::io::Error,
21    },
22    #[error("config is not valid TOML: {0}")]
23    Parse(#[from] toml_edit::TomlError),
24    #[error("[server].operations exists but is not an array of strings — refusing to edit")]
25    NotAnArray,
26}
27
28/// In-memory representation of a config we're editing. Created via
29/// [`Editor::open`], mutated via [`Editor::add`] / [`Editor::remove`],
30/// persisted via [`Editor::save`].
31pub struct Editor {
32    path: std::path::PathBuf,
33    doc: DocumentMut,
34}
35
36impl Editor {
37    pub fn open(path: &Path) -> Result<Self, EditError> {
38        let text = std::fs::read_to_string(path).map_err(|source| EditError::Read {
39            path: path.display().to_string(),
40            source,
41        })?;
42        let doc: DocumentMut = text.parse()?;
43        Ok(Self {
44            path: path.to_path_buf(),
45            doc,
46        })
47    }
48
49    /// Snapshot of the current `[server].operations` list (raw strings,
50    /// not parsed selectors).
51    pub fn operations(&self) -> Result<Vec<String>, EditError> {
52        let Some(arr) = self.array_ref()? else {
53            return Ok(Vec::new());
54        };
55        let mut out = Vec::with_capacity(arr.len());
56        for v in arr.iter() {
57            match v.as_str() {
58                Some(s) => out.push(s.to_string()),
59                None => return Err(EditError::NotAnArray),
60            }
61        }
62        Ok(out)
63    }
64
65    /// Append a selector to `[server].operations`, creating the
66    /// section, `framework` key, and array as needed.
67    /// Returns `true` if the entry was added, `false` if it was already
68    /// present (no edit applied).
69    pub fn add(&mut self, selector: &str) -> Result<bool, EditError> {
70        if self.operations()?.iter().any(|s| s == selector) {
71            return Ok(false);
72        }
73        let arr = self.ensure_array()?;
74        arr.push(selector);
75        Ok(true)
76    }
77
78    /// Remove the first entry equal to `selector`. Returns `true` if
79    /// something was removed, `false` if it was absent.
80    pub fn remove(&mut self, selector: &str) -> Result<bool, EditError> {
81        let Some(arr) = self.array_mut()? else {
82            return Ok(false);
83        };
84        let pos = arr.iter().position(|v| v.as_str() == Some(selector));
85        match pos {
86            Some(i) => {
87                arr.remove(i);
88                Ok(true)
89            }
90            None => Ok(false),
91        }
92    }
93
94    pub fn save(&self) -> Result<(), EditError> {
95        std::fs::write(&self.path, self.doc.to_string()).map_err(|source| EditError::Write {
96            path: self.path.display().to_string(),
97            source,
98        })
99    }
100
101    /// Render the current document as a string without writing. Useful
102    /// for `--dry-run` and for tests.
103    pub fn rendered(&self) -> String {
104        self.doc.to_string()
105    }
106
107    // --- internals ---
108
109    fn array_ref(&self) -> Result<Option<&Array>, EditError> {
110        let Some(server) = self.doc.get("server").and_then(Item::as_table) else {
111            return Ok(None);
112        };
113        let Some(item) = server.get("operations") else {
114            return Ok(None);
115        };
116        match item.as_array() {
117            Some(arr) => Ok(Some(arr)),
118            None => Err(EditError::NotAnArray),
119        }
120    }
121
122    fn array_mut(&mut self) -> Result<Option<&mut Array>, EditError> {
123        let Some(server) = self.doc.get_mut("server").and_then(Item::as_table_mut) else {
124            return Ok(None);
125        };
126        let Some(item) = server.get_mut("operations") else {
127            return Ok(None);
128        };
129        match item.as_array_mut() {
130            Some(arr) => Ok(Some(arr)),
131            None => Err(EditError::NotAnArray),
132        }
133    }
134
135    fn ensure_array(&mut self) -> Result<&mut Array, EditError> {
136        // Ensure [server] table exists.
137        if !matches!(self.doc.get("server"), Some(Item::Table(_))) {
138            let mut tbl = Table::new();
139            tbl.set_implicit(false);
140            tbl.insert("framework", Item::Value(Value::from("axum")));
141            tbl.insert("operations", Item::Value(Value::Array(Array::new())));
142            self.doc.insert("server", Item::Table(tbl));
143        }
144        let server = self
145            .doc
146            .get_mut("server")
147            .and_then(Item::as_table_mut)
148            .ok_or(EditError::NotAnArray)?;
149        if !server.contains_key("framework") {
150            server.insert("framework", Item::Value(Value::from("axum")));
151        }
152        if !server.contains_key("operations") {
153            server.insert("operations", Item::Value(Value::Array(Array::new())));
154        }
155        server
156            .get_mut("operations")
157            .and_then(Item::as_array_mut)
158            .ok_or(EditError::NotAnArray)
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use std::io::Write;
166    use tempfile::NamedTempFile;
167
168    fn editor_from(contents: &str) -> (Editor, NamedTempFile) {
169        let mut f = NamedTempFile::new().unwrap();
170        write!(f, "{contents}").unwrap();
171        let ed = Editor::open(f.path()).unwrap();
172        (ed, f)
173    }
174
175    #[test]
176    fn add_creates_section_when_absent() {
177        let (mut ed, _f) = editor_from(
178            r#"[generator]
179spec_path = "x.json"
180output_dir = "src/gen"
181module_name = "api"
182"#,
183        );
184        assert!(ed.add("createResponse").unwrap());
185        let rendered = ed.rendered();
186        assert!(rendered.contains("[server]"));
187        assert!(rendered.contains("framework = \"axum\""));
188        assert!(rendered.contains("createResponse"));
189    }
190
191    #[test]
192    fn add_preserves_existing_comments_and_keys() {
193        let (mut ed, _f) = editor_from(
194            r#"# Top-level comment
195[generator]
196spec_path = "x.json"  # path comment
197output_dir = "src/gen"
198module_name = "api"
199
200[server]
201framework = "axum"
202# pinned for the chat replica service
203operations = ["existing"]
204"#,
205        );
206        assert!(ed.add("createResponse").unwrap());
207        let r = ed.rendered();
208        assert!(r.contains("# Top-level comment"));
209        assert!(r.contains("# path comment"));
210        assert!(r.contains("# pinned for the chat replica service"));
211        assert!(r.contains("\"existing\""));
212        assert!(r.contains("\"createResponse\""));
213    }
214
215    #[test]
216    fn add_is_idempotent() {
217        let (mut ed, _f) = editor_from(
218            r#"[generator]
219spec_path = "x.json"
220output_dir = "g"
221module_name = "m"
222
223[server]
224framework = "axum"
225operations = ["createResponse"]
226"#,
227        );
228        assert!(!ed.add("createResponse").unwrap());
229        assert_eq!(ed.operations().unwrap(), vec!["createResponse".to_string()]);
230    }
231
232    #[test]
233    fn remove_removes_first_match() {
234        let (mut ed, _f) = editor_from(
235            r#"[generator]
236spec_path = "x.json"
237output_dir = "g"
238module_name = "m"
239
240[server]
241framework = "axum"
242operations = ["a", "b", "c"]
243"#,
244        );
245        assert!(ed.remove("b").unwrap());
246        assert_eq!(
247            ed.operations().unwrap(),
248            vec!["a".to_string(), "c".to_string()]
249        );
250    }
251
252    #[test]
253    fn remove_absent_is_noop_returns_false() {
254        let (mut ed, _f) = editor_from(
255            r#"[generator]
256spec_path = "x.json"
257output_dir = "g"
258module_name = "m"
259
260[server]
261framework = "axum"
262operations = ["a"]
263"#,
264        );
265        assert!(!ed.remove("zzz").unwrap());
266        assert_eq!(ed.operations().unwrap(), vec!["a".to_string()]);
267    }
268
269    #[test]
270    fn remove_when_no_server_section_is_noop() {
271        let (mut ed, _f) = editor_from(
272            r#"[generator]
273spec_path = "x.json"
274output_dir = "g"
275module_name = "m"
276"#,
277        );
278        assert!(!ed.remove("a").unwrap());
279    }
280
281    #[test]
282    fn rejects_non_string_array() {
283        let (ed, _f) = editor_from(
284            r#"[generator]
285spec_path = "x.json"
286output_dir = "g"
287module_name = "m"
288
289[server]
290framework = "axum"
291operations = [42]
292"#,
293        );
294        assert!(matches!(ed.operations(), Err(EditError::NotAnArray)));
295    }
296
297    #[test]
298    fn save_writes_to_disk() {
299        let (mut ed, f) = editor_from(
300            r#"[generator]
301spec_path = "x.json"
302output_dir = "g"
303module_name = "m"
304"#,
305        );
306        ed.add("foo").unwrap();
307        ed.save().unwrap();
308        let on_disk = std::fs::read_to_string(f.path()).unwrap();
309        assert!(on_disk.contains("[server]"));
310        assert!(on_disk.contains("\"foo\""));
311    }
312}