Skip to main content

phoenix_cli/
scaffold.rs

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
149/// Create a complete Phoenix application that can immediately run `px dev`.
150///
151/// # Errors
152///
153/// Returns an error for invalid names, non-empty targets, invalid local framework
154/// paths, file-system failures, or dependency/bootstrap command failures.
155pub 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    /// Locate the Phoenix project containing `start`.
192    ///
193    /// # Errors
194    ///
195    /// Returns an error when no parent contains the expected Phoenix layout.
196    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    /// Refresh generated TypeScript contracts after a Rust generator runs.
214    ///
215    /// Returns `Ok(false)` before JavaScript dependencies are installed; Vite
216    /// will still generate the files when development starts.
217    ///
218    /// # Errors
219    ///
220    /// Returns an error when the installed Phoenix Vite generator fails.
221    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    /// Generate a controller and optionally its conventional resource route.
230    ///
231    /// # Errors
232    ///
233    /// Returns an error for invalid names, conflicts, or malformed managed files.
234    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    /// Generate a Toasty model and any requested companion artifacts.
254    ///
255    /// `--all` creates the currently supported cohesive business slice: model,
256    /// migration, request, API resource, resource controller/route, and index page.
257    ///
258    /// # Errors
259    ///
260    /// Returns an error for invalid names, conflicts, or malformed managed files.
261    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    /// Generate one migration and register it in `database/migrations/mod.rs`.
328    ///
329    /// # Errors
330    ///
331    /// Returns an error for invalid names, conflicts, time, or managed files.
332    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    /// Generate a validated request DTO and update its Rust module.
348    ///
349    /// # Errors
350    ///
351    /// Returns an error for invalid names, conflicts, or malformed managed files.
352    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    /// Generate a browser-safe API resource and update its Rust module.
361    ///
362    /// # Errors
363    ///
364    /// Returns an error for invalid names, conflicts, or malformed managed files.
365    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    /// Generate a pass-through middleware ready for application logic.
380    ///
381    /// # Errors
382    ///
383    /// Returns an error for invalid names, conflicts, or malformed managed files.
384    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    /// Generate a React page plus its Rust Page Props contract.
401    ///
402    /// # Errors
403    ///
404    /// Returns an error for invalid names, conflicts, or malformed managed files.
405    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    /// Generate a React Island component.
417    ///
418    /// # Errors
419    ///
420    /// Returns an error for invalid names, conflicts, or file-system failures.
421    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    /// Generate a console command and register it in `app/commands/mod.rs`.
436    ///
437    /// # Errors
438    ///
439    /// Returns an error for invalid names, conflicts, or malformed managed files.
440    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            // crates.io package is `phoenixrs` (phoenix / phoenix-rs taken);
481            // lib crate remains `phoenix` so apps keep `use phoenix::…`.
482            "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}