Skip to main content

romance_core/
utils.rs

1use anyhow::Result;
2use std::fs;
3use std::path::Path;
4
5/// Write content to a file, creating parent directories as needed.
6pub 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
14/// Read a file and split at the ROMANCE:CUSTOM marker.
15/// Returns (before_marker, custom_block) where custom_block includes the marker line.
16pub 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
26/// Write generated content, preserving custom block if file already exists.
27pub 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
36/// Insert a line before a named marker in a file.
37///
38/// Returns an error if the marker is not found in the file.
39pub 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
56/// Pluralize an English word (same rules as the Tera `plural` filter).
57pub 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
72/// Rust reserved keywords that must be escaped with `r#` when used as identifiers.
73pub 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    // Reserved for future use
79    "abstract", "become", "box", "do", "final", "macro", "override", "priv", "try",
80    "typeof", "unsized", "virtual",
81];
82
83/// Escape a field name for use as a Rust identifier.
84/// Adds `r#` prefix if the name is a Rust reserved word.
85pub 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
93/// Pretty CLI output helpers using the `colored` crate.
94pub mod ui {
95    use colored::Colorize;
96
97    /// Print a "create" action (green)
98    pub fn created(path: &str) {
99        println!("  {} {}", "create".green(), path);
100    }
101
102    /// Print an "update" action (cyan)
103    pub fn updated(path: &str) {
104        println!("  {} {}", "update".cyan(), path);
105    }
106
107    /// Print a "skip" action (yellow)
108    pub fn skipped(path: &str, reason: &str) {
109        println!("  {} {} ({})", "skip".yellow(), path, reason);
110    }
111
112    /// Print a "remove" action (red)
113    pub fn removed(path: &str) {
114        println!("  {} {}", "remove".red(), path);
115    }
116
117    /// Print an "inject" action (magenta)
118    pub fn injected(target: &str, what: &str) {
119        println!("  {} {} → {}", "inject".magenta(), what, target);
120    }
121
122    /// Print a section header (bold)
123    pub fn section(title: &str) {
124        println!("\n{}", title.bold());
125    }
126
127    /// Print a success message (green bold)
128    pub fn success(msg: &str) {
129        println!("\n{}", msg.green().bold());
130    }
131
132    /// Print a warning (yellow)
133    pub fn warn(msg: &str) {
134        println!("  {} {}", "warn".yellow(), msg);
135    }
136
137    /// Print an error (red)
138    pub fn error(msg: &str) {
139        eprintln!("  {} {}", "error".red(), msg);
140    }
141
142    /// Print a check result (pass)
143    pub fn check_pass(msg: &str) {
144        println!("  {} {}", "✓".green(), msg);
145    }
146
147    /// Print a check result (fail)
148    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    // ── pluralize ─────────────────────────────────────────────────────
160
161    #[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    // ── rust_ident ────────────────────────────────────────────────────
208
209    #[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    // ── write_file ────────────────────────────────────────────────────
238
239    #[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    // ── insert_at_marker ──────────────────────────────────────────────
248
249    #[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        // Should appear exactly once
275        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        // Both should be before the marker
291        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    // ── read_with_custom_block ────────────────────────────────────────
313
314    #[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    // ── write_generated ───────────────────────────────────────────────
342
343    #[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        // Old generated content should be gone
365        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}