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