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    str::FromStr,
8    time::{SystemTime, UNIX_EPOCH},
9};
10
11use thiserror::Error;
12
13const MODULES_START: &str = "// <phoenix:modules>";
14const MODULES_END: &str = "// </phoenix:modules>";
15const MODELS_START: &str = "// <phoenix:model-registry>";
16const MODELS_END: &str = "// </phoenix:model-registry>";
17const MIGRATIONS_START: &str = "// <phoenix:migration-registry>";
18const MIGRATIONS_END: &str = "// </phoenix:migration-registry>";
19const COMMANDS_START: &str = "// <phoenix:commands>";
20const COMMANDS_END: &str = "// </phoenix:commands>";
21const PROJECT_OPTIONS_FILE: &str = ".phoenix";
22const PHOENIXRS_VERSION: &str = "0.1.3";
23const APIZERO_REACT_VERSION: &str = "0.1.2";
24const APIZERO_REACT_SSR_VERSION: &str = "0.1.2";
25const APIZERO_VITE_VERSION: &str = "0.1.3";
26
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub enum DependencySource {
29    Registry,
30    Local(PathBuf),
31}
32
33/// Options for [`ProjectGenerator::update_core`] / `px update`.
34#[derive(Clone, Debug)]
35pub struct UpdateProjectOptions {
36    pub dependencies: DependencySource,
37    pub install_dependencies: bool,
38    pub dry_run: bool,
39}
40
41impl UpdateProjectOptions {
42    #[must_use]
43    pub fn new() -> Self {
44        Self {
45            dependencies: DependencySource::discover(),
46            install_dependencies: true,
47            dry_run: false,
48        }
49    }
50
51    #[must_use]
52    pub fn dependencies(mut self, dependencies: DependencySource) -> Self {
53        self.dependencies = dependencies;
54        self
55    }
56
57    #[must_use]
58    pub const fn install_dependencies(mut self, install: bool) -> Self {
59        self.install_dependencies = install;
60        self
61    }
62
63    #[must_use]
64    pub const fn dry_run(mut self, dry_run: bool) -> Self {
65        self.dry_run = dry_run;
66        self
67    }
68}
69
70impl Default for UpdateProjectOptions {
71    fn default() -> Self {
72        Self::new()
73    }
74}
75
76#[derive(Clone, Debug, Eq, PartialEq)]
77struct StoredProjectOptions {
78    frontend: ProjectFrontend,
79    database: Option<ProjectDatabase>,
80    render_mode: ProjectRenderMode,
81    tailwind: bool,
82}
83
84/// Initial React rendering mode selected by `px new`.
85#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
86pub enum ProjectRenderMode {
87    Spa,
88    Ssr,
89    #[default]
90    Islands,
91}
92
93impl ProjectRenderMode {
94    #[must_use]
95    pub const fn page_builder(self) -> &'static str {
96        match self {
97            Self::Spa => ".spa()",
98            Self::Ssr => ".ssr()",
99            Self::Islands => ".islands()",
100        }
101    }
102}
103
104impl FromStr for ProjectRenderMode {
105    type Err = String;
106
107    fn from_str(value: &str) -> Result<Self, Self::Err> {
108        match value.trim().to_ascii_lowercase().as_str() {
109            "spa" => Ok(Self::Spa),
110            "ssr" => Ok(Self::Ssr),
111            "islands" | "island" => Ok(Self::Islands),
112            _ => Err("render mode must be spa, ssr, or islands".to_owned()),
113        }
114    }
115}
116
117/// One optional database driver selected when a project is created.
118#[derive(Clone, Copy, Debug, Eq, PartialEq)]
119pub enum ProjectDatabase {
120    Sqlite,
121    Pgsql,
122    Mysql,
123    All,
124}
125
126/// Source format used for generated React components.
127#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
128pub enum ProjectFrontend {
129    Jsx,
130    #[default]
131    Tsx,
132}
133
134impl ProjectFrontend {
135    #[must_use]
136    pub const fn extension(self) -> &'static str {
137        match self {
138            Self::Jsx => "jsx",
139            Self::Tsx => "tsx",
140        }
141    }
142}
143
144impl FromStr for ProjectFrontend {
145    type Err = String;
146
147    fn from_str(value: &str) -> Result<Self, Self::Err> {
148        match value.trim().to_ascii_lowercase().as_str() {
149            "jsx" | "js" => Ok(Self::Jsx),
150            "tsx" | "ts" => Ok(Self::Tsx),
151            _ => Err("frontend must be jsx or tsx".to_owned()),
152        }
153    }
154}
155
156impl ProjectDatabase {
157    #[must_use]
158    pub const fn cargo_feature(self) -> &'static str {
159        match self {
160            Self::Sqlite => "sqlite",
161            Self::Pgsql => "pgsql",
162            Self::Mysql => "mysql",
163            Self::All => "all-databases",
164        }
165    }
166
167    #[must_use]
168    pub const fn toasty_features(self) -> &'static str {
169        match self {
170            Self::Sqlite => "serde\", \"sqlite",
171            Self::Pgsql => "postgresql\", \"serde",
172            Self::Mysql => "mysql\", \"serde",
173            Self::All => "mysql\", \"postgresql\", \"serde\", \"sqlite",
174        }
175    }
176}
177
178impl FromStr for ProjectDatabase {
179    type Err = String;
180
181    fn from_str(value: &str) -> Result<Self, Self::Err> {
182        match value.trim().to_ascii_lowercase().as_str() {
183            "sqlite" => Ok(Self::Sqlite),
184            "pgsql" | "postgres" | "postgresql" => Ok(Self::Pgsql),
185            "mysql" | "mariadb" => Ok(Self::Mysql),
186            "all" => Ok(Self::All),
187            _ => Err("database must be sqlite, pgsql, mysql, or all".to_owned()),
188        }
189    }
190}
191
192impl DependencySource {
193    #[must_use]
194    pub fn discover() -> Self {
195        if let Some(path) = env::var_os("PHOENIX_FRAMEWORK_PATH") {
196            let path = PathBuf::from(path);
197            if is_framework_root(&path) {
198                return Self::Local(path);
199            }
200        }
201        let Ok(executable) = env::current_exe() else {
202            return Self::Registry;
203        };
204        for ancestor in executable.ancestors() {
205            if is_framework_root(ancestor) {
206                return Self::Local(ancestor.to_path_buf());
207            }
208        }
209        let build_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
210            .join("../..")
211            .canonicalize()
212            .unwrap_or_else(|_| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."));
213        if is_framework_root(&build_root) {
214            return Self::Local(build_root);
215        }
216        Self::Registry
217    }
218}
219
220fn is_framework_root(path: &Path) -> bool {
221    path.join("crates/phoenix/Cargo.toml").is_file()
222        && path.join("packages/phoenix-react/package.json").is_file()
223        && path.join("packages/phoenix-vite/package.json").is_file()
224}
225
226#[derive(Clone, Debug)]
227pub struct NewProjectOptions {
228    pub target: PathBuf,
229    pub dependencies: DependencySource,
230    pub initialize_git: bool,
231    pub install_dependencies: bool,
232    pub render_mode: ProjectRenderMode,
233    pub database: Option<ProjectDatabase>,
234    pub frontend: ProjectFrontend,
235    pub tailwind: bool,
236}
237
238impl NewProjectOptions {
239    #[must_use]
240    pub fn new(target: impl Into<PathBuf>) -> Self {
241        Self {
242            target: target.into(),
243            dependencies: DependencySource::discover(),
244            initialize_git: false,
245            install_dependencies: true,
246            render_mode: ProjectRenderMode::default(),
247            database: None,
248            frontend: ProjectFrontend::default(),
249            tailwind: false,
250        }
251    }
252
253    #[must_use]
254    pub fn dependencies(mut self, dependencies: DependencySource) -> Self {
255        self.dependencies = dependencies;
256        self
257    }
258
259    #[must_use]
260    pub const fn initialize_git(mut self, initialize: bool) -> Self {
261        self.initialize_git = initialize;
262        self
263    }
264
265    #[must_use]
266    pub const fn install_dependencies(mut self, install: bool) -> Self {
267        self.install_dependencies = install;
268        self
269    }
270
271    #[must_use]
272    pub const fn render_mode(mut self, render_mode: ProjectRenderMode) -> Self {
273        self.render_mode = render_mode;
274        self
275    }
276
277    #[must_use]
278    pub const fn database(mut self, database: Option<ProjectDatabase>) -> Self {
279        self.database = database;
280        self
281    }
282
283    #[must_use]
284    pub const fn frontend(mut self, frontend: ProjectFrontend) -> Self {
285        self.frontend = frontend;
286        self
287    }
288
289    #[must_use]
290    pub const fn tailwind(mut self, tailwind: bool) -> Self {
291        self.tailwind = tailwind;
292        self
293    }
294}
295
296#[derive(Clone, Copy, Debug, Default)]
297pub struct GenerateOptions {
298    pub force: bool,
299}
300
301#[derive(Clone, Copy, Debug, Default)]
302pub struct ControllerOptions {
303    pub force: bool,
304    pub resource: bool,
305    pub route: bool,
306}
307
308#[derive(Clone, Copy, Debug, Default)]
309#[allow(clippy::struct_excessive_bools)]
310pub struct ModelOptions {
311    pub all: bool,
312    pub api_resource: bool,
313    pub controller: bool,
314    pub force: bool,
315    pub migration: bool,
316    pub page: bool,
317    pub request: bool,
318    pub resource_controller: bool,
319}
320
321#[derive(Debug, Error)]
322pub enum ScaffoldError {
323    #[error("invalid Phoenix name `{0}`; use letters, numbers, dashes, underscores, / or ::")]
324    InvalidName(String),
325    #[error("project target {0} already exists and is not empty")]
326    ProjectNotEmpty(PathBuf),
327    #[error("{0} is not a Phoenix project root")]
328    NotProject(PathBuf),
329    #[error("refusing to overwrite existing file {0}; pass --force to replace it")]
330    AlreadyExists(PathBuf),
331    #[error("Phoenix managed markers are missing or malformed in {0}")]
332    InvalidManagedFile(PathBuf),
333    #[error("local Phoenix framework root is invalid: {0}")]
334    InvalidFrameworkRoot(PathBuf),
335    #[error("failed to read or write {path}: {source}")]
336    Io {
337        path: PathBuf,
338        source: std::io::Error,
339    },
340    #[error("{program} exited unsuccessfully while preparing the project")]
341    CommandFailed { program: &'static str },
342    #[error("the current time is before the Unix epoch")]
343    InvalidClock,
344}
345
346/// Create a complete Phoenix application that can immediately run `px dev`.
347///
348/// # Errors
349///
350/// Returns an error for invalid names, non-empty targets, invalid local framework
351/// paths, file-system failures, or dependency/bootstrap command failures.
352pub fn create_project(options: &NewProjectOptions) -> Result<(), ScaffoldError> {
353    let target = absolute_path(&options.target)?;
354    ensure_empty_target(&target)?;
355    let directory_name = target
356        .file_name()
357        .and_then(|value| value.to_str())
358        .ok_or_else(|| ScaffoldError::InvalidName(target.display().to_string()))?;
359    let package = package_name(directory_name)?;
360    if let DependencySource::Local(root) = &options.dependencies
361        && !is_framework_root(root)
362    {
363        return Err(ScaffoldError::InvalidFrameworkRoot(root.clone()));
364    }
365
366    let mut editor = ProjectEditor::new(&target, false);
367    for (path, content) in project_files(&package, options)? {
368        editor.create(path, content)?;
369    }
370    editor.commit()?;
371
372    if options.initialize_git {
373        run_optional("git", &["init", "--quiet"], &target)?;
374    }
375    if options.install_dependencies {
376        run_required("npm", &["install"], &target)?;
377        run_required("npm", &["run", "types", "--silent"], &target)?;
378    }
379    Ok(())
380}
381
382#[derive(Clone, Debug)]
383pub struct ProjectGenerator {
384    root: PathBuf,
385}
386
387impl ProjectGenerator {
388    /// Locate the Phoenix project containing `start`.
389    ///
390    /// # Errors
391    ///
392    /// Returns an error when no parent contains the expected Phoenix layout.
393    pub fn discover(start: impl AsRef<Path>) -> Result<Self, ScaffoldError> {
394        let start = absolute_path(start.as_ref())?;
395        for candidate in start.ancestors() {
396            if is_project_root(candidate) {
397                return Ok(Self {
398                    root: candidate.to_path_buf(),
399                });
400            }
401        }
402        Err(ScaffoldError::NotProject(start))
403    }
404
405    #[must_use]
406    pub fn root(&self) -> &Path {
407        &self.root
408    }
409
410    /// Refresh generated TypeScript contracts after a Rust generator runs.
411    ///
412    /// Returns `Ok(false)` before JavaScript dependencies are installed; Vite
413    /// will still generate the files when development starts.
414    ///
415    /// # Errors
416    ///
417    /// Returns an error when the installed Phoenix Vite generator fails.
418    pub fn refresh_types(&self) -> Result<bool, ScaffoldError> {
419        if !self.root.join("node_modules/@apizero/vite").is_dir() {
420            return Ok(false);
421        }
422        run_required("npm", &["run", "types", "--silent"], &self.root)?;
423        Ok(true)
424    }
425
426    /// Generate a controller and optionally its conventional resource route.
427    ///
428    /// # Errors
429    ///
430    /// Returns an error for invalid names, conflicts, or malformed managed files.
431    pub fn controller(
432        &self,
433        name: &str,
434        options: ControllerOptions,
435    ) -> Result<Vec<PathBuf>, ScaffoldError> {
436        let name = QualifiedName::parse_with_suffix(name, "Controller")?;
437        let mut editor = ProjectEditor::new(&self.root, options.force);
438        add_rust_item(
439            &mut editor,
440            "app/controllers",
441            &name,
442            &controller_template(&name.class, options.resource),
443        )?;
444        if options.route || options.resource {
445            add_controller_route(&mut editor, &name, options.resource, None)?;
446        }
447        editor.commit()
448    }
449
450    /// Generate a Toasty model and any requested companion artifacts.
451    ///
452    /// `--all` creates the currently supported cohesive business slice: model,
453    /// migration, request, API resource, resource controller/route, and index page.
454    ///
455    /// # Errors
456    ///
457    /// Returns an error for invalid names, conflicts, or malformed managed files.
458    pub fn model(
459        &self,
460        name: &str,
461        mut options: ModelOptions,
462    ) -> Result<Vec<PathBuf>, ScaffoldError> {
463        if options.all {
464            options.migration = true;
465            options.request = true;
466            options.api_resource = true;
467            options.controller = true;
468            options.resource_controller = true;
469            options.page = true;
470        }
471        let model = QualifiedName::parse(name)?;
472        let request = model.with_leaf(format!("Store{}Request", model.class));
473        let resource = model.with_leaf(format!("{}Resource", model.class));
474        let controller = model.with_leaf(format!("{}Controller", model.class));
475        let page = model.index_page_name();
476        let props = page_props_name(&page);
477        let cohesive = options.request
478            && options.api_resource
479            && options.controller
480            && options.resource_controller
481            && options.page;
482        let mut editor = ProjectEditor::new(&self.root, options.force);
483        add_model(&mut editor, &model)?;
484        if options.migration {
485            add_model_migration(&mut editor, &model)?;
486        }
487        if options.request {
488            add_rust_item(
489                &mut editor,
490                "app/requests",
491                &request,
492                &request_template(&request.class),
493            )?;
494        }
495        if options.api_resource {
496            add_rust_item(
497                &mut editor,
498                "app/resources",
499                &resource,
500                &resource_template(&resource.class),
501            )?;
502        }
503        if options.controller || options.resource_controller {
504            let content = if cohesive {
505                model_controller_template(&controller, &request, &resource, &props, &page.route)
506            } else {
507                controller_template(&controller.class, options.resource_controller)
508            };
509            add_rust_item(&mut editor, "app/controllers", &controller, &content)?;
510            let action = cohesive.then_some((&request, &resource));
511            add_controller_route(
512                &mut editor,
513                &controller,
514                options.resource_controller,
515                action,
516            )?;
517        }
518        if options.page {
519            add_page(&mut editor, &page, self.frontend())?;
520        }
521        editor.commit()
522    }
523
524    /// Generate one migration and register it in `database/migrations/mod.rs`.
525    ///
526    /// # Errors
527    ///
528    /// Returns an error for invalid names, conflicts, time, or managed files.
529    pub fn migration(
530        &self,
531        name: &str,
532        options: GenerateOptions,
533    ) -> Result<Vec<PathBuf>, ScaffoldError> {
534        let migration_name = snake_identifier(name)?;
535        let mut editor = ProjectEditor::new(&self.root, options.force);
536        add_migration(
537            &mut editor,
538            &migration_name,
539            inferred_table(&migration_name),
540        )?;
541        editor.commit()
542    }
543
544    /// Generate a validated request DTO and update its Rust module.
545    ///
546    /// # Errors
547    ///
548    /// Returns an error for invalid names, conflicts, or malformed managed files.
549    pub fn request(
550        &self,
551        name: &str,
552        options: GenerateOptions,
553    ) -> Result<Vec<PathBuf>, ScaffoldError> {
554        self.rust_contract(name, "Request", "app/requests", request_template, options)
555    }
556
557    /// Generate a browser-safe API resource and update its Rust module.
558    ///
559    /// # Errors
560    ///
561    /// Returns an error for invalid names, conflicts, or malformed managed files.
562    pub fn resource(
563        &self,
564        name: &str,
565        options: GenerateOptions,
566    ) -> Result<Vec<PathBuf>, ScaffoldError> {
567        self.rust_contract(
568            name,
569            "Resource",
570            "app/resources",
571            resource_template,
572            options,
573        )
574    }
575
576    /// Generate a pass-through middleware ready for application logic.
577    ///
578    /// # Errors
579    ///
580    /// Returns an error for invalid names, conflicts, or malformed managed files.
581    pub fn middleware(
582        &self,
583        name: &str,
584        options: GenerateOptions,
585    ) -> Result<Vec<PathBuf>, ScaffoldError> {
586        let name = QualifiedName::parse_with_suffix(name, "Middleware")?;
587        let mut editor = ProjectEditor::new(&self.root, options.force);
588        add_rust_item(
589            &mut editor,
590            "app/middleware",
591            &name,
592            &middleware_template(&name.class),
593        )?;
594        editor.commit()
595    }
596
597    /// Generate a React page plus its Rust Page Props contract.
598    ///
599    /// # Errors
600    ///
601    /// Returns an error for invalid names, conflicts, or malformed managed files.
602    pub fn page(
603        &self,
604        name: &str,
605        options: GenerateOptions,
606    ) -> Result<Vec<PathBuf>, ScaffoldError> {
607        let page = PageName::parse(name)?;
608        let mut editor = ProjectEditor::new(&self.root, options.force);
609        add_page(&mut editor, &page, self.frontend())?;
610        editor.commit()
611    }
612
613    /// Generate a React Island component.
614    ///
615    /// # Errors
616    ///
617    /// Returns an error for invalid names, conflicts, or file-system failures.
618    pub fn island(
619        &self,
620        name: &str,
621        options: GenerateOptions,
622    ) -> Result<Vec<PathBuf>, ScaffoldError> {
623        let name = QualifiedName::parse(name)?;
624        let mut editor = ProjectEditor::new(&self.root, options.force);
625        let mut path = PathBuf::from("views/islands");
626        path.extend(name.modules.iter().map(|module| kebab_case(module)));
627        let frontend = self.frontend();
628        path.push(format!(
629            "{}.{}",
630            kebab_case(&name.class),
631            frontend.extension()
632        ));
633        editor.create(path, island_template(&name.class, frontend))?;
634        editor.commit()
635    }
636
637    /// Generate a console command and register it in `app/commands/mod.rs`.
638    ///
639    /// # Errors
640    ///
641    /// Returns an error for invalid names, conflicts, or malformed managed files.
642    pub fn command(
643        &self,
644        name: &str,
645        options: GenerateOptions,
646    ) -> Result<Vec<PathBuf>, ScaffoldError> {
647        let name = QualifiedName::parse(name)?;
648        let mut editor = ProjectEditor::new(&self.root, options.force);
649        add_command(&mut editor, &name)?;
650        editor.commit()
651    }
652
653    fn rust_contract(
654        &self,
655        name: &str,
656        suffix: &str,
657        directory: &str,
658        template: fn(&str) -> String,
659        options: GenerateOptions,
660    ) -> Result<Vec<PathBuf>, ScaffoldError> {
661        let name = QualifiedName::parse_with_suffix(name, suffix)?;
662        let mut editor = ProjectEditor::new(&self.root, options.force);
663        add_rust_item(&mut editor, directory, &name, &template(&name.class))?;
664        editor.commit()
665    }
666
667    fn frontend(&self) -> ProjectFrontend {
668        self.stored_options().frontend
669    }
670
671    fn stored_options(&self) -> StoredProjectOptions {
672        read_stored_options(&self.root)
673    }
674
675    /// Refresh framework-owned core files without touching business code.
676    ///
677    /// Updates `src/lib.rs` / `src/main.rs`, Vite/TS config, config schemas, dependency
678    /// pins for `phoenixrs` / `@apizero/*`, and (when the project uses a database)
679    /// `src/bin/phoenix-manage.rs`. Leaves controllers, routes, pages, and user TOML alone.
680    ///
681    /// # Errors
682    ///
683    /// Returns an error for invalid local framework roots, I/O failures, or npm failures.
684    pub fn update_core(
685        &self,
686        options: &UpdateProjectOptions,
687    ) -> Result<Vec<PathBuf>, ScaffoldError> {
688        if let DependencySource::Local(root) = &options.dependencies
689            && !is_framework_root(root)
690        {
691            return Err(ScaffoldError::InvalidFrameworkRoot(root.clone()));
692        }
693
694        let package = read_package_name(&self.root)?;
695        let crate_name = package.replace('-', "_");
696        let stored = self.stored_options();
697        let has_database = stored.database.is_some();
698        let (rust_dependency, react, react_ssr, vite) =
699            framework_dependency_pins(&options.dependencies)?;
700
701        let mut planned: BTreeMap<PathBuf, String> = BTreeMap::new();
702        planned.insert("src/main.rs".into(), main_template(&crate_name));
703        planned.insert("src/lib.rs".into(), lib_template(has_database));
704        planned.insert("config/mod.rs".into(), config_template(has_database));
705        planned.insert(
706            "config/schemas/phoenix-config-app.schema.json".into(),
707            include_str!("../schemas/phoenix-config-app.schema.json").to_owned(),
708        );
709        planned.insert(
710            "taplo.toml".into(),
711            app_taplo_template(has_database),
712        );
713        planned.insert(
714            "vite.config.ts".into(),
715            vite_template(false, stored.tailwind),
716        );
717        planned.insert(
718            "vite.ssr.config.ts".into(),
719            vite_template(true, stored.tailwind),
720        );
721        planned.insert("tsconfig.json".into(), tsconfig_template());
722        planned.insert(
723            "deploy/restart.sh.example".into(),
724            deploy_restart_example(),
725        );
726        planned.insert(
727            ".gitignore".into(),
728            "/target\n/node_modules\n/public/assets\n/public/ssr\n/views/generated/*.ts\n/dist\n/storage/*.sqlite\n/storage/*.sqlite-*\n.env\n.DS_Store\n".to_owned(),
729        );
730        planned.insert(
731            PROJECT_OPTIONS_FILE.into(),
732            format_stored_options(&stored),
733        );
734
735        if let Some(database) = stored.database {
736            planned.insert(
737                "src/bin/phoenix-manage.rs".into(),
738                management_template(&crate_name),
739            );
740            planned.insert(
741                "config/schemas/phoenix-config-database.schema.json".into(),
742                include_str!("../schemas/phoenix-config-database.schema.json").to_owned(),
743            );
744            // Ensure empty registries exist when upgrading a no-db project that later
745            // recorded a database in `.phoenix` — do not overwrite populated registries.
746            for (path, content) in [
747                (
748                    "app/models/mod.rs",
749                    empty_model_registry(),
750                ),
751                (
752                    "database/migrations/mod.rs",
753                    empty_migration_registry(),
754                ),
755                ("database/seeders/mod.rs", seeder_template()),
756            ] {
757                if !self.root.join(path).is_file() {
758                    planned.insert(path.into(), content);
759                }
760            }
761            if !self.root.join("config/database.toml").is_file() {
762                planned.insert(
763                    "config/database.toml".into(),
764                    database_toml_template(database),
765                );
766            }
767        }
768
769        let cargo_path = PathBuf::from("Cargo.toml");
770        let cargo = fs::read_to_string(self.root.join(&cargo_path)).map_err(|source| {
771            ScaffoldError::Io {
772                path: self.root.join(&cargo_path),
773                source,
774            }
775        })?;
776        let cargo = patch_cargo_toml_core(&cargo, &rust_dependency);
777        planned.insert(cargo_path, cargo);
778
779        let package_json_path = PathBuf::from("package.json");
780        let package_json =
781            fs::read_to_string(self.root.join(&package_json_path)).map_err(|source| {
782                ScaffoldError::Io {
783                    path: self.root.join(&package_json_path),
784                    source,
785                }
786            })?;
787        let package_json =
788            patch_package_json_core(&package_json, &react, &react_ssr, &vite, stored.tailwind)?;
789        planned.insert(package_json_path, package_json);
790
791        let mut changed = Vec::new();
792        for (relative, content) in &planned {
793            let absolute = self.root.join(relative);
794            let existing = match fs::read_to_string(&absolute) {
795                Ok(text) => text,
796                Err(error) if error.kind() == ErrorKind::NotFound => String::new(),
797                Err(source) => {
798                    return Err(ScaffoldError::Io {
799                        path: absolute,
800                        source,
801                    });
802                }
803            };
804            if existing == *content {
805                continue;
806            }
807            changed.push(absolute.clone());
808            if options.dry_run {
809                continue;
810            }
811            if let Some(parent) = absolute.parent() {
812                fs::create_dir_all(parent).map_err(|source| ScaffoldError::Io {
813                    path: parent.to_path_buf(),
814                    source,
815                })?;
816            }
817            fs::write(&absolute, content).map_err(|source| ScaffoldError::Io {
818                path: absolute,
819                source,
820            })?;
821        }
822
823        if !options.dry_run && options.install_dependencies && self.root.join("package.json").is_file()
824        {
825            run_required("npm", &["install"], &self.root)?;
826            let _ = self.refresh_types()?;
827        }
828
829        Ok(changed)
830    }
831}
832
833fn is_project_root(path: &Path) -> bool {
834    path.join("Cargo.toml").is_file()
835        && path.join("package.json").is_file()
836        && path.join("app").is_dir()
837        && path.join("routes").is_dir()
838        && path.join("views").is_dir()
839}
840
841fn framework_dependency_pins(
842    source: &DependencySource,
843) -> Result<(String, String, String, String), ScaffoldError> {
844    match source {
845        DependencySource::Registry => Ok((
846            format!(
847                "phoenix = {{ package = \"phoenixrs\", version = \"{PHOENIXRS_VERSION}\", default-features = false }}"
848            ),
849            format!(
850                "https://registry.npmjs.org/@apizero/react/-/react-{APIZERO_REACT_VERSION}.tgz"
851            ),
852            format!(
853                "https://registry.npmjs.org/@apizero/react-ssr/-/react-ssr-{APIZERO_REACT_SSR_VERSION}.tgz"
854            ),
855            format!(
856                "https://registry.npmjs.org/@apizero/vite/-/vite-{APIZERO_VITE_VERSION}.tgz"
857            ),
858        )),
859        DependencySource::Local(root) => {
860            let root = absolute_path(root)?;
861            Ok((
862                format!(
863                    "phoenix = {{ package = \"phoenixrs\", path = {}, default-features = false }}",
864                    json_string(&root.join("crates/phoenix").to_string_lossy())
865                ),
866                format!("file:{}", root.join("packages/phoenix-react").display()),
867                format!("file:{}", root.join("packages/phoenix-react-ssr").display()),
868                format!("file:{}", root.join("packages/phoenix-vite").display()),
869            ))
870        }
871    }
872}
873
874fn format_stored_options(options: &StoredProjectOptions) -> String {
875    let database = options.database.map_or("none", database_option_key);
876    let render_mode = match options.render_mode {
877        ProjectRenderMode::Spa => "spa",
878        ProjectRenderMode::Ssr => "ssr",
879        ProjectRenderMode::Islands => "islands",
880    };
881    format!(
882        "frontend={}\nrender_mode={render_mode}\ndatabase={database}\ntailwind={}\n",
883        options.frontend.extension(),
884        if options.tailwind { "true" } else { "false" },
885    )
886}
887
888fn database_option_key(database: ProjectDatabase) -> &'static str {
889    match database {
890        ProjectDatabase::Sqlite => "sqlite",
891        ProjectDatabase::Pgsql => "pgsql",
892        ProjectDatabase::Mysql => "mysql",
893        ProjectDatabase::All => "all",
894    }
895}
896
897fn read_stored_options(root: &Path) -> StoredProjectOptions {
898    let mut options = StoredProjectOptions {
899        frontend: ProjectFrontend::default(),
900        database: None,
901        render_mode: ProjectRenderMode::default(),
902        tailwind: false,
903    };
904
905    if let Ok(source) = fs::read_to_string(root.join(PROJECT_OPTIONS_FILE)) {
906        for line in source.lines() {
907            if let Some(value) = line.strip_prefix("frontend=") {
908                if let Ok(frontend) = value.parse() {
909                    options.frontend = frontend;
910                }
911            } else if let Some(value) = line.strip_prefix("render_mode=") {
912                if let Ok(render_mode) = value.parse() {
913                    options.render_mode = render_mode;
914                }
915            } else if let Some(value) = line.strip_prefix("database=") {
916                options.database = match value.trim() {
917                    "" | "none" | "no" => None,
918                    other => other.parse().ok(),
919                };
920            } else if let Some(value) = line.strip_prefix("tailwind=") {
921                options.tailwind = matches!(value.trim(), "1" | "true" | "yes");
922            }
923        }
924    }
925
926    if options.database.is_none()
927        && (root.join("src/bin/phoenix-manage.rs").is_file()
928            || root.join("config/database.toml").is_file())
929    {
930        options.database = infer_database(root);
931    }
932    if !options.tailwind
933        && fs::read_to_string(root.join("package.json"))
934            .ok()
935            .is_some_and(|text| text.contains("tailwindcss"))
936    {
937        options.tailwind = true;
938    }
939    if options.render_mode == ProjectRenderMode::default() {
940        if let Ok(controller) = fs::read_to_string(root.join("app/controllers/home_controller.rs"))
941        {
942            if controller.contains(".spa()") {
943                options.render_mode = ProjectRenderMode::Spa;
944            } else if controller.contains(".ssr()") {
945                options.render_mode = ProjectRenderMode::Ssr;
946            } else if controller.contains(".islands()") {
947                options.render_mode = ProjectRenderMode::Islands;
948            }
949        }
950    }
951    if options.frontend == ProjectFrontend::default()
952        && root.join("views/pages/home.jsx").is_file()
953        && !root.join("views/pages/home.tsx").is_file()
954    {
955        options.frontend = ProjectFrontend::Jsx;
956    }
957    options
958}
959
960fn infer_database(root: &Path) -> Option<ProjectDatabase> {
961    if let Ok(cargo) = fs::read_to_string(root.join("Cargo.toml")) {
962        if cargo.contains("default = [\"all-databases\"]") || cargo.contains("all-databases =") {
963            return Some(ProjectDatabase::All);
964        }
965        if cargo.contains("default = [\"mysql\"]") {
966            return Some(ProjectDatabase::Mysql);
967        }
968        if cargo.contains("default = [\"pgsql\"]") {
969            return Some(ProjectDatabase::Pgsql);
970        }
971        if cargo.contains("default = [\"sqlite\"]") {
972            return Some(ProjectDatabase::Sqlite);
973        }
974    }
975    if let Ok(database) = fs::read_to_string(root.join("config/database.toml")) {
976        for line in database.lines() {
977            if let Some(value) = line.trim().strip_prefix("default = ") {
978                let value = value.trim().trim_matches('"');
979                return value.parse().ok();
980            }
981        }
982    }
983    Some(ProjectDatabase::Sqlite)
984}
985
986fn read_package_name(root: &Path) -> Result<String, ScaffoldError> {
987    let path = root.join("Cargo.toml");
988    let text = fs::read_to_string(&path).map_err(|source| ScaffoldError::Io {
989        path: path.clone(),
990        source,
991    })?;
992    let mut in_package = false;
993    for line in text.lines() {
994        let trimmed = line.trim();
995        if trimmed.starts_with('[') && trimmed.ends_with(']') {
996            in_package = trimmed == "[package]";
997            continue;
998        }
999        if in_package && let Some(value) = trimmed.strip_prefix("name = ") {
1000            let value = value.trim();
1001            if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
1002                return Ok(value[1..value.len() - 1].to_owned());
1003            }
1004        }
1005    }
1006    Err(ScaffoldError::InvalidManagedFile(path))
1007}
1008
1009fn patch_cargo_toml_core(cargo: &str, phoenix_dependency: &str) -> String {
1010    let mut lines = cargo.lines().map(str::to_owned).collect::<Vec<_>>();
1011    let mut replaced = false;
1012    for line in &mut lines {
1013        let trimmed = line.trim_start();
1014        if trimmed.starts_with("phoenix =") || trimmed.starts_with("phoenixrs =") {
1015            *line = phoenix_dependency.to_owned();
1016            replaced = true;
1017        }
1018    }
1019    if !replaced {
1020        if let Some(index) = lines.iter().position(|line| line.trim() == "[dependencies]") {
1021            lines.insert(index + 1, phoenix_dependency.to_owned());
1022        } else {
1023            lines.push(String::new());
1024            lines.push("[dependencies]".to_owned());
1025            lines.push(phoenix_dependency.to_owned());
1026        }
1027    }
1028
1029    let required_features = [
1030        ("tls =", "tls = [\"phoenix/tls\"]"),
1031        ("websocket =", "websocket = [\"phoenix/websocket\"]"),
1032        ("sse =", "sse = [\"phoenix/sse\"]"),
1033        ("auth =", "auth = [\"phoenix/auth\"]"),
1034        ("jwt =", "jwt = [\"phoenix/jwt\"]"),
1035        ("password =", "password = [\"phoenix/password\"]"),
1036        ("metrics =", "metrics = [\"phoenix/metrics\"]"),
1037        ("redis =", "redis = [\"phoenix/redis\"]"),
1038        ("storage =", "storage = [\"phoenix/storage\"]"),
1039        ("queue =", "queue = [\"phoenix/queue\"]"),
1040        ("mail =", "mail = [\"phoenix/mail\"]"),
1041        ("testing =", "testing = [\"phoenix/testing\"]"),
1042    ];
1043    let mut missing = Vec::new();
1044    for (prefix, line) in required_features {
1045        if !lines.iter().any(|existing| existing.trim_start().starts_with(prefix)) {
1046            missing.push(line.to_owned());
1047        }
1048    }
1049    if !missing.is_empty() {
1050        let insert_at = lines
1051            .iter()
1052            .position(|line| line.trim() == "[dependencies]")
1053            .unwrap_or(lines.len());
1054        for (offset, line) in missing.into_iter().enumerate() {
1055            lines.insert(insert_at + offset, line);
1056        }
1057    }
1058
1059    let mut body = lines.join("\n");
1060    if !body.ends_with('\n') {
1061        body.push('\n');
1062    }
1063    body
1064}
1065
1066fn patch_package_json_core(
1067    raw: &str,
1068    react: &str,
1069    react_ssr: &str,
1070    vite: &str,
1071    tailwind: bool,
1072) -> Result<String, ScaffoldError> {
1073    let mut value: serde_json::Value = serde_json::from_str(raw).map_err(|source| {
1074        ScaffoldError::Io {
1075            path: PathBuf::from("package.json"),
1076            source: std::io::Error::new(ErrorKind::InvalidData, source.to_string()),
1077        }
1078    })?;
1079    let object = value.as_object_mut().ok_or_else(|| ScaffoldError::Io {
1080        path: PathBuf::from("package.json"),
1081        source: std::io::Error::new(ErrorKind::InvalidData, "package.json root must be an object"),
1082    })?;
1083
1084    let dependencies = object
1085        .entry("dependencies")
1086        .or_insert_with(|| serde_json::json!({}));
1087    if let Some(deps) = dependencies.as_object_mut() {
1088        deps.insert(
1089            "@apizero/react".to_owned(),
1090            serde_json::Value::String(react.to_owned()),
1091        );
1092        deps.insert(
1093            "@apizero/react-ssr".to_owned(),
1094            serde_json::Value::String(react_ssr.to_owned()),
1095        );
1096        deps.insert(
1097            "@apizero/vite".to_owned(),
1098            serde_json::Value::String(vite.to_owned()),
1099        );
1100    }
1101
1102    let scripts = object
1103        .entry("scripts")
1104        .or_insert_with(|| serde_json::json!({}));
1105    if let Some(scripts) = scripts.as_object_mut() {
1106        scripts.insert(
1107            "dev".to_owned(),
1108            serde_json::Value::String("vite --host 127.0.0.1".to_owned()),
1109        );
1110        scripts.insert(
1111            "build".to_owned(),
1112            serde_json::Value::String("npm run build:client && npm run build:ssr".to_owned()),
1113        );
1114        scripts.insert(
1115            "build:client".to_owned(),
1116            serde_json::Value::String("vite build".to_owned()),
1117        );
1118        scripts.insert(
1119            "build:ssr".to_owned(),
1120            serde_json::Value::String("vite build --config vite.ssr.config.ts".to_owned()),
1121        );
1122        scripts.insert(
1123            "types".to_owned(),
1124            serde_json::Value::String(
1125                "node -e \"import('@apizero/vite').then(({ generateRouteTypes }) => generateRouteTypes('routes', 'views/generated/routes.ts', '.', 'views/generated/contracts.ts'))\""
1126                    .to_owned(),
1127            ),
1128        );
1129        scripts.insert(
1130            "typecheck".to_owned(),
1131            serde_json::Value::String("npm run types && tsc --noEmit".to_owned()),
1132        );
1133    }
1134
1135    if tailwind {
1136        let dev_dependencies = object
1137            .entry("devDependencies")
1138            .or_insert_with(|| serde_json::json!({}));
1139        if let Some(deps) = dev_dependencies.as_object_mut() {
1140            deps.entry("@tailwindcss/vite".to_owned())
1141                .or_insert_with(|| serde_json::Value::String("^4.3.0".to_owned()));
1142            deps.entry("tailwindcss".to_owned())
1143                .or_insert_with(|| serde_json::Value::String("^4.3.0".to_owned()));
1144        }
1145    }
1146
1147    let mut rendered = serde_json::to_string_pretty(&value).map_err(|source| ScaffoldError::Io {
1148        path: PathBuf::from("package.json"),
1149        source: std::io::Error::new(ErrorKind::InvalidData, source.to_string()),
1150    })?;
1151    rendered.push('\n');
1152    Ok(rendered)
1153}
1154
1155fn project_files(
1156    package: &str,
1157    options: &NewProjectOptions,
1158) -> Result<Vec<(PathBuf, String)>, ScaffoldError> {
1159    let (rust_dependency, react, react_ssr, vite) =
1160        framework_dependency_pins(&options.dependencies)?;
1161    let crate_name = package.replace('-', "_");
1162    let tailwind = if options.tailwind {
1163        ",\n    \"@tailwindcss/vite\": \"^4.3.0\",\n    \"tailwindcss\": \"^4.3.0\""
1164    } else {
1165        ""
1166    };
1167    let package_json = format!(
1168        r#"{{
1169  "name": {package},
1170  "version": "0.1.0",
1171  "private": true,
1172  "type": "module",
1173  "scripts": {{
1174    "dev": "vite --host 127.0.0.1",
1175    "build": "npm run build:client && npm run build:ssr",
1176    "build:client": "vite build",
1177    "build:ssr": "vite build --config vite.ssr.config.ts",
1178    "types": "node -e \"import('@apizero/vite').then(({{ generateRouteTypes }}) => generateRouteTypes('routes', 'views/generated/routes.ts', '.', 'views/generated/contracts.ts'))\"",
1179    "typecheck": "npm run types && tsc --noEmit"
1180  }},
1181  "dependencies": {{
1182    "@apizero/react": {react},
1183    "@apizero/react-ssr": {react_ssr},
1184    "@apizero/vite": {vite},
1185    "react": "^19.1.0",
1186    "react-dom": "^19.1.0"
1187  }},
1188  "devDependencies": {{
1189    "@types/react": "^19.0.0",
1190    "@types/react-dom": "^19.0.0",
1191    "typescript": "^5.8.0",
1192    "vite": "^7.3.6"{tailwind}
1193  }}
1194}}
1195"#,
1196        package = json_string(package),
1197        react = json_string(&react),
1198        react_ssr = json_string(&react_ssr),
1199        vite = json_string(&vite),
1200        tailwind = tailwind,
1201    );
1202    let (database_features, toasty_dependency) = options.database.map_or_else(
1203        || ("default = []\ndatabase = []\n".to_owned(), String::new()),
1204        |database| {
1205            let app_feature = database.cargo_feature();
1206            let phoenix_features = match database {
1207                ProjectDatabase::All => "\"database\", \"phoenix/sqlite\", \"phoenix/pgsql\", \"phoenix/mysql\"".to_owned(),
1208                _ => format!("\"database\", \"phoenix/{app_feature}\""),
1209            };
1210            let toasty_features = database.toasty_features();
1211            (
1212                format!(
1213                    "default = [\"{app_feature}\"]\ndatabase = [\"phoenix/database\"]\n{app_feature} = [{phoenix_features}]\n"
1214                ),
1215                format!(
1216                    "toasty = {{ version = \"0.8\", default-features = false, features = [\"migration\", \"{toasty_features}\"] }}\n"
1217                ),
1218            )
1219        },
1220    );
1221    let cargo_toml = format!(
1222        "[package]\nname = {package}\nversion = \"0.1.0\"\nedition = \"2024\"\nrust-version = \"1.95\"\npublish = false\ndefault-run = {package}\n\n[features]\n# Database support is omitted unless selected during `px new`.\n{database_features}tls = [\"phoenix/tls\"]\nwebsocket = [\"phoenix/websocket\"]\nsse = [\"phoenix/sse\"]\nauth = [\"phoenix/auth\"]\njwt = [\"phoenix/jwt\"]\npassword = [\"phoenix/password\"]\nmetrics = [\"phoenix/metrics\"]\nredis = [\"phoenix/redis\"]\nstorage = [\"phoenix/storage\"]\nqueue = [\"phoenix/queue\"]\nmail = [\"phoenix/mail\"]\ntesting = [\"phoenix/testing\"]\n\n[dependencies]\n{rust_dependency}\nserde = {{ version = \"1\", features = [\"derive\"] }}\nserde_json = \"1\"\n{toasty_dependency}tokio = {{ version = \"1\", features = [\"macros\", \"rt-multi-thread\", \"signal\"] }}\n\n[profile.release]\ncodegen-units = 1\nlto = true\nopt-level = \"z\"\npanic = \"unwind\"\nstrip = \"symbols\"\n\n[workspace]\n",
1223        package = json_string(package),
1224        database_features = database_features,
1225        rust_dependency = rust_dependency,
1226        toasty_dependency = toasty_dependency,
1227    );
1228
1229    let stored = StoredProjectOptions {
1230        frontend: options.frontend,
1231        database: options.database,
1232        render_mode: options.render_mode,
1233        tailwind: options.tailwind,
1234    };
1235
1236    let mut files = vec![
1237        ("Cargo.toml".into(), cargo_toml),
1238        (
1239            PROJECT_OPTIONS_FILE.into(),
1240            format_stored_options(&stored),
1241        ),
1242        ("package.json".into(), package_json),
1243        (".gitignore".into(), "/target\n/node_modules\n/public/assets\n/public/ssr\n/views/generated/*.ts\n/dist\n/storage/*.sqlite\n/storage/*.sqlite-*\n.env\n.DS_Store\n".to_owned()),
1244        (".env.example".into(), env_example_template(options.database.is_some())),
1245        ("README.md".into(), project_readme(package, options)),
1246        ("src/main.rs".into(), main_template(&crate_name)),
1247        ("src/lib.rs".into(), lib_template(options.database.is_some())),
1248        ("config/mod.rs".into(), config_template(options.database.is_some())),
1249        ("config/app.toml".into(), app_toml_template(package)),
1250        ("config/schemas/phoenix-config-app.schema.json".into(), include_str!("../schemas/phoenix-config-app.schema.json").to_owned()),
1251        ("taplo.toml".into(), app_taplo_template(options.database.is_some())),
1252        ("deploy/restart.sh.example".into(), deploy_restart_example()),
1253        ("app/controllers/mod.rs".into(), managed_modules(&["pub mod home_controller;", "pub use home_controller::HomeController;"])),
1254        ("app/controllers/home_controller.rs".into(), home_controller_template(options.render_mode)),
1255        ("app/props/mod.rs".into(), managed_modules(&["pub mod home_props;", "pub use home_props::HomeProps;"])),
1256        ("app/props/home_props.rs".into(), home_props_template()),
1257        ("app/requests/mod.rs".into(), managed_modules(&[])),
1258        ("app/resources/mod.rs".into(), managed_modules(&[])),
1259        ("app/middleware/mod.rs".into(), managed_modules(&[])),
1260        ("app/commands/mod.rs".into(), commands_mod_template()),
1261        ("routes/web.rs".into(), home_route_template()),
1262        (
1263            format!("views/pages/home.{}", options.frontend.extension()).into(),
1264            home_page_template(options.frontend),
1265        ),
1266        ("views/styles.css".into(), styles_template(options.tailwind)),
1267        ("views/generated/contracts.ts".into(), generated_contracts_template()),
1268        ("views/generated/routes.ts".into(), generated_routes_template()),
1269        ("vite.config.ts".into(), vite_template(false, options.tailwind)),
1270        ("vite.ssr.config.ts".into(), vite_template(true, options.tailwind)),
1271        ("tsconfig.json".into(), tsconfig_template()),
1272        ("public/.gitkeep".into(), String::new()),
1273        ("storage/cache/.gitkeep".into(), String::new()),
1274        ("storage/logs/.gitkeep".into(), String::new()),
1275        ("views/components/.gitkeep".into(), String::new()),
1276        ("views/islands/.gitkeep".into(), String::new()),
1277        ("views/layouts/.gitkeep".into(), String::new()),
1278    ];
1279    if options.database.is_some() {
1280        files.extend([
1281            (
1282                "config/database.toml".into(),
1283                database_toml_template(options.database.expect("database selected")),
1284            ),
1285            (
1286                "config/schemas/phoenix-config-database.schema.json".into(),
1287                include_str!("../schemas/phoenix-config-database.schema.json").to_owned(),
1288            ),
1289            ("app/models/mod.rs".into(), empty_model_registry()),
1290            (
1291                "database/migrations/mod.rs".into(),
1292                empty_migration_registry(),
1293            ),
1294            ("database/seeders/mod.rs".into(), seeder_template()),
1295            (
1296                "src/bin/phoenix-manage.rs".into(),
1297                management_template(&crate_name),
1298            ),
1299        ]);
1300    }
1301    Ok(files)
1302}
1303
1304fn env_example_template(database: bool) -> String {
1305    let database = if database {
1306        "\n# Database overrides for the driver selected during `px new`.\n# DB_PASSWORD=secret\n# DATABASE_URL=...\n"
1307    } else {
1308        ""
1309    };
1310    format!(
1311        "# Copy to `.env` for local secrets and overrides.\n# Precedence: config/*.toml < .env < process environment.\n\nAPP_ENV=development\nAPP_ADDR=127.0.0.1:3000\nAPP_URL=http://127.0.0.1:3000\n{database}\nTRUSTED_PROXIES=none\nALLOWED_HOSTS=127.0.0.1,localhost,[::1]\nRATE_LIMIT_REQUESTS=60\nRATE_LIMIT_WINDOW_SECONDS=60\nVITE_DEV_URL=http://127.0.0.1:5173\nPHOENIX_LOG=info,hyper=warn\n"
1312    )
1313}
1314
1315fn app_toml_template(package: &str) -> String {
1316    format!(
1317        r#"# Application settings (Laravel-style config/app).
1318# Secrets and machine-specific overrides belong in `.env`.
1319# Editor autocomplete: Even Better TOML / Taplo + #:schema below.
1320
1321#:schema ./schemas/phoenix-config-app.schema.json
1322
1323name = {package}
1324env = "development"
1325addr = "127.0.0.1:3000"
1326url = "http://127.0.0.1:3000"
1327"#,
1328        package = json_string(package),
1329    )
1330}
1331
1332fn database_toml_template(database: ProjectDatabase) -> String {
1333    format!(
1334        r#"# Database connections (Laravel-style config/database).
1335#
1336# Switch engines by changing `default`:
1337#   default = "sqlite"   # local file, zero setup
1338#   default = "pgsql"    # PostgreSQL
1339#   default = "mysql"    # MySQL / MariaDB
1340# Enable the matching Cargo feature only when this application uses a database.
1341#
1342# Or set DB_CONNECTION=pgsql|mysql in `.env` without editing this file.
1343# Put DB_PASSWORD in `.env` — do not commit production passwords here.
1344# Editor autocomplete: Even Better TOML / Taplo + #:schema below.
1345
1346#:schema ./schemas/phoenix-config-database.schema.json
1347
1348default = "{default}"
1349
1350[connections.sqlite]
1351driver = "sqlite"
1352# Path is relative to the application root (creates parent dirs as needed by the OS/driver).
1353database = "storage/app.sqlite"
1354
1355[connections.pgsql]
1356driver = "pgsql"
1357host = "127.0.0.1"
1358port = 5432
1359database = "phoenix"
1360username = "phoenix"
1361password = ""
1362
1363[connections.mysql]
1364driver = "mysql"
1365host = "127.0.0.1"
1366port = 3306
1367database = "phoenix"
1368username = "phoenix"
1369password = ""
1370"#,
1371        default = if database == ProjectDatabase::All {
1372            "sqlite"
1373        } else {
1374            database.cargo_feature()
1375        },
1376    )
1377}
1378
1379fn app_taplo_template(database: bool) -> String {
1380    let database_rule = if database {
1381        "\n[[rule]]\ninclude = [\"config/database.toml\"]\n[rule.schema]\npath = \"./config/schemas/phoenix-config-database.schema.json\"\n"
1382    } else {
1383        ""
1384    };
1385    format!(
1386        "# Taplo / Even Better TOML schema associations for config/*.toml autocomplete.\n\n[[rule]]\ninclude = [\"config/app.toml\"]\n[rule.schema]\npath = \"./config/schemas/phoenix-config-app.schema.json\"\n{database_rule}"
1387    )
1388}
1389
1390fn deploy_restart_example() -> String {
1391    r"#!/bin/sh
1392# Copy to deploy/restart.sh and make executable.
1393# Used by `px release:install` / `px release:rollback` when --restart-cmd is omitted.
1394set -eu
1395systemctl restart my-app
1396"
1397    .to_owned()
1398}
1399
1400fn config_template(database: bool) -> String {
1401    let database = if database {
1402        " + `config/database.toml`"
1403    } else {
1404        ""
1405    };
1406    format!(
1407        "pub use phoenix::config::{{AppConfig, AppConfigBuilder, ConfigError, Environment, SecretValue}};\n\n/// Load this application's configuration.\n///\n/// Reads `config/app.toml`{database}, then `.env`, then process environment.\n///\n/// # Errors\n///\n/// Returns a source, validation, or production-requirement error.\npub fn load() -> Result<AppConfig, ConfigError> {{\n    AppConfig::load()\n}}\n"
1408    )
1409}
1410
1411fn management_template(crate_name: &str) -> String {
1412    r#"#[cfg(feature = "database")]
1413use std::{env, error::Error, io};
1414
1415#[cfg(feature = "database")]
1416use phoenix::database::MigrationRunner;
1417
1418#[cfg(feature = "database")]
1419type CommandResult<T = ()> = Result<T, Box<dyn Error>>;
1420
1421#[cfg(not(feature = "database"))]
1422fn main() {
1423    println!("Database support is disabled; enable the sqlite, pgsql, or mysql feature.");
1424}
1425
1426#[cfg(feature = "database")]
1427#[tokio::main]
1428async fn main() -> CommandResult {
1429    let arguments = env::args().skip(1).collect::<Vec<_>>();
1430    let command = arguments
1431        .first()
1432        .map(String::as_str)
1433        .ok_or_else(|| input_error("expected migrate, status, rollback, fresh, or seed"))?;
1434    let options = &arguments[1..];
1435    if !matches!(command, "migrate" | "status" | "rollback" | "fresh" | "seed") {
1436        return Err(input_error(format!("unknown management command `{command}`")).into());
1437    }
1438
1439    let config = __PHOENIX_APP_CRATE__::config::load()?;
1440    let mut database = __PHOENIX_APP_CRATE__::database(&config).await?;
1441    if command == "seed" {
1442        require_no_options(options)?;
1443        __PHOENIX_APP_CRATE__::seeders::run(&mut database).await?;
1444        println!("Seeders completed.");
1445        return Ok(());
1446    }
1447
1448    let mut runner = MigrationRunner::new(
1449        &mut database,
1450        __PHOENIX_APP_CRATE__::migrations::all(),
1451    )?;
1452    match command {
1453        "migrate" => {
1454            require_no_options(options)?;
1455            let applied = runner.up().await?;
1456            println!("Applied {applied} migration(s).");
1457        }
1458        "status" => {
1459            require_no_options(options)?;
1460            let plan = runner.plan().await?;
1461            if plan.applied.is_empty() && plan.pending.is_empty() {
1462                println!("No migrations registered or applied.");
1463            }
1464            for migration in plan.applied {
1465                println!(
1466                    "APPLIED  {}  batch={}  {}  {}",
1467                    migration.id, migration.batch, migration.applied_at, migration.name
1468                );
1469            }
1470            for id in plan.pending {
1471                println!("PENDING  {id}");
1472            }
1473        }
1474        "rollback" => {
1475            let steps = parse_rollback_steps(options)?;
1476            let rolled_back = runner.down(steps).await?;
1477            println!("Rolled back {rolled_back} migration(s).");
1478        }
1479        "fresh" => {
1480            let run_seeders = parse_fresh_options(options)?;
1481            let applied = runner.plan().await?.applied.len();
1482            let rolled_back = runner.down(applied).await?;
1483            let migrated = runner.up().await?;
1484            println!(
1485                "Rebuilt the database: rolled back {rolled_back}, applied {migrated} migration(s)."
1486            );
1487            drop(runner);
1488            if run_seeders {
1489                __PHOENIX_APP_CRATE__::seeders::run(&mut database).await?;
1490                println!("Seeders completed.");
1491            }
1492        }
1493        "seed" => unreachable!("seed is handled before creating the migration runner"),
1494        _ => unreachable!("management commands are validated before connecting"),
1495    }
1496    Ok(())
1497}
1498
1499#[cfg(feature = "database")]
1500fn require_no_options(options: &[String]) -> CommandResult {
1501    if options.is_empty() {
1502        Ok(())
1503    } else {
1504        Err(input_error(format!("unexpected arguments: {}", options.join(" "))).into())
1505    }
1506}
1507
1508#[cfg(feature = "database")]
1509fn parse_rollback_steps(options: &[String]) -> CommandResult<usize> {
1510    let [steps] = options else {
1511        return Err(input_error("rollback expects one positive step count").into());
1512    };
1513    steps
1514        .parse::<usize>()
1515        .ok()
1516        .filter(|steps| *steps > 0)
1517        .ok_or_else(|| input_error("rollback step count must be a positive integer").into())
1518}
1519
1520#[cfg(feature = "database")]
1521fn parse_fresh_options(options: &[String]) -> CommandResult<bool> {
1522    match options {
1523        [] => Ok(false),
1524        [option] if option == "--seed" => Ok(true),
1525        _ => Err(input_error("fresh only accepts --seed").into()),
1526    }
1527}
1528
1529#[cfg(feature = "database")]
1530fn input_error(message: impl Into<String>) -> io::Error {
1531    io::Error::new(io::ErrorKind::InvalidInput, message.into())
1532}
1533"#
1534    .replace("__PHOENIX_APP_CRATE__", crate_name)
1535}
1536
1537fn seeder_template() -> String {
1538    r"use std::error::Error;
1539
1540use phoenix::database::Database;
1541
1542/// Insert repeatable development or test data.
1543///
1544/// # Errors
1545///
1546/// Returns the first application or database error raised by a seeder.
1547pub async fn run(_database: &mut Database) -> Result<(), Box<dyn Error>> {
1548    Ok(())
1549}
1550"
1551    .to_owned()
1552}
1553
1554fn empty_model_registry() -> String {
1555    format!(
1556        "{MODULES_START}\n{MODULES_END}\n\n{MODELS_START}\n{}\n{MODELS_END}\n",
1557        render_model_registry(&BTreeSet::new()).join("\n")
1558    )
1559}
1560
1561fn empty_migration_registry() -> String {
1562    format!(
1563        "{MIGRATIONS_START}\n{}\n{MIGRATIONS_END}\n",
1564        render_migration_registry(&BTreeSet::new()).join("\n")
1565    )
1566}
1567
1568fn project_readme(package: &str, options: &NewProjectOptions) -> String {
1569    let mode = match options.render_mode {
1570        ProjectRenderMode::Spa => "SPA",
1571        ProjectRenderMode::Ssr => "SSR",
1572        ProjectRenderMode::Islands => "Islands",
1573    };
1574    let database = options.database.map_or_else(
1575        || "This project has no database dependency. Create one with `px new --database sqlite|pgsql|mysql`.".to_owned(),
1576        |database| format!("Selected driver: `{}`. Use `px migrate` and `px status` for migrations.", database.cargo_feature()),
1577    );
1578    let tailwind = if options.tailwind {
1579        "Tailwind CSS v4 is enabled through `@tailwindcss/vite`."
1580    } else {
1581        "Tailwind CSS is not enabled; add it at creation time with `px new --tailwind`."
1582    };
1583    format!(
1584        "# {package}\n\nPhoenix Rust + React application.\n\n## Start\n\n```bash\ncp .env.example .env\npx dev\n```\n\n`px dev` builds the browser and renderer bundles before it starts the app, then rebuilds them whenever Rust or React source changes. The development document therefore uses the same Vite assets and renderer contract as `npm run build`.\n\n## Rendering mode\n\nThis application starts in **{mode}** mode. Change only the page chain in `app/controllers/home_controller.rs`:\n\n```rust\n.spa()       // SPA\n.ssr()       // SSR\n.islands()   // Islands\n```\n\nThe controller, routes, renderer and build pipeline stay unchanged.\n\n## Optional integrations\n\n- {database}\n- {tailwind}\n\n## Release\n\n```bash\npx release --version 0.1.0 --tarball\n```\n"
1585    )
1586}
1587
1588fn main_template(crate_name: &str) -> String {
1589    format!(
1590        r#"use phoenix::prelude::{{CommandResult, Console, LogFormat, Logging}};
1591
1592use {crate_name}::commands;
1593
1594#[tokio::main]
1595async fn main() -> CommandResult {{
1596    Console::new(env!("CARGO_PKG_NAME"))
1597        .about("Phoenix application")
1598        .serve(|_ctx| async move {{
1599            let config = {crate_name}::config::load()?;
1600            let address = config.address().to_owned();
1601            let public_url = config.public_url().to_owned();
1602            let production = config.environment().is_production();
1603            let _logging = Logging::new()
1604                .format(if production {{ LogFormat::Json }} else {{ LogFormat::Compact }})
1605                .ansi(!production)
1606                .init()?;
1607            let server = {crate_name}::application(config).await?.bind(&address).await?;
1608            println!(
1609                "Phoenix application ready at {{public_url}} (listening on {{}})",
1610                server.local_addr()
1611            );
1612            server
1613                .run_with_shutdown(async {{
1614                    let _ = tokio::signal::ctrl_c().await;
1615                }})
1616                .await?;
1617            Ok(())
1618        }})
1619        .commands(commands::registry())
1620        .run()
1621        .await
1622}}
1623"#
1624    )
1625}
1626
1627fn lib_template(_database: bool) -> String {
1628    r#"#[path = "../config/mod.rs"]
1629pub mod config;
1630#[path = "../app/commands/mod.rs"]
1631pub mod commands;
1632#[path = "../app/controllers/mod.rs"]
1633pub mod controllers;
1634#[path = "../app/middleware/mod.rs"]
1635pub mod middleware;
1636#[cfg(feature = "database")]
1637#[path = "../app/models/mod.rs"]
1638pub mod models;
1639#[path = "../app/props/mod.rs"]
1640pub mod props;
1641#[path = "../app/requests/mod.rs"]
1642pub mod requests;
1643#[path = "../app/resources/mod.rs"]
1644pub mod resources;
1645#[cfg(feature = "database")]
1646#[path = "../database/migrations/mod.rs"]
1647pub mod migrations;
1648#[cfg(feature = "database")]
1649#[path = "../database/seeders/mod.rs"]
1650pub mod seeders;
1651
1652use phoenix::prelude::{
1653    AccessLog, Application, AssetManifest, Csrf, HostAllowlist, NodeRenderer,
1654    NonceSecurityPolicy, RateLimit, RateLimitConfig, RendererConfig, RendererManifest, RequestId, Routes, ServeProductionAssets, SessionConfig, SessionMiddleware,
1655    SessionStore, StateMiddleware, TrustedProxies,
1656};
1657#[cfg(feature = "database")]
1658use phoenix::prelude::{Database, DatabaseError};
1659
1660use config::AppConfig;
1661
1662#[must_use]
1663#[allow(clippy::duplicate_mod)]
1664pub fn routes(
1665    config: &AppConfig,
1666    assets: Option<&AssetManifest>,
1667    renderer: &NodeRenderer,
1668) -> Routes {
1669    let session_config = SessionConfig {
1670        secure: config.public_url().starts_with("https://"),
1671        ..SessionConfig::default()
1672    };
1673    let session_store = SessionStore::memory(session_config.max_age);
1674
1675    let mut routes = phoenix::mount_routes!()
1676        .with_middleware(TrustedProxies::new(config.trusted_proxies().iter().copied()))
1677        .with_middleware(RequestId)
1678        .with_middleware(AccessLog);
1679    if let Some(assets) = assets.cloned() {
1680        // Serve hashed Vite assets before session/CSRF so static GETs stay cheap.
1681        routes = routes.with_middleware(ServeProductionAssets::new(assets, "public/assets"));
1682    }
1683    routes
1684        .with_middleware(HostAllowlist::new(config.allowed_hosts().iter().cloned()))
1685        .with_middleware(RateLimit::new(RateLimitConfig {
1686            requests: config.rate_limit_requests(),
1687            window: config.rate_limit_window(),
1688        }))
1689        .with_middleware(content_security_policy(config))
1690        .with_middleware(SessionMiddleware::new(session_store, session_config))
1691        .with_middleware(Csrf)
1692        .with_middleware(StateMiddleware::new(config.clone()))
1693        .with_middleware(StateMiddleware::new(assets.cloned()))
1694        .with_middleware(StateMiddleware::new(renderer.clone()))
1695}
1696
1697fn content_security_policy(config: &AppConfig) -> NonceSecurityPolicy {
1698    if !config.environment().is_production() {
1699        return NonceSecurityPolicy::development(
1700            config
1701                .vite_dev_url()
1702                .expect("development configuration always has a Vite origin"),
1703        )
1704        .expect("AppConfig validates VITE_DEV_URL as one trusted HTTP(S) origin");
1705    }
1706    NonceSecurityPolicy::default()
1707}
1708
1709/// Build the Phoenix application.
1710///
1711/// # Errors
1712///
1713/// Returns a route error when route names or patterns conflict.
1714pub async fn application(
1715    config: AppConfig,
1716) -> Result<Application, Box<dyn std::error::Error + Send + Sync>> {
1717    let production = config.environment().is_production();
1718    let (assets, renderer) = if production {
1719        let assets = AssetManifest::load("public/assets/phoenix-manifest.json")?;
1720        let renderer_manifest = RendererManifest::load("public/ssr/phoenix-renderer.json")?;
1721        let renderer = NodeRenderer::new(
1722            RendererConfig::production(&assets, &renderer_manifest, "public/ssr")?,
1723        );
1724        (Some(assets), renderer)
1725    } else {
1726        // Development uses Vite's browser entry so HMR/full reload remains live.
1727        // px dev still builds the same renderer bundle before starting Rust.
1728        (None, NodeRenderer::new(RendererConfig::node("public/ssr/renderer.js")))
1729    };
1730    renderer.warm_up().await?;
1731    Ok(Application::new(routes(&config, assets.as_ref(), &renderer))?)
1732}
1733
1734/// Connect the configured database with every registered Toasty model.
1735///
1736/// # Errors
1737///
1738/// Returns a database error when the URL or connection is invalid.
1739#[cfg(feature = "database")]
1740pub async fn database(config: &AppConfig) -> Result<Database, DatabaseError> {
1741    Database::builder(models::all())
1742        .connect(config.database_url())
1743        .await
1744}
1745"#
1746    .to_owned()
1747}
1748
1749fn commands_mod_template() -> String {
1750    format!(
1751        "use phoenix::prelude::commands;\n\n{MODULES_START}\n{MODULES_END}\n\ncommands! {{\n{COMMANDS_START}\n{COMMANDS_END}\n}}\n"
1752    )
1753}
1754
1755fn command_template(function_name: &str) -> String {
1756    format!(
1757        r#"use phoenix::prelude::{{CommandContext, CommandResult}};
1758
1759/// Application console command.
1760#[allow(clippy::unused_async)]
1761pub async fn {function_name}(_ctx: CommandContext<'_>) -> CommandResult {{
1762    println!("{function_name} ran.");
1763    Ok(())
1764}}
1765"#
1766    )
1767}
1768
1769fn home_controller_template(render_mode: ProjectRenderMode) -> String {
1770    format!(
1771        r#"use phoenix::prelude::{{AssetManifest, NodeRenderer, Page, Request, Response, StatusCode}};
1772
1773use crate::props::HomeProps;
1774
1775pub struct HomeController;
1776
1777impl HomeController {{
1778    pub async fn index(request: Request) -> Response {{
1779        let renderer = request.extensions().get::<NodeRenderer>().cloned();
1780        let assets = request
1781            .extensions()
1782            .get::<Option<AssetManifest>>()
1783            .and_then(Option::as_ref);
1784        let mut page = Page::new(
1785            "home",
1786            HomeProps {{
1787                title: "Phoenix is ready".to_owned(),
1788                description: "Rust owns the application contract; React renders the page.".to_owned(),
1789            }},
1790        )
1791        {render_mode};
1792        if let Some(assets) = assets {{
1793            page = match page.production_assets(assets, "client") {{
1794                Ok(page) => page,
1795                Err(error) => {{
1796                    return Response::text(format!("asset manifest error: {{error}}"))
1797                        .with_status(StatusCode::INTERNAL_SERVER_ERROR);
1798                }}
1799            }};
1800        }}
1801        match renderer {{
1802            Some(renderer) => page.respond_with_renderer(&request, &renderer).await,
1803            None => Response::text("Phoenix renderer is unavailable")
1804                .with_status(StatusCode::INTERNAL_SERVER_ERROR),
1805        }}
1806    }}
1807}}
1808"#,
1809        render_mode = render_mode.page_builder(),
1810    )
1811}
1812
1813fn home_props_template() -> String {
1814    r#"use serde::Serialize;
1815
1816#[phoenix::contract(page, page = "home")]
1817#[derive(Serialize)]
1818pub struct HomeProps {
1819    pub title: String,
1820    pub description: String,
1821}
1822"#
1823    .to_owned()
1824}
1825
1826fn home_route_template() -> String {
1827    r#"use phoenix::prelude::Routes;
1828
1829use crate::controllers::HomeController;
1830
1831#[must_use]
1832pub fn routes() -> Routes {
1833    Routes::new()
1834        .get("/", HomeController::index)
1835        .name("home")
1836}
1837"#
1838    .to_owned()
1839}
1840
1841fn home_page_template(frontend: ProjectFrontend) -> String {
1842    match frontend {
1843        ProjectFrontend::Tsx => r#"import type { HomeProps } from "../generated/contracts.js";
1844
1845export default function Home({ title, description }: HomeProps) {
1846  return (
1847    <main className="welcome">
1848      <p className="eyebrow">PHOENIX / RUST + REACT</p>
1849      <h1>{title}</h1>
1850      <p>{description}</p>
1851      <code>px make:model Post --all</code>
1852    </main>
1853  );
1854}
1855"#
1856        .to_owned(),
1857        ProjectFrontend::Jsx => r#"export default function Home({ title, description }) {
1858  return (
1859    <main className="welcome">
1860      <p className="eyebrow">PHOENIX / RUST + REACT</p>
1861      <h1>{title}</h1>
1862      <p>{description}</p>
1863      <code>px make:model Post --all</code>
1864    </main>
1865  );
1866}
1867"#
1868        .to_owned(),
1869    }
1870}
1871
1872fn styles_template(tailwind: bool) -> String {
1873    let tailwind = if tailwind {
1874        "@import \"tailwindcss\";\n\n"
1875    } else {
1876        ""
1877    };
1878    format!(
1879        r#"{tailwind}:root {{
1880  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
1881  color: #172033;
1882  background: #f5f7fb;
1883}}
1884* {{ box-sizing: border-box; }}
1885body {{ margin: 0; min-width: 320px; min-height: 100vh; }}
1886.welcome {{ width: min(760px, calc(100% - 40px)); margin: 16vh auto 0; }}
1887.eyebrow {{ color: #315bd6; font-size: 12px; font-weight: 800; letter-spacing: 0.14em; }}
1888h1 {{ margin: 12px 0; font-size: clamp(42px, 8vw, 76px); line-height: 0.98; }}
1889.welcome > p:not(.eyebrow) {{ max-width: 640px; color: #5d6879; font-size: 18px; line-height: 1.7; }}
1890code {{ display: inline-block; margin-top: 18px; padding: 12px 14px; border: 1px solid #d7dce5; background: white; }}
1891"#
1892    )
1893}
1894
1895fn generated_contracts_template() -> String {
1896    r#"// Generated by Phoenix. Vite will refresh this file from Rust contracts.
1897export interface HomeProps {
1898  title: string;
1899  description: string;
1900}
1901export interface PhoenixPageProps { home: HomeProps }
1902export type PhoenixSharedProps = Record<string, never>;
1903export const contractHash = "scaffold" as const;
1904"#
1905    .to_owned()
1906}
1907
1908fn generated_routes_template() -> String {
1909    r#"// Generated by Phoenix. Vite will refresh this file from Rust routes.
1910export const routes = { home: "home" } as const;
1911export type PhoenixRouteName = "home";
1912export const home = routes.home;
1913"#
1914    .to_owned()
1915}
1916
1917fn vite_template(renderer: bool, tailwind: bool) -> String {
1918    let tailwind_import = if tailwind {
1919        "import tailwindcss from \"@tailwindcss/vite\";\n"
1920    } else {
1921        ""
1922    };
1923    let tailwind_plugin = if tailwind { ", tailwindcss()" } else { "" };
1924    format!(
1925        "import {{ defineConfig }} from \"vite\";\nimport {{ phoenix }} from \"@apizero/vite\";\n{tailwind_import}\nexport default defineConfig({{\n  plugins: [phoenix({renderer}){tailwind_plugin}],\n}});\n",
1926        renderer = if renderer { "{ renderer: true }" } else { "" },
1927    )
1928}
1929
1930fn tsconfig_template() -> String {
1931    r#"{
1932  "compilerOptions": {
1933    "target": "ES2022",
1934    "lib": ["ES2022", "DOM", "DOM.Iterable"],
1935    "module": "ESNext",
1936    "moduleResolution": "Bundler",
1937    "jsx": "react-jsx",
1938    "strict": true,
1939    "noEmit": true,
1940    "preserveSymlinks": true,
1941    "skipLibCheck": true,
1942    "types": ["vite/client"]
1943  },
1944  "include": ["views/**/*.js", "views/**/*.jsx", "views/**/*.ts", "views/**/*.tsx", "vite.config.ts", "vite.ssr.config.ts"]
1945}
1946"#
1947    .to_owned()
1948}
1949
1950fn managed_modules(entries: &[&str]) -> String {
1951    let body = entries.join("\n");
1952    if body.is_empty() {
1953        format!("{MODULES_START}\n{MODULES_END}\n")
1954    } else {
1955        format!("{MODULES_START}\n{body}\n{MODULES_END}\n")
1956    }
1957}
1958
1959fn add_rust_item(
1960    editor: &mut ProjectEditor,
1961    base: &str,
1962    name: &QualifiedName,
1963    content: &str,
1964) -> Result<(), ScaffoldError> {
1965    let mut directory = PathBuf::from(base);
1966    let mut parent_module = directory.join("mod.rs");
1967    for namespace in &name.modules {
1968        let module = snake_case(namespace);
1969        editor.update_managed_lines(
1970            &parent_module,
1971            MODULES_START,
1972            MODULES_END,
1973            &[format!("pub mod {module};")],
1974        )?;
1975        directory.push(&module);
1976        parent_module = directory.join("mod.rs");
1977    }
1978    let module = snake_case(&name.class);
1979    editor.create(directory.join(format!("{module}.rs")), content.to_owned())?;
1980    editor.update_managed_lines(
1981        &parent_module,
1982        MODULES_START,
1983        MODULES_END,
1984        &[
1985            format!("pub mod {module};"),
1986            format!("pub use {module}::{};", name.class),
1987        ],
1988    )?;
1989    Ok(())
1990}
1991
1992fn add_command(editor: &mut ProjectEditor, name: &QualifiedName) -> Result<(), ScaffoldError> {
1993    let function_name = snake_case(&name.class);
1994    if matches!(function_name.as_str(), "serve" | "help") {
1995        return Err(ScaffoldError::InvalidName(function_name));
1996    }
1997
1998    let mut directory = PathBuf::from("app/commands");
1999    let mut parent_module = directory.join("mod.rs");
2000    let mut export_path = Vec::new();
2001    for namespace in &name.modules {
2002        let module = snake_case(namespace);
2003        editor.update_managed_lines(
2004            &parent_module,
2005            MODULES_START,
2006            MODULES_END,
2007            &[format!("pub mod {module};")],
2008        )?;
2009        directory.push(&module);
2010        parent_module = directory.join("mod.rs");
2011        export_path.push(module);
2012    }
2013
2014    editor.create(
2015        directory.join(format!("{function_name}.rs")),
2016        command_template(&function_name),
2017    )?;
2018    editor.update_managed_lines(
2019        &parent_module,
2020        MODULES_START,
2021        MODULES_END,
2022        &[
2023            format!("pub mod {function_name};"),
2024            format!("pub use {function_name}::{function_name};"),
2025        ],
2026    )?;
2027
2028    if !export_path.is_empty() {
2029        let path = format!("{}::{function_name}", export_path.join("::"));
2030        editor.update_managed_lines(
2031            "app/commands/mod.rs",
2032            MODULES_START,
2033            MODULES_END,
2034            &[format!("pub use {path};")],
2035        )?;
2036    }
2037
2038    editor.update_managed_lines(
2039        "app/commands/mod.rs",
2040        COMMANDS_START,
2041        COMMANDS_END,
2042        &[format!("{function_name},")],
2043    )?;
2044    Ok(())
2045}
2046
2047fn add_model(editor: &mut ProjectEditor, model: &QualifiedName) -> Result<(), ScaffoldError> {
2048    add_rust_item(editor, "app/models", model, &model_template(&model.class))?;
2049    let path = if model.modules.is_empty() {
2050        model.class.clone()
2051    } else {
2052        format!(
2053            "{}::{}",
2054            model
2055                .modules
2056                .iter()
2057                .map(|part| snake_case(part))
2058                .collect::<Vec<_>>()
2059                .join("::"),
2060            model.class
2061        )
2062    };
2063    editor.update_registry(
2064        "app/models/mod.rs",
2065        MODELS_START,
2066        MODELS_END,
2067        "model",
2068        &path,
2069        render_model_registry,
2070    )
2071}
2072
2073fn add_model_migration(
2074    editor: &mut ProjectEditor,
2075    model: &QualifiedName,
2076) -> Result<(), ScaffoldError> {
2077    let table = pluralize(&snake_case(&model.class));
2078    add_migration(editor, &format!("create_{table}_table"), &table)
2079}
2080
2081fn add_migration(editor: &mut ProjectEditor, name: &str, table: &str) -> Result<(), ScaffoldError> {
2082    let milliseconds = SystemTime::now()
2083        .duration_since(UNIX_EPOCH)
2084        .map_err(|_| ScaffoldError::InvalidClock)?
2085        .as_millis();
2086    let id = milliseconds.to_string();
2087    let module = format!("m_{id}_{name}");
2088    editor.create(
2089        format!("database/migrations/{module}.rs"),
2090        migration_template(&id, name, table),
2091    )?;
2092    editor.update_registry(
2093        "database/migrations/mod.rs",
2094        MIGRATIONS_START,
2095        MIGRATIONS_END,
2096        "migration",
2097        &module,
2098        render_migration_registry,
2099    )
2100}
2101
2102fn add_controller_route(
2103    editor: &mut ProjectEditor,
2104    controller: &QualifiedName,
2105    resource: bool,
2106    action: Option<(&QualifiedName, &QualifiedName)>,
2107) -> Result<(), ScaffoldError> {
2108    let base = controller
2109        .class
2110        .strip_suffix("Controller")
2111        .unwrap_or(&controller.class);
2112    let plural = pluralize(&snake_case(base));
2113    let namespace_modules = controller
2114        .modules
2115        .iter()
2116        .map(|part| snake_case(part))
2117        .collect::<Vec<_>>();
2118    let route_file = if namespace_modules.is_empty() {
2119        plural.clone()
2120    } else {
2121        format!("{}_{}", namespace_modules.join("_"), plural)
2122    };
2123    let import = if namespace_modules.is_empty() {
2124        format!("crate::controllers::{}", controller.class)
2125    } else {
2126        format!(
2127            "crate::controllers::{}::{}",
2128            namespace_modules.join("::"),
2129            controller.class
2130        )
2131    };
2132    let route_name = if namespace_modules.is_empty() {
2133        plural.clone()
2134    } else {
2135        format!("{}.{}", namespace_modules.join("."), plural)
2136    };
2137    let path = if namespace_modules.is_empty() {
2138        format!("/{}", plural.replace('_', "-"))
2139    } else {
2140        format!(
2141            "/{}/{}",
2142            namespace_modules
2143                .iter()
2144                .map(|part| kebab_case(part))
2145                .collect::<Vec<_>>()
2146                .join("/"),
2147            plural.replace('_', "-")
2148        )
2149    };
2150    editor.create(
2151        format!("routes/{route_file}.rs"),
2152        controller_route_template(
2153            &import,
2154            &route_name,
2155            &path,
2156            &controller.class,
2157            resource,
2158            action,
2159        ),
2160    )
2161}
2162
2163fn add_page(
2164    editor: &mut ProjectEditor,
2165    page: &PageName,
2166    frontend: ProjectFrontend,
2167) -> Result<(), ScaffoldError> {
2168    let props = page_props_name(page);
2169    add_rust_item(
2170        editor,
2171        "app/props",
2172        &props,
2173        &page_props_template(&props.class, &page.route),
2174    )?;
2175    let mut path = PathBuf::from("views/pages");
2176    for part in &page.parts[..page.parts.len() - 1] {
2177        path.push(kebab_case(part));
2178    }
2179    path.push(format!(
2180        "{}.{}",
2181        kebab_case(page.parts.last().expect("page has one part")),
2182        frontend.extension()
2183    ));
2184    editor.create(
2185        path,
2186        page_template(&page.class, &props.class, page.parts.len(), frontend),
2187    )
2188}
2189
2190fn page_props_name(page: &PageName) -> QualifiedName {
2191    QualifiedName {
2192        modules: page.parts[..page.parts.len() - 1].to_vec(),
2193        class: format!("{}Props", page.class),
2194    }
2195}
2196
2197fn model_template(name: &str) -> String {
2198    format!(
2199        r"use phoenix::database::Model;
2200
2201#[derive(Debug, Model)]
2202pub struct {name} {{
2203    #[key]
2204    #[auto]
2205    pub id: u64,
2206    pub name: String,
2207}}
2208"
2209    )
2210}
2211
2212fn request_template(name: &str) -> String {
2213    format!(
2214        r#"use phoenix::prelude::{{Validate, ValidationErrors, Validator, max_length, required, rules, string}};
2215use serde::Deserialize;
2216
2217#[phoenix::contract(input)]
2218#[derive(Debug, Deserialize)]
2219pub struct {name} {{
2220    pub name: String,
2221}}
2222
2223impl Validate for {name} {{
2224    fn validate(&self) -> Result<(), ValidationErrors> {{
2225        let data = serde_json::json!({{ "name": self.name }});
2226        Validator::new(&data)
2227            .field("name", rules![required(), string(), max_length(255)])
2228            .validate()
2229    }}
2230}}
2231"#
2232    )
2233}
2234
2235fn resource_template(name: &str) -> String {
2236    format!(
2237        r#"use serde::Serialize;
2238
2239#[phoenix::contract(resource)]
2240#[derive(Clone, Debug, Serialize)]
2241#[serde(rename_all = "camelCase")]
2242pub struct {name} {{
2243    pub id: String,
2244    pub name: String,
2245}}
2246"#
2247    )
2248}
2249
2250fn controller_template(name: &str, resource: bool) -> String {
2251    if !resource {
2252        return format!(
2253            r#"use phoenix::prelude::{{Request, Response}};
2254
2255pub struct {name};
2256
2257impl {name} {{
2258    #[allow(clippy::unused_async)]
2259    pub async fn index(_request: Request) -> Response {{
2260        Response::text("{name}@index")
2261    }}
2262}}
2263"#
2264        );
2265    }
2266    format!(
2267        r#"use phoenix::prelude::{{Request, Response, StatusCode}};
2268
2269pub struct {name};
2270
2271impl {name} {{
2272    #[allow(clippy::unused_async)]
2273    pub async fn index(_request: Request) -> Response {{ Response::text("{name}@index") }}
2274
2275    #[allow(clippy::unused_async)]
2276    pub async fn create(_request: Request) -> Response {{ Response::text("{name}@create") }}
2277
2278    #[allow(clippy::unused_async)]
2279    pub async fn store(_request: Request) -> Response {{
2280        Response::text("{name}@store").with_status(StatusCode::CREATED)
2281    }}
2282
2283    #[allow(clippy::unused_async)]
2284    pub async fn show(_request: Request) -> Response {{ Response::text("{name}@show") }}
2285
2286    #[allow(clippy::unused_async)]
2287    pub async fn edit(_request: Request) -> Response {{ Response::text("{name}@edit") }}
2288
2289    #[allow(clippy::unused_async)]
2290    pub async fn update(_request: Request) -> Response {{ Response::text("{name}@update") }}
2291
2292    #[allow(clippy::unused_async)]
2293    pub async fn destroy(_request: Request) -> Response {{
2294        Response::new(StatusCode::NO_CONTENT, phoenix::http::Bytes::new())
2295    }}
2296}}
2297"#
2298    )
2299}
2300
2301fn model_controller_template(
2302    controller: &QualifiedName,
2303    request: &QualifiedName,
2304    resource: &QualifiedName,
2305    props: &QualifiedName,
2306    page: &str,
2307) -> String {
2308    let request_path = rust_item_path("requests", request);
2309    let resource_path = rust_item_path("resources", resource);
2310    let props_path = rust_item_path("props", props);
2311    let name = &controller.class;
2312    let title = page
2313        .split('/')
2314        .next()
2315        .map_or_else(|| "Items".to_owned(), pascal_case);
2316    format!(
2317        r#"use phoenix::prelude::{{Json, Page, PageResponseError, Request, Response, StatusCode, Validated}};
2318
2319use {props_path};
2320use {request_path};
2321use {resource_path};
2322
2323pub struct {name};
2324
2325impl {name} {{
2326    pub async fn index(request: Request) -> Result<Response, PageResponseError> {{
2327        Page::new("{page}", {props_class} {{ title: "{title}".to_owned() }})
2328            .spa()
2329            .respond_to(&request, None)
2330    }}
2331
2332    #[allow(clippy::unused_async)]
2333    pub async fn create(_request: Request) -> Response {{ Response::text("{name}@create") }}
2334
2335    #[allow(clippy::unused_async)]
2336    pub async fn store(
2337        Validated(Json(input)): Validated<Json<{request_class}>>,
2338    ) -> (StatusCode, Json<{resource_class}>) {{
2339        (
2340            StatusCode::CREATED,
2341            Json({resource_class} {{ id: "generated".to_owned(), name: input.name }}),
2342        )
2343    }}
2344
2345    #[allow(clippy::unused_async)]
2346    pub async fn show(_request: Request) -> Response {{ Response::text("{name}@show") }}
2347
2348    #[allow(clippy::unused_async)]
2349    pub async fn edit(_request: Request) -> Response {{ Response::text("{name}@edit") }}
2350
2351    #[allow(clippy::unused_async)]
2352    pub async fn update(_request: Request) -> Response {{ Response::text("{name}@update") }}
2353
2354    #[allow(clippy::unused_async)]
2355    pub async fn destroy(_request: Request) -> Response {{
2356        Response::new(StatusCode::NO_CONTENT, phoenix::http::Bytes::new())
2357    }}
2358}}
2359"#,
2360        props_class = props.class,
2361        request_class = request.class,
2362        resource_class = resource.class,
2363    )
2364}
2365
2366fn middleware_template(name: &str) -> String {
2367    format!(
2368        r"use phoenix::prelude::{{BoxFuture, Middleware, Next, Request, Response}};
2369
2370pub struct {name};
2371
2372impl Middleware for {name} {{
2373    fn handle(&self, request: Request, next: Next) -> BoxFuture<Response> {{
2374        Box::pin(async move {{
2375            // Add authorization, request context, or response policy here.
2376            next.run(request).await
2377        }})
2378    }}
2379}}
2380"
2381    )
2382}
2383
2384fn migration_template(id: &str, name: &str, table: &str) -> String {
2385    format!(
2386        r#"use phoenix::database::Migration;
2387
2388#[must_use]
2389pub fn migration() -> Migration {{
2390    Migration::new("{id}", "{description}")
2391        .up("CREATE TABLE {table} (id BIGINT PRIMARY KEY, name TEXT NOT NULL)")
2392        .down("DROP TABLE {table}")
2393}}
2394"#,
2395        description = name.replace('_', " "),
2396    )
2397}
2398
2399fn controller_route_template(
2400    import: &str,
2401    route_name: &str,
2402    path: &str,
2403    controller: &str,
2404    resource: bool,
2405    action: Option<(&QualifiedName, &QualifiedName)>,
2406) -> String {
2407    if !resource {
2408        return format!(
2409            "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"
2410        );
2411    }
2412    let parameter = snake_case(controller.strip_suffix("Controller").unwrap_or(controller));
2413    let (prelude, action_imports, store, action_binding) = action.map_or_else(
2414        || {
2415            (
2416                "Routes".to_owned(),
2417                String::new(),
2418                format!("{controller}::store"),
2419                String::new(),
2420            )
2421        },
2422        |(input, output)| {
2423            (
2424                "Routes, typed".to_owned(),
2425                format!(
2426                    "use {};\nuse {};\n",
2427                    rust_item_path("requests", input),
2428                    rust_item_path("resources", output),
2429                ),
2430                format!("typed({controller}::store)"),
2431                format!("\n        .action::<{}, {}>()", input.class, output.class),
2432            )
2433        },
2434    );
2435    format!(
2436        r#"use phoenix::prelude::{{{prelude}}};
2437
2438use {import};
2439{action_imports}
2440
2441#[must_use]
2442pub fn routes() -> Routes {{
2443    let member = "{path}/{{{parameter}}}";
2444    Routes::new()
2445        .get("{path}", {controller}::index)
2446        .name("{route_name}.index")
2447        .get("{path}/create", {controller}::create)
2448        .name("{route_name}.create")
2449        .post("{path}", {store})
2450        .name("{route_name}.store"){action_binding}
2451        .get(member, {controller}::show)
2452        .name("{route_name}.show")
2453        .get(format!("{{member}}/edit"), {controller}::edit)
2454        .name("{route_name}.edit")
2455        .put(member, {controller}::update)
2456        .name("{route_name}.update")
2457        .patch(member, {controller}::update)
2458        .delete(member, {controller}::destroy)
2459        .name("{route_name}.destroy")
2460}}
2461"#
2462    )
2463}
2464
2465fn rust_item_path(category: &str, name: &QualifiedName) -> String {
2466    if name.modules.is_empty() {
2467        format!("crate::{category}::{}", name.class)
2468    } else {
2469        format!(
2470            "crate::{category}::{}::{}",
2471            name.modules
2472                .iter()
2473                .map(|part| snake_case(part))
2474                .collect::<Vec<_>>()
2475                .join("::"),
2476            name.class,
2477        )
2478    }
2479}
2480
2481fn page_props_template(name: &str, route: &str) -> String {
2482    format!(
2483        r#"use serde::Serialize;
2484
2485#[phoenix::contract(page, page = "{route}")]
2486#[derive(Serialize)]
2487#[serde(rename_all = "camelCase")]
2488pub struct {name} {{
2489    pub title: String,
2490}}
2491"#
2492    )
2493}
2494
2495fn page_template(component: &str, props: &str, depth: usize, frontend: ProjectFrontend) -> String {
2496    let contracts = format!("{}generated/contracts.js", "../".repeat(depth));
2497    if frontend == ProjectFrontend::Jsx {
2498        return format!(
2499            r#"export default function {component}({{ title }}) {{
2500  return (
2501    <main>
2502      <h1>{{title}}</h1>
2503    </main>
2504  );
2505}}
2506"#
2507        );
2508    }
2509    format!(
2510        r#"import type {{ {props} }} from "{contracts}";
2511
2512export default function {component}({{ title }}: {props}) {{
2513  return (
2514    <main>
2515      <h1>{{title}}</h1>
2516    </main>
2517  );
2518}}
2519"#
2520    )
2521}
2522
2523fn island_template(component: &str, frontend: ProjectFrontend) -> String {
2524    if frontend == ProjectFrontend::Jsx {
2525        return format!(
2526            r#"import {{ useState }} from "react";
2527
2528export default function {component}({{ initialCount = 0 }}) {{
2529  const [count, setCount] = useState(initialCount);
2530  return <button type="button" onClick={{() => setCount((value) => value + 1)}}>{{count}}</button>;
2531}}
2532"#
2533        );
2534    }
2535    format!(
2536        r#"import {{ useState }} from "react";
2537
2538export interface {component}Props {{
2539  initialCount?: number;
2540}}
2541
2542export default function {component}({{ initialCount = 0 }}: {component}Props) {{
2543  const [count, setCount] = useState(initialCount);
2544  return <button type="button" onClick={{() => setCount((value) => value + 1)}}>{{count}}</button>;
2545}}
2546"#
2547    )
2548}
2549
2550fn render_model_registry(values: &BTreeSet<String>) -> Vec<String> {
2551    let mut lines = values
2552        .iter()
2553        .map(|value| format!("// phoenix:model: {value}"))
2554        .collect::<Vec<_>>();
2555    if !lines.is_empty() {
2556        lines.push(String::new());
2557    }
2558    lines.extend([
2559        "#[must_use]".to_owned(),
2560        "pub fn all() -> phoenix::database::ModelSet {".to_owned(),
2561        "    phoenix::database::models!(".to_owned(),
2562    ]);
2563    lines.extend(values.iter().map(|value| format!("        {value},")));
2564    lines.extend(["    )".to_owned(), "}".to_owned()]);
2565    lines
2566}
2567
2568fn render_migration_registry(values: &BTreeSet<String>) -> Vec<String> {
2569    let mut lines = Vec::new();
2570    for value in values {
2571        lines.push(format!("// phoenix:migration: {value}"));
2572        lines.push(format!("pub mod {value};"));
2573    }
2574    if !lines.is_empty() {
2575        lines.push(String::new());
2576    }
2577    lines.extend([
2578        "#[must_use]".to_owned(),
2579        "pub fn all() -> Vec<phoenix::database::Migration> {".to_owned(),
2580        "    vec![".to_owned(),
2581    ]);
2582    lines.extend(
2583        values
2584            .iter()
2585            .map(|value| format!("        {value}::migration(),")),
2586    );
2587    lines.extend(["    ]".to_owned(), "}".to_owned()]);
2588    lines
2589}
2590
2591struct ProjectEditor {
2592    root: PathBuf,
2593    force: bool,
2594    changes: BTreeMap<PathBuf, String>,
2595}
2596
2597impl ProjectEditor {
2598    fn new(root: &Path, force: bool) -> Self {
2599        Self {
2600            root: root.to_path_buf(),
2601            force,
2602            changes: BTreeMap::new(),
2603        }
2604    }
2605
2606    fn create(
2607        &mut self,
2608        relative: impl Into<PathBuf>,
2609        content: String,
2610    ) -> Result<(), ScaffoldError> {
2611        let relative = safe_relative(relative.into())?;
2612        let absolute = self.root.join(&relative);
2613        if !self.force && (absolute.exists() || self.changes.contains_key(&relative)) {
2614            return Err(ScaffoldError::AlreadyExists(absolute));
2615        }
2616        self.changes.insert(relative, content);
2617        Ok(())
2618    }
2619
2620    fn read(&self, relative: &Path) -> Result<String, ScaffoldError> {
2621        if let Some(content) = self.changes.get(relative) {
2622            return Ok(content.clone());
2623        }
2624        let absolute = self.root.join(relative);
2625        match fs::read_to_string(&absolute) {
2626            Ok(content) => Ok(content),
2627            Err(error) if error.kind() == ErrorKind::NotFound => Ok(String::new()),
2628            Err(source) => Err(ScaffoldError::Io {
2629                path: absolute,
2630                source,
2631            }),
2632        }
2633    }
2634
2635    fn update_managed_lines(
2636        &mut self,
2637        relative: impl AsRef<Path>,
2638        start: &str,
2639        end: &str,
2640        added: &[String],
2641    ) -> Result<(), ScaffoldError> {
2642        let relative = safe_relative(relative.as_ref().to_path_buf())?;
2643        let existing = self.read(&relative)?;
2644        let initialized = if existing.is_empty() {
2645            format!("{start}\n{end}\n")
2646        } else {
2647            existing
2648        };
2649        let (before, managed, after) = managed_parts(&initialized, start, end)
2650            .ok_or_else(|| ScaffoldError::InvalidManagedFile(self.root.join(&relative)))?;
2651        let mut lines = managed
2652            .lines()
2653            .map(str::trim)
2654            .filter(|line| !line.is_empty())
2655            .map(str::to_owned)
2656            .collect::<BTreeSet<_>>();
2657        lines.extend(added.iter().cloned());
2658        let body = lines.into_iter().collect::<Vec<_>>().join("\n");
2659        let body = if body.is_empty() {
2660            body
2661        } else {
2662            format!("{body}\n")
2663        };
2664        self.changes
2665            .insert(relative, format!("{before}{start}\n{body}{end}{after}"));
2666        Ok(())
2667    }
2668
2669    fn update_registry(
2670        &mut self,
2671        relative: impl AsRef<Path>,
2672        start: &str,
2673        end: &str,
2674        key: &str,
2675        value: &str,
2676        render: fn(&BTreeSet<String>) -> Vec<String>,
2677    ) -> Result<(), ScaffoldError> {
2678        let relative = safe_relative(relative.as_ref().to_path_buf())?;
2679        let existing = self.read(&relative)?;
2680        let initialized = if existing.is_empty() {
2681            format!("{start}\n{end}\n")
2682        } else {
2683            existing
2684        };
2685        let (before, managed, after) = managed_parts(&initialized, start, end)
2686            .ok_or_else(|| ScaffoldError::InvalidManagedFile(self.root.join(&relative)))?;
2687        let prefix = format!("// phoenix:{key}: ");
2688        let mut values = managed
2689            .lines()
2690            .filter_map(|line| line.trim().strip_prefix(&prefix).map(str::to_owned))
2691            .collect::<BTreeSet<_>>();
2692        values.insert(value.to_owned());
2693        let rendered = render(&values).join("\n");
2694        let body = if rendered.is_empty() {
2695            rendered
2696        } else {
2697            format!("{rendered}\n")
2698        };
2699        self.changes
2700            .insert(relative, format!("{before}{start}\n{body}{end}{after}"));
2701        Ok(())
2702    }
2703
2704    fn commit(self) -> Result<Vec<PathBuf>, ScaffoldError> {
2705        let mut written = Vec::with_capacity(self.changes.len());
2706        for (relative, content) in self.changes {
2707            let path = self.root.join(relative);
2708            if let Some(parent) = path.parent() {
2709                fs::create_dir_all(parent).map_err(|source| ScaffoldError::Io {
2710                    path: parent.to_path_buf(),
2711                    source,
2712                })?;
2713            }
2714            fs::write(&path, content).map_err(|source| ScaffoldError::Io {
2715                path: path.clone(),
2716                source,
2717            })?;
2718            written.push(path);
2719        }
2720        Ok(written)
2721    }
2722}
2723
2724fn managed_parts<'a>(
2725    content: &'a str,
2726    start: &str,
2727    end: &str,
2728) -> Option<(&'a str, &'a str, &'a str)> {
2729    let start_index = content.find(start)?;
2730    let managed_start = start_index + start.len();
2731    let end_relative = content[managed_start..].find(end)?;
2732    let end_index = managed_start + end_relative;
2733    if content[end_index + end.len()..].contains(end) || content[..start_index].contains(start) {
2734        return None;
2735    }
2736    Some((
2737        &content[..start_index],
2738        content[managed_start..end_index].trim_matches('\n'),
2739        &content[end_index + end.len()..],
2740    ))
2741}
2742
2743#[derive(Clone, Debug)]
2744struct QualifiedName {
2745    modules: Vec<String>,
2746    class: String,
2747}
2748
2749impl QualifiedName {
2750    fn parse(value: &str) -> Result<Self, ScaffoldError> {
2751        let parts = name_parts(value)?;
2752        let class = pascal_case(parts.last().expect("validated names have a leaf"));
2753        let modules = parts[..parts.len() - 1]
2754            .iter()
2755            .map(|part| pascal_case(part))
2756            .collect();
2757        Ok(Self { modules, class })
2758    }
2759
2760    fn parse_with_suffix(value: &str, suffix: &str) -> Result<Self, ScaffoldError> {
2761        let mut name = Self::parse(value)?;
2762        if !name.class.ends_with(suffix) {
2763            name.class.push_str(suffix);
2764        }
2765        Ok(name)
2766    }
2767
2768    fn with_leaf(&self, class: String) -> Self {
2769        Self {
2770            modules: self.modules.clone(),
2771            class,
2772        }
2773    }
2774
2775    fn index_page_name(&self) -> PageName {
2776        let mut parts = self.modules.clone();
2777        parts.push(pluralize(&self.class));
2778        parts.push("Index".to_owned());
2779        PageName::from_parts(parts)
2780    }
2781}
2782
2783#[derive(Clone, Debug)]
2784struct PageName {
2785    parts: Vec<String>,
2786    route: String,
2787    class: String,
2788}
2789
2790impl PageName {
2791    fn parse(value: &str) -> Result<Self, ScaffoldError> {
2792        let parts = name_parts(value)?
2793            .into_iter()
2794            .map(|part| pascal_case(&part))
2795            .collect();
2796        Ok(Self::from_parts(parts))
2797    }
2798
2799    fn from_parts(parts: Vec<String>) -> Self {
2800        let route = parts
2801            .iter()
2802            .map(|part| kebab_case(part))
2803            .collect::<Vec<_>>()
2804            .join("/");
2805        let class = parts.iter().map(String::as_str).collect::<String>();
2806        Self {
2807            parts,
2808            route,
2809            class,
2810        }
2811    }
2812}
2813
2814fn name_parts(value: &str) -> Result<Vec<String>, ScaffoldError> {
2815    let normalized = value.replace("::", "/").replace('\\', "/");
2816    let parts = normalized
2817        .split('/')
2818        .filter(|part| !part.is_empty())
2819        .map(str::to_owned)
2820        .collect::<Vec<_>>();
2821    if parts.is_empty()
2822        || parts.iter().any(|part| {
2823            !part.chars().all(|character| {
2824                character.is_ascii_alphanumeric() || character == '_' || character == '-'
2825            }) || !part
2826                .chars()
2827                .any(|character| character.is_ascii_alphabetic())
2828        })
2829    {
2830        return Err(ScaffoldError::InvalidName(value.to_owned()));
2831    }
2832    Ok(parts)
2833}
2834
2835fn package_name(value: &str) -> Result<String, ScaffoldError> {
2836    let value = kebab_case(value).trim_matches('-').to_owned();
2837    if value.is_empty()
2838        || value.starts_with(|character: char| character.is_ascii_digit())
2839        || !value.chars().all(|character| {
2840            character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
2841        })
2842    {
2843        return Err(ScaffoldError::InvalidName(value));
2844    }
2845    Ok(value)
2846}
2847
2848fn snake_identifier(value: &str) -> Result<String, ScaffoldError> {
2849    let parts = name_parts(value)?;
2850    Ok(parts
2851        .iter()
2852        .map(|part| snake_case(part))
2853        .collect::<Vec<_>>()
2854        .join("_"))
2855}
2856
2857fn pascal_case(value: &str) -> String {
2858    words(value)
2859        .into_iter()
2860        .map(|word| {
2861            let mut characters = word.chars();
2862            characters.next().map_or_else(String::new, |first| {
2863                format!(
2864                    "{}{}",
2865                    first.to_ascii_uppercase(),
2866                    characters.as_str().to_ascii_lowercase()
2867                )
2868            })
2869        })
2870        .collect()
2871}
2872
2873fn snake_case(value: &str) -> String {
2874    words(value).join("_").to_ascii_lowercase()
2875}
2876
2877fn kebab_case(value: &str) -> String {
2878    words(value).join("-").to_ascii_lowercase()
2879}
2880
2881fn words(value: &str) -> Vec<String> {
2882    let mut output = String::new();
2883    let mut previous_lower_or_digit = false;
2884    for character in value.chars() {
2885        if character == '-' || character == '_' || character.is_ascii_whitespace() {
2886            if !output.ends_with('_') && !output.is_empty() {
2887                output.push('_');
2888            }
2889            previous_lower_or_digit = false;
2890        } else {
2891            if character.is_ascii_uppercase() && previous_lower_or_digit {
2892                output.push('_');
2893            }
2894            output.push(character);
2895            previous_lower_or_digit = character.is_ascii_lowercase() || character.is_ascii_digit();
2896        }
2897    }
2898    output
2899        .split('_')
2900        .filter(|part| !part.is_empty())
2901        .map(str::to_owned)
2902        .collect()
2903}
2904
2905fn pluralize(value: &str) -> String {
2906    if let Some(stem) = value.strip_suffix('y')
2907        && !stem.ends_with(['a', 'e', 'i', 'o', 'u'])
2908    {
2909        return format!("{stem}ies");
2910    }
2911    if value.ends_with(['s', 'x', 'z']) || value.ends_with("ch") || value.ends_with("sh") {
2912        format!("{value}es")
2913    } else {
2914        format!("{value}s")
2915    }
2916}
2917
2918fn inferred_table(migration: &str) -> &str {
2919    migration
2920        .strip_prefix("create_")
2921        .and_then(|name| name.strip_suffix("_table"))
2922        .unwrap_or(migration)
2923}
2924
2925fn safe_relative(path: PathBuf) -> Result<PathBuf, ScaffoldError> {
2926    if path.is_absolute()
2927        || path
2928            .components()
2929            .any(|component| !matches!(component, Component::Normal(_)))
2930    {
2931        return Err(ScaffoldError::InvalidName(path.display().to_string()));
2932    }
2933    Ok(path)
2934}
2935
2936fn absolute_path(path: impl AsRef<Path>) -> Result<PathBuf, ScaffoldError> {
2937    let path = path.as_ref();
2938    if path.is_absolute() {
2939        return Ok(path.to_path_buf());
2940    }
2941    env::current_dir()
2942        .map(|current| current.join(path))
2943        .map_err(|source| ScaffoldError::Io {
2944            path: path.to_path_buf(),
2945            source,
2946        })
2947}
2948
2949fn ensure_empty_target(path: &Path) -> Result<(), ScaffoldError> {
2950    match fs::read_dir(path) {
2951        Ok(mut entries) => {
2952            if entries.next().is_some() {
2953                Err(ScaffoldError::ProjectNotEmpty(path.to_path_buf()))
2954            } else {
2955                Ok(())
2956            }
2957        }
2958        Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
2959        Err(source) => Err(ScaffoldError::Io {
2960            path: path.to_path_buf(),
2961            source,
2962        }),
2963    }
2964}
2965
2966fn json_string(value: &str) -> String {
2967    serde_json::to_string(value).expect("strings always serialize")
2968}
2969
2970fn run_optional(program: &'static str, args: &[&str], cwd: &Path) -> Result<(), ScaffoldError> {
2971    match Command::new(program).args(args).current_dir(cwd).status() {
2972        Ok(status) if status.success() => Ok(()),
2973        Ok(_) => Err(ScaffoldError::CommandFailed { program }),
2974        Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
2975        Err(source) => Err(ScaffoldError::Io {
2976            path: cwd.to_path_buf(),
2977            source,
2978        }),
2979    }
2980}
2981
2982fn run_required(program: &'static str, args: &[&str], cwd: &Path) -> Result<(), ScaffoldError> {
2983    match Command::new(program).args(args).current_dir(cwd).status() {
2984        Ok(status) if status.success() => Ok(()),
2985        Ok(_) => Err(ScaffoldError::CommandFailed { program }),
2986        Err(source) => Err(ScaffoldError::Io {
2987            path: cwd.to_path_buf(),
2988            source,
2989        }),
2990    }
2991}
2992
2993#[cfg(test)]
2994mod tests {
2995    use super::*;
2996
2997    fn temporary_directory(label: &str) -> PathBuf {
2998        let id = SystemTime::now()
2999            .duration_since(UNIX_EPOCH)
3000            .unwrap()
3001            .as_nanos();
3002        env::temp_dir().join(format!("phoenix-cli-{label}-{}-{id}", std::process::id()))
3003    }
3004
3005    fn framework_root() -> PathBuf {
3006        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3007            .join("../..")
3008            .canonicalize()
3009            .unwrap()
3010    }
3011
3012    #[test]
3013    fn creates_a_complete_local_project_without_installing() {
3014        let root = temporary_directory("new");
3015        create_project(
3016            &NewProjectOptions::new(&root)
3017                .dependencies(DependencySource::Local(framework_root()))
3018                .database(Some(ProjectDatabase::Sqlite))
3019                .initialize_git(false)
3020                .install_dependencies(false),
3021        )
3022        .unwrap();
3023
3024        assert!(root.join("src/main.rs").is_file());
3025        assert!(root.join("src/bin/phoenix-manage.rs").is_file());
3026        assert!(root.join("config/app.toml").is_file());
3027        assert!(root.join("config/database.toml").is_file());
3028        assert!(
3029            root.join("config/schemas/phoenix-config-database.schema.json")
3030                .is_file()
3031        );
3032        assert!(root.join("taplo.toml").is_file());
3033        assert!(
3034            fs::read_to_string(root.join("config/database.toml"))
3035                .unwrap()
3036                .contains("connections.mysql")
3037        );
3038        assert!(root.join("app/commands/mod.rs").is_file());
3039        assert!(root.join("config/mod.rs").is_file());
3040        assert!(root.join("database/seeders/mod.rs").is_file());
3041        assert!(root.join("routes/web.rs").is_file());
3042        assert!(root.join("views/pages/home.tsx").is_file());
3043        let manifest = fs::read_to_string(root.join("Cargo.toml")).unwrap();
3044        assert!(manifest.contains("crates/phoenix"));
3045        assert!(manifest.contains("default-run = \"phoenix-cli-new-"));
3046        assert!(manifest.contains("default = [\"sqlite\"]"));
3047        assert!(manifest.contains("sqlite = [\"database\", \"phoenix/sqlite\"]"));
3048        assert!(manifest.contains("features = [\"migration\", \"serde\", \"sqlite\"]"));
3049        assert!(manifest.contains("tls = [\"phoenix/tls\"]"));
3050        assert!(manifest.contains("websocket = [\"phoenix/websocket\"]"));
3051        assert!(manifest.contains("sse = [\"phoenix/sse\"]"));
3052        assert!(manifest.contains("default-features = false"));
3053        assert!(manifest.contains("opt-level = \"z\""));
3054        assert!(manifest.contains("strip = \"symbols\""));
3055        assert!(
3056            fs::read_to_string(root.join("package.json"))
3057                .unwrap()
3058                .contains("file:")
3059        );
3060        let main = fs::read_to_string(root.join("src/main.rs")).unwrap();
3061        assert!(main.contains("Console::new"));
3062        assert!(main.contains("commands::registry()"));
3063        assert!(main.contains(".serve("));
3064        let commands = fs::read_to_string(root.join("app/commands/mod.rs")).unwrap();
3065        assert!(commands.contains("commands!"));
3066        assert!(commands.contains("<phoenix:commands>"));
3067        let application = fs::read_to_string(root.join("src/lib.rs")).unwrap();
3068        assert!(application.contains("pub mod commands"));
3069        assert!(application.contains("NonceSecurityPolicy::development"));
3070        assert!(application.contains("with_middleware(content_security_policy(config))"));
3071        assert!(application.contains("with_middleware(RequestId)"));
3072        assert!(application.contains("with_middleware(AccessLog)"));
3073        assert!(application.contains("SessionMiddleware::new"));
3074        assert!(application.contains("with_middleware(Csrf)"));
3075        assert!(application.contains("TrustedProxies::new"));
3076        assert!(application.contains("HostAllowlist::new"));
3077        assert!(application.contains("RateLimit::new"));
3078        assert!(application.contains("StateMiddleware::new(config.clone())"));
3079        assert!(application.contains("StateMiddleware::new(renderer.clone())"));
3080        assert!(application.contains("RendererConfig::production"));
3081        assert!(application.contains("renderer.warm_up().await"));
3082        assert!(application.contains("let production = config.environment().is_production()"));
3083        assert!(application.contains("RendererConfig::node(\"public/ssr/renderer.js\")"));
3084        let home_controller =
3085            fs::read_to_string(root.join("app/controllers/home_controller.rs")).unwrap();
3086        assert!(home_controller.contains("get::<NodeRenderer>().cloned()"));
3087        assert!(home_controller.contains("respond_with_renderer(&request, &renderer).await"));
3088        let home_route = fs::read_to_string(root.join("routes/web.rs")).unwrap();
3089        assert!(home_route.contains(".get(\"/\", HomeController::index)"));
3090        let cargo = env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
3091        let status = Command::new(cargo)
3092            .args(["check", "--quiet"])
3093            .current_dir(&root)
3094            .status()
3095            .unwrap();
3096        assert!(status.success());
3097        let config = fs::read_to_string(root.join("config/mod.rs")).unwrap();
3098        assert!(config.contains("AppConfig::load()"));
3099        assert!(config.lines().count() < 20);
3100        let manager = fs::read_to_string(root.join("src/bin/phoenix-manage.rs")).unwrap();
3101        assert!(manager.contains("MigrationRunner::new"));
3102        assert!(manager.contains("migrations::all()"));
3103        assert!(manager.contains("seeders::run"));
3104        let models = fs::read_to_string(root.join("app/models/mod.rs")).unwrap();
3105        let migrations = fs::read_to_string(root.join("database/migrations/mod.rs")).unwrap();
3106        assert!(models.contains("pub fn all()"));
3107        assert!(migrations.contains("pub fn all()"));
3108        fs::remove_dir_all(root).unwrap();
3109    }
3110
3111    #[test]
3112    fn default_project_is_islands_without_database_or_git() {
3113        let root = temporary_directory("default-options");
3114        let options = NewProjectOptions::new(&root)
3115            .dependencies(DependencySource::Local(framework_root()))
3116            .install_dependencies(false);
3117        assert!(!options.initialize_git);
3118        assert_eq!(options.render_mode, ProjectRenderMode::Islands);
3119        assert_eq!(options.database, None);
3120        assert_eq!(options.frontend, ProjectFrontend::Tsx);
3121        create_project(&options).unwrap();
3122
3123        let cargo = fs::read_to_string(root.join("Cargo.toml")).unwrap();
3124        assert!(cargo.contains("default = []"));
3125        assert!(!cargo.contains("toasty ="));
3126        assert!(!root.join("config/database.toml").exists());
3127        assert!(!root.join("src/bin/phoenix-manage.rs").exists());
3128        assert!(
3129            fs::read_to_string(root.join("app/controllers/home_controller.rs"))
3130                .unwrap()
3131                .contains(".islands()")
3132        );
3133        assert!(!root.join(".git").exists());
3134
3135        let cargo = env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
3136        let status = Command::new(cargo)
3137            .args(["check", "--quiet"])
3138            .current_dir(&root)
3139            .status()
3140            .unwrap();
3141        assert!(status.success());
3142        fs::remove_dir_all(root).unwrap();
3143    }
3144
3145    #[test]
3146    fn project_options_select_jsx_tailwind_and_database() {
3147        let root = temporary_directory("options");
3148        create_project(
3149            &NewProjectOptions::new(&root)
3150                .dependencies(DependencySource::Local(framework_root()))
3151                .database(Some(ProjectDatabase::Pgsql))
3152                .render_mode(ProjectRenderMode::Ssr)
3153                .frontend(ProjectFrontend::Jsx)
3154                .tailwind(true)
3155                .install_dependencies(false),
3156        )
3157        .unwrap();
3158
3159        let cargo = fs::read_to_string(root.join("Cargo.toml")).unwrap();
3160        assert!(cargo.contains("default = [\"pgsql\"]"));
3161        assert!(cargo.contains("\"postgresql\""));
3162        assert!(root.join("views/pages/home.jsx").is_file());
3163        assert!(!root.join("views/pages/home.tsx").exists());
3164        assert!(
3165            fs::read_to_string(root.join("app/controllers/home_controller.rs"))
3166                .unwrap()
3167                .contains(".ssr()")
3168        );
3169        assert!(
3170            fs::read_to_string(root.join("package.json"))
3171                .unwrap()
3172                .contains("@tailwindcss/vite")
3173        );
3174        assert!(
3175            fs::read_to_string(root.join("views/styles.css"))
3176                .unwrap()
3177                .starts_with("@import \"tailwindcss\";")
3178        );
3179        assert!(
3180            fs::read_to_string(root.join("vite.config.ts"))
3181                .unwrap()
3182                .contains("tailwindcss()")
3183        );
3184        let generator = ProjectGenerator::discover(&root).unwrap();
3185        generator
3186            .page("reports/index", GenerateOptions::default())
3187            .unwrap();
3188        generator
3189            .island("Counter", GenerateOptions::default())
3190            .unwrap();
3191        assert!(root.join("views/pages/reports/index.jsx").is_file());
3192        assert!(root.join("views/islands/counter.jsx").is_file());
3193        assert!(
3194            !fs::read_to_string(root.join("views/pages/reports/index.jsx"))
3195                .unwrap()
3196                .contains("import type")
3197        );
3198        fs::remove_dir_all(root).unwrap();
3199    }
3200
3201    #[test]
3202    fn all_database_option_writes_all_toasty_drivers() {
3203        let root = temporary_directory("all-databases");
3204        create_project(
3205            &NewProjectOptions::new(&root)
3206                .dependencies(DependencySource::Local(framework_root()))
3207                .database(Some(ProjectDatabase::All))
3208                .install_dependencies(false),
3209        )
3210        .unwrap();
3211        let cargo = fs::read_to_string(root.join("Cargo.toml")).unwrap();
3212        assert!(cargo.contains(
3213            "toasty = { version = \"0.8\", default-features = false, features = [\"migration\", \"mysql\", \"postgresql\", \"serde\", \"sqlite\"] }"
3214        ));
3215        assert!(cargo.contains("all-databases = [\"database\", \"phoenix/sqlite\", \"phoenix/pgsql\", \"phoenix/mysql\"]"));
3216        let cargo = env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
3217        let status = Command::new(cargo)
3218            .args(["check", "--quiet"])
3219            .current_dir(&root)
3220            .status()
3221            .unwrap();
3222        assert!(status.success());
3223        fs::remove_dir_all(root).unwrap();
3224    }
3225
3226    #[test]
3227    fn make_command_registers_async_handler() {
3228        let root = temporary_directory("command");
3229        create_project(
3230            &NewProjectOptions::new(&root)
3231                .dependencies(DependencySource::Local(framework_root()))
3232                .database(Some(ProjectDatabase::Sqlite))
3233                .initialize_git(false)
3234                .install_dependencies(false),
3235        )
3236        .unwrap();
3237        let generator = ProjectGenerator::discover(&root).unwrap();
3238        generator
3239            .command("Update", GenerateOptions::default())
3240            .unwrap();
3241
3242        assert!(root.join("app/commands/update.rs").is_file());
3243        let module = fs::read_to_string(root.join("app/commands/mod.rs")).unwrap();
3244        assert!(module.contains("pub mod update;"));
3245        assert!(module.contains("pub use update::update;"));
3246        assert!(module.contains("update,"));
3247        let command = fs::read_to_string(root.join("app/commands/update.rs")).unwrap();
3248        assert!(command.contains("pub async fn update"));
3249        assert!(command.contains("CommandContext<'_>"));
3250        fs::remove_dir_all(root).unwrap();
3251    }
3252
3253    #[test]
3254    fn model_all_registers_every_supported_business_artifact() {
3255        let root = temporary_directory("model-all");
3256        create_project(
3257            &NewProjectOptions::new(&root)
3258                .dependencies(DependencySource::Local(framework_root()))
3259                .database(Some(ProjectDatabase::Sqlite))
3260                .initialize_git(false)
3261                .install_dependencies(false),
3262        )
3263        .unwrap();
3264        let generator = ProjectGenerator::discover(&root).unwrap();
3265        generator
3266            .model(
3267                "Admin/Post",
3268                ModelOptions {
3269                    all: true,
3270                    ..ModelOptions::default()
3271                },
3272            )
3273            .unwrap();
3274        generator.model("Comment", ModelOptions::default()).unwrap();
3275
3276        assert!(root.join("app/models/admin/post.rs").is_file());
3277        assert!(
3278            root.join("app/controllers/admin/post_controller.rs")
3279                .is_file()
3280        );
3281        assert!(
3282            root.join("app/requests/admin/store_post_request.rs")
3283                .is_file()
3284        );
3285        assert!(root.join("app/resources/admin/post_resource.rs").is_file());
3286        assert!(root.join("routes/admin_posts.rs").is_file());
3287        assert!(root.join("views/pages/admin/posts/index.tsx").is_file());
3288        let routes = fs::read_to_string(root.join("routes/admin_posts.rs")).unwrap();
3289        assert!(routes.contains(".name(\"admin.posts.index\")"));
3290        assert!(routes.contains(".name(\"admin.posts.destroy\")"));
3291        assert!(routes.contains("typed(PostController::store)"));
3292        assert!(routes.contains(".action::<StorePostRequest, PostResource>()"));
3293        let controller =
3294            fs::read_to_string(root.join("app/controllers/admin/post_controller.rs")).unwrap();
3295        assert!(controller.contains("Validated(Json(input))"));
3296        assert!(controller.contains("Page::new(\"admin/posts/index\""));
3297        let page = fs::read_to_string(root.join("views/pages/admin/posts/index.tsx")).unwrap();
3298        assert!(page.contains("../../../generated/contracts.js"));
3299        let models = fs::read_to_string(root.join("app/models/mod.rs")).unwrap();
3300        assert!(models.contains("admin::Post"));
3301        assert!(models.contains("Comment"));
3302        let migrations = fs::read_to_string(root.join("database/migrations/mod.rs")).unwrap();
3303        assert!(migrations.contains("pub fn all()"));
3304        fs::remove_dir_all(root).unwrap();
3305    }
3306
3307    #[test]
3308    fn update_core_refreshes_framework_files_only() {
3309        let root = temporary_directory("update-core");
3310        create_project(
3311            &NewProjectOptions::new(&root)
3312                .dependencies(DependencySource::Local(framework_root()))
3313                .initialize_git(false)
3314                .install_dependencies(false),
3315        )
3316        .unwrap();
3317
3318        let route_before = fs::read_to_string(root.join("routes/web.rs")).unwrap();
3319        let page_before = fs::read_to_string(root.join("views/pages/home.tsx")).unwrap();
3320        fs::write(root.join("src/lib.rs"), "// stale core\n").unwrap();
3321        fs::write(
3322            root.join("routes/web.rs"),
3323            format!("{route_before}\n// business marker\n"),
3324        )
3325        .unwrap();
3326
3327        let generator = ProjectGenerator::discover(&root).unwrap();
3328        let changed = generator
3329            .update_core(
3330                &UpdateProjectOptions::new()
3331                    .dependencies(DependencySource::Local(framework_root()))
3332                    .install_dependencies(false),
3333            )
3334            .unwrap();
3335        assert!(
3336            changed
3337                .iter()
3338                .any(|path| path.ends_with("src/lib.rs")),
3339            "expected src/lib.rs to be refreshed"
3340        );
3341
3342        let lib = fs::read_to_string(root.join("src/lib.rs")).unwrap();
3343        assert!(lib.contains("let production = config.environment().is_production()"));
3344        assert!(!lib.contains("// stale core"));
3345        let route_after = fs::read_to_string(root.join("routes/web.rs")).unwrap();
3346        assert!(route_after.contains("// business marker"));
3347        assert_eq!(
3348            fs::read_to_string(root.join("views/pages/home.tsx")).unwrap(),
3349            page_before
3350        );
3351        let options = fs::read_to_string(root.join(".phoenix")).unwrap();
3352        assert!(options.contains("render_mode=islands"));
3353        assert!(options.contains("database=none"));
3354        fs::remove_dir_all(root).unwrap();
3355    }
3356
3357    #[test]
3358    fn generators_refuse_overwrites_and_path_traversal() {
3359        let root = temporary_directory("safety");
3360        create_project(
3361            &NewProjectOptions::new(&root)
3362                .dependencies(DependencySource::Local(framework_root()))
3363                .database(Some(ProjectDatabase::Sqlite))
3364                .initialize_git(false)
3365                .install_dependencies(false),
3366        )
3367        .unwrap();
3368        let generator = ProjectGenerator::discover(&root).unwrap();
3369        generator
3370            .controller("Report", ControllerOptions::default())
3371            .unwrap();
3372        assert!(matches!(
3373            generator.controller("Report", ControllerOptions::default()),
3374            Err(ScaffoldError::AlreadyExists(_))
3375        ));
3376        assert!(matches!(
3377            generator.page("../outside", GenerateOptions::default()),
3378            Err(ScaffoldError::InvalidName(_))
3379        ));
3380        fs::remove_dir_all(root).unwrap();
3381    }
3382}