use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use super::name::{ArtifactName, pluralize, to_pascal_case};
use crate::cli::parser::MakeKind;
#[derive(Debug, Clone)]
pub struct Artifact {
pub path: PathBuf,
pub contents: String,
pub register_module: bool,
pub notes: Vec<String>,
}
#[must_use]
pub fn plan(kind: MakeKind, name: &ArtifactName) -> Artifact {
match kind {
MakeKind::Controller => rust(name, "app/controllers", "Controller", controller),
MakeKind::Model => rust(name, "app/models", "", model),
MakeKind::Migration => migration(name),
MakeKind::Request => rust(name, "app/requests", "Request", request),
MakeKind::Resource => rust(name, "app/resources", "Resource", resource),
MakeKind::Policy => policy(name),
MakeKind::Service => rust(name, "app/services", "Service", service),
MakeKind::Job => rust(name, "app/jobs", "", job),
MakeKind::Event => rust(name, "app/events", "", event),
MakeKind::Listener => listener(name),
MakeKind::Middleware => rust(name, "app/middleware", "", middleware),
MakeKind::Command => rust(name, "app/commands", "", command),
MakeKind::Page => rust(name, "app/pages", "Page", page),
MakeKind::Test => test(name),
MakeKind::Factory => rust(name, "database/factories", "Factory", factory),
MakeKind::Seeder => rust(name, "database/seeders", "Seeder", seeder),
}
}
fn rust(
name: &ArtifactName,
root: &str,
suffix: &str,
render: fn(&Rendered) -> String,
) -> Artifact {
let rendered = Rendered::new(name, suffix);
Artifact {
path: destination(root, name, &rendered.stem),
contents: render(&rendered),
register_module: true,
notes: Vec::new(),
}
}
fn destination(root: &str, name: &ArtifactName, stem: &str) -> PathBuf {
let mut path = PathBuf::from(root);
for segment in name.segments() {
path.push(segment);
}
path.push(format!("{stem}.rs"));
path
}
pub struct Rendered {
pub stem: String,
pub type_name: String,
pub slash_path: String,
pub base_type: String,
pub base_stem: String,
}
impl Rendered {
fn new(name: &ArtifactName, suffix: &str) -> Self {
let base_stem = name.file_stem("");
Self {
stem: name.file_stem(suffix),
type_name: name.type_name(suffix),
slash_path: name.slash_path(),
base_type: to_pascal_case(&base_stem),
base_stem,
}
}
}
fn controller(r: &Rendered) -> String {
let Rendered {
type_name,
slash_path,
..
} = r;
format!(
"//! The `{type_name}`: HTTP entry points for `{slash_path}`.\n\
\n\
use arcature::prelude::*;\n\
\n\
/// The `{slash_path}` controller.\n\
pub struct {type_name};\n\
\n\
#[controller]\n\
impl {type_name} {{\n\
\x20 /// The index action. Register it in `routes/mod.rs`, then\n\
\x20 /// replace this body with the real response.\n\
\x20 pub async fn index() -> Result<Response> {{\n\
\x20 Ok(text(StatusCode::OK, \"{type_name}::index\"))\n\
\x20 }}\n\
}}\n"
)
}
fn model(r: &Rendered) -> String {
let Rendered {
type_name,
base_stem,
..
} = r;
let table = pluralize(base_stem);
format!(
"//! The `{type_name}` model: one row of `{table}`.\n\
\n\
use arcature::prelude::*;\n\
\n\
/// A `{table}` row.\n\
#[model(table = \"{table}\")]\n\
pub struct {type_name} {{\n\
\x20 #[sea_orm(primary_key)]\n\
\x20 pub id: i64,\n\
}}\n"
)
}
fn migration(name: &ArtifactName) -> Artifact {
let stem = name.file_stem("");
let module = format!("m{}_{stem}", utc_stamp());
let mut path = PathBuf::from("database/migrations");
for segment in name.segments() {
path.push(segment);
}
path.push(format!("{module}.rs"));
let contents = format!(
"//! Migration `{stem}`.\n\
//!\n\
//! `up` runs on `arc migrate`. `down` has to undo exactly what `up`\n\
//! did -- a rollback that leaves the schema in a state no migration\n\
//! describes is worse than no rollback at all.\n\
\n\
use arcature::database::sea_orm_migration::prelude::*;\n\
\n\
/// The `{stem}` schema change.\n\
#[derive(DeriveMigrationName)]\n\
pub struct Migration;\n\
\n\
#[async_trait::async_trait]\n\
impl MigrationTrait for Migration {{\n\
\x20 async fn up(&self, _manager: &SchemaManager) -> Result<(), DbErr> {{\n\
\x20 todo!(\"describe the schema change for `{stem}`\")\n\
\x20 }}\n\
\n\
\x20 async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {{\n\
\x20 todo!(\"undo the schema change for `{stem}`\")\n\
\x20 }}\n\
}}\n"
);
Artifact {
path,
contents,
register_module: true,
notes: vec![format!(
"add `Box::new({module}::Migration)` to `Migrator::migrations()` \
in database/migrations/mod.rs -- ordering is yours to choose, so \
the generator does not guess it"
)],
}
}
fn request(r: &Rendered) -> String {
let Rendered { type_name, .. } = r;
format!(
"//! The `{type_name}` payload.\n\
\n\
use arcature::prelude::*;\n\
\n\
/// A validated request body. `#[request]` adds `Validate`; the\n\
/// `Deserialize` derive stays explicit so the extractor can\n\
/// deserialize and validate in one step.\n\
#[request]\n\
#[derive(Debug, Clone, Deserialize)]\n\
pub struct {type_name} {{\n\
\x20 #[validate(length(min = 1, max = 255))]\n\
\x20 pub name: String,\n\
}}\n"
)
}
fn resource(r: &Rendered) -> String {
let Rendered { type_name, .. } = r;
format!(
"//! The `{type_name}`: the JSON shape the client sees.\n\
\n\
use arcature::prelude::*;\n\
\n\
/// A browser-safe projection. Convert from the model explicitly\n\
/// (`impl From<Model> for {type_name}`) so the database schema can\n\
/// change without breaking the API.\n\
#[resource]\n\
pub struct {type_name} {{\n\
\x20 pub id: String,\n\
\x20 pub name: String,\n\
}}\n"
)
}
fn policy(name: &ArtifactName) -> Artifact {
let r = Rendered::new(name, "Policy");
let Rendered {
type_name,
base_type,
base_stem,
..
} = &r;
let contents = format!(
"//! The `{type_name}` authorization policy.\n\
//!\n\
//! `#[policy(M)]` records *which* model this policy guards; the\n\
//! `Policy<M>` impl below is the decision itself, and no macro can\n\
//! guess it. Point the import, the attribute, and `type User` at\n\
//! real types -- until then this file names `{base_type}` and a user\n\
//! model that may not exist yet.\n\
\n\
use arcature::prelude::*;\n\
\n\
use crate::app::models::{base_stem}::{base_type};\n\
use crate::app::models::user::User;\n\
\n\
/// Authorization decisions for `{base_type}`.\n\
#[policy({base_type})]\n\
pub struct {type_name};\n\
\n\
impl Policy<{base_type}> for {type_name} {{\n\
\x20 type User = User;\n\
\n\
\x20 fn check(_user: &Self::User, action: &str, _resource: &{base_type}) -> bool {{\n\
\x20 matches!(action, \"view\")\n\
\x20 }}\n\
}}\n"
);
Artifact {
path: destination("app/policies", name, &r.stem),
contents,
register_module: true,
notes: vec![format!(
"{} names `{base_type}` and `User`; point them at the model this \
policy guards and the application's user type",
r.stem
)],
}
}
fn service(r: &Rendered) -> String {
let Rendered { type_name, .. } = r;
format!(
"//! The `{type_name}`: business logic over the models.\n\
\n\
use arcature::prelude::*;\n\
\n\
/// `#[service]` builds this per request from the application's\n\
/// resources. Keep the methods framework-agnostic -- take domain\n\
/// values, return domain values, and let the controller map the\n\
/// result to HTTP.\n\
#[service]\n\
pub struct {type_name} {{\n\
\x20 db: Db,\n\
}}\n\
\n\
impl {type_name} {{\n\
\x20 /// The pool this service was resolved with.\n\
\x20 pub fn db(&self) -> &Db {{\n\
\x20 &self.db\n\
\x20 }}\n\
}}\n"
)
}
fn job(r: &Rendered) -> String {
let Rendered { type_name, .. } = r;
format!(
"//! The `{type_name}` background job.\n\
\n\
use arcature::Job;\n\
use arcature::prelude::*;\n\
\n\
/// The payload the worker deserializes. Keep it small and\n\
/// self-contained: a job outlives the request that enqueued it, so\n\
/// anything it needs has to travel in these fields or be re-read\n\
/// from the database by the handler.\n\
#[derive(Debug, Clone, Serialize, Deserialize, Job)]\n\
pub struct {type_name} {{\n\
\x20 pub id: i64,\n\
}}\n\
\n\
/// The handler. Register it with `Registry::add` at startup.\n\
#[job_handler]\n\
pub async fn handle(_job: {type_name}) -> Result<()> {{\n\
\x20 Ok(())\n\
}}\n"
)
}
fn event(r: &Rendered) -> String {
let Rendered { type_name, .. } = r;
format!(
"//! The `{type_name}` in-process event.\n\
\n\
use arcature::Event;\n\
use arcature::prelude::*;\n\
\n\
/// Dispatched through the `Dispatcher`; listeners receive it by\n\
/// reference, so the fields describe what happened rather than what\n\
/// should happen next.\n\
#[derive(Debug, Clone, Event)]\n\
pub struct {type_name} {{\n\
\x20 pub id: i64,\n\
}}\n"
)
}
fn listener(name: &ArtifactName) -> Artifact {
let r = Rendered::new(name, "");
let Rendered {
stem,
base_type,
base_stem,
..
} = &r;
let event_type = format!("{base_type}Event");
let contents = format!(
"//! The `{stem}` listener.\n\
//!\n\
//! A listener is meaningless without an event, and the generator\n\
//! cannot know which one -- `{event_type}` is a placeholder. Point\n\
//! the import, the `#[listener(..)]` attribute, and the handler\n\
//! argument at the event this reacts to.\n\
\n\
use arcature::prelude::*;\n\
\n\
use crate::app::events::{base_stem}::{event_type};\n\
\n\
/// Reacts to `{event_type}`. Register it on the `Dispatcher` at\n\
/// startup; the attribute only records the binding for inspection.\n\
#[listener({event_type})]\n\
pub async fn {stem}(_event: &{event_type}) -> Result<()> {{\n\
\x20 Ok(())\n\
}}\n"
);
Artifact {
path: destination("app/listeners", name, stem),
contents,
register_module: true,
notes: vec![format!(
"{stem} listens for the placeholder `{event_type}`; point it at a \
real event before building"
)],
}
}
fn middleware(r: &Rendered) -> String {
let Rendered {
stem, type_name, ..
} = r;
format!(
"//! The `{type_name}` middleware.\n\
\n\
use arcature::prelude::*;\n\
use arcature::routing::Request;\n\
\n\
/// `#[middleware]` turns this function into a `pub struct\n\
/// {type_name}` implementing `Middleware`, so `routes!` can name it\n\
/// as `middleware: [{type_name}]`. The function stays callable\n\
/// directly, which is what makes it testable without a router.\n\
#[middleware]\n\
pub async fn {stem}(request: Request, next: Next) -> Result<Response> {{\n\
\x20 Ok(next.run(request).await)\n\
}}\n"
)
}
fn command(r: &Rendered) -> String {
let Rendered {
stem, slash_path, ..
} = r;
let command_name = slash_path.replace('/', ":");
format!(
"//! The `{command_name}` application command.\n\
\n\
use arcature::prelude::*;\n\
\n\
/// Invoked by name. The attribute records the binding for\n\
/// inspection; registration stays explicit in the application's\n\
/// `CommandRegistry` so nothing runs that was not asked for.\n\
#[command(\"{command_name}\")]\n\
pub async fn {stem}() -> Result<()> {{\n\
\x20 Ok(())\n\
}}\n"
)
}
fn page(r: &Rendered) -> String {
let Rendered {
type_name,
slash_path,
..
} = r;
format!(
"//! The props for the `{slash_path}` Inertia page.\n\
\n\
use arcature::prelude::*;\n\
\n\
/// Everything the `{slash_path}` component receives. Every field\n\
/// crosses the Client Exposure Firewall, so a nested type has to be\n\
/// a `#[resource]` (or another `#[page]`) -- a plain `Serialize`\n\
/// domain model will not compile here, by design.\n\
#[page(\"{slash_path}\")]\n\
pub struct {type_name} {{\n\
\x20 pub title: String,\n\
}}\n"
)
}
fn test(name: &ArtifactName) -> Artifact {
let stem = name.file_stem("");
let mut path = PathBuf::from("tests");
for segment in name.segments() {
path.push(segment);
}
path.push(format!("{stem}.rs"));
let contents = format!(
"//! Integration test: {stem}.\n\
\n\
#[test]\n\
fn {stem}_behaves_as_specified() {{\n\
\x20 // Replace with the behaviour under test. Name the test after\n\
\x20 // the guarantee it protects, not after the function it calls.\n\
}}\n"
);
Artifact {
path,
contents,
register_module: false,
notes: Vec::new(),
}
}
fn factory(r: &Rendered) -> String {
let Rendered {
type_name,
base_type,
base_stem,
..
} = r;
format!(
"//! The `{type_name}`: deterministic `{base_type}` values for tests\n\
//! and seeders.\n\
//!\n\
//! Arcature ships no factory runtime, so a factory is a plain\n\
//! constructor. The counter keeps generated values unique inside one\n\
//! test without reaching for a random source, which is what makes a\n\
//! failure reproducible.\n\
\n\
/// Builds `{base_type}` field values.\n\
#[derive(Debug, Default)]\n\
pub struct {type_name} {{\n\
\x20 sequence: u32,\n\
}}\n\
\n\
impl {type_name} {{\n\
\x20 /// A fresh factory, starting its sequence at zero.\n\
\x20 pub fn new() -> Self {{\n\
\x20 Self::default()\n\
\x20 }}\n\
\n\
\x20 /// The next unique `(id, name)` pair.\n\
\x20 pub fn next(&mut self) -> (u32, String) {{\n\
\x20 self.sequence += 1;\n\
\x20 (self.sequence, format!(\"{base_stem}-{{}}\", self.sequence))\n\
\x20 }}\n\
}}\n"
)
}
fn seeder(r: &Rendered) -> String {
let Rendered { type_name, .. } = r;
format!(
"//! The `{type_name}`: rows this seeder owns.\n\
\n\
use arcature::prelude::*;\n\
\n\
/// Seeds a known starting state. `arc db:seed` reaches this through\n\
/// the application's own binary, which decides what runs and in what\n\
/// order -- the CLI never guesses at seeder ordering.\n\
pub struct {type_name};\n\
\n\
impl {type_name} {{\n\
\x20 /// Insert this seeder's rows.\n\
\x20 pub async fn run(_db: &Db) -> Result<()> {{\n\
\x20 Ok(())\n\
\x20 }}\n\
}}\n"
)
}
fn utc_stamp() -> String {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let days = i64::try_from(seconds / 86_400).unwrap_or(0);
let time_of_day = seconds % 86_400;
let (year, month, day) = civil_from_days(days);
let (hour, minute, second) = (
time_of_day / 3_600,
(time_of_day % 3_600) / 60,
time_of_day % 60,
);
format!("{year:04}{month:02}{day:02}_{hour:02}{minute:02}{second:02}")
}
fn civil_from_days(days: i64) -> (i64, u64, u64) {
let shifted = days + 719_468;
let era = if shifted >= 0 {
shifted
} else {
shifted - 146_096
} / 146_097;
let day_of_era = (shifted - era * 146_097) as u64; let year_of_era =
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let year = year_of_era as i64 + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_prime = (5 * day_of_year + 2) / 153; let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
let month = if month_prime < 10 {
month_prime + 3
} else {
month_prime - 9
};
(if month <= 2 { year + 1 } else { year }, month, day)
}
#[cfg(test)]
mod tests {
use super::*;
fn planned(kind: MakeKind, name: &str) -> Artifact {
plan(kind, &ArtifactName::parse(name).expect("valid name"))
}
#[test]
fn every_kind_plans_a_rust_file_under_a_known_root() {
for kind in MakeKind::ALL {
let artifact = planned(*kind, "widget");
assert_eq!(
artifact.path.extension().and_then(|e| e.to_str()),
Some("rs"),
"{} did not plan a .rs file",
kind.as_str()
);
assert!(
!artifact.contents.trim().is_empty(),
"{} planned an empty file",
kind.as_str()
);
assert!(
artifact.contents.ends_with('\n'),
"{} planned a file without a trailing newline",
kind.as_str()
);
}
}
#[test]
fn a_nested_name_nests_the_generated_file() {
let artifact = planned(MakeKind::Controller, "admin/users");
assert_eq!(
artifact.path,
PathBuf::from("app/controllers/admin/users_controller.rs")
);
assert!(artifact.contents.contains("pub struct UsersController;"));
}
#[test]
fn a_model_guesses_a_plural_table_name() {
let artifact = planned(MakeKind::Model, "Category");
assert_eq!(artifact.path, PathBuf::from("app/models/category.rs"));
assert!(
artifact
.contents
.contains("#[model(table = \"categories\")]")
);
}
#[test]
fn a_page_carries_the_name_the_developer_typed_as_its_contract() {
let artifact = planned(MakeKind::Page, "users/show");
assert_eq!(artifact.path, PathBuf::from("app/pages/users/show_page.rs"));
assert!(artifact.contents.contains("#[page(\"users/show\")]"));
assert!(artifact.contents.contains("pub struct ShowPage"));
}
#[test]
fn a_command_name_uses_colons_where_the_path_used_slashes() {
let artifact = planned(MakeKind::Command, "users/prune");
assert!(artifact.contents.contains("#[command(\"users:prune\")]"));
}
#[test]
fn a_migration_is_timestamped_and_asks_to_be_registered() {
let artifact = planned(MakeKind::Migration, "create_users_table");
let file = artifact.path.file_name().and_then(|n| n.to_str()).unwrap();
assert!(
file.starts_with('m'),
"{file} is missing its ordering prefix"
);
assert!(file.ends_with("_create_users_table.rs"), "{file}");
assert_eq!(artifact.notes.len(), 1);
assert!(artifact.notes[0].contains("Migrator::migrations()"));
}
#[test]
fn a_test_is_not_registered_in_a_module_tree() {
let artifact = planned(MakeKind::Test, "checkout");
assert_eq!(artifact.path, PathBuf::from("tests/checkout.rs"));
assert!(!artifact.register_module);
}
#[test]
fn the_two_blueprints_with_placeholders_say_so() {
for kind in [MakeKind::Policy, MakeKind::Listener] {
let artifact = planned(kind, "widget");
assert!(
!artifact.notes.is_empty(),
"{} has a placeholder but no note",
kind.as_str()
);
}
}
#[test]
fn the_civil_calendar_matches_known_dates() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
assert_eq!(civil_from_days(-1), (1969, 12, 31));
assert_eq!(civil_from_days(11_016), (2000, 2, 29));
assert_eq!(civil_from_days(20_454), (2026, 1, 1));
}
#[test]
fn the_migration_stamp_is_fixed_width_and_sortable() {
let stamp = utc_stamp();
assert_eq!(stamp.len(), 15, "{stamp}");
assert_eq!(&stamp[8..9], "_");
assert!(
stamp
.chars()
.enumerate()
.all(|(i, c)| i == 8 || c.is_ascii_digit()),
"{stamp}"
);
}
}