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