1use anyhow::Result;
2use std::fs;
3use std::path::Path;
4
5pub fn write_file(path: &Path, content: &str) -> Result<()> {
7 if let Some(parent) = path.parent() {
8 fs::create_dir_all(parent)?;
9 }
10 fs::write(path, content)?;
11 Ok(())
12}
13
14pub fn read_with_custom_block(path: &Path) -> Option<(String, String)> {
17 let content = fs::read_to_string(path).ok()?;
18 let marker = "// === ROMANCE:CUSTOM ===";
19 if let Some(pos) = content.find(marker) {
20 Some((content[..pos].to_string(), content[pos..].to_string()))
21 } else {
22 None
23 }
24}
25
26pub fn write_generated(path: &Path, generated: &str) -> Result<()> {
28 let content = if let Some((_, custom_block)) = read_with_custom_block(path) {
29 format!("{}{}", generated, custom_block)
30 } else {
31 generated.to_string()
32 };
33 write_file(path, &content)
34}
35
36pub fn insert_at_marker(path: &Path, marker: &str, line: &str) -> Result<()> {
40 let content = fs::read_to_string(path)?;
41 if content.contains(line) {
42 return Ok(());
43 }
44 if !content.contains(marker) {
45 anyhow::bail!(
46 "Marker '{}' not found in {}",
47 marker,
48 path.display()
49 );
50 }
51 let new_content = content.replace(marker, &format!("{}\n{}", line, marker));
52 fs::write(path, new_content)?;
53 Ok(())
54}
55
56pub fn pluralize(s: &str) -> String {
58 if s.ends_with('s') || s.ends_with('x') || s.ends_with("ch") || s.ends_with("sh") {
59 format!("{}es", s)
60 } else if s.ends_with('y')
61 && !s.ends_with("ay")
62 && !s.ends_with("ey")
63 && !s.ends_with("oy")
64 && !s.ends_with("uy")
65 {
66 format!("{}ies", &s[..s.len() - 1])
67 } else {
68 format!("{}s", s)
69 }
70}
71
72pub const RUST_RESERVED_WORDS: &[&str] = &[
74 "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum",
75 "extern", "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod",
76 "move", "mut", "pub", "ref", "return", "self", "Self", "static", "struct", "super",
77 "trait", "true", "type", "unsafe", "use", "where", "while", "yield",
78 "abstract", "become", "box", "do", "final", "macro", "override", "priv", "try",
80 "typeof", "unsized", "virtual",
81];
82
83pub fn rust_ident(name: &str) -> String {
86 if RUST_RESERVED_WORDS.contains(&name) {
87 format!("r#{}", name)
88 } else {
89 name.to_string()
90 }
91}
92
93pub mod ui {
95 use colored::Colorize;
96
97 pub fn created(path: &str) {
99 println!(" {} {}", "create".green(), path);
100 }
101
102 pub fn updated(path: &str) {
104 println!(" {} {}", "update".cyan(), path);
105 }
106
107 pub fn skipped(path: &str, reason: &str) {
109 println!(" {} {} ({})", "skip".yellow(), path, reason);
110 }
111
112 pub fn removed(path: &str) {
114 println!(" {} {}", "remove".red(), path);
115 }
116
117 pub fn injected(target: &str, what: &str) {
119 println!(" {} {} → {}", "inject".magenta(), what, target);
120 }
121
122 pub fn section(title: &str) {
124 println!("\n{}", title.bold());
125 }
126
127 pub fn success(msg: &str) {
129 println!("\n{}", msg.green().bold());
130 }
131
132 pub fn warn(msg: &str) {
134 println!(" {} {}", "warn".yellow(), msg);
135 }
136
137 pub fn error(msg: &str) {
139 eprintln!(" {} {}", "error".red(), msg);
140 }
141
142 pub fn check_pass(msg: &str) {
144 println!(" {} {}", "✓".green(), msg);
145 }
146
147 pub fn check_fail(msg: &str) {
149 println!(" {} {}", "✗".red(), msg);
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use std::io::Write;
157 use tempfile::NamedTempFile;
158
159 #[test]
162 fn pluralize_regular_word() {
163 assert_eq!(pluralize("post"), "posts");
164 assert_eq!(pluralize("user"), "users");
165 assert_eq!(pluralize("product"), "products");
166 }
167
168 #[test]
169 fn pluralize_ending_in_s() {
170 assert_eq!(pluralize("bus"), "buses");
171 assert_eq!(pluralize("class"), "classes");
172 }
173
174 #[test]
175 fn pluralize_ending_in_x() {
176 assert_eq!(pluralize("box"), "boxes");
177 assert_eq!(pluralize("tax"), "taxes");
178 }
179
180 #[test]
181 fn pluralize_ending_in_ch() {
182 assert_eq!(pluralize("match"), "matches");
183 assert_eq!(pluralize("church"), "churches");
184 }
185
186 #[test]
187 fn pluralize_ending_in_sh() {
188 assert_eq!(pluralize("dish"), "dishes");
189 assert_eq!(pluralize("wish"), "wishes");
190 }
191
192 #[test]
193 fn pluralize_consonant_y() {
194 assert_eq!(pluralize("category"), "categories");
195 assert_eq!(pluralize("city"), "cities");
196 assert_eq!(pluralize("company"), "companies");
197 }
198
199 #[test]
200 fn pluralize_vowel_y_preserved() {
201 assert_eq!(pluralize("day"), "days");
202 assert_eq!(pluralize("key"), "keys");
203 assert_eq!(pluralize("boy"), "boys");
204 assert_eq!(pluralize("guy"), "guys");
205 }
206
207 #[test]
210 fn rust_ident_regular_name() {
211 assert_eq!(rust_ident("title"), "title");
212 assert_eq!(rust_ident("name"), "name");
213 assert_eq!(rust_ident("author_id"), "author_id");
214 }
215
216 #[test]
217 fn rust_ident_reserved_word() {
218 assert_eq!(rust_ident("type"), "r#type");
219 assert_eq!(rust_ident("match"), "r#match");
220 assert_eq!(rust_ident("fn"), "r#fn");
221 assert_eq!(rust_ident("struct"), "r#struct");
222 assert_eq!(rust_ident("impl"), "r#impl");
223 assert_eq!(rust_ident("use"), "r#use");
224 assert_eq!(rust_ident("mod"), "r#mod");
225 assert_eq!(rust_ident("async"), "r#async");
226 assert_eq!(rust_ident("await"), "r#await");
227 assert_eq!(rust_ident("yield"), "r#yield");
228 }
229
230 #[test]
231 fn rust_ident_future_reserved() {
232 assert_eq!(rust_ident("abstract"), "r#abstract");
233 assert_eq!(rust_ident("try"), "r#try");
234 assert_eq!(rust_ident("final"), "r#final");
235 }
236
237 #[test]
240 fn write_file_creates_parent_dirs() {
241 let dir = tempfile::tempdir().unwrap();
242 let path = dir.path().join("a/b/c/test.txt");
243 write_file(&path, "hello").unwrap();
244 assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello");
245 }
246
247 #[test]
250 fn insert_at_marker_basic() {
251 let mut tmp = NamedTempFile::new().unwrap();
252 writeln!(tmp, "// header").unwrap();
253 writeln!(tmp, "// === ROMANCE:MODS ===").unwrap();
254 writeln!(tmp, "// footer").unwrap();
255 tmp.flush().unwrap();
256
257 insert_at_marker(tmp.path(), "// === ROMANCE:MODS ===", "pub mod post;").unwrap();
258
259 let content = std::fs::read_to_string(tmp.path()).unwrap();
260 assert!(content.contains("pub mod post;\n// === ROMANCE:MODS ==="));
261 }
262
263 #[test]
264 fn insert_at_marker_idempotent() {
265 let mut tmp = NamedTempFile::new().unwrap();
266 writeln!(tmp, "// header").unwrap();
267 writeln!(tmp, "// === ROMANCE:MODS ===").unwrap();
268 tmp.flush().unwrap();
269
270 insert_at_marker(tmp.path(), "// === ROMANCE:MODS ===", "pub mod post;").unwrap();
271 insert_at_marker(tmp.path(), "// === ROMANCE:MODS ===", "pub mod post;").unwrap();
272
273 let content = std::fs::read_to_string(tmp.path()).unwrap();
274 assert_eq!(content.matches("pub mod post;").count(), 1);
276 }
277
278 #[test]
279 fn insert_at_marker_multiple_lines() {
280 let mut tmp = NamedTempFile::new().unwrap();
281 writeln!(tmp, "// === ROMANCE:MODS ===").unwrap();
282 tmp.flush().unwrap();
283
284 insert_at_marker(tmp.path(), "// === ROMANCE:MODS ===", "pub mod post;").unwrap();
285 insert_at_marker(tmp.path(), "// === ROMANCE:MODS ===", "pub mod user;").unwrap();
286
287 let content = std::fs::read_to_string(tmp.path()).unwrap();
288 assert!(content.contains("pub mod post;"));
289 assert!(content.contains("pub mod user;"));
290 let marker_pos = content.find("// === ROMANCE:MODS ===").unwrap();
292 let post_pos = content.find("pub mod post;").unwrap();
293 let user_pos = content.find("pub mod user;").unwrap();
294 assert!(post_pos < marker_pos);
295 assert!(user_pos < marker_pos);
296 }
297
298 #[test]
299 fn insert_at_marker_missing_marker_errors() {
300 let mut tmp = NamedTempFile::new().unwrap();
301 writeln!(tmp, "// header").unwrap();
302 writeln!(tmp, "// no marker here").unwrap();
303 tmp.flush().unwrap();
304
305 let result = insert_at_marker(tmp.path(), "// === ROMANCE:MODS ===", "pub mod post;");
306 assert!(result.is_err());
307 let err_msg = result.unwrap_err().to_string();
308 assert!(err_msg.contains("Marker"));
309 assert!(err_msg.contains("ROMANCE:MODS"));
310 }
311
312 #[test]
315 fn read_with_custom_block_splits_correctly() {
316 let mut tmp = NamedTempFile::new().unwrap();
317 write!(tmp, "generated code\n// === ROMANCE:CUSTOM ===\nuser code\n").unwrap();
318 tmp.flush().unwrap();
319
320 let (generated, custom) = read_with_custom_block(tmp.path()).unwrap();
321 assert_eq!(generated, "generated code\n");
322 assert!(custom.starts_with("// === ROMANCE:CUSTOM ==="));
323 assert!(custom.contains("user code"));
324 }
325
326 #[test]
327 fn read_with_custom_block_no_marker() {
328 let mut tmp = NamedTempFile::new().unwrap();
329 write!(tmp, "just some code without marker\n").unwrap();
330 tmp.flush().unwrap();
331
332 assert!(read_with_custom_block(tmp.path()).is_none());
333 }
334
335 #[test]
336 fn read_with_custom_block_nonexistent_file() {
337 let path = Path::new("/tmp/romance_test_nonexistent_file_12345.rs");
338 assert!(read_with_custom_block(path).is_none());
339 }
340
341 #[test]
344 fn write_generated_new_file() {
345 let dir = tempfile::tempdir().unwrap();
346 let path = dir.path().join("new.rs");
347
348 write_generated(&path, "generated content\n").unwrap();
349 assert_eq!(std::fs::read_to_string(&path).unwrap(), "generated content\n");
350 }
351
352 #[test]
353 fn write_generated_preserves_custom_block() {
354 let mut tmp = NamedTempFile::new().unwrap();
355 write!(tmp, "old generated\n// === ROMANCE:CUSTOM ===\nmy custom code\n").unwrap();
356 tmp.flush().unwrap();
357
358 write_generated(tmp.path(), "new generated\n").unwrap();
359
360 let content = std::fs::read_to_string(tmp.path()).unwrap();
361 assert!(content.starts_with("new generated\n"));
362 assert!(content.contains("// === ROMANCE:CUSTOM ==="));
363 assert!(content.contains("my custom code"));
364 assert!(!content.contains("old generated"));
366 }
367
368 #[test]
369 fn write_generated_no_custom_block_replaces_entirely() {
370 let mut tmp = NamedTempFile::new().unwrap();
371 write!(tmp, "old content without custom marker\n").unwrap();
372 tmp.flush().unwrap();
373
374 write_generated(tmp.path(), "new content\n").unwrap();
375
376 let content = std::fs::read_to_string(tmp.path()).unwrap();
377 assert_eq!(content, "new content\n");
378 }
379}