openapi_to_rust/server/
edit.rs1use 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
28pub 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 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 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 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 pub fn rendered(&self) -> String {
104 self.doc.to_string()
105 }
106
107 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 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}