arcature-cli 2026.1.1

Developer lifecycle CLI for Arcature applications.
Documentation
//! `arc make mail <name> --module <module>` — generate a typed mail message.
//!
//! Creates `src/<module>/<name>_mail.rs` with a function that builds the
//! message through the certified `arcature_mail::Email` facade (no hand-rolled
//! MIME, no fake transport), and appends `pub mod <name>_mail;` to the
//! module's `mod.rs`. The write is a transactional [`super::plan::Plan`]:
//! conflict-detect → dry-run → stage → rollback-on-failure (PROGRAM.md
//! AP2.1-11). The generator is independent of the Data subsystem — it builds
//! a plain mail-message builder, not a model-backed mailer.
//!
//! The generated source uses the `arcature::mail` facade (the
//! `arcature_mail` re-export, including its `lettre` re-export), so the
//! application needs no direct `lettre` dependency. It requires the `mail`
//! feature on the `arcature` crate (enabled by default in `arc new` projects
//! that include the mail subsystem).

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;

/// Build the transactional [`Plan`] for `arc make mail <name> --module
/// <module>`. The plan is not executed here; the caller runs dry-run or
/// commit. Returns the plan and the file stem (for status messages).
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"),
        };
        // Uses the certified arcature::mail facade, not a hand-rolled MIME.
        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"));
        // Typed function with the snake_case name.
        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);
    }
}