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