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(());
}
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 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 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 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");
}
}