1use std::{
2 collections::{BTreeMap, BTreeSet},
3 env, fs,
4 io::ErrorKind,
5 path::{Component, Path, PathBuf},
6 process::Command,
7 time::{SystemTime, UNIX_EPOCH},
8};
9
10use thiserror::Error;
11
12const MODULES_START: &str = "// <phoenix:modules>";
13const MODULES_END: &str = "// </phoenix:modules>";
14const MODELS_START: &str = "// <phoenix:model-registry>";
15const MODELS_END: &str = "// </phoenix:model-registry>";
16const MIGRATIONS_START: &str = "// <phoenix:migration-registry>";
17const MIGRATIONS_END: &str = "// </phoenix:migration-registry>";
18const COMMANDS_START: &str = "// <phoenix:commands>";
19const COMMANDS_END: &str = "// </phoenix:commands>";
20
21#[derive(Clone, Debug, Eq, PartialEq)]
22pub enum DependencySource {
23 Registry,
24 Local(PathBuf),
25}
26
27impl DependencySource {
28 #[must_use]
29 pub fn discover() -> Self {
30 if let Some(path) = env::var_os("PHOENIX_FRAMEWORK_PATH") {
31 let path = PathBuf::from(path);
32 if is_framework_root(&path) {
33 return Self::Local(path);
34 }
35 }
36 let Ok(executable) = env::current_exe() else {
37 return Self::Registry;
38 };
39 for ancestor in executable.ancestors() {
40 if is_framework_root(ancestor) {
41 return Self::Local(ancestor.to_path_buf());
42 }
43 }
44 let build_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
45 .join("../..")
46 .canonicalize()
47 .unwrap_or_else(|_| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."));
48 if is_framework_root(&build_root) {
49 return Self::Local(build_root);
50 }
51 Self::Registry
52 }
53}
54
55fn is_framework_root(path: &Path) -> bool {
56 path.join("crates/phoenix/Cargo.toml").is_file()
57 && path.join("packages/phoenix-react/package.json").is_file()
58 && path.join("packages/phoenix-vite/package.json").is_file()
59}
60
61#[derive(Clone, Debug)]
62pub struct NewProjectOptions {
63 pub target: PathBuf,
64 pub dependencies: DependencySource,
65 pub initialize_git: bool,
66 pub install_dependencies: bool,
67}
68
69impl NewProjectOptions {
70 #[must_use]
71 pub fn new(target: impl Into<PathBuf>) -> Self {
72 Self {
73 target: target.into(),
74 dependencies: DependencySource::discover(),
75 initialize_git: true,
76 install_dependencies: true,
77 }
78 }
79
80 #[must_use]
81 pub fn dependencies(mut self, dependencies: DependencySource) -> Self {
82 self.dependencies = dependencies;
83 self
84 }
85
86 #[must_use]
87 pub const fn initialize_git(mut self, initialize: bool) -> Self {
88 self.initialize_git = initialize;
89 self
90 }
91
92 #[must_use]
93 pub const fn install_dependencies(mut self, install: bool) -> Self {
94 self.install_dependencies = install;
95 self
96 }
97}
98
99#[derive(Clone, Copy, Debug, Default)]
100pub struct GenerateOptions {
101 pub force: bool,
102}
103
104#[derive(Clone, Copy, Debug, Default)]
105pub struct ControllerOptions {
106 pub force: bool,
107 pub resource: bool,
108 pub route: bool,
109}
110
111#[derive(Clone, Copy, Debug, Default)]
112#[allow(clippy::struct_excessive_bools)]
113pub struct ModelOptions {
114 pub all: bool,
115 pub api_resource: bool,
116 pub controller: bool,
117 pub force: bool,
118 pub migration: bool,
119 pub page: bool,
120 pub request: bool,
121 pub resource_controller: bool,
122}
123
124#[derive(Debug, Error)]
125pub enum ScaffoldError {
126 #[error("invalid Phoenix name `{0}`; use letters, numbers, dashes, underscores, / or ::")]
127 InvalidName(String),
128 #[error("project target {0} already exists and is not empty")]
129 ProjectNotEmpty(PathBuf),
130 #[error("{0} is not a Phoenix project root")]
131 NotProject(PathBuf),
132 #[error("refusing to overwrite existing file {0}; pass --force to replace it")]
133 AlreadyExists(PathBuf),
134 #[error("Phoenix managed markers are missing or malformed in {0}")]
135 InvalidManagedFile(PathBuf),
136 #[error("local Phoenix framework root is invalid: {0}")]
137 InvalidFrameworkRoot(PathBuf),
138 #[error("failed to read or write {path}: {source}")]
139 Io {
140 path: PathBuf,
141 source: std::io::Error,
142 },
143 #[error("{program} exited unsuccessfully while preparing the project")]
144 CommandFailed { program: &'static str },
145 #[error("the current time is before the Unix epoch")]
146 InvalidClock,
147}
148
149pub fn create_project(options: &NewProjectOptions) -> Result<(), ScaffoldError> {
156 let target = absolute_path(&options.target)?;
157 ensure_empty_target(&target)?;
158 let directory_name = target
159 .file_name()
160 .and_then(|value| value.to_str())
161 .ok_or_else(|| ScaffoldError::InvalidName(target.display().to_string()))?;
162 let package = package_name(directory_name)?;
163 if let DependencySource::Local(root) = &options.dependencies
164 && !is_framework_root(root)
165 {
166 return Err(ScaffoldError::InvalidFrameworkRoot(root.clone()));
167 }
168
169 let mut editor = ProjectEditor::new(&target, false);
170 for (path, content) in project_files(&package, &options.dependencies)? {
171 editor.create(path, content)?;
172 }
173 editor.commit()?;
174
175 if options.initialize_git {
176 run_optional("git", &["init", "--quiet"], &target)?;
177 }
178 if options.install_dependencies {
179 run_required("npm", &["install"], &target)?;
180 run_required("npm", &["run", "types", "--silent"], &target)?;
181 }
182 Ok(())
183}
184
185#[derive(Clone, Debug)]
186pub struct ProjectGenerator {
187 root: PathBuf,
188}
189
190impl ProjectGenerator {
191 pub fn discover(start: impl AsRef<Path>) -> Result<Self, ScaffoldError> {
197 let start = absolute_path(start.as_ref())?;
198 for candidate in start.ancestors() {
199 if is_project_root(candidate) {
200 return Ok(Self {
201 root: candidate.to_path_buf(),
202 });
203 }
204 }
205 Err(ScaffoldError::NotProject(start))
206 }
207
208 #[must_use]
209 pub fn root(&self) -> &Path {
210 &self.root
211 }
212
213 pub fn refresh_types(&self) -> Result<bool, ScaffoldError> {
222 if !self.root.join("node_modules/@apizero/vite").is_dir() {
223 return Ok(false);
224 }
225 run_required("npm", &["run", "types", "--silent"], &self.root)?;
226 Ok(true)
227 }
228
229 pub fn controller(
235 &self,
236 name: &str,
237 options: ControllerOptions,
238 ) -> Result<Vec<PathBuf>, ScaffoldError> {
239 let name = QualifiedName::parse_with_suffix(name, "Controller")?;
240 let mut editor = ProjectEditor::new(&self.root, options.force);
241 add_rust_item(
242 &mut editor,
243 "app/controllers",
244 &name,
245 &controller_template(&name.class, options.resource),
246 )?;
247 if options.route || options.resource {
248 add_controller_route(&mut editor, &name, options.resource, None)?;
249 }
250 editor.commit()
251 }
252
253 pub fn model(
262 &self,
263 name: &str,
264 mut options: ModelOptions,
265 ) -> Result<Vec<PathBuf>, ScaffoldError> {
266 if options.all {
267 options.migration = true;
268 options.request = true;
269 options.api_resource = true;
270 options.controller = true;
271 options.resource_controller = true;
272 options.page = true;
273 }
274 let model = QualifiedName::parse(name)?;
275 let request = model.with_leaf(format!("Store{}Request", model.class));
276 let resource = model.with_leaf(format!("{}Resource", model.class));
277 let controller = model.with_leaf(format!("{}Controller", model.class));
278 let page = model.index_page_name();
279 let props = page_props_name(&page);
280 let cohesive = options.request
281 && options.api_resource
282 && options.controller
283 && options.resource_controller
284 && options.page;
285 let mut editor = ProjectEditor::new(&self.root, options.force);
286 add_model(&mut editor, &model)?;
287 if options.migration {
288 add_model_migration(&mut editor, &model)?;
289 }
290 if options.request {
291 add_rust_item(
292 &mut editor,
293 "app/requests",
294 &request,
295 &request_template(&request.class),
296 )?;
297 }
298 if options.api_resource {
299 add_rust_item(
300 &mut editor,
301 "app/resources",
302 &resource,
303 &resource_template(&resource.class),
304 )?;
305 }
306 if options.controller || options.resource_controller {
307 let content = if cohesive {
308 model_controller_template(&controller, &request, &resource, &props, &page.route)
309 } else {
310 controller_template(&controller.class, options.resource_controller)
311 };
312 add_rust_item(&mut editor, "app/controllers", &controller, &content)?;
313 let action = cohesive.then_some((&request, &resource));
314 add_controller_route(
315 &mut editor,
316 &controller,
317 options.resource_controller,
318 action,
319 )?;
320 }
321 if options.page {
322 add_page(&mut editor, &page)?;
323 }
324 editor.commit()
325 }
326
327 pub fn migration(
333 &self,
334 name: &str,
335 options: GenerateOptions,
336 ) -> Result<Vec<PathBuf>, ScaffoldError> {
337 let migration_name = snake_identifier(name)?;
338 let mut editor = ProjectEditor::new(&self.root, options.force);
339 add_migration(
340 &mut editor,
341 &migration_name,
342 inferred_table(&migration_name),
343 )?;
344 editor.commit()
345 }
346
347 pub fn request(
353 &self,
354 name: &str,
355 options: GenerateOptions,
356 ) -> Result<Vec<PathBuf>, ScaffoldError> {
357 self.rust_contract(name, "Request", "app/requests", request_template, options)
358 }
359
360 pub fn resource(
366 &self,
367 name: &str,
368 options: GenerateOptions,
369 ) -> Result<Vec<PathBuf>, ScaffoldError> {
370 self.rust_contract(
371 name,
372 "Resource",
373 "app/resources",
374 resource_template,
375 options,
376 )
377 }
378
379 pub fn middleware(
385 &self,
386 name: &str,
387 options: GenerateOptions,
388 ) -> Result<Vec<PathBuf>, ScaffoldError> {
389 let name = QualifiedName::parse_with_suffix(name, "Middleware")?;
390 let mut editor = ProjectEditor::new(&self.root, options.force);
391 add_rust_item(
392 &mut editor,
393 "app/middleware",
394 &name,
395 &middleware_template(&name.class),
396 )?;
397 editor.commit()
398 }
399
400 pub fn page(
406 &self,
407 name: &str,
408 options: GenerateOptions,
409 ) -> Result<Vec<PathBuf>, ScaffoldError> {
410 let page = PageName::parse(name)?;
411 let mut editor = ProjectEditor::new(&self.root, options.force);
412 add_page(&mut editor, &page)?;
413 editor.commit()
414 }
415
416 pub fn island(
422 &self,
423 name: &str,
424 options: GenerateOptions,
425 ) -> Result<Vec<PathBuf>, ScaffoldError> {
426 let name = QualifiedName::parse(name)?;
427 let mut editor = ProjectEditor::new(&self.root, options.force);
428 let mut path = PathBuf::from("views/islands");
429 path.extend(name.modules.iter().map(|module| kebab_case(module)));
430 path.push(format!("{}.tsx", kebab_case(&name.class)));
431 editor.create(path, island_template(&name.class))?;
432 editor.commit()
433 }
434
435 pub fn command(
441 &self,
442 name: &str,
443 options: GenerateOptions,
444 ) -> Result<Vec<PathBuf>, ScaffoldError> {
445 let name = QualifiedName::parse(name)?;
446 let mut editor = ProjectEditor::new(&self.root, options.force);
447 add_command(&mut editor, &name)?;
448 editor.commit()
449 }
450
451 fn rust_contract(
452 &self,
453 name: &str,
454 suffix: &str,
455 directory: &str,
456 template: fn(&str) -> String,
457 options: GenerateOptions,
458 ) -> Result<Vec<PathBuf>, ScaffoldError> {
459 let name = QualifiedName::parse_with_suffix(name, suffix)?;
460 let mut editor = ProjectEditor::new(&self.root, options.force);
461 add_rust_item(&mut editor, directory, &name, &template(&name.class))?;
462 editor.commit()
463 }
464}
465
466fn is_project_root(path: &Path) -> bool {
467 path.join("Cargo.toml").is_file()
468 && path.join("package.json").is_file()
469 && path.join("app").is_dir()
470 && path.join("routes").is_dir()
471 && path.join("views").is_dir()
472}
473
474fn project_files(
475 package: &str,
476 dependencies: &DependencySource,
477) -> Result<Vec<(PathBuf, String)>, ScaffoldError> {
478 let (rust_dependency, react, react_ssr, vite) = match dependencies {
479 DependencySource::Registry => (
480 "phoenix = { package = \"phoenixrs\", version = \"0.1.0\" }".to_owned(),
486 "https://registry.npmjs.org/@apizero/react/-/react-0.1.2.tgz".to_owned(),
487 "https://registry.npmjs.org/@apizero/react-ssr/-/react-ssr-0.1.2.tgz".to_owned(),
488 "https://registry.npmjs.org/@apizero/vite/-/vite-0.1.3.tgz".to_owned(),
489 ),
490 DependencySource::Local(root) => {
491 let root = absolute_path(root)?;
492 (
493 format!(
494 "phoenix = {{ package = \"phoenixrs\", path = {} }}",
495 json_string(&root.join("crates/phoenix").to_string_lossy())
496 ),
497 format!("file:{}", root.join("packages/phoenix-react").display()),
498 format!("file:{}", root.join("packages/phoenix-react-ssr").display()),
499 format!("file:{}", root.join("packages/phoenix-vite").display()),
500 )
501 }
502 };
503 let crate_name = package.replace('-', "_");
504 let package_json = format!(
505 r#"{{
506 "name": {package},
507 "version": "0.1.0",
508 "private": true,
509 "type": "module",
510 "scripts": {{
511 "dev": "vite --host 127.0.0.1",
512 "build": "npm run build:client && npm run build:ssr",
513 "build:client": "vite build",
514 "build:ssr": "vite build --config vite.ssr.config.ts",
515 "types": "node -e \"import('@apizero/vite').then(({{ generateRouteTypes }}) => generateRouteTypes('routes', 'views/generated/routes.ts', '.', 'views/generated/contracts.ts'))\"",
516 "typecheck": "npm run types && tsc --noEmit"
517 }},
518 "dependencies": {{
519 "@apizero/react": {react},
520 "@apizero/react-ssr": {react_ssr},
521 "@apizero/vite": {vite},
522 "react": "^19.1.0",
523 "react-dom": "^19.1.0"
524 }},
525 "devDependencies": {{
526 "@types/react": "^19.0.0",
527 "@types/react-dom": "^19.0.0",
528 "typescript": "^5.8.0",
529 "vite": "^7.3.6"
530 }}
531}}
532"#,
533 package = json_string(package),
534 react = json_string(&react),
535 react_ssr = json_string(&react_ssr),
536 vite = json_string(&vite),
537 );
538
539 Ok(vec![
540 (
541 "Cargo.toml".into(),
542 format!(
543 "[package]\nname = {package}\nversion = \"0.1.0\"\nedition = \"2024\"\nrust-version = \"1.95\"\npublish = false\ndefault-run = {package}\n\n[dependencies]\n{rust_dependency}\nserde = {{ version = \"1\", features = [\"derive\"] }}\nserde_json = \"1\"\ntoasty = {{ version = \"0.8\", features = [\"migration\", \"mysql\", \"postgresql\", \"serde\", \"sqlite\"] }}\ntokio = {{ version = \"1\", features = [\"macros\", \"rt-multi-thread\", \"signal\"] }}\n\n[workspace]\n",
544 package = json_string(package),
545 ),
546 ),
547 ("package.json".into(), package_json),
548 (".gitignore".into(), "/target\n/node_modules\n/public/assets\n/public/ssr\n/views/generated/*.ts\n/dist\n.env\n.DS_Store\n".to_owned()),
549 (".env.example".into(), env_example_template()),
550 ("README.md".into(), project_readme(package)),
551 ("src/main.rs".into(), main_template(&crate_name)),
552 (
553 "src/bin/phoenix-manage.rs".into(),
554 management_template(&crate_name),
555 ),
556 ("src/lib.rs".into(), lib_template()),
557 ("config/mod.rs".into(), config_template()),
558 ("config/app.toml".into(), app_toml_template(package)),
559 ("config/database.toml".into(), database_toml_template()),
560 (
561 "config/schemas/phoenix-config-app.schema.json".into(),
562 include_str!("../schemas/phoenix-config-app.schema.json").to_owned(),
563 ),
564 (
565 "config/schemas/phoenix-config-database.schema.json".into(),
566 include_str!("../schemas/phoenix-config-database.schema.json").to_owned(),
567 ),
568 ("taplo.toml".into(), app_taplo_template()),
569 ("deploy/restart.sh.example".into(), deploy_restart_example()),
570 ("app/controllers/mod.rs".into(), managed_modules(&["pub mod home_controller;", "pub use home_controller::HomeController;"])),
571 ("app/controllers/home_controller.rs".into(), home_controller_template()),
572 ("app/props/mod.rs".into(), managed_modules(&["pub mod home_props;", "pub use home_props::HomeProps;"])),
573 ("app/props/home_props.rs".into(), home_props_template()),
574 ("app/models/mod.rs".into(), empty_model_registry()),
575 ("app/requests/mod.rs".into(), managed_modules(&[])),
576 ("app/resources/mod.rs".into(), managed_modules(&[])),
577 ("app/middleware/mod.rs".into(), managed_modules(&[])),
578 ("app/commands/mod.rs".into(), commands_mod_template()),
579 (
580 "database/migrations/mod.rs".into(),
581 empty_migration_registry(),
582 ),
583 ("database/seeders/mod.rs".into(), seeder_template()),
584 ("routes/web.rs".into(), home_route_template()),
585 ("views/pages/home.tsx".into(), home_page_template()),
586 ("views/styles.css".into(), styles_template()),
587 ("views/generated/contracts.ts".into(), generated_contracts_template()),
588 ("views/generated/routes.ts".into(), generated_routes_template()),
589 ("vite.config.ts".into(), vite_template(false)),
590 ("vite.ssr.config.ts".into(), vite_template(true)),
591 ("tsconfig.json".into(), tsconfig_template()),
592 ("public/.gitkeep".into(), String::new()),
593 ("storage/cache/.gitkeep".into(), String::new()),
594 ("storage/logs/.gitkeep".into(), String::new()),
595 ("views/components/.gitkeep".into(), String::new()),
596 ("views/islands/.gitkeep".into(), String::new()),
597 ("views/layouts/.gitkeep".into(), String::new()),
598 ])
599}
600
601fn env_example_template() -> String {
602 r#"# Copy to `.env` for local secrets and overrides.
603# Structured defaults live in config/app.toml and config/database.toml.
604# Precedence: config/*.toml < .env < process environment.
605
606APP_ENV=development
607APP_ADDR=127.0.0.1:3000
608APP_URL=http://127.0.0.1:3000
609
610# Database: prefer editing config/database.toml default = "sqlite" | "pgsql" | "mysql".
611# Optional overrides:
612# DB_CONNECTION=pgsql
613# DB_CONNECTION=mysql
614# DB_PASSWORD=secret
615# DATABASE_URL=postgresql://phoenix:secret@127.0.0.1:5432/phoenix
616# DATABASE_URL=mysql://phoenix:secret@127.0.0.1:3306/phoenix
617
618TRUSTED_PROXIES=none
619ALLOWED_HOSTS=127.0.0.1,localhost,[::1]
620RATE_LIMIT_REQUESTS=60
621RATE_LIMIT_WINDOW_SECONDS=60
622VITE_DEV_URL=http://127.0.0.1:5173
623PHOENIX_LOG=info,hyper=warn
624"#
625 .to_owned()
626}
627
628fn app_toml_template(package: &str) -> String {
629 format!(
630 r#"# Application settings (Laravel-style config/app).
631# Secrets and machine-specific overrides belong in `.env`.
632# Editor autocomplete: Even Better TOML / Taplo + #:schema below.
633
634#:schema ./schemas/phoenix-config-app.schema.json
635
636name = {package}
637env = "development"
638addr = "127.0.0.1:3000"
639url = "http://127.0.0.1:3000"
640"#,
641 package = json_string(package),
642 )
643}
644
645fn database_toml_template() -> String {
646 r#"# Database connections (Laravel-style config/database).
647#
648# Switch engines by changing `default`:
649# default = "sqlite" # local file, zero setup
650# default = "pgsql" # PostgreSQL
651# default = "mysql" # MySQL / MariaDB
652#
653# Or set DB_CONNECTION=pgsql|mysql in `.env` without editing this file.
654# Put DB_PASSWORD in `.env` — do not commit production passwords here.
655# Editor autocomplete: Even Better TOML / Taplo + #:schema below.
656
657#:schema ./schemas/phoenix-config-database.schema.json
658
659default = "sqlite"
660
661[connections.sqlite]
662driver = "sqlite"
663# Path is relative to the application root (creates parent dirs as needed by the OS/driver).
664database = "storage/app.sqlite"
665
666[connections.pgsql]
667driver = "pgsql"
668host = "127.0.0.1"
669port = 5432
670database = "phoenix"
671username = "phoenix"
672password = ""
673
674[connections.mysql]
675driver = "mysql"
676host = "127.0.0.1"
677port = 3306
678database = "phoenix"
679username = "phoenix"
680password = ""
681"#
682 .to_owned()
683}
684
685fn app_taplo_template() -> String {
686 r#"# Taplo / Even Better TOML schema associations for config/*.toml autocomplete.
687
688[[rule]]
689include = ["config/app.toml"]
690[rule.schema]
691path = "./config/schemas/phoenix-config-app.schema.json"
692
693[[rule]]
694include = ["config/database.toml"]
695[rule.schema]
696path = "./config/schemas/phoenix-config-database.schema.json"
697"#
698 .to_owned()
699}
700
701fn deploy_restart_example() -> String {
702 r"#!/bin/sh
703# Copy to deploy/restart.sh and make executable.
704# Used by `px release:install` / `px release:rollback` when --restart-cmd is omitted.
705set -eu
706systemctl restart my-app
707"
708 .to_owned()
709}
710
711fn config_template() -> String {
712 r#"pub use phoenix::config::{AppConfig, AppConfigBuilder, ConfigError, Environment, SecretValue};
713
714/// Load this application's configuration.
715///
716/// Reads `config/app.toml` + `config/database.toml`, then `.env`, then process
717/// environment. To require JWT/encryption secrets in production:
718/// `AppConfig::builder().required_secret("JWT_SECRET", 32).load()`.
719///
720/// # Errors
721///
722/// Returns a source, validation, or production-requirement error.
723pub fn load() -> Result<AppConfig, ConfigError> {
724 AppConfig::load()
725}
726"#
727 .to_owned()
728}
729
730#[allow(clippy::too_many_lines)]
731fn management_template(crate_name: &str) -> String {
732 r#"use std::{env, error::Error, io};
733
734use phoenix::database::MigrationRunner;
735
736type CommandResult<T = ()> = Result<T, Box<dyn Error>>;
737
738#[tokio::main]
739async fn main() -> CommandResult {
740 let arguments = env::args().skip(1).collect::<Vec<_>>();
741 let command = arguments
742 .first()
743 .map(String::as_str)
744 .ok_or_else(|| input_error("expected migrate, status, rollback, fresh, or seed"))?;
745 let options = &arguments[1..];
746 if !matches!(command, "migrate" | "status" | "rollback" | "fresh" | "seed") {
747 return Err(input_error(format!("unknown management command `{command}`")).into());
748 }
749
750 let config = __PHOENIX_APP_CRATE__::config::load()?;
751 let mut database = __PHOENIX_APP_CRATE__::database(&config).await?;
752 if command == "seed" {
753 require_no_options(options)?;
754 __PHOENIX_APP_CRATE__::seeders::run(&mut database).await?;
755 println!("Seeders completed.");
756 return Ok(());
757 }
758
759 let mut runner = MigrationRunner::new(
760 &mut database,
761 __PHOENIX_APP_CRATE__::migrations::all(),
762 )?;
763 match command {
764 "migrate" => {
765 require_no_options(options)?;
766 let applied = runner.up().await?;
767 println!("Applied {applied} migration(s).");
768 }
769 "status" => {
770 require_no_options(options)?;
771 let plan = runner.plan().await?;
772 if plan.applied.is_empty() && plan.pending.is_empty() {
773 println!("No migrations registered or applied.");
774 }
775 for migration in plan.applied {
776 println!(
777 "APPLIED {} batch={} {} {}",
778 migration.id, migration.batch, migration.applied_at, migration.name
779 );
780 }
781 for id in plan.pending {
782 println!("PENDING {id}");
783 }
784 }
785 "rollback" => {
786 let steps = parse_rollback_steps(options)?;
787 let rolled_back = runner.down(steps).await?;
788 println!("Rolled back {rolled_back} migration(s).");
789 }
790 "fresh" => {
791 let run_seeders = parse_fresh_options(options)?;
792 let applied = runner.plan().await?.applied.len();
793 let rolled_back = runner.down(applied).await?;
794 let migrated = runner.up().await?;
795 println!(
796 "Rebuilt the database: rolled back {rolled_back}, applied {migrated} migration(s)."
797 );
798 drop(runner);
799 if run_seeders {
800 __PHOENIX_APP_CRATE__::seeders::run(&mut database).await?;
801 println!("Seeders completed.");
802 }
803 }
804 "seed" => unreachable!("seed is handled before creating the migration runner"),
805 _ => unreachable!("management commands are validated before connecting"),
806 }
807 Ok(())
808}
809
810fn require_no_options(options: &[String]) -> CommandResult {
811 if options.is_empty() {
812 Ok(())
813 } else {
814 Err(input_error(format!("unexpected arguments: {}", options.join(" "))).into())
815 }
816}
817
818fn parse_rollback_steps(options: &[String]) -> CommandResult<usize> {
819 let [steps] = options else {
820 return Err(input_error("rollback expects one positive step count").into());
821 };
822 steps
823 .parse::<usize>()
824 .ok()
825 .filter(|steps| *steps > 0)
826 .ok_or_else(|| input_error("rollback step count must be a positive integer").into())
827}
828
829fn parse_fresh_options(options: &[String]) -> CommandResult<bool> {
830 match options {
831 [] => Ok(false),
832 [option] if option == "--seed" => Ok(true),
833 _ => Err(input_error("fresh only accepts --seed").into()),
834 }
835}
836
837fn input_error(message: impl Into<String>) -> io::Error {
838 io::Error::new(io::ErrorKind::InvalidInput, message.into())
839}
840"#
841 .replace("__PHOENIX_APP_CRATE__", crate_name)
842}
843
844fn seeder_template() -> String {
845 r"use std::error::Error;
846
847use phoenix::database::Database;
848
849/// Insert repeatable development or test data.
850///
851/// # Errors
852///
853/// Returns the first application or database error raised by a seeder.
854pub async fn run(_database: &mut Database) -> Result<(), Box<dyn Error>> {
855 Ok(())
856}
857"
858 .to_owned()
859}
860
861fn empty_model_registry() -> String {
862 format!(
863 "{MODULES_START}\n{MODULES_END}\n\n{MODELS_START}\n{}\n{MODELS_END}\n",
864 render_model_registry(&BTreeSet::new()).join("\n")
865 )
866}
867
868fn empty_migration_registry() -> String {
869 format!(
870 "{MIGRATIONS_START}\n{}\n{MIGRATIONS_END}\n",
871 render_migration_registry(&BTreeSet::new()).join("\n")
872 )
873}
874
875fn project_readme(package: &str) -> String {
876 format!(
877 "# {package}\n\nPhoenix Rust + React application.\n\n## Start\n\n```bash\ncp .env.example .env\nnpm install\npx migrate\npx dev\n```\n\nOpen <http://127.0.0.1:3000>.\n\n## Configuration\n\nLaravel-style TOML lives in `config/`:\n\n- `config/app.toml` — app name, env, listen address, public URL\n- `config/database.toml` — **choose the database** with `default = \"sqlite\"`, `\"pgsql\"`, or `\"mysql\"`\n\nPut secrets in `.env` (for example `DB_PASSWORD`). Precedence: `config/*.toml` < `.env` < process environment.\n\nEditor autocomplete for `config/*.toml` uses JSON Schema (`config/schemas/`) via Taplo / Even Better TOML (`taplo.toml`).\n\nThird-party Features (plugins): implement `Plugin`, then `FeatureSet::new().plugin(...)` and `.merge(features.into_routes())`. See Phoenix-rs `docs/FEATURES.md`.\n\n## Release\n\n```bash\npx release --version 0.1.0 --tarball\n# upload dist/releases/.../*.tar.gz, then on the server:\n# export PHOENIX_DEPLOY_ROOT=/var/www/my-app\n# px release:install --tarball /path/to/app-0.1.0.tar.gz --version 0.1.0\n# px release:rollback --steps 1\n```\n\nSee Phoenix-rs `docs/RELEASE_PIPELINE.md`.\n\n## Console\n\n```bash\ncargo run -- serve\ncargo run -- update\ncargo run -- help\n```\n\n## Database\n\n```bash\npx status\npx migrate\npx rollback --step 1\npx fresh --seed\npx seed\n```\n\nMigrations are registered in `database/migrations/mod.rs`. Add repeatable development data in `database/seeders/mod.rs`.\n\nProduction startup requires explicit `APP_URL`, database settings, `TRUSTED_PROXIES`, and `ALLOWED_HOSTS` values. Use `TRUSTED_PROXIES=none` when the service has no trusted reverse proxy. Declare purpose-specific JWT or encryption keys with `AppConfigBuilder::required_secret` only when the corresponding service consumes them.\n\n## Generate business code\n\n```bash\npx make:model Post --all\npx make:controller AdminController\npx make:request StorePostRequest\npx make:resource PostResource\npx make:middleware RequireLoginMiddleware\npx make:page posts/index\npx make:island LikeButton\npx make:command Update\n```\n"
878 )
879}
880
881fn main_template(crate_name: &str) -> String {
882 format!(
883 r#"use phoenix::prelude::{{CommandResult, Console, LogFormat, Logging}};
884
885use {crate_name}::commands;
886
887#[tokio::main]
888async fn main() -> CommandResult {{
889 Console::new(env!("CARGO_PKG_NAME"))
890 .about("Phoenix application")
891 .serve(|_ctx| async move {{
892 let config = {crate_name}::config::load()?;
893 let address = config.address().to_owned();
894 let public_url = config.public_url().to_owned();
895 let production = config.environment().is_production();
896 let _logging = Logging::new()
897 .format(if production {{ LogFormat::Json }} else {{ LogFormat::Compact }})
898 .ansi(!production)
899 .init()?;
900 let server = {crate_name}::application(config)?.bind(&address).await?;
901 println!(
902 "Phoenix application ready at {{public_url}} (listening on {{}})",
903 server.local_addr()
904 );
905 server
906 .run_with_shutdown(async {{
907 let _ = tokio::signal::ctrl_c().await;
908 }})
909 .await?;
910 Ok(())
911 }})
912 .commands(commands::registry())
913 .run()
914 .await
915}}
916"#
917 )
918}
919
920fn lib_template() -> String {
921 r#"#[path = "../config/mod.rs"]
922pub mod config;
923#[path = "../app/commands/mod.rs"]
924pub mod commands;
925#[path = "../app/controllers/mod.rs"]
926pub mod controllers;
927#[path = "../app/middleware/mod.rs"]
928pub mod middleware;
929#[path = "../app/models/mod.rs"]
930pub mod models;
931#[path = "../app/props/mod.rs"]
932pub mod props;
933#[path = "../app/requests/mod.rs"]
934pub mod requests;
935#[path = "../app/resources/mod.rs"]
936pub mod resources;
937#[path = "../database/migrations/mod.rs"]
938pub mod migrations;
939#[path = "../database/seeders/mod.rs"]
940pub mod seeders;
941
942use phoenix::prelude::{
943 AccessLog, Application, AssetManifest, Csrf, Database, DatabaseError, HostAllowlist,
944 NonceSecurityPolicy, RateLimit, RateLimitConfig, RequestId, RouteBuildError, Routes,
945 ServeProductionAssets, SessionConfig, SessionMiddleware, SessionStore, StateMiddleware,
946 TrustedProxies,
947};
948
949use config::AppConfig;
950
951#[must_use]
952#[allow(clippy::duplicate_mod)]
953pub fn routes(config: &AppConfig, assets: Option<&AssetManifest>) -> Routes {
954 let session_config = SessionConfig {
955 secure: config.public_url().starts_with("https://"),
956 ..SessionConfig::default()
957 };
958 let session_store = SessionStore::memory(session_config.max_age);
959
960 let mut routes = phoenix::mount_routes!()
961 .with_middleware(TrustedProxies::new(config.trusted_proxies().iter().copied()))
962 .with_middleware(RequestId)
963 .with_middleware(AccessLog);
964 if let Some(assets) = assets.cloned() {
965 // Serve hashed Vite assets before session/CSRF so static GETs stay cheap.
966 routes = routes.with_middleware(ServeProductionAssets::new(assets, "public/assets"));
967 }
968 routes
969 .with_middleware(HostAllowlist::new(config.allowed_hosts().iter().cloned()))
970 .with_middleware(RateLimit::new(RateLimitConfig {
971 requests: config.rate_limit_requests(),
972 window: config.rate_limit_window(),
973 }))
974 .with_middleware(content_security_policy(config))
975 .with_middleware(SessionMiddleware::new(session_store, session_config))
976 .with_middleware(Csrf)
977 .with_middleware(StateMiddleware::new(config.clone()))
978 .with_middleware(StateMiddleware::new(assets.cloned()))
979}
980
981fn content_security_policy(config: &AppConfig) -> NonceSecurityPolicy {
982 if !config.environment().is_production() {
983 return NonceSecurityPolicy::development(
984 config
985 .vite_dev_url()
986 .expect("development configuration always has a Vite origin"),
987 )
988 .expect("AppConfig validates VITE_DEV_URL as one trusted HTTP(S) origin");
989 }
990 NonceSecurityPolicy::default()
991}
992
993/// Build the Phoenix application.
994///
995/// # Errors
996///
997/// Returns a route error when route names or patterns conflict.
998pub fn application(config: AppConfig) -> Result<Application, RouteBuildError> {
999 let assets = AssetManifest::load("public/assets/phoenix-manifest.json").ok();
1000 Application::new(routes(&config, assets.as_ref()))
1001}
1002
1003/// Connect the configured database with every registered Toasty model.
1004///
1005/// # Errors
1006///
1007/// Returns a database error when the URL or connection is invalid.
1008pub async fn database(config: &AppConfig) -> Result<Database, DatabaseError> {
1009 Database::builder(models::all())
1010 .connect(config.database_url())
1011 .await
1012}
1013"#
1014 .to_owned()
1015}
1016
1017fn commands_mod_template() -> String {
1018 format!(
1019 "use phoenix::prelude::commands;\n\n{MODULES_START}\n{MODULES_END}\n\ncommands! {{\n{COMMANDS_START}\n{COMMANDS_END}\n}}\n"
1020 )
1021}
1022
1023fn command_template(function_name: &str) -> String {
1024 format!(
1025 r#"use phoenix::prelude::{{CommandContext, CommandResult}};
1026
1027/// Application console command.
1028#[allow(clippy::unused_async)]
1029pub async fn {function_name}(_ctx: CommandContext<'_>) -> CommandResult {{
1030 println!("{function_name} ran.");
1031 Ok(())
1032}}
1033"#
1034 )
1035}
1036
1037fn home_controller_template() -> String {
1038 r#"use phoenix::prelude::{AssetManifest, IntoResponse, Page, Request, Response, StatusCode};
1039
1040use crate::props::HomeProps;
1041
1042pub struct HomeController;
1043
1044impl HomeController {
1045 pub async fn index(request: Request) -> Response {
1046 let assets = request
1047 .extensions()
1048 .get::<Option<AssetManifest>>()
1049 .and_then(Option::as_ref);
1050 let mut page = Page::new(
1051 "home",
1052 HomeProps {
1053 title: "Phoenix is ready".to_owned(),
1054 description: "Rust owns the application contract; React renders the page.".to_owned(),
1055 },
1056 )
1057 .spa();
1058 if let Some(assets) = assets {
1059 page = match page.production_assets(assets, "client") {
1060 Ok(page) => page,
1061 Err(error) => {
1062 return Response::text(format!("asset manifest error: {error}"))
1063 .with_status(StatusCode::INTERNAL_SERVER_ERROR);
1064 }
1065 };
1066 }
1067 page.respond_to(&request, None)
1068 .unwrap_or_else(IntoResponse::into_response)
1069 }
1070}
1071"#
1072 .to_owned()
1073}
1074
1075fn home_props_template() -> String {
1076 r#"use serde::Serialize;
1077
1078#[phoenix::contract(page, page = "home")]
1079#[derive(Serialize)]
1080pub struct HomeProps {
1081 pub title: String,
1082 pub description: String,
1083}
1084"#
1085 .to_owned()
1086}
1087
1088fn home_route_template() -> String {
1089 r#"use phoenix::prelude::Routes;
1090
1091use crate::controllers::HomeController;
1092
1093#[must_use]
1094pub fn routes() -> Routes {
1095 Routes::new()
1096 .get("/", HomeController::index)
1097 .name("home")
1098}
1099"#
1100 .to_owned()
1101}
1102
1103fn home_page_template() -> String {
1104 r#"import type { HomeProps } from "../generated/contracts.js";
1105
1106export default function Home({ title, description }: HomeProps) {
1107 return (
1108 <main className="welcome">
1109 <p className="eyebrow">PHOENIX / RUST + REACT</p>
1110 <h1>{title}</h1>
1111 <p>{description}</p>
1112 <code>px make:model Post --all</code>
1113 </main>
1114 );
1115}
1116"#
1117 .to_owned()
1118}
1119
1120fn styles_template() -> String {
1121 r":root {
1122 font-family: Inter, ui-sans-serif, system-ui, sans-serif;
1123 color: #172033;
1124 background: #f5f7fb;
1125}
1126* { box-sizing: border-box; }
1127body { margin: 0; min-width: 320px; min-height: 100vh; }
1128.welcome { width: min(760px, calc(100% - 40px)); margin: 16vh auto 0; }
1129.eyebrow { color: #315bd6; font-size: 12px; font-weight: 800; letter-spacing: 0.14em; }
1130h1 { margin: 12px 0; font-size: clamp(42px, 8vw, 76px); line-height: 0.98; }
1131.welcome > p:not(.eyebrow) { max-width: 640px; color: #5d6879; font-size: 18px; line-height: 1.7; }
1132code { display: inline-block; margin-top: 18px; padding: 12px 14px; border: 1px solid #d7dce5; background: white; }
1133".to_owned()
1134}
1135
1136fn generated_contracts_template() -> String {
1137 r#"// Generated by Phoenix. Vite will refresh this file from Rust contracts.
1138export interface HomeProps {
1139 title: string;
1140 description: string;
1141}
1142export interface PhoenixPageProps { home: HomeProps }
1143export type PhoenixSharedProps = Record<string, never>;
1144export const contractHash = "scaffold" as const;
1145"#
1146 .to_owned()
1147}
1148
1149fn generated_routes_template() -> String {
1150 r#"// Generated by Phoenix. Vite will refresh this file from Rust routes.
1151export const routes = { home: "home" } as const;
1152export type PhoenixRouteName = "home";
1153export const home = routes.home;
1154"#
1155 .to_owned()
1156}
1157
1158fn vite_template(renderer: bool) -> String {
1159 format!(
1160 "import {{ defineConfig }} from \"vite\";\nimport {{ phoenix }} from \"@apizero/vite\";\n\nexport default defineConfig({{\n plugins: [phoenix({renderer})],\n}});\n",
1161 renderer = if renderer { "{ renderer: true }" } else { "" },
1162 )
1163}
1164
1165fn tsconfig_template() -> String {
1166 r#"{
1167 "compilerOptions": {
1168 "target": "ES2022",
1169 "lib": ["ES2022", "DOM", "DOM.Iterable"],
1170 "module": "ESNext",
1171 "moduleResolution": "Bundler",
1172 "jsx": "react-jsx",
1173 "strict": true,
1174 "noEmit": true,
1175 "preserveSymlinks": true,
1176 "skipLibCheck": true,
1177 "types": ["vite/client"]
1178 },
1179 "include": ["views/**/*.ts", "views/**/*.tsx", "vite.config.ts", "vite.ssr.config.ts"]
1180}
1181"#
1182 .to_owned()
1183}
1184
1185fn managed_modules(entries: &[&str]) -> String {
1186 let body = entries.join("\n");
1187 if body.is_empty() {
1188 format!("{MODULES_START}\n{MODULES_END}\n")
1189 } else {
1190 format!("{MODULES_START}\n{body}\n{MODULES_END}\n")
1191 }
1192}
1193
1194fn add_rust_item(
1195 editor: &mut ProjectEditor,
1196 base: &str,
1197 name: &QualifiedName,
1198 content: &str,
1199) -> Result<(), ScaffoldError> {
1200 let mut directory = PathBuf::from(base);
1201 let mut parent_module = directory.join("mod.rs");
1202 for namespace in &name.modules {
1203 let module = snake_case(namespace);
1204 editor.update_managed_lines(
1205 &parent_module,
1206 MODULES_START,
1207 MODULES_END,
1208 &[format!("pub mod {module};")],
1209 )?;
1210 directory.push(&module);
1211 parent_module = directory.join("mod.rs");
1212 }
1213 let module = snake_case(&name.class);
1214 editor.create(directory.join(format!("{module}.rs")), content.to_owned())?;
1215 editor.update_managed_lines(
1216 &parent_module,
1217 MODULES_START,
1218 MODULES_END,
1219 &[
1220 format!("pub mod {module};"),
1221 format!("pub use {module}::{};", name.class),
1222 ],
1223 )?;
1224 Ok(())
1225}
1226
1227fn add_command(editor: &mut ProjectEditor, name: &QualifiedName) -> Result<(), ScaffoldError> {
1228 let function_name = snake_case(&name.class);
1229 if matches!(function_name.as_str(), "serve" | "help") {
1230 return Err(ScaffoldError::InvalidName(function_name));
1231 }
1232
1233 let mut directory = PathBuf::from("app/commands");
1234 let mut parent_module = directory.join("mod.rs");
1235 let mut export_path = Vec::new();
1236 for namespace in &name.modules {
1237 let module = snake_case(namespace);
1238 editor.update_managed_lines(
1239 &parent_module,
1240 MODULES_START,
1241 MODULES_END,
1242 &[format!("pub mod {module};")],
1243 )?;
1244 directory.push(&module);
1245 parent_module = directory.join("mod.rs");
1246 export_path.push(module);
1247 }
1248
1249 editor.create(
1250 directory.join(format!("{function_name}.rs")),
1251 command_template(&function_name),
1252 )?;
1253 editor.update_managed_lines(
1254 &parent_module,
1255 MODULES_START,
1256 MODULES_END,
1257 &[
1258 format!("pub mod {function_name};"),
1259 format!("pub use {function_name}::{function_name};"),
1260 ],
1261 )?;
1262
1263 if !export_path.is_empty() {
1264 let path = format!("{}::{function_name}", export_path.join("::"));
1265 editor.update_managed_lines(
1266 "app/commands/mod.rs",
1267 MODULES_START,
1268 MODULES_END,
1269 &[format!("pub use {path};")],
1270 )?;
1271 }
1272
1273 editor.update_managed_lines(
1274 "app/commands/mod.rs",
1275 COMMANDS_START,
1276 COMMANDS_END,
1277 &[format!("{function_name},")],
1278 )?;
1279 Ok(())
1280}
1281
1282fn add_model(editor: &mut ProjectEditor, model: &QualifiedName) -> Result<(), ScaffoldError> {
1283 add_rust_item(editor, "app/models", model, &model_template(&model.class))?;
1284 let path = if model.modules.is_empty() {
1285 model.class.clone()
1286 } else {
1287 format!(
1288 "{}::{}",
1289 model
1290 .modules
1291 .iter()
1292 .map(|part| snake_case(part))
1293 .collect::<Vec<_>>()
1294 .join("::"),
1295 model.class
1296 )
1297 };
1298 editor.update_registry(
1299 "app/models/mod.rs",
1300 MODELS_START,
1301 MODELS_END,
1302 "model",
1303 &path,
1304 render_model_registry,
1305 )
1306}
1307
1308fn add_model_migration(
1309 editor: &mut ProjectEditor,
1310 model: &QualifiedName,
1311) -> Result<(), ScaffoldError> {
1312 let table = pluralize(&snake_case(&model.class));
1313 add_migration(editor, &format!("create_{table}_table"), &table)
1314}
1315
1316fn add_migration(editor: &mut ProjectEditor, name: &str, table: &str) -> Result<(), ScaffoldError> {
1317 let milliseconds = SystemTime::now()
1318 .duration_since(UNIX_EPOCH)
1319 .map_err(|_| ScaffoldError::InvalidClock)?
1320 .as_millis();
1321 let id = milliseconds.to_string();
1322 let module = format!("m_{id}_{name}");
1323 editor.create(
1324 format!("database/migrations/{module}.rs"),
1325 migration_template(&id, name, table),
1326 )?;
1327 editor.update_registry(
1328 "database/migrations/mod.rs",
1329 MIGRATIONS_START,
1330 MIGRATIONS_END,
1331 "migration",
1332 &module,
1333 render_migration_registry,
1334 )
1335}
1336
1337fn add_controller_route(
1338 editor: &mut ProjectEditor,
1339 controller: &QualifiedName,
1340 resource: bool,
1341 action: Option<(&QualifiedName, &QualifiedName)>,
1342) -> Result<(), ScaffoldError> {
1343 let base = controller
1344 .class
1345 .strip_suffix("Controller")
1346 .unwrap_or(&controller.class);
1347 let plural = pluralize(&snake_case(base));
1348 let namespace_modules = controller
1349 .modules
1350 .iter()
1351 .map(|part| snake_case(part))
1352 .collect::<Vec<_>>();
1353 let route_file = if namespace_modules.is_empty() {
1354 plural.clone()
1355 } else {
1356 format!("{}_{}", namespace_modules.join("_"), plural)
1357 };
1358 let import = if namespace_modules.is_empty() {
1359 format!("crate::controllers::{}", controller.class)
1360 } else {
1361 format!(
1362 "crate::controllers::{}::{}",
1363 namespace_modules.join("::"),
1364 controller.class
1365 )
1366 };
1367 let route_name = if namespace_modules.is_empty() {
1368 plural.clone()
1369 } else {
1370 format!("{}.{}", namespace_modules.join("."), plural)
1371 };
1372 let path = if namespace_modules.is_empty() {
1373 format!("/{}", plural.replace('_', "-"))
1374 } else {
1375 format!(
1376 "/{}/{}",
1377 namespace_modules
1378 .iter()
1379 .map(|part| kebab_case(part))
1380 .collect::<Vec<_>>()
1381 .join("/"),
1382 plural.replace('_', "-")
1383 )
1384 };
1385 editor.create(
1386 format!("routes/{route_file}.rs"),
1387 controller_route_template(
1388 &import,
1389 &route_name,
1390 &path,
1391 &controller.class,
1392 resource,
1393 action,
1394 ),
1395 )
1396}
1397
1398fn add_page(editor: &mut ProjectEditor, page: &PageName) -> Result<(), ScaffoldError> {
1399 let props = page_props_name(page);
1400 add_rust_item(
1401 editor,
1402 "app/props",
1403 &props,
1404 &page_props_template(&props.class, &page.route),
1405 )?;
1406 let mut path = PathBuf::from("views/pages");
1407 for part in &page.parts[..page.parts.len() - 1] {
1408 path.push(kebab_case(part));
1409 }
1410 path.push(format!(
1411 "{}.tsx",
1412 kebab_case(page.parts.last().expect("page has one part"))
1413 ));
1414 editor.create(
1415 path,
1416 page_template(&page.class, &props.class, page.parts.len()),
1417 )
1418}
1419
1420fn page_props_name(page: &PageName) -> QualifiedName {
1421 QualifiedName {
1422 modules: page.parts[..page.parts.len() - 1].to_vec(),
1423 class: format!("{}Props", page.class),
1424 }
1425}
1426
1427fn model_template(name: &str) -> String {
1428 format!(
1429 r"use phoenix::database::Model;
1430
1431#[derive(Debug, Model)]
1432pub struct {name} {{
1433 #[key]
1434 #[auto]
1435 pub id: u64,
1436 pub name: String,
1437}}
1438"
1439 )
1440}
1441
1442fn request_template(name: &str) -> String {
1443 format!(
1444 r#"use phoenix::prelude::{{Validate, ValidationErrors, Validator, max_length, required, rules, string}};
1445use serde::Deserialize;
1446
1447#[phoenix::contract(input)]
1448#[derive(Debug, Deserialize)]
1449pub struct {name} {{
1450 pub name: String,
1451}}
1452
1453impl Validate for {name} {{
1454 fn validate(&self) -> Result<(), ValidationErrors> {{
1455 let data = serde_json::json!({{ "name": self.name }});
1456 Validator::new(&data)
1457 .field("name", rules![required(), string(), max_length(255)])
1458 .validate()
1459 }}
1460}}
1461"#
1462 )
1463}
1464
1465fn resource_template(name: &str) -> String {
1466 format!(
1467 r#"use serde::Serialize;
1468
1469#[phoenix::contract(resource)]
1470#[derive(Clone, Debug, Serialize)]
1471#[serde(rename_all = "camelCase")]
1472pub struct {name} {{
1473 pub id: String,
1474 pub name: String,
1475}}
1476"#
1477 )
1478}
1479
1480fn controller_template(name: &str, resource: bool) -> String {
1481 if !resource {
1482 return format!(
1483 r#"use phoenix::prelude::{{Request, Response}};
1484
1485pub struct {name};
1486
1487impl {name} {{
1488 #[allow(clippy::unused_async)]
1489 pub async fn index(_request: Request) -> Response {{
1490 Response::text("{name}@index")
1491 }}
1492}}
1493"#
1494 );
1495 }
1496 format!(
1497 r#"use phoenix::prelude::{{Request, Response, StatusCode}};
1498
1499pub struct {name};
1500
1501impl {name} {{
1502 #[allow(clippy::unused_async)]
1503 pub async fn index(_request: Request) -> Response {{ Response::text("{name}@index") }}
1504
1505 #[allow(clippy::unused_async)]
1506 pub async fn create(_request: Request) -> Response {{ Response::text("{name}@create") }}
1507
1508 #[allow(clippy::unused_async)]
1509 pub async fn store(_request: Request) -> Response {{
1510 Response::text("{name}@store").with_status(StatusCode::CREATED)
1511 }}
1512
1513 #[allow(clippy::unused_async)]
1514 pub async fn show(_request: Request) -> Response {{ Response::text("{name}@show") }}
1515
1516 #[allow(clippy::unused_async)]
1517 pub async fn edit(_request: Request) -> Response {{ Response::text("{name}@edit") }}
1518
1519 #[allow(clippy::unused_async)]
1520 pub async fn update(_request: Request) -> Response {{ Response::text("{name}@update") }}
1521
1522 #[allow(clippy::unused_async)]
1523 pub async fn destroy(_request: Request) -> Response {{
1524 Response::new(StatusCode::NO_CONTENT, phoenix::http::Bytes::new())
1525 }}
1526}}
1527"#
1528 )
1529}
1530
1531fn model_controller_template(
1532 controller: &QualifiedName,
1533 request: &QualifiedName,
1534 resource: &QualifiedName,
1535 props: &QualifiedName,
1536 page: &str,
1537) -> String {
1538 let request_path = rust_item_path("requests", request);
1539 let resource_path = rust_item_path("resources", resource);
1540 let props_path = rust_item_path("props", props);
1541 let name = &controller.class;
1542 let title = page
1543 .split('/')
1544 .next()
1545 .map_or_else(|| "Items".to_owned(), pascal_case);
1546 format!(
1547 r#"use phoenix::prelude::{{Json, Page, PageResponseError, Request, Response, StatusCode, Validated}};
1548
1549use {props_path};
1550use {request_path};
1551use {resource_path};
1552
1553pub struct {name};
1554
1555impl {name} {{
1556 pub async fn index(request: Request) -> Result<Response, PageResponseError> {{
1557 Page::new("{page}", {props_class} {{ title: "{title}".to_owned() }})
1558 .spa()
1559 .respond_to(&request, None)
1560 }}
1561
1562 #[allow(clippy::unused_async)]
1563 pub async fn create(_request: Request) -> Response {{ Response::text("{name}@create") }}
1564
1565 #[allow(clippy::unused_async)]
1566 pub async fn store(
1567 Validated(Json(input)): Validated<Json<{request_class}>>,
1568 ) -> (StatusCode, Json<{resource_class}>) {{
1569 (
1570 StatusCode::CREATED,
1571 Json({resource_class} {{ id: "generated".to_owned(), name: input.name }}),
1572 )
1573 }}
1574
1575 #[allow(clippy::unused_async)]
1576 pub async fn show(_request: Request) -> Response {{ Response::text("{name}@show") }}
1577
1578 #[allow(clippy::unused_async)]
1579 pub async fn edit(_request: Request) -> Response {{ Response::text("{name}@edit") }}
1580
1581 #[allow(clippy::unused_async)]
1582 pub async fn update(_request: Request) -> Response {{ Response::text("{name}@update") }}
1583
1584 #[allow(clippy::unused_async)]
1585 pub async fn destroy(_request: Request) -> Response {{
1586 Response::new(StatusCode::NO_CONTENT, phoenix::http::Bytes::new())
1587 }}
1588}}
1589"#,
1590 props_class = props.class,
1591 request_class = request.class,
1592 resource_class = resource.class,
1593 )
1594}
1595
1596fn middleware_template(name: &str) -> String {
1597 format!(
1598 r"use phoenix::prelude::{{BoxFuture, Middleware, Next, Request, Response}};
1599
1600pub struct {name};
1601
1602impl Middleware for {name} {{
1603 fn handle(&self, request: Request, next: Next) -> BoxFuture<Response> {{
1604 Box::pin(async move {{
1605 // Add authorization, request context, or response policy here.
1606 next.run(request).await
1607 }})
1608 }}
1609}}
1610"
1611 )
1612}
1613
1614fn migration_template(id: &str, name: &str, table: &str) -> String {
1615 format!(
1616 r#"use phoenix::database::Migration;
1617
1618#[must_use]
1619pub fn migration() -> Migration {{
1620 Migration::new("{id}", "{description}")
1621 .up("CREATE TABLE {table} (id BIGINT PRIMARY KEY, name TEXT NOT NULL)")
1622 .down("DROP TABLE {table}")
1623}}
1624"#,
1625 description = name.replace('_', " "),
1626 )
1627}
1628
1629fn controller_route_template(
1630 import: &str,
1631 route_name: &str,
1632 path: &str,
1633 controller: &str,
1634 resource: bool,
1635 action: Option<(&QualifiedName, &QualifiedName)>,
1636) -> String {
1637 if !resource {
1638 return format!(
1639 "use phoenix::prelude::Routes;\n\nuse {import};\n\n#[must_use]\npub fn routes() -> Routes {{\n Routes::new()\n .get(\"{path}\", {controller}::index)\n .name(\"{route_name}.index\")\n}}\n"
1640 );
1641 }
1642 let parameter = snake_case(controller.strip_suffix("Controller").unwrap_or(controller));
1643 let (prelude, action_imports, store, action_binding) = action.map_or_else(
1644 || {
1645 (
1646 "Routes".to_owned(),
1647 String::new(),
1648 format!("{controller}::store"),
1649 String::new(),
1650 )
1651 },
1652 |(input, output)| {
1653 (
1654 "Routes, typed".to_owned(),
1655 format!(
1656 "use {};\nuse {};\n",
1657 rust_item_path("requests", input),
1658 rust_item_path("resources", output),
1659 ),
1660 format!("typed({controller}::store)"),
1661 format!("\n .action::<{}, {}>()", input.class, output.class),
1662 )
1663 },
1664 );
1665 format!(
1666 r#"use phoenix::prelude::{{{prelude}}};
1667
1668use {import};
1669{action_imports}
1670
1671#[must_use]
1672pub fn routes() -> Routes {{
1673 let member = "{path}/{{{parameter}}}";
1674 Routes::new()
1675 .get("{path}", {controller}::index)
1676 .name("{route_name}.index")
1677 .get("{path}/create", {controller}::create)
1678 .name("{route_name}.create")
1679 .post("{path}", {store})
1680 .name("{route_name}.store"){action_binding}
1681 .get(member, {controller}::show)
1682 .name("{route_name}.show")
1683 .get(format!("{{member}}/edit"), {controller}::edit)
1684 .name("{route_name}.edit")
1685 .put(member, {controller}::update)
1686 .name("{route_name}.update")
1687 .patch(member, {controller}::update)
1688 .delete(member, {controller}::destroy)
1689 .name("{route_name}.destroy")
1690}}
1691"#
1692 )
1693}
1694
1695fn rust_item_path(category: &str, name: &QualifiedName) -> String {
1696 if name.modules.is_empty() {
1697 format!("crate::{category}::{}", name.class)
1698 } else {
1699 format!(
1700 "crate::{category}::{}::{}",
1701 name.modules
1702 .iter()
1703 .map(|part| snake_case(part))
1704 .collect::<Vec<_>>()
1705 .join("::"),
1706 name.class,
1707 )
1708 }
1709}
1710
1711fn page_props_template(name: &str, route: &str) -> String {
1712 format!(
1713 r#"use serde::Serialize;
1714
1715#[phoenix::contract(page, page = "{route}")]
1716#[derive(Serialize)]
1717#[serde(rename_all = "camelCase")]
1718pub struct {name} {{
1719 pub title: String,
1720}}
1721"#
1722 )
1723}
1724
1725fn page_template(component: &str, props: &str, depth: usize) -> String {
1726 let contracts = format!("{}generated/contracts.js", "../".repeat(depth));
1727 format!(
1728 r#"import type {{ {props} }} from "{contracts}";
1729
1730export default function {component}({{ title }}: {props}) {{
1731 return (
1732 <main>
1733 <h1>{{title}}</h1>
1734 </main>
1735 );
1736}}
1737"#
1738 )
1739}
1740
1741fn island_template(component: &str) -> String {
1742 format!(
1743 r#"import {{ useState }} from "react";
1744
1745export interface {component}Props {{
1746 initialCount?: number;
1747}}
1748
1749export default function {component}({{ initialCount = 0 }}: {component}Props) {{
1750 const [count, setCount] = useState(initialCount);
1751 return <button type="button" onClick={{() => setCount((value) => value + 1)}}>{{count}}</button>;
1752}}
1753"#
1754 )
1755}
1756
1757fn render_model_registry(values: &BTreeSet<String>) -> Vec<String> {
1758 let mut lines = values
1759 .iter()
1760 .map(|value| format!("// phoenix:model: {value}"))
1761 .collect::<Vec<_>>();
1762 if !lines.is_empty() {
1763 lines.push(String::new());
1764 }
1765 lines.extend([
1766 "#[must_use]".to_owned(),
1767 "pub fn all() -> phoenix::database::ModelSet {".to_owned(),
1768 " phoenix::database::models!(".to_owned(),
1769 ]);
1770 lines.extend(values.iter().map(|value| format!(" {value},")));
1771 lines.extend([" )".to_owned(), "}".to_owned()]);
1772 lines
1773}
1774
1775fn render_migration_registry(values: &BTreeSet<String>) -> Vec<String> {
1776 let mut lines = Vec::new();
1777 for value in values {
1778 lines.push(format!("// phoenix:migration: {value}"));
1779 lines.push(format!("pub mod {value};"));
1780 }
1781 if !lines.is_empty() {
1782 lines.push(String::new());
1783 }
1784 lines.extend([
1785 "#[must_use]".to_owned(),
1786 "pub fn all() -> Vec<phoenix::database::Migration> {".to_owned(),
1787 " vec![".to_owned(),
1788 ]);
1789 lines.extend(
1790 values
1791 .iter()
1792 .map(|value| format!(" {value}::migration(),")),
1793 );
1794 lines.extend([" ]".to_owned(), "}".to_owned()]);
1795 lines
1796}
1797
1798struct ProjectEditor {
1799 root: PathBuf,
1800 force: bool,
1801 changes: BTreeMap<PathBuf, String>,
1802}
1803
1804impl ProjectEditor {
1805 fn new(root: &Path, force: bool) -> Self {
1806 Self {
1807 root: root.to_path_buf(),
1808 force,
1809 changes: BTreeMap::new(),
1810 }
1811 }
1812
1813 fn create(
1814 &mut self,
1815 relative: impl Into<PathBuf>,
1816 content: String,
1817 ) -> Result<(), ScaffoldError> {
1818 let relative = safe_relative(relative.into())?;
1819 let absolute = self.root.join(&relative);
1820 if !self.force && (absolute.exists() || self.changes.contains_key(&relative)) {
1821 return Err(ScaffoldError::AlreadyExists(absolute));
1822 }
1823 self.changes.insert(relative, content);
1824 Ok(())
1825 }
1826
1827 fn read(&self, relative: &Path) -> Result<String, ScaffoldError> {
1828 if let Some(content) = self.changes.get(relative) {
1829 return Ok(content.clone());
1830 }
1831 let absolute = self.root.join(relative);
1832 match fs::read_to_string(&absolute) {
1833 Ok(content) => Ok(content),
1834 Err(error) if error.kind() == ErrorKind::NotFound => Ok(String::new()),
1835 Err(source) => Err(ScaffoldError::Io {
1836 path: absolute,
1837 source,
1838 }),
1839 }
1840 }
1841
1842 fn update_managed_lines(
1843 &mut self,
1844 relative: impl AsRef<Path>,
1845 start: &str,
1846 end: &str,
1847 added: &[String],
1848 ) -> Result<(), ScaffoldError> {
1849 let relative = safe_relative(relative.as_ref().to_path_buf())?;
1850 let existing = self.read(&relative)?;
1851 let initialized = if existing.is_empty() {
1852 format!("{start}\n{end}\n")
1853 } else {
1854 existing
1855 };
1856 let (before, managed, after) = managed_parts(&initialized, start, end)
1857 .ok_or_else(|| ScaffoldError::InvalidManagedFile(self.root.join(&relative)))?;
1858 let mut lines = managed
1859 .lines()
1860 .map(str::trim)
1861 .filter(|line| !line.is_empty())
1862 .map(str::to_owned)
1863 .collect::<BTreeSet<_>>();
1864 lines.extend(added.iter().cloned());
1865 let body = lines.into_iter().collect::<Vec<_>>().join("\n");
1866 let body = if body.is_empty() {
1867 body
1868 } else {
1869 format!("{body}\n")
1870 };
1871 self.changes
1872 .insert(relative, format!("{before}{start}\n{body}{end}{after}"));
1873 Ok(())
1874 }
1875
1876 fn update_registry(
1877 &mut self,
1878 relative: impl AsRef<Path>,
1879 start: &str,
1880 end: &str,
1881 key: &str,
1882 value: &str,
1883 render: fn(&BTreeSet<String>) -> Vec<String>,
1884 ) -> Result<(), ScaffoldError> {
1885 let relative = safe_relative(relative.as_ref().to_path_buf())?;
1886 let existing = self.read(&relative)?;
1887 let initialized = if existing.is_empty() {
1888 format!("{start}\n{end}\n")
1889 } else {
1890 existing
1891 };
1892 let (before, managed, after) = managed_parts(&initialized, start, end)
1893 .ok_or_else(|| ScaffoldError::InvalidManagedFile(self.root.join(&relative)))?;
1894 let prefix = format!("// phoenix:{key}: ");
1895 let mut values = managed
1896 .lines()
1897 .filter_map(|line| line.trim().strip_prefix(&prefix).map(str::to_owned))
1898 .collect::<BTreeSet<_>>();
1899 values.insert(value.to_owned());
1900 let rendered = render(&values).join("\n");
1901 let body = if rendered.is_empty() {
1902 rendered
1903 } else {
1904 format!("{rendered}\n")
1905 };
1906 self.changes
1907 .insert(relative, format!("{before}{start}\n{body}{end}{after}"));
1908 Ok(())
1909 }
1910
1911 fn commit(self) -> Result<Vec<PathBuf>, ScaffoldError> {
1912 let mut written = Vec::with_capacity(self.changes.len());
1913 for (relative, content) in self.changes {
1914 let path = self.root.join(relative);
1915 if let Some(parent) = path.parent() {
1916 fs::create_dir_all(parent).map_err(|source| ScaffoldError::Io {
1917 path: parent.to_path_buf(),
1918 source,
1919 })?;
1920 }
1921 fs::write(&path, content).map_err(|source| ScaffoldError::Io {
1922 path: path.clone(),
1923 source,
1924 })?;
1925 written.push(path);
1926 }
1927 Ok(written)
1928 }
1929}
1930
1931fn managed_parts<'a>(
1932 content: &'a str,
1933 start: &str,
1934 end: &str,
1935) -> Option<(&'a str, &'a str, &'a str)> {
1936 let start_index = content.find(start)?;
1937 let managed_start = start_index + start.len();
1938 let end_relative = content[managed_start..].find(end)?;
1939 let end_index = managed_start + end_relative;
1940 if content[end_index + end.len()..].contains(end) || content[..start_index].contains(start) {
1941 return None;
1942 }
1943 Some((
1944 &content[..start_index],
1945 content[managed_start..end_index].trim_matches('\n'),
1946 &content[end_index + end.len()..],
1947 ))
1948}
1949
1950#[derive(Clone, Debug)]
1951struct QualifiedName {
1952 modules: Vec<String>,
1953 class: String,
1954}
1955
1956impl QualifiedName {
1957 fn parse(value: &str) -> Result<Self, ScaffoldError> {
1958 let parts = name_parts(value)?;
1959 let class = pascal_case(parts.last().expect("validated names have a leaf"));
1960 let modules = parts[..parts.len() - 1]
1961 .iter()
1962 .map(|part| pascal_case(part))
1963 .collect();
1964 Ok(Self { modules, class })
1965 }
1966
1967 fn parse_with_suffix(value: &str, suffix: &str) -> Result<Self, ScaffoldError> {
1968 let mut name = Self::parse(value)?;
1969 if !name.class.ends_with(suffix) {
1970 name.class.push_str(suffix);
1971 }
1972 Ok(name)
1973 }
1974
1975 fn with_leaf(&self, class: String) -> Self {
1976 Self {
1977 modules: self.modules.clone(),
1978 class,
1979 }
1980 }
1981
1982 fn index_page_name(&self) -> PageName {
1983 let mut parts = self.modules.clone();
1984 parts.push(pluralize(&self.class));
1985 parts.push("Index".to_owned());
1986 PageName::from_parts(parts)
1987 }
1988}
1989
1990#[derive(Clone, Debug)]
1991struct PageName {
1992 parts: Vec<String>,
1993 route: String,
1994 class: String,
1995}
1996
1997impl PageName {
1998 fn parse(value: &str) -> Result<Self, ScaffoldError> {
1999 let parts = name_parts(value)?
2000 .into_iter()
2001 .map(|part| pascal_case(&part))
2002 .collect();
2003 Ok(Self::from_parts(parts))
2004 }
2005
2006 fn from_parts(parts: Vec<String>) -> Self {
2007 let route = parts
2008 .iter()
2009 .map(|part| kebab_case(part))
2010 .collect::<Vec<_>>()
2011 .join("/");
2012 let class = parts.iter().map(String::as_str).collect::<String>();
2013 Self {
2014 parts,
2015 route,
2016 class,
2017 }
2018 }
2019}
2020
2021fn name_parts(value: &str) -> Result<Vec<String>, ScaffoldError> {
2022 let normalized = value.replace("::", "/").replace('\\', "/");
2023 let parts = normalized
2024 .split('/')
2025 .filter(|part| !part.is_empty())
2026 .map(str::to_owned)
2027 .collect::<Vec<_>>();
2028 if parts.is_empty()
2029 || parts.iter().any(|part| {
2030 !part.chars().all(|character| {
2031 character.is_ascii_alphanumeric() || character == '_' || character == '-'
2032 }) || !part
2033 .chars()
2034 .any(|character| character.is_ascii_alphabetic())
2035 })
2036 {
2037 return Err(ScaffoldError::InvalidName(value.to_owned()));
2038 }
2039 Ok(parts)
2040}
2041
2042fn package_name(value: &str) -> Result<String, ScaffoldError> {
2043 let value = kebab_case(value).trim_matches('-').to_owned();
2044 if value.is_empty()
2045 || value.starts_with(|character: char| character.is_ascii_digit())
2046 || !value.chars().all(|character| {
2047 character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
2048 })
2049 {
2050 return Err(ScaffoldError::InvalidName(value));
2051 }
2052 Ok(value)
2053}
2054
2055fn snake_identifier(value: &str) -> Result<String, ScaffoldError> {
2056 let parts = name_parts(value)?;
2057 Ok(parts
2058 .iter()
2059 .map(|part| snake_case(part))
2060 .collect::<Vec<_>>()
2061 .join("_"))
2062}
2063
2064fn pascal_case(value: &str) -> String {
2065 words(value)
2066 .into_iter()
2067 .map(|word| {
2068 let mut characters = word.chars();
2069 characters.next().map_or_else(String::new, |first| {
2070 format!(
2071 "{}{}",
2072 first.to_ascii_uppercase(),
2073 characters.as_str().to_ascii_lowercase()
2074 )
2075 })
2076 })
2077 .collect()
2078}
2079
2080fn snake_case(value: &str) -> String {
2081 words(value).join("_").to_ascii_lowercase()
2082}
2083
2084fn kebab_case(value: &str) -> String {
2085 words(value).join("-").to_ascii_lowercase()
2086}
2087
2088fn words(value: &str) -> Vec<String> {
2089 let mut output = String::new();
2090 let mut previous_lower_or_digit = false;
2091 for character in value.chars() {
2092 if character == '-' || character == '_' || character.is_ascii_whitespace() {
2093 if !output.ends_with('_') && !output.is_empty() {
2094 output.push('_');
2095 }
2096 previous_lower_or_digit = false;
2097 } else {
2098 if character.is_ascii_uppercase() && previous_lower_or_digit {
2099 output.push('_');
2100 }
2101 output.push(character);
2102 previous_lower_or_digit = character.is_ascii_lowercase() || character.is_ascii_digit();
2103 }
2104 }
2105 output
2106 .split('_')
2107 .filter(|part| !part.is_empty())
2108 .map(str::to_owned)
2109 .collect()
2110}
2111
2112fn pluralize(value: &str) -> String {
2113 if let Some(stem) = value.strip_suffix('y')
2114 && !stem.ends_with(['a', 'e', 'i', 'o', 'u'])
2115 {
2116 return format!("{stem}ies");
2117 }
2118 if value.ends_with(['s', 'x', 'z']) || value.ends_with("ch") || value.ends_with("sh") {
2119 format!("{value}es")
2120 } else {
2121 format!("{value}s")
2122 }
2123}
2124
2125fn inferred_table(migration: &str) -> &str {
2126 migration
2127 .strip_prefix("create_")
2128 .and_then(|name| name.strip_suffix("_table"))
2129 .unwrap_or(migration)
2130}
2131
2132fn safe_relative(path: PathBuf) -> Result<PathBuf, ScaffoldError> {
2133 if path.is_absolute()
2134 || path
2135 .components()
2136 .any(|component| !matches!(component, Component::Normal(_)))
2137 {
2138 return Err(ScaffoldError::InvalidName(path.display().to_string()));
2139 }
2140 Ok(path)
2141}
2142
2143fn absolute_path(path: impl AsRef<Path>) -> Result<PathBuf, ScaffoldError> {
2144 let path = path.as_ref();
2145 if path.is_absolute() {
2146 return Ok(path.to_path_buf());
2147 }
2148 env::current_dir()
2149 .map(|current| current.join(path))
2150 .map_err(|source| ScaffoldError::Io {
2151 path: path.to_path_buf(),
2152 source,
2153 })
2154}
2155
2156fn ensure_empty_target(path: &Path) -> Result<(), ScaffoldError> {
2157 match fs::read_dir(path) {
2158 Ok(mut entries) => {
2159 if entries.next().is_some() {
2160 Err(ScaffoldError::ProjectNotEmpty(path.to_path_buf()))
2161 } else {
2162 Ok(())
2163 }
2164 }
2165 Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
2166 Err(source) => Err(ScaffoldError::Io {
2167 path: path.to_path_buf(),
2168 source,
2169 }),
2170 }
2171}
2172
2173fn json_string(value: &str) -> String {
2174 serde_json::to_string(value).expect("strings always serialize")
2175}
2176
2177fn run_optional(program: &'static str, args: &[&str], cwd: &Path) -> Result<(), ScaffoldError> {
2178 match Command::new(program).args(args).current_dir(cwd).status() {
2179 Ok(status) if status.success() => Ok(()),
2180 Ok(_) => Err(ScaffoldError::CommandFailed { program }),
2181 Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
2182 Err(source) => Err(ScaffoldError::Io {
2183 path: cwd.to_path_buf(),
2184 source,
2185 }),
2186 }
2187}
2188
2189fn run_required(program: &'static str, args: &[&str], cwd: &Path) -> Result<(), ScaffoldError> {
2190 match Command::new(program).args(args).current_dir(cwd).status() {
2191 Ok(status) if status.success() => Ok(()),
2192 Ok(_) => Err(ScaffoldError::CommandFailed { program }),
2193 Err(source) => Err(ScaffoldError::Io {
2194 path: cwd.to_path_buf(),
2195 source,
2196 }),
2197 }
2198}
2199
2200#[cfg(test)]
2201mod tests {
2202 use super::*;
2203
2204 fn temporary_directory(label: &str) -> PathBuf {
2205 let id = SystemTime::now()
2206 .duration_since(UNIX_EPOCH)
2207 .unwrap()
2208 .as_nanos();
2209 env::temp_dir().join(format!("phoenix-cli-{label}-{}-{id}", std::process::id()))
2210 }
2211
2212 fn framework_root() -> PathBuf {
2213 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2214 .join("../..")
2215 .canonicalize()
2216 .unwrap()
2217 }
2218
2219 #[test]
2220 fn creates_a_complete_local_project_without_installing() {
2221 let root = temporary_directory("new");
2222 create_project(
2223 &NewProjectOptions::new(&root)
2224 .dependencies(DependencySource::Local(framework_root()))
2225 .initialize_git(false)
2226 .install_dependencies(false),
2227 )
2228 .unwrap();
2229
2230 assert!(root.join("src/main.rs").is_file());
2231 assert!(root.join("src/bin/phoenix-manage.rs").is_file());
2232 assert!(root.join("config/app.toml").is_file());
2233 assert!(root.join("config/database.toml").is_file());
2234 assert!(
2235 root.join("config/schemas/phoenix-config-database.schema.json")
2236 .is_file()
2237 );
2238 assert!(root.join("taplo.toml").is_file());
2239 assert!(
2240 fs::read_to_string(root.join("config/database.toml"))
2241 .unwrap()
2242 .contains("connections.mysql")
2243 );
2244 assert!(root.join("app/commands/mod.rs").is_file());
2245 assert!(root.join("config/mod.rs").is_file());
2246 assert!(root.join("database/seeders/mod.rs").is_file());
2247 assert!(root.join("routes/web.rs").is_file());
2248 assert!(root.join("views/pages/home.tsx").is_file());
2249 let manifest = fs::read_to_string(root.join("Cargo.toml")).unwrap();
2250 assert!(manifest.contains("crates/phoenix"));
2251 assert!(manifest.contains("default-run = \"phoenix-cli-new-"));
2252 assert!(
2253 fs::read_to_string(root.join("package.json"))
2254 .unwrap()
2255 .contains("file:")
2256 );
2257 let main = fs::read_to_string(root.join("src/main.rs")).unwrap();
2258 assert!(main.contains("Console::new"));
2259 assert!(main.contains("commands::registry()"));
2260 assert!(main.contains(".serve("));
2261 let commands = fs::read_to_string(root.join("app/commands/mod.rs")).unwrap();
2262 assert!(commands.contains("commands!"));
2263 assert!(commands.contains("<phoenix:commands>"));
2264 let application = fs::read_to_string(root.join("src/lib.rs")).unwrap();
2265 assert!(application.contains("pub mod commands"));
2266 assert!(application.contains("NonceSecurityPolicy::development"));
2267 assert!(application.contains("with_middleware(content_security_policy(config))"));
2268 assert!(application.contains("with_middleware(RequestId)"));
2269 assert!(application.contains("with_middleware(AccessLog)"));
2270 assert!(application.contains("SessionMiddleware::new"));
2271 assert!(application.contains("with_middleware(Csrf)"));
2272 assert!(application.contains("TrustedProxies::new"));
2273 assert!(application.contains("HostAllowlist::new"));
2274 assert!(application.contains("RateLimit::new"));
2275 assert!(application.contains("StateMiddleware::new(config.clone())"));
2276 let config = fs::read_to_string(root.join("config/mod.rs")).unwrap();
2277 assert!(config.contains("AppConfig::load()"));
2278 assert!(config.lines().count() < 20);
2279 let manager = fs::read_to_string(root.join("src/bin/phoenix-manage.rs")).unwrap();
2280 assert!(manager.contains("MigrationRunner::new"));
2281 assert!(manager.contains("migrations::all()"));
2282 assert!(manager.contains("seeders::run"));
2283 let models = fs::read_to_string(root.join("app/models/mod.rs")).unwrap();
2284 let migrations = fs::read_to_string(root.join("database/migrations/mod.rs")).unwrap();
2285 assert!(models.contains("pub fn all()"));
2286 assert!(migrations.contains("pub fn all()"));
2287 fs::remove_dir_all(root).unwrap();
2288 }
2289
2290 #[test]
2291 fn make_command_registers_async_handler() {
2292 let root = temporary_directory("command");
2293 create_project(
2294 &NewProjectOptions::new(&root)
2295 .dependencies(DependencySource::Local(framework_root()))
2296 .initialize_git(false)
2297 .install_dependencies(false),
2298 )
2299 .unwrap();
2300 let generator = ProjectGenerator::discover(&root).unwrap();
2301 generator
2302 .command("Update", GenerateOptions::default())
2303 .unwrap();
2304
2305 assert!(root.join("app/commands/update.rs").is_file());
2306 let module = fs::read_to_string(root.join("app/commands/mod.rs")).unwrap();
2307 assert!(module.contains("pub mod update;"));
2308 assert!(module.contains("pub use update::update;"));
2309 assert!(module.contains("update,"));
2310 let command = fs::read_to_string(root.join("app/commands/update.rs")).unwrap();
2311 assert!(command.contains("pub async fn update"));
2312 assert!(command.contains("CommandContext<'_>"));
2313 fs::remove_dir_all(root).unwrap();
2314 }
2315
2316 #[test]
2317 fn model_all_registers_every_supported_business_artifact() {
2318 let root = temporary_directory("model-all");
2319 create_project(
2320 &NewProjectOptions::new(&root)
2321 .dependencies(DependencySource::Local(framework_root()))
2322 .initialize_git(false)
2323 .install_dependencies(false),
2324 )
2325 .unwrap();
2326 let generator = ProjectGenerator::discover(&root).unwrap();
2327 generator
2328 .model(
2329 "Admin/Post",
2330 ModelOptions {
2331 all: true,
2332 ..ModelOptions::default()
2333 },
2334 )
2335 .unwrap();
2336 generator.model("Comment", ModelOptions::default()).unwrap();
2337
2338 assert!(root.join("app/models/admin/post.rs").is_file());
2339 assert!(
2340 root.join("app/controllers/admin/post_controller.rs")
2341 .is_file()
2342 );
2343 assert!(
2344 root.join("app/requests/admin/store_post_request.rs")
2345 .is_file()
2346 );
2347 assert!(root.join("app/resources/admin/post_resource.rs").is_file());
2348 assert!(root.join("routes/admin_posts.rs").is_file());
2349 assert!(root.join("views/pages/admin/posts/index.tsx").is_file());
2350 let routes = fs::read_to_string(root.join("routes/admin_posts.rs")).unwrap();
2351 assert!(routes.contains(".name(\"admin.posts.index\")"));
2352 assert!(routes.contains(".name(\"admin.posts.destroy\")"));
2353 assert!(routes.contains("typed(PostController::store)"));
2354 assert!(routes.contains(".action::<StorePostRequest, PostResource>()"));
2355 let controller =
2356 fs::read_to_string(root.join("app/controllers/admin/post_controller.rs")).unwrap();
2357 assert!(controller.contains("Validated(Json(input))"));
2358 assert!(controller.contains("Page::new(\"admin/posts/index\""));
2359 let page = fs::read_to_string(root.join("views/pages/admin/posts/index.tsx")).unwrap();
2360 assert!(page.contains("../../../generated/contracts.js"));
2361 let models = fs::read_to_string(root.join("app/models/mod.rs")).unwrap();
2362 assert!(models.contains("admin::Post"));
2363 assert!(models.contains("Comment"));
2364 let migrations = fs::read_to_string(root.join("database/migrations/mod.rs")).unwrap();
2365 assert!(migrations.contains("pub fn all()"));
2366 fs::remove_dir_all(root).unwrap();
2367 }
2368
2369 #[test]
2370 fn generators_refuse_overwrites_and_path_traversal() {
2371 let root = temporary_directory("safety");
2372 create_project(
2373 &NewProjectOptions::new(&root)
2374 .dependencies(DependencySource::Local(framework_root()))
2375 .initialize_git(false)
2376 .install_dependencies(false),
2377 )
2378 .unwrap();
2379 let generator = ProjectGenerator::discover(&root).unwrap();
2380 generator
2381 .controller("Report", ControllerOptions::default())
2382 .unwrap();
2383 assert!(matches!(
2384 generator.controller("Report", ControllerOptions::default()),
2385 Err(ScaffoldError::AlreadyExists(_))
2386 ));
2387 assert!(matches!(
2388 generator.page("../outside", GenerateOptions::default()),
2389 Err(ScaffoldError::InvalidName(_))
2390 ));
2391 fs::remove_dir_all(root).unwrap();
2392 }
2393}