use std::path::Path;
use super::naming::{
mod_declaration, module_file_path, snake_case, validate_module_name, validate_type_name,
};
use super::plan::Plan;
use super::write::module_mod_rs;
#[cfg(test)]
use super::plan::PlannedOp;
pub(crate) fn plan(
root: &Path,
raw_name: &str,
raw_module: &str,
) -> Result<(Plan, String), String> {
let name = validate_type_name(raw_name)?;
let module = validate_module_name(raw_module)?;
let stem = format!("{}_mail", snake_case(&name));
let path = module_file_path(root, &module, &format!("{stem}.rs"))?;
let content = render(&name, &module, &stem);
let mod_rs = module_mod_rs(root, &module);
let declaration = mod_declaration(&stem)?;
let plan = Plan::new()
.create(path, content)
.append(mod_rs, declaration);
Ok((plan, stem))
}
fn render(name: &str, module: &str, fn_name: &str) -> String {
let pascal = name;
format!(
"//! `{pascal}` mail message for the `{module}` module.\n\
\n\
/// Build the `{pascal}` email message through the certified\n\
/// `arcature::mail::Email` facade (no hand-rolled MIME, no fake\n\
/// transport). Send the returned message with\n\
/// `arcature::mail::Mailer::send`.\n\
\n\
/// This generator is independent of the Data subsystem: it builds a\n\
/// plain mail-message builder. Add parameters (recipients, payload\n\
/// fields) as your application needs them.\n\
\n\
/// Requires the `mail` feature on the `arcature` crate.\n\
\n\
/// # Errors\n\
\n\
/// Returns `arcature::mail::EmailError` if the message body cannot\n\
/// be encoded.\n\
pub fn {fn_name}(\n\
\x20 from: arcature::mail::lettre::message::Mailbox,\n\
\x20 to: arcature::mail::lettre::message::Mailbox,\n\
) -> Result<arcature::mail::lettre::Message, arcature::mail::EmailError> {{\n\
\x20 arcature::mail::Email::builder()\n\
\x20 .from(from)\n\
\x20 .to(to)\n\
\x20 .subject(\"{pascal}\")\n\
\x20 .plain(\"Replace this body with the `{pascal}` mail content.\")\n\
}}\n"
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
fn temp_root(label: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!(
"arcature-cli-mail-{label}-{}-{}",
std::process::id(),
unique_suffix()
));
fs::create_dir_all(&root).expect("temp root should be created");
fs::create_dir_all(root.join("src")).expect("src dir should be created");
root
}
fn unique_suffix() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos())
}
#[test]
fn plan_builds_create_and_append_ops() {
let root = temp_root("plan");
let (plan, stem) =
plan(&root.join("src"), "Welcome", "accounts").expect("plan should build");
assert_eq!(stem, "welcome_mail");
let ops = plan.ops();
assert_eq!(ops.len(), 2, "mail plan has a create + a mod.rs append");
assert!(
matches!(&ops[0], PlannedOp::Create { path, .. } if path.ends_with("src/accounts/welcome_mail.rs"))
);
assert!(
matches!(&ops[1], PlannedOp::Append { path, declaration } if path.ends_with("src/accounts/mod.rs") && declaration == "pub mod welcome_mail;")
);
cleanup(&root);
}
#[test]
fn rendered_content_uses_certified_facade() {
let root = temp_root("render");
let (plan, _) = plan(&root.join("src"), "Welcome", "accounts").expect("plan should build");
let content = match &plan.ops()[0] {
PlannedOp::Create { content, .. } => content.clone(),
_ => panic!("first op should be a Create"),
};
assert!(content.contains("arcature::mail::Email::builder()"));
assert!(content.contains("arcature::mail::lettre::message::Mailbox"));
assert!(content.contains("arcature::mail::lettre::Message"));
assert!(content.contains("arcature::mail::EmailError"));
assert!(content.contains("pub fn welcome_mail("));
assert!(content.contains(".subject(\"Welcome\")"));
cleanup(&root);
}
#[test]
fn plan_rejects_invalid_name_and_module() {
let root = temp_root("invalid");
assert!(
plan(&root.join("src"), "123Bad", "accounts").is_err(),
"name must start with a letter"
);
assert!(
plan(&root.join("src"), "Welcome", "Accounts").is_err(),
"module must be lowercase"
);
assert!(
plan(&root.join("src"), "Welcome", "../escape").is_err(),
"module must not be a path"
);
cleanup(&root);
}
#[test]
fn commit_writes_file_and_appends_mod_declaration() {
let root = temp_root("commit");
let (mut plan, _) =
plan(&root.join("src"), "Welcome", "accounts").expect("plan should build");
plan.execute(super::super::plan::OverwritePolicy::Refuse, None)
.expect("commit should succeed");
let file = root.join("src").join("accounts").join("welcome_mail.rs");
assert!(file.is_file(), "mail source file should be created");
let mod_rs = root.join("src").join("accounts").join("mod.rs");
assert!(mod_rs.is_file(), "mod.rs should be created");
assert!(
fs::read_to_string(&mod_rs)
.unwrap()
.contains("pub mod welcome_mail;")
);
cleanup(&root);
}
#[test]
fn dry_run_writes_nothing() {
let root = temp_root("dry-run");
let (plan, _) = plan(&root.join("src"), "Welcome", "accounts").expect("plan should build");
let report = plan
.dry_run_report(super::super::plan::OverwritePolicy::Refuse)
.expect("dry-run should report");
assert!(report.contains("dry run: no files will be written"));
assert!(
!root.join("src").join("accounts").exists(),
"dry run must not create module directories"
);
cleanup(&root);
}
#[test]
fn conflict_on_existing_mail_file() {
let root = temp_root("conflict");
let file = root.join("src").join("accounts").join("welcome_mail.rs");
fs::create_dir_all(file.parent().unwrap()).unwrap();
fs::write(&file, "user content").unwrap();
let (mut plan, _) =
plan(&root.join("src"), "Welcome", "accounts").expect("plan should build");
let err = plan
.execute(super::super::plan::OverwritePolicy::Refuse, None)
.expect_err("should refuse to overwrite");
assert!(matches!(err, super::super::plan::PlanError::Conflict(p) if p == file));
assert_eq!(fs::read_to_string(&file).unwrap(), "user content");
cleanup(&root);
}
fn cleanup(root: &Path) {
let _ = fs::remove_dir_all(root);
}
}