use anyhow::Result;
use std::fs;
use std::path::Path;
pub fn write_file(path: &Path, content: &str) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, content)?;
Ok(())
}
pub fn read_with_custom_block(path: &Path) -> Option<(String, String)> {
let content = fs::read_to_string(path).ok()?;
let marker = "// === ROMANCE:CUSTOM ===";
if let Some(pos) = content.find(marker) {
Some((content[..pos].to_string(), content[pos..].to_string()))
} else {
None
}
}
pub fn write_generated(path: &Path, generated: &str) -> Result<()> {
let content = if let Some((_, custom_block)) = read_with_custom_block(path) {
format!("{}{}", generated, custom_block)
} else {
generated.to_string()
};
write_file(path, &content)
}
pub fn insert_at_marker(path: &Path, marker: &str, line: &str) -> Result<()> {
let content = fs::read_to_string(path)?;
if content.contains(line) {
return Ok(());
}
if !content.contains(marker) {
anyhow::bail!(
"Marker '{}' not found in {}",
marker,
path.display()
);
}
let new_content = content.replace(marker, &format!("{}\n{}", line, marker));
fs::write(path, new_content)?;
Ok(())
}
pub fn pluralize(s: &str) -> String {
if s.ends_with('s') || s.ends_with('x') || s.ends_with("ch") || s.ends_with("sh") {
format!("{}es", s)
} else if s.ends_with('y')
&& !s.ends_with("ay")
&& !s.ends_with("ey")
&& !s.ends_with("oy")
&& !s.ends_with("uy")
{
format!("{}ies", &s[..s.len() - 1])
} else {
format!("{}s", s)
}
}
pub const RUST_RESERVED_WORDS: &[&str] = &[
"as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum",
"extern", "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod",
"move", "mut", "pub", "ref", "return", "self", "Self", "static", "struct", "super",
"trait", "true", "type", "unsafe", "use", "where", "while", "yield",
"abstract", "become", "box", "do", "final", "macro", "override", "priv", "try",
"typeof", "unsized", "virtual",
];
pub fn rust_ident(name: &str) -> String {
if RUST_RESERVED_WORDS.contains(&name) {
format!("r#{}", name)
} else {
name.to_string()
}
}
pub mod ui {
use colored::Colorize;
pub fn created(path: &str) {
println!(" {} {}", "create".green(), path);
}
pub fn updated(path: &str) {
println!(" {} {}", "update".cyan(), path);
}
pub fn skipped(path: &str, reason: &str) {
println!(" {} {} ({})", "skip".yellow(), path, reason);
}
pub fn removed(path: &str) {
println!(" {} {}", "remove".red(), path);
}
pub fn injected(target: &str, what: &str) {
println!(" {} {} → {}", "inject".magenta(), what, target);
}
pub fn section(title: &str) {
println!("\n{}", title.bold());
}
pub fn success(msg: &str) {
println!("\n{}", msg.green().bold());
}
pub fn warn(msg: &str) {
println!(" {} {}", "warn".yellow(), msg);
}
pub fn error(msg: &str) {
eprintln!(" {} {}", "error".red(), msg);
}
pub fn check_pass(msg: &str) {
println!(" {} {}", "✓".green(), msg);
}
pub fn check_fail(msg: &str) {
println!(" {} {}", "✗".red(), msg);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn pluralize_regular_word() {
assert_eq!(pluralize("post"), "posts");
assert_eq!(pluralize("user"), "users");
assert_eq!(pluralize("product"), "products");
}
#[test]
fn pluralize_ending_in_s() {
assert_eq!(pluralize("bus"), "buses");
assert_eq!(pluralize("class"), "classes");
}
#[test]
fn pluralize_ending_in_x() {
assert_eq!(pluralize("box"), "boxes");
assert_eq!(pluralize("tax"), "taxes");
}
#[test]
fn pluralize_ending_in_ch() {
assert_eq!(pluralize("match"), "matches");
assert_eq!(pluralize("church"), "churches");
}
#[test]
fn pluralize_ending_in_sh() {
assert_eq!(pluralize("dish"), "dishes");
assert_eq!(pluralize("wish"), "wishes");
}
#[test]
fn pluralize_consonant_y() {
assert_eq!(pluralize("category"), "categories");
assert_eq!(pluralize("city"), "cities");
assert_eq!(pluralize("company"), "companies");
}
#[test]
fn pluralize_vowel_y_preserved() {
assert_eq!(pluralize("day"), "days");
assert_eq!(pluralize("key"), "keys");
assert_eq!(pluralize("boy"), "boys");
assert_eq!(pluralize("guy"), "guys");
}
#[test]
fn rust_ident_regular_name() {
assert_eq!(rust_ident("title"), "title");
assert_eq!(rust_ident("name"), "name");
assert_eq!(rust_ident("author_id"), "author_id");
}
#[test]
fn rust_ident_reserved_word() {
assert_eq!(rust_ident("type"), "r#type");
assert_eq!(rust_ident("match"), "r#match");
assert_eq!(rust_ident("fn"), "r#fn");
assert_eq!(rust_ident("struct"), "r#struct");
assert_eq!(rust_ident("impl"), "r#impl");
assert_eq!(rust_ident("use"), "r#use");
assert_eq!(rust_ident("mod"), "r#mod");
assert_eq!(rust_ident("async"), "r#async");
assert_eq!(rust_ident("await"), "r#await");
assert_eq!(rust_ident("yield"), "r#yield");
}
#[test]
fn rust_ident_future_reserved() {
assert_eq!(rust_ident("abstract"), "r#abstract");
assert_eq!(rust_ident("try"), "r#try");
assert_eq!(rust_ident("final"), "r#final");
}
#[test]
fn write_file_creates_parent_dirs() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("a/b/c/test.txt");
write_file(&path, "hello").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello");
}
#[test]
fn insert_at_marker_basic() {
let mut tmp = NamedTempFile::new().unwrap();
writeln!(tmp, "// header").unwrap();
writeln!(tmp, "// === ROMANCE:MODS ===").unwrap();
writeln!(tmp, "// footer").unwrap();
tmp.flush().unwrap();
insert_at_marker(tmp.path(), "// === ROMANCE:MODS ===", "pub mod post;").unwrap();
let content = std::fs::read_to_string(tmp.path()).unwrap();
assert!(content.contains("pub mod post;\n// === ROMANCE:MODS ==="));
}
#[test]
fn insert_at_marker_idempotent() {
let mut tmp = NamedTempFile::new().unwrap();
writeln!(tmp, "// header").unwrap();
writeln!(tmp, "// === ROMANCE:MODS ===").unwrap();
tmp.flush().unwrap();
insert_at_marker(tmp.path(), "// === ROMANCE:MODS ===", "pub mod post;").unwrap();
insert_at_marker(tmp.path(), "// === ROMANCE:MODS ===", "pub mod post;").unwrap();
let content = std::fs::read_to_string(tmp.path()).unwrap();
assert_eq!(content.matches("pub mod post;").count(), 1);
}
#[test]
fn insert_at_marker_multiple_lines() {
let mut tmp = NamedTempFile::new().unwrap();
writeln!(tmp, "// === ROMANCE:MODS ===").unwrap();
tmp.flush().unwrap();
insert_at_marker(tmp.path(), "// === ROMANCE:MODS ===", "pub mod post;").unwrap();
insert_at_marker(tmp.path(), "// === ROMANCE:MODS ===", "pub mod user;").unwrap();
let content = std::fs::read_to_string(tmp.path()).unwrap();
assert!(content.contains("pub mod post;"));
assert!(content.contains("pub mod user;"));
let marker_pos = content.find("// === ROMANCE:MODS ===").unwrap();
let post_pos = content.find("pub mod post;").unwrap();
let user_pos = content.find("pub mod user;").unwrap();
assert!(post_pos < marker_pos);
assert!(user_pos < marker_pos);
}
#[test]
fn insert_at_marker_missing_marker_errors() {
let mut tmp = NamedTempFile::new().unwrap();
writeln!(tmp, "// header").unwrap();
writeln!(tmp, "// no marker here").unwrap();
tmp.flush().unwrap();
let result = insert_at_marker(tmp.path(), "// === ROMANCE:MODS ===", "pub mod post;");
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Marker"));
assert!(err_msg.contains("ROMANCE:MODS"));
}
#[test]
fn read_with_custom_block_splits_correctly() {
let mut tmp = NamedTempFile::new().unwrap();
write!(tmp, "generated code\n// === ROMANCE:CUSTOM ===\nuser code\n").unwrap();
tmp.flush().unwrap();
let (generated, custom) = read_with_custom_block(tmp.path()).unwrap();
assert_eq!(generated, "generated code\n");
assert!(custom.starts_with("// === ROMANCE:CUSTOM ==="));
assert!(custom.contains("user code"));
}
#[test]
fn read_with_custom_block_no_marker() {
let mut tmp = NamedTempFile::new().unwrap();
write!(tmp, "just some code without marker\n").unwrap();
tmp.flush().unwrap();
assert!(read_with_custom_block(tmp.path()).is_none());
}
#[test]
fn read_with_custom_block_nonexistent_file() {
let path = Path::new("/tmp/romance_test_nonexistent_file_12345.rs");
assert!(read_with_custom_block(path).is_none());
}
#[test]
fn write_generated_new_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("new.rs");
write_generated(&path, "generated content\n").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "generated content\n");
}
#[test]
fn write_generated_preserves_custom_block() {
let mut tmp = NamedTempFile::new().unwrap();
write!(tmp, "old generated\n// === ROMANCE:CUSTOM ===\nmy custom code\n").unwrap();
tmp.flush().unwrap();
write_generated(tmp.path(), "new generated\n").unwrap();
let content = std::fs::read_to_string(tmp.path()).unwrap();
assert!(content.starts_with("new generated\n"));
assert!(content.contains("// === ROMANCE:CUSTOM ==="));
assert!(content.contains("my custom code"));
assert!(!content.contains("old generated"));
}
#[test]
fn write_generated_no_custom_block_replaces_entirely() {
let mut tmp = NamedTempFile::new().unwrap();
write!(tmp, "old content without custom marker\n").unwrap();
tmp.flush().unwrap();
write_generated(tmp.path(), "new content\n").unwrap();
let content = std::fs::read_to_string(tmp.path()).unwrap();
assert_eq!(content, "new content\n");
}
}