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 vite_dev_server = std::env::var_os("PHOENIX_VITE_DEV").is_some();
1718    let (assets, renderer) = if !vite_dev_server {
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        // `px dev` sets PHOENIX_VITE_DEV so this process uses Vite's browser
1727        // entry while HMR/full reload remains live.
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::config::AppConfig;
1774use crate::props::HomeProps;
1775
1776pub struct HomeController;
1777
1778impl HomeController {{
1779    pub async fn index(request: Request) -> Response {{
1780        let renderer = request.extensions().get::<NodeRenderer>().cloned();
1781        let assets = request
1782            .extensions()
1783            .get::<Option<AssetManifest>>()
1784            .and_then(Option::as_ref);
1785        let vite_dev_url = request
1786            .extensions()
1787            .get::<AppConfig>()
1788            .and_then(AppConfig::vite_dev_url);
1789        let mut page = Page::new(
1790            "home",
1791            HomeProps {{
1792                title: "Phoenix is ready".to_owned(),
1793                description: "Rust owns the application contract; React renders the page.".to_owned(),
1794            }},
1795        )
1796        {render_mode};
1797        if let Some(assets) = assets {{
1798            page = match page.production_assets(assets, "client") {{
1799                Ok(page) => page,
1800                Err(error) => {{
1801                    return Response::text(format!("asset manifest error: {{error}}"))
1802                        .with_status(StatusCode::INTERNAL_SERVER_ERROR);
1803                }}
1804            }};
1805        }} else if let Some(vite_dev_url) = vite_dev_url {{
1806            page = page.script_src(format!(
1807                "{{}}/@id/__x00__virtual:phoenix/client",
1808                vite_dev_url.trim_end_matches('/'),
1809            ));
1810        }}
1811        match renderer {{
1812            Some(renderer) => page.respond_with_renderer(&request, &renderer).await,
1813            None => Response::text("Phoenix renderer is unavailable")
1814                .with_status(StatusCode::INTERNAL_SERVER_ERROR),
1815        }}
1816    }}
1817}}
1818"#,
1819        render_mode = render_mode.page_builder(),
1820    )
1821}
1822
1823fn home_props_template() -> String {
1824    r#"use serde::Serialize;
1825
1826#[phoenix::contract(page, page = "home")]
1827#[derive(Serialize)]
1828pub struct HomeProps {
1829    pub title: String,
1830    pub description: String,
1831}
1832"#
1833    .to_owned()
1834}
1835
1836fn home_route_template() -> String {
1837    r#"use phoenix::prelude::Routes;
1838
1839use crate::controllers::HomeController;
1840
1841#[must_use]
1842pub fn routes() -> Routes {
1843    Routes::new()
1844        .get("/", HomeController::index)
1845        .name("home")
1846}
1847"#
1848    .to_owned()
1849}
1850
1851fn home_page_template(frontend: ProjectFrontend) -> String {
1852    match frontend {
1853        ProjectFrontend::Tsx => r#"import type { HomeProps } from "../generated/contracts.js";
1854
1855export default function Home({ title, description }: HomeProps) {
1856  return (
1857    <main className="welcome">
1858      <p className="eyebrow">PHOENIX / RUST + REACT</p>
1859      <h1>{title}</h1>
1860      <p>{description}</p>
1861      <code>px make:model Post --all</code>
1862    </main>
1863  );
1864}
1865"#
1866        .to_owned(),
1867        ProjectFrontend::Jsx => r#"export default function Home({ title, description }) {
1868  return (
1869    <main className="welcome">
1870      <p className="eyebrow">PHOENIX / RUST + REACT</p>
1871      <h1>{title}</h1>
1872      <p>{description}</p>
1873      <code>px make:model Post --all</code>
1874    </main>
1875  );
1876}
1877"#
1878        .to_owned(),
1879    }
1880}
1881
1882fn styles_template(tailwind: bool) -> String {
1883    let tailwind = if tailwind {
1884        "@import \"tailwindcss\";\n\n"
1885    } else {
1886        ""
1887    };
1888    format!(
1889        r#"{tailwind}:root {{
1890  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
1891  color: #172033;
1892  background: #f5f7fb;
1893}}
1894* {{ box-sizing: border-box; }}
1895body {{ margin: 0; min-width: 320px; min-height: 100vh; }}
1896.welcome {{ width: min(760px, calc(100% - 40px)); margin: 16vh auto 0; }}
1897.eyebrow {{ color: #315bd6; font-size: 12px; font-weight: 800; letter-spacing: 0.14em; }}
1898h1 {{ margin: 12px 0; font-size: clamp(42px, 8vw, 76px); line-height: 0.98; }}
1899.welcome > p:not(.eyebrow) {{ max-width: 640px; color: #5d6879; font-size: 18px; line-height: 1.7; }}
1900code {{ display: inline-block; margin-top: 18px; padding: 12px 14px; border: 1px solid #d7dce5; background: white; }}
1901"#
1902    )
1903}
1904
1905fn generated_contracts_template() -> String {
1906    r#"// Generated by Phoenix. Vite will refresh this file from Rust contracts.
1907export interface HomeProps {
1908  title: string;
1909  description: string;
1910}
1911export interface PhoenixPageProps { home: HomeProps }
1912export type PhoenixSharedProps = Record<string, never>;
1913export const contractHash = "scaffold" as const;
1914"#
1915    .to_owned()
1916}
1917
1918fn generated_routes_template() -> String {
1919    r#"// Generated by Phoenix. Vite will refresh this file from Rust routes.
1920export const routes = { home: "home" } as const;
1921export type PhoenixRouteName = "home";
1922export const home = routes.home;
1923"#
1924    .to_owned()
1925}
1926
1927fn vite_template(renderer: bool, tailwind: bool) -> String {
1928    let tailwind_import = if tailwind {
1929        "import tailwindcss from \"@tailwindcss/vite\";\n"
1930    } else {
1931        ""
1932    };
1933    let tailwind_plugin = if tailwind { ", tailwindcss()" } else { "" };
1934    format!(
1935        "import {{ defineConfig }} from \"vite\";\nimport {{ phoenix }} from \"@apizero/vite\";\n{tailwind_import}\nexport default defineConfig({{\n  plugins: [phoenix({renderer}){tailwind_plugin}],\n}});\n",
1936        renderer = if renderer { "{ renderer: true }" } else { "" },
1937    )
1938}
1939
1940fn tsconfig_template() -> String {
1941    r#"{
1942  "compilerOptions": {
1943    "target": "ES2022",
1944    "lib": ["ES2022", "DOM", "DOM.Iterable"],
1945    "module": "ESNext",
1946    "moduleResolution": "Bundler",
1947    "jsx": "react-jsx",
1948    "strict": true,
1949    "noEmit": true,
1950    "preserveSymlinks": true,
1951    "skipLibCheck": true,
1952    "types": ["vite/client"]
1953  },
1954  "include": ["views/**/*.js", "views/**/*.jsx", "views/**/*.ts", "views/**/*.tsx", "vite.config.ts", "vite.ssr.config.ts"]
1955}
1956"#
1957    .to_owned()
1958}
1959
1960fn managed_modules(entries: &[&str]) -> String {
1961    let body = entries.join("\n");
1962    if body.is_empty() {
1963        format!("{MODULES_START}\n{MODULES_END}\n")
1964    } else {
1965        format!("{MODULES_START}\n{body}\n{MODULES_END}\n")
1966    }
1967}
1968
1969fn add_rust_item(
1970    editor: &mut ProjectEditor,
1971    base: &str,
1972    name: &QualifiedName,
1973    content: &str,
1974) -> Result<(), ScaffoldError> {
1975    let mut directory = PathBuf::from(base);
1976    let mut parent_module = directory.join("mod.rs");
1977    for namespace in &name.modules {
1978        let module = snake_case(namespace);
1979        editor.update_managed_lines(
1980            &parent_module,
1981            MODULES_START,
1982            MODULES_END,
1983            &[format!("pub mod {module};")],
1984        )?;
1985        directory.push(&module);
1986        parent_module = directory.join("mod.rs");
1987    }
1988    let module = snake_case(&name.class);
1989    editor.create(directory.join(format!("{module}.rs")), content.to_owned())?;
1990    editor.update_managed_lines(
1991        &parent_module,
1992        MODULES_START,
1993        MODULES_END,
1994        &[
1995            format!("pub mod {module};"),
1996            format!("pub use {module}::{};", name.class),
1997        ],
1998    )?;
1999    Ok(())
2000}
2001
2002fn add_command(editor: &mut ProjectEditor, name: &QualifiedName) -> Result<(), ScaffoldError> {
2003    let function_name = snake_case(&name.class);
2004    if matches!(function_name.as_str(), "serve" | "help") {
2005        return Err(ScaffoldError::InvalidName(function_name));
2006    }
2007
2008    let mut directory = PathBuf::from("app/commands");
2009    let mut parent_module = directory.join("mod.rs");
2010    let mut export_path = Vec::new();
2011    for namespace in &name.modules {
2012        let module = snake_case(namespace);
2013        editor.update_managed_lines(
2014            &parent_module,
2015            MODULES_START,
2016            MODULES_END,
2017            &[format!("pub mod {module};")],
2018        )?;
2019        directory.push(&module);
2020        parent_module = directory.join("mod.rs");
2021        export_path.push(module);
2022    }
2023
2024    editor.create(
2025        directory.join(format!("{function_name}.rs")),
2026        command_template(&function_name),
2027    )?;
2028    editor.update_managed_lines(
2029        &parent_module,
2030        MODULES_START,
2031        MODULES_END,
2032        &[
2033            format!("pub mod {function_name};"),
2034            format!("pub use {function_name}::{function_name};"),
2035        ],
2036    )?;
2037
2038    if !export_path.is_empty() {
2039        let path = format!("{}::{function_name}", export_path.join("::"));
2040        editor.update_managed_lines(
2041            "app/commands/mod.rs",
2042            MODULES_START,
2043            MODULES_END,
2044            &[format!("pub use {path};")],
2045        )?;
2046    }
2047
2048    editor.update_managed_lines(
2049        "app/commands/mod.rs",
2050        COMMANDS_START,
2051        COMMANDS_END,
2052        &[format!("{function_name},")],
2053    )?;
2054    Ok(())
2055}
2056
2057fn add_model(editor: &mut ProjectEditor, model: &QualifiedName) -> Result<(), ScaffoldError> {
2058    add_rust_item(editor, "app/models", model, &model_template(&model.class))?;
2059    let path = if model.modules.is_empty() {
2060        model.class.clone()
2061    } else {
2062        format!(
2063            "{}::{}",
2064            model
2065                .modules
2066                .iter()
2067                .map(|part| snake_case(part))
2068                .collect::<Vec<_>>()
2069                .join("::"),
2070            model.class
2071        )
2072    };
2073    editor.update_registry(
2074        "app/models/mod.rs",
2075        MODELS_START,
2076        MODELS_END,
2077        "model",
2078        &path,
2079        render_model_registry,
2080    )
2081}
2082
2083fn add_model_migration(
2084    editor: &mut ProjectEditor,
2085    model: &QualifiedName,
2086) -> Result<(), ScaffoldError> {
2087    let table = pluralize(&snake_case(&model.class));
2088    add_migration(editor, &format!("create_{table}_table"), &table)
2089}
2090
2091fn add_migration(editor: &mut ProjectEditor, name: &str, table: &str) -> Result<(), ScaffoldError> {
2092    let milliseconds = SystemTime::now()
2093        .duration_since(UNIX_EPOCH)
2094        .map_err(|_| ScaffoldError::InvalidClock)?
2095        .as_millis();
2096    let id = milliseconds.to_string();
2097    let module = format!("m_{id}_{name}");
2098    editor.create(
2099        format!("database/migrations/{module}.rs"),
2100        migration_template(&id, name, table),
2101    )?;
2102    editor.update_registry(
2103        "database/migrations/mod.rs",
2104        MIGRATIONS_START,
2105        MIGRATIONS_END,
2106        "migration",
2107        &module,
2108        render_migration_registry,
2109    )
2110}
2111
2112fn add_controller_route(
2113    editor: &mut ProjectEditor,
2114    controller: &QualifiedName,
2115    resource: bool,
2116    action: Option<(&QualifiedName, &QualifiedName)>,
2117) -> Result<(), ScaffoldError> {
2118    let base = controller
2119        .class
2120        .strip_suffix("Controller")
2121        .unwrap_or(&controller.class);
2122    let plural = pluralize(&snake_case(base));
2123    let namespace_modules = controller
2124        .modules
2125        .iter()
2126        .map(|part| snake_case(part))
2127        .collect::<Vec<_>>();
2128    let route_file = if namespace_modules.is_empty() {
2129        plural.clone()
2130    } else {
2131        format!("{}_{}", namespace_modules.join("_"), plural)
2132    };
2133    let import = if namespace_modules.is_empty() {
2134        format!("crate::controllers::{}", controller.class)
2135    } else {
2136        format!(
2137            "crate::controllers::{}::{}",
2138            namespace_modules.join("::"),
2139            controller.class
2140        )
2141    };
2142    let route_name = if namespace_modules.is_empty() {
2143        plural.clone()
2144    } else {
2145        format!("{}.{}", namespace_modules.join("."), plural)
2146    };
2147    let path = if namespace_modules.is_empty() {
2148        format!("/{}", plural.replace('_', "-"))
2149    } else {
2150        format!(
2151            "/{}/{}",
2152            namespace_modules
2153                .iter()
2154                .map(|part| kebab_case(part))
2155                .collect::<Vec<_>>()
2156                .join("/"),
2157            plural.replace('_', "-")
2158        )
2159    };
2160    editor.create(
2161        format!("routes/{route_file}.rs"),
2162        controller_route_template(
2163            &import,
2164            &route_name,
2165            &path,
2166            &controller.class,
2167            resource,
2168            action,
2169        ),
2170    )
2171}
2172
2173fn add_page(
2174    editor: &mut ProjectEditor,
2175    page: &PageName,
2176    frontend: ProjectFrontend,
2177) -> Result<(), ScaffoldError> {
2178    let props = page_props_name(page);
2179    add_rust_item(
2180        editor,
2181        "app/props",
2182        &props,
2183        &page_props_template(&props.class, &page.route),
2184    )?;
2185    let mut path = PathBuf::from("views/pages");
2186    for part in &page.parts[..page.parts.len() - 1] {
2187        path.push(kebab_case(part));
2188    }
2189    path.push(format!(
2190        "{}.{}",
2191        kebab_case(page.parts.last().expect("page has one part")),
2192        frontend.extension()
2193    ));
2194    editor.create(
2195        path,
2196        page_template(&page.class, &props.class, page.parts.len(), frontend),
2197    )
2198}
2199
2200fn page_props_name(page: &PageName) -> QualifiedName {
2201    QualifiedName {
2202        modules: page.parts[..page.parts.len() - 1].to_vec(),
2203        class: format!("{}Props", page.class),
2204    }
2205}
2206
2207fn model_template(name: &str) -> String {
2208    format!(
2209        r"use phoenix::database::Model;
2210
2211#[derive(Debug, Model)]
2212pub struct {name} {{
2213    #[key]
2214    #[auto]
2215    pub id: u64,
2216    pub name: String,
2217}}
2218"
2219    )
2220}
2221
2222fn request_template(name: &str) -> String {
2223    format!(
2224        r#"use phoenix::prelude::{{Validate, ValidationErrors, Validator, max_length, required, rules, string}};
2225use serde::Deserialize;
2226
2227#[phoenix::contract(input)]
2228#[derive(Debug, Deserialize)]
2229pub struct {name} {{
2230    pub name: String,
2231}}
2232
2233impl Validate for {name} {{
2234    fn validate(&self) -> Result<(), ValidationErrors> {{
2235        let data = serde_json::json!({{ "name": self.name }});
2236        Validator::new(&data)
2237            .field("name", rules![required(), string(), max_length(255)])
2238            .validate()
2239    }}
2240}}
2241"#
2242    )
2243}
2244
2245fn resource_template(name: &str) -> String {
2246    format!(
2247        r#"use serde::Serialize;
2248
2249#[phoenix::contract(resource)]
2250#[derive(Clone, Debug, Serialize)]
2251#[serde(rename_all = "camelCase")]
2252pub struct {name} {{
2253    pub id: String,
2254    pub name: String,
2255}}
2256"#
2257    )
2258}
2259
2260fn controller_template(name: &str, resource: bool) -> String {
2261    if !resource {
2262        return format!(
2263            r#"use phoenix::prelude::{{Request, Response}};
2264
2265pub struct {name};
2266
2267impl {name} {{
2268    #[allow(clippy::unused_async)]
2269    pub async fn index(_request: Request) -> Response {{
2270        Response::text("{name}@index")
2271    }}
2272}}
2273"#
2274        );
2275    }
2276    format!(
2277        r#"use phoenix::prelude::{{Request, Response, StatusCode}};
2278
2279pub struct {name};
2280
2281impl {name} {{
2282    #[allow(clippy::unused_async)]
2283    pub async fn index(_request: Request) -> Response {{ Response::text("{name}@index") }}
2284
2285    #[allow(clippy::unused_async)]
2286    pub async fn create(_request: Request) -> Response {{ Response::text("{name}@create") }}
2287
2288    #[allow(clippy::unused_async)]
2289    pub async fn store(_request: Request) -> Response {{
2290        Response::text("{name}@store").with_status(StatusCode::CREATED)
2291    }}
2292
2293    #[allow(clippy::unused_async)]
2294    pub async fn show(_request: Request) -> Response {{ Response::text("{name}@show") }}
2295
2296    #[allow(clippy::unused_async)]
2297    pub async fn edit(_request: Request) -> Response {{ Response::text("{name}@edit") }}
2298
2299    #[allow(clippy::unused_async)]
2300    pub async fn update(_request: Request) -> Response {{ Response::text("{name}@update") }}
2301
2302    #[allow(clippy::unused_async)]
2303    pub async fn destroy(_request: Request) -> Response {{
2304        Response::new(StatusCode::NO_CONTENT, phoenix::http::Bytes::new())
2305    }}
2306}}
2307"#
2308    )
2309}
2310
2311fn model_controller_template(
2312    controller: &QualifiedName,
2313    request: &QualifiedName,
2314    resource: &QualifiedName,
2315    props: &QualifiedName,
2316    page: &str,
2317) -> String {
2318    let request_path = rust_item_path("requests", request);
2319    let resource_path = rust_item_path("resources", resource);
2320    let props_path = rust_item_path("props", props);
2321    let name = &controller.class;
2322    let title = page
2323        .split('/')
2324        .next()
2325        .map_or_else(|| "Items".to_owned(), pascal_case);
2326    format!(
2327        r#"use phoenix::prelude::{{Json, Page, PageResponseError, Request, Response, StatusCode, Validated}};
2328
2329use {props_path};
2330use {request_path};
2331use {resource_path};
2332
2333pub struct {name};
2334
2335impl {name} {{
2336    pub async fn index(request: Request) -> Result<Response, PageResponseError> {{
2337        Page::new("{page}", {props_class} {{ title: "{title}".to_owned() }})
2338            .spa()
2339            .respond_to(&request, None)
2340    }}
2341
2342    #[allow(clippy::unused_async)]
2343    pub async fn create(_request: Request) -> Response {{ Response::text("{name}@create") }}
2344
2345    #[allow(clippy::unused_async)]
2346    pub async fn store(
2347        Validated(Json(input)): Validated<Json<{request_class}>>,
2348    ) -> (StatusCode, Json<{resource_class}>) {{
2349        (
2350            StatusCode::CREATED,
2351            Json({resource_class} {{ id: "generated".to_owned(), name: input.name }}),
2352        )
2353    }}
2354
2355    #[allow(clippy::unused_async)]
2356    pub async fn show(_request: Request) -> Response {{ Response::text("{name}@show") }}
2357
2358    #[allow(clippy::unused_async)]
2359    pub async fn edit(_request: Request) -> Response {{ Response::text("{name}@edit") }}
2360
2361    #[allow(clippy::unused_async)]
2362    pub async fn update(_request: Request) -> Response {{ Response::text("{name}@update") }}
2363
2364    #[allow(clippy::unused_async)]
2365    pub async fn destroy(_request: Request) -> Response {{
2366        Response::new(StatusCode::NO_CONTENT, phoenix::http::Bytes::new())
2367    }}
2368}}
2369"#,
2370        props_class = props.class,
2371        request_class = request.class,
2372        resource_class = resource.class,
2373    )
2374}
2375
2376fn middleware_template(name: &str) -> String {
2377    format!(
2378        r"use phoenix::prelude::{{BoxFuture, Middleware, Next, Request, Response}};
2379
2380pub struct {name};
2381
2382impl Middleware for {name} {{
2383    fn handle(&self, request: Request, next: Next) -> BoxFuture<Response> {{
2384        Box::pin(async move {{
2385            // Add authorization, request context, or response policy here.
2386            next.run(request).await
2387        }})
2388    }}
2389}}
2390"
2391    )
2392}
2393
2394fn migration_template(id: &str, name: &str, table: &str) -> String {
2395    format!(
2396        r#"use phoenix::database::Migration;
2397
2398#[must_use]
2399pub fn migration() -> Migration {{
2400    Migration::new("{id}", "{description}")
2401        .up("CREATE TABLE {table} (id BIGINT PRIMARY KEY, name TEXT NOT NULL)")
2402        .down("DROP TABLE {table}")
2403}}
2404"#,
2405        description = name.replace('_', " "),
2406    )
2407}
2408
2409fn controller_route_template(
2410    import: &str,
2411    route_name: &str,
2412    path: &str,
2413    controller: &str,
2414    resource: bool,
2415    action: Option<(&QualifiedName, &QualifiedName)>,
2416) -> String {
2417    if !resource {
2418        return format!(
2419            "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"
2420        );
2421    }
2422    let parameter = snake_case(controller.strip_suffix("Controller").unwrap_or(controller));
2423    let (prelude, action_imports, store, action_binding) = action.map_or_else(
2424        || {
2425            (
2426                "Routes".to_owned(),
2427                String::new(),
2428                format!("{controller}::store"),
2429                String::new(),
2430            )
2431        },
2432        |(input, output)| {
2433            (
2434                "Routes, typed".to_owned(),
2435                format!(
2436                    "use {};\nuse {};\n",
2437                    rust_item_path("requests", input),
2438                    rust_item_path("resources", output),
2439                ),
2440                format!("typed({controller}::store)"),
2441                format!("\n        .action::<{}, {}>()", input.class, output.class),
2442            )
2443        },
2444    );
2445    format!(
2446        r#"use phoenix::prelude::{{{prelude}}};
2447
2448use {import};
2449{action_imports}
2450
2451#[must_use]
2452pub fn routes() -> Routes {{
2453    let member = "{path}/{{{parameter}}}";
2454    Routes::new()
2455        .get("{path}", {controller}::index)
2456        .name("{route_name}.index")
2457        .get("{path}/create", {controller}::create)
2458        .name("{route_name}.create")
2459        .post("{path}", {store})
2460        .name("{route_name}.store"){action_binding}
2461        .get(member, {controller}::show)
2462        .name("{route_name}.show")
2463        .get(format!("{{member}}/edit"), {controller}::edit)
2464        .name("{route_name}.edit")
2465        .put(member, {controller}::update)
2466        .name("{route_name}.update")
2467        .patch(member, {controller}::update)
2468        .delete(member, {controller}::destroy)
2469        .name("{route_name}.destroy")
2470}}
2471"#
2472    )
2473}
2474
2475fn rust_item_path(category: &str, name: &QualifiedName) -> String {
2476    if name.modules.is_empty() {
2477        format!("crate::{category}::{}", name.class)
2478    } else {
2479        format!(
2480            "crate::{category}::{}::{}",
2481            name.modules
2482                .iter()
2483                .map(|part| snake_case(part))
2484                .collect::<Vec<_>>()
2485                .join("::"),
2486            name.class,
2487        )
2488    }
2489}
2490
2491fn page_props_template(name: &str, route: &str) -> String {
2492    format!(
2493        r#"use serde::Serialize;
2494
2495#[phoenix::contract(page, page = "{route}")]
2496#[derive(Serialize)]
2497#[serde(rename_all = "camelCase")]
2498pub struct {name} {{
2499    pub title: String,
2500}}
2501"#
2502    )
2503}
2504
2505fn page_template(component: &str, props: &str, depth: usize, frontend: ProjectFrontend) -> String {
2506    let contracts = format!("{}generated/contracts.js", "../".repeat(depth));
2507    if frontend == ProjectFrontend::Jsx {
2508        return format!(
2509            r#"export default function {component}({{ title }}) {{
2510  return (
2511    <main>
2512      <h1>{{title}}</h1>
2513    </main>
2514  );
2515}}
2516"#
2517        );
2518    }
2519    format!(
2520        r#"import type {{ {props} }} from "{contracts}";
2521
2522export default function {component}({{ title }}: {props}) {{
2523  return (
2524    <main>
2525      <h1>{{title}}</h1>
2526    </main>
2527  );
2528}}
2529"#
2530    )
2531}
2532
2533fn island_template(component: &str, frontend: ProjectFrontend) -> String {
2534    if frontend == ProjectFrontend::Jsx {
2535        return format!(
2536            r#"import {{ useState }} from "react";
2537
2538export default function {component}({{ initialCount = 0 }}) {{
2539  const [count, setCount] = useState(initialCount);
2540  return <button type="button" onClick={{() => setCount((value) => value + 1)}}>{{count}}</button>;
2541}}
2542"#
2543        );
2544    }
2545    format!(
2546        r#"import {{ useState }} from "react";
2547
2548export interface {component}Props {{
2549  initialCount?: number;
2550}}
2551
2552export default function {component}({{ initialCount = 0 }}: {component}Props) {{
2553  const [count, setCount] = useState(initialCount);
2554  return <button type="button" onClick={{() => setCount((value) => value + 1)}}>{{count}}</button>;
2555}}
2556"#
2557    )
2558}
2559
2560fn render_model_registry(values: &BTreeSet<String>) -> Vec<String> {
2561    let mut lines = values
2562        .iter()
2563        .map(|value| format!("// phoenix:model: {value}"))
2564        .collect::<Vec<_>>();
2565    if !lines.is_empty() {
2566        lines.push(String::new());
2567    }
2568    lines.extend([
2569        "#[must_use]".to_owned(),
2570        "pub fn all() -> phoenix::database::ModelSet {".to_owned(),
2571        "    phoenix::database::models!(".to_owned(),
2572    ]);
2573    lines.extend(values.iter().map(|value| format!("        {value},")));
2574    lines.extend(["    )".to_owned(), "}".to_owned()]);
2575    lines
2576}
2577
2578fn render_migration_registry(values: &BTreeSet<String>) -> Vec<String> {
2579    let mut lines = Vec::new();
2580    for value in values {
2581        lines.push(format!("// phoenix:migration: {value}"));
2582        lines.push(format!("pub mod {value};"));
2583    }
2584    if !lines.is_empty() {
2585        lines.push(String::new());
2586    }
2587    lines.extend([
2588        "#[must_use]".to_owned(),
2589        "pub fn all() -> Vec<phoenix::database::Migration> {".to_owned(),
2590        "    vec![".to_owned(),
2591    ]);
2592    lines.extend(
2593        values
2594            .iter()
2595            .map(|value| format!("        {value}::migration(),")),
2596    );
2597    lines.extend(["    ]".to_owned(), "}".to_owned()]);
2598    lines
2599}
2600
2601struct ProjectEditor {
2602    root: PathBuf,
2603    force: bool,
2604    changes: BTreeMap<PathBuf, String>,
2605}
2606
2607impl ProjectEditor {
2608    fn new(root: &Path, force: bool) -> Self {
2609        Self {
2610            root: root.to_path_buf(),
2611            force,
2612            changes: BTreeMap::new(),
2613        }
2614    }
2615
2616    fn create(
2617        &mut self,
2618        relative: impl Into<PathBuf>,
2619        content: String,
2620    ) -> Result<(), ScaffoldError> {
2621        let relative = safe_relative(relative.into())?;
2622        let absolute = self.root.join(&relative);
2623        if !self.force && (absolute.exists() || self.changes.contains_key(&relative)) {
2624            return Err(ScaffoldError::AlreadyExists(absolute));
2625        }
2626        self.changes.insert(relative, content);
2627        Ok(())
2628    }
2629
2630    fn read(&self, relative: &Path) -> Result<String, ScaffoldError> {
2631        if let Some(content) = self.changes.get(relative) {
2632            return Ok(content.clone());
2633        }
2634        let absolute = self.root.join(relative);
2635        match fs::read_to_string(&absolute) {
2636            Ok(content) => Ok(content),
2637            Err(error) if error.kind() == ErrorKind::NotFound => Ok(String::new()),
2638            Err(source) => Err(ScaffoldError::Io {
2639                path: absolute,
2640                source,
2641            }),
2642        }
2643    }
2644
2645    fn update_managed_lines(
2646        &mut self,
2647        relative: impl AsRef<Path>,
2648        start: &str,
2649        end: &str,
2650        added: &[String],
2651    ) -> Result<(), ScaffoldError> {
2652        let relative = safe_relative(relative.as_ref().to_path_buf())?;
2653        let existing = self.read(&relative)?;
2654        let initialized = if existing.is_empty() {
2655            format!("{start}\n{end}\n")
2656        } else {
2657            existing
2658        };
2659        let (before, managed, after) = managed_parts(&initialized, start, end)
2660            .ok_or_else(|| ScaffoldError::InvalidManagedFile(self.root.join(&relative)))?;
2661        let mut lines = managed
2662            .lines()
2663            .map(str::trim)
2664            .filter(|line| !line.is_empty())
2665            .map(str::to_owned)
2666            .collect::<BTreeSet<_>>();
2667        lines.extend(added.iter().cloned());
2668        let body = lines.into_iter().collect::<Vec<_>>().join("\n");
2669        let body = if body.is_empty() {
2670            body
2671        } else {
2672            format!("{body}\n")
2673        };
2674        self.changes
2675            .insert(relative, format!("{before}{start}\n{body}{end}{after}"));
2676        Ok(())
2677    }
2678
2679    fn update_registry(
2680        &mut self,
2681        relative: impl AsRef<Path>,
2682        start: &str,
2683        end: &str,
2684        key: &str,
2685        value: &str,
2686        render: fn(&BTreeSet<String>) -> Vec<String>,
2687    ) -> Result<(), ScaffoldError> {
2688        let relative = safe_relative(relative.as_ref().to_path_buf())?;
2689        let existing = self.read(&relative)?;
2690        let initialized = if existing.is_empty() {
2691            format!("{start}\n{end}\n")
2692        } else {
2693            existing
2694        };
2695        let (before, managed, after) = managed_parts(&initialized, start, end)
2696            .ok_or_else(|| ScaffoldError::InvalidManagedFile(self.root.join(&relative)))?;
2697        let prefix = format!("// phoenix:{key}: ");
2698        let mut values = managed
2699            .lines()
2700            .filter_map(|line| line.trim().strip_prefix(&prefix).map(str::to_owned))
2701            .collect::<BTreeSet<_>>();
2702        values.insert(value.to_owned());
2703        let rendered = render(&values).join("\n");
2704        let body = if rendered.is_empty() {
2705            rendered
2706        } else {
2707            format!("{rendered}\n")
2708        };
2709        self.changes
2710            .insert(relative, format!("{before}{start}\n{body}{end}{after}"));
2711        Ok(())
2712    }
2713
2714    fn commit(self) -> Result<Vec<PathBuf>, ScaffoldError> {
2715        let mut written = Vec::with_capacity(self.changes.len());
2716        for (relative, content) in self.changes {
2717            let path = self.root.join(relative);
2718            if let Some(parent) = path.parent() {
2719                fs::create_dir_all(parent).map_err(|source| ScaffoldError::Io {
2720                    path: parent.to_path_buf(),
2721                    source,
2722                })?;
2723            }
2724            fs::write(&path, content).map_err(|source| ScaffoldError::Io {
2725                path: path.clone(),
2726                source,
2727            })?;
2728            written.push(path);
2729        }
2730        Ok(written)
2731    }
2732}
2733
2734fn managed_parts<'a>(
2735    content: &'a str,
2736    start: &str,
2737    end: &str,
2738) -> Option<(&'a str, &'a str, &'a str)> {
2739    let start_index = content.find(start)?;
2740    let managed_start = start_index + start.len();
2741    let end_relative = content[managed_start..].find(end)?;
2742    let end_index = managed_start + end_relative;
2743    if content[end_index + end.len()..].contains(end) || content[..start_index].contains(start) {
2744        return None;
2745    }
2746    Some((
2747        &content[..start_index],
2748        content[managed_start..end_index].trim_matches('\n'),
2749        &content[end_index + end.len()..],
2750    ))
2751}
2752
2753#[derive(Clone, Debug)]
2754struct QualifiedName {
2755    modules: Vec<String>,
2756    class: String,
2757}
2758
2759impl QualifiedName {
2760    fn parse(value: &str) -> Result<Self, ScaffoldError> {
2761        let parts = name_parts(value)?;
2762        let class = pascal_case(parts.last().expect("validated names have a leaf"));
2763        let modules = parts[..parts.len() - 1]
2764            .iter()
2765            .map(|part| pascal_case(part))
2766            .collect();
2767        Ok(Self { modules, class })
2768    }
2769
2770    fn parse_with_suffix(value: &str, suffix: &str) -> Result<Self, ScaffoldError> {
2771        let mut name = Self::parse(value)?;
2772        if !name.class.ends_with(suffix) {
2773            name.class.push_str(suffix);
2774        }
2775        Ok(name)
2776    }
2777
2778    fn with_leaf(&self, class: String) -> Self {
2779        Self {
2780            modules: self.modules.clone(),
2781            class,
2782        }
2783    }
2784
2785    fn index_page_name(&self) -> PageName {
2786        let mut parts = self.modules.clone();
2787        parts.push(pluralize(&self.class));
2788        parts.push("Index".to_owned());
2789        PageName::from_parts(parts)
2790    }
2791}
2792
2793#[derive(Clone, Debug)]
2794struct PageName {
2795    parts: Vec<String>,
2796    route: String,
2797    class: String,
2798}
2799
2800impl PageName {
2801    fn parse(value: &str) -> Result<Self, ScaffoldError> {
2802        let parts = name_parts(value)?
2803            .into_iter()
2804            .map(|part| pascal_case(&part))
2805            .collect();
2806        Ok(Self::from_parts(parts))
2807    }
2808
2809    fn from_parts(parts: Vec<String>) -> Self {
2810        let route = parts
2811            .iter()
2812            .map(|part| kebab_case(part))
2813            .collect::<Vec<_>>()
2814            .join("/");
2815        let class = parts.iter().map(String::as_str).collect::<String>();
2816        Self {
2817            parts,
2818            route,
2819            class,
2820        }
2821    }
2822}
2823
2824fn name_parts(value: &str) -> Result<Vec<String>, ScaffoldError> {
2825    let normalized = value.replace("::", "/").replace('\\', "/");
2826    let parts = normalized
2827        .split('/')
2828        .filter(|part| !part.is_empty())
2829        .map(str::to_owned)
2830        .collect::<Vec<_>>();
2831    if parts.is_empty()
2832        || parts.iter().any(|part| {
2833            !part.chars().all(|character| {
2834                character.is_ascii_alphanumeric() || character == '_' || character == '-'
2835            }) || !part
2836                .chars()
2837                .any(|character| character.is_ascii_alphabetic())
2838        })
2839    {
2840        return Err(ScaffoldError::InvalidName(value.to_owned()));
2841    }
2842    Ok(parts)
2843}
2844
2845fn package_name(value: &str) -> Result<String, ScaffoldError> {
2846    let value = kebab_case(value).trim_matches('-').to_owned();
2847    if value.is_empty()
2848        || value.starts_with(|character: char| character.is_ascii_digit())
2849        || !value.chars().all(|character| {
2850            character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
2851        })
2852    {
2853        return Err(ScaffoldError::InvalidName(value));
2854    }
2855    Ok(value)
2856}
2857
2858fn snake_identifier(value: &str) -> Result<String, ScaffoldError> {
2859    let parts = name_parts(value)?;
2860    Ok(parts
2861        .iter()
2862        .map(|part| snake_case(part))
2863        .collect::<Vec<_>>()
2864        .join("_"))
2865}
2866
2867fn pascal_case(value: &str) -> String {
2868    words(value)
2869        .into_iter()
2870        .map(|word| {
2871            let mut characters = word.chars();
2872            characters.next().map_or_else(String::new, |first| {
2873                format!(
2874                    "{}{}",
2875                    first.to_ascii_uppercase(),
2876                    characters.as_str().to_ascii_lowercase()
2877                )
2878            })
2879        })
2880        .collect()
2881}
2882
2883fn snake_case(value: &str) -> String {
2884    words(value).join("_").to_ascii_lowercase()
2885}
2886
2887fn kebab_case(value: &str) -> String {
2888    words(value).join("-").to_ascii_lowercase()
2889}
2890
2891fn words(value: &str) -> Vec<String> {
2892    let mut output = String::new();
2893    let mut previous_lower_or_digit = false;
2894    for character in value.chars() {
2895        if character == '-' || character == '_' || character.is_ascii_whitespace() {
2896            if !output.ends_with('_') && !output.is_empty() {
2897                output.push('_');
2898            }
2899            previous_lower_or_digit = false;
2900        } else {
2901            if character.is_ascii_uppercase() && previous_lower_or_digit {
2902                output.push('_');
2903            }
2904            output.push(character);
2905            previous_lower_or_digit = character.is_ascii_lowercase() || character.is_ascii_digit();
2906        }
2907    }
2908    output
2909        .split('_')
2910        .filter(|part| !part.is_empty())
2911        .map(str::to_owned)
2912        .collect()
2913}
2914
2915fn pluralize(value: &str) -> String {
2916    if let Some(stem) = value.strip_suffix('y')
2917        && !stem.ends_with(['a', 'e', 'i', 'o', 'u'])
2918    {
2919        return format!("{stem}ies");
2920    }
2921    if value.ends_with(['s', 'x', 'z']) || value.ends_with("ch") || value.ends_with("sh") {
2922        format!("{value}es")
2923    } else {
2924        format!("{value}s")
2925    }
2926}
2927
2928fn inferred_table(migration: &str) -> &str {
2929    migration
2930        .strip_prefix("create_")
2931        .and_then(|name| name.strip_suffix("_table"))
2932        .unwrap_or(migration)
2933}
2934
2935fn safe_relative(path: PathBuf) -> Result<PathBuf, ScaffoldError> {
2936    if path.is_absolute()
2937        || path
2938            .components()
2939            .any(|component| !matches!(component, Component::Normal(_)))
2940    {
2941        return Err(ScaffoldError::InvalidName(path.display().to_string()));
2942    }
2943    Ok(path)
2944}
2945
2946fn absolute_path(path: impl AsRef<Path>) -> Result<PathBuf, ScaffoldError> {
2947    let path = path.as_ref();
2948    if path.is_absolute() {
2949        return Ok(path.to_path_buf());
2950    }
2951    env::current_dir()
2952        .map(|current| current.join(path))
2953        .map_err(|source| ScaffoldError::Io {
2954            path: path.to_path_buf(),
2955            source,
2956        })
2957}
2958
2959fn ensure_empty_target(path: &Path) -> Result<(), ScaffoldError> {
2960    match fs::read_dir(path) {
2961        Ok(mut entries) => {
2962            if entries.next().is_some() {
2963                Err(ScaffoldError::ProjectNotEmpty(path.to_path_buf()))
2964            } else {
2965                Ok(())
2966            }
2967        }
2968        Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
2969        Err(source) => Err(ScaffoldError::Io {
2970            path: path.to_path_buf(),
2971            source,
2972        }),
2973    }
2974}
2975
2976fn json_string(value: &str) -> String {
2977    serde_json::to_string(value).expect("strings always serialize")
2978}
2979
2980fn run_optional(program: &'static str, args: &[&str], cwd: &Path) -> Result<(), ScaffoldError> {
2981    match Command::new(program).args(args).current_dir(cwd).status() {
2982        Ok(status) if status.success() => Ok(()),
2983        Ok(_) => Err(ScaffoldError::CommandFailed { program }),
2984        Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
2985        Err(source) => Err(ScaffoldError::Io {
2986            path: cwd.to_path_buf(),
2987            source,
2988        }),
2989    }
2990}
2991
2992fn run_required(program: &'static str, args: &[&str], cwd: &Path) -> Result<(), ScaffoldError> {
2993    match Command::new(program).args(args).current_dir(cwd).status() {
2994        Ok(status) if status.success() => Ok(()),
2995        Ok(_) => Err(ScaffoldError::CommandFailed { program }),
2996        Err(source) => Err(ScaffoldError::Io {
2997            path: cwd.to_path_buf(),
2998            source,
2999        }),
3000    }
3001}
3002
3003#[cfg(test)]
3004mod tests {
3005    use super::*;
3006
3007    fn temporary_directory(label: &str) -> PathBuf {
3008        let id = SystemTime::now()
3009            .duration_since(UNIX_EPOCH)
3010            .unwrap()
3011            .as_nanos();
3012        env::temp_dir().join(format!("phoenix-cli-{label}-{}-{id}", std::process::id()))
3013    }
3014
3015    fn framework_root() -> PathBuf {
3016        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3017            .join("../..")
3018            .canonicalize()
3019            .unwrap()
3020    }
3021
3022    #[test]
3023    fn creates_a_complete_local_project_without_installing() {
3024        let root = temporary_directory("new");
3025        create_project(
3026            &NewProjectOptions::new(&root)
3027                .dependencies(DependencySource::Local(framework_root()))
3028                .database(Some(ProjectDatabase::Sqlite))
3029                .initialize_git(false)
3030                .install_dependencies(false),
3031        )
3032        .unwrap();
3033
3034        assert!(root.join("src/main.rs").is_file());
3035        assert!(root.join("src/bin/phoenix-manage.rs").is_file());
3036        assert!(root.join("config/app.toml").is_file());
3037        assert!(root.join("config/database.toml").is_file());
3038        assert!(
3039            root.join("config/schemas/phoenix-config-database.schema.json")
3040                .is_file()
3041        );
3042        assert!(root.join("taplo.toml").is_file());
3043        assert!(
3044            fs::read_to_string(root.join("config/database.toml"))
3045                .unwrap()
3046                .contains("connections.mysql")
3047        );
3048        assert!(root.join("app/commands/mod.rs").is_file());
3049        assert!(root.join("config/mod.rs").is_file());
3050        assert!(root.join("database/seeders/mod.rs").is_file());
3051        assert!(root.join("routes/web.rs").is_file());
3052        assert!(root.join("views/pages/home.tsx").is_file());
3053        let manifest = fs::read_to_string(root.join("Cargo.toml")).unwrap();
3054        assert!(manifest.contains("crates/phoenix"));
3055        assert!(manifest.contains("default-run = \"phoenix-cli-new-"));
3056        assert!(manifest.contains("default = [\"sqlite\"]"));
3057        assert!(manifest.contains("sqlite = [\"database\", \"phoenix/sqlite\"]"));
3058        assert!(manifest.contains("features = [\"migration\", \"serde\", \"sqlite\"]"));
3059        assert!(manifest.contains("tls = [\"phoenix/tls\"]"));
3060        assert!(manifest.contains("websocket = [\"phoenix/websocket\"]"));
3061        assert!(manifest.contains("sse = [\"phoenix/sse\"]"));
3062        assert!(manifest.contains("default-features = false"));
3063        assert!(manifest.contains("opt-level = \"z\""));
3064        assert!(manifest.contains("strip = \"symbols\""));
3065        assert!(
3066            fs::read_to_string(root.join("package.json"))
3067                .unwrap()
3068                .contains("file:")
3069        );
3070        let main = fs::read_to_string(root.join("src/main.rs")).unwrap();
3071        assert!(main.contains("Console::new"));
3072        assert!(main.contains("commands::registry()"));
3073        assert!(main.contains(".serve("));
3074        let commands = fs::read_to_string(root.join("app/commands/mod.rs")).unwrap();
3075        assert!(commands.contains("commands!"));
3076        assert!(commands.contains("<phoenix:commands>"));
3077        let application = fs::read_to_string(root.join("src/lib.rs")).unwrap();
3078        assert!(application.contains("pub mod commands"));
3079        assert!(application.contains("NonceSecurityPolicy::development"));
3080        assert!(application.contains("with_middleware(content_security_policy(config))"));
3081        assert!(application.contains("with_middleware(RequestId)"));
3082        assert!(application.contains("with_middleware(AccessLog)"));
3083        assert!(application.contains("SessionMiddleware::new"));
3084        assert!(application.contains("with_middleware(Csrf)"));
3085        assert!(application.contains("TrustedProxies::new"));
3086        assert!(application.contains("HostAllowlist::new"));
3087        assert!(application.contains("RateLimit::new"));
3088        assert!(application.contains("StateMiddleware::new(config.clone())"));
3089        assert!(application.contains("StateMiddleware::new(renderer.clone())"));
3090        assert!(application.contains("RendererConfig::production"));
3091        assert!(application.contains("renderer.warm_up().await"));
3092        assert!(application.contains("let vite_dev_server = std::env::var_os(\"PHOENIX_VITE_DEV\").is_some()"));
3093        assert!(application.contains("RendererConfig::node(\"public/ssr/renderer.js\")"));
3094        let home_controller =
3095            fs::read_to_string(root.join("app/controllers/home_controller.rs")).unwrap();
3096        assert!(home_controller.contains("get::<NodeRenderer>().cloned()"));
3097        assert!(home_controller.contains("respond_with_renderer(&request, &renderer).await"));
3098        let home_route = fs::read_to_string(root.join("routes/web.rs")).unwrap();
3099        assert!(home_route.contains(".get(\"/\", HomeController::index)"));
3100        let cargo = env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
3101        let status = Command::new(cargo)
3102            .args(["check", "--quiet"])
3103            .current_dir(&root)
3104            .status()
3105            .unwrap();
3106        assert!(status.success());
3107        let config = fs::read_to_string(root.join("config/mod.rs")).unwrap();
3108        assert!(config.contains("AppConfig::load()"));
3109        assert!(config.lines().count() < 20);
3110        let manager = fs::read_to_string(root.join("src/bin/phoenix-manage.rs")).unwrap();
3111        assert!(manager.contains("MigrationRunner::new"));
3112        assert!(manager.contains("migrations::all()"));
3113        assert!(manager.contains("seeders::run"));
3114        let models = fs::read_to_string(root.join("app/models/mod.rs")).unwrap();
3115        let migrations = fs::read_to_string(root.join("database/migrations/mod.rs")).unwrap();
3116        assert!(models.contains("pub fn all()"));
3117        assert!(migrations.contains("pub fn all()"));
3118        fs::remove_dir_all(root).unwrap();
3119    }
3120
3121    #[test]
3122    fn default_project_is_islands_without_database_or_git() {
3123        let root = temporary_directory("default-options");
3124        let options = NewProjectOptions::new(&root)
3125            .dependencies(DependencySource::Local(framework_root()))
3126            .install_dependencies(false);
3127        assert!(!options.initialize_git);
3128        assert_eq!(options.render_mode, ProjectRenderMode::Islands);
3129        assert_eq!(options.database, None);
3130        assert_eq!(options.frontend, ProjectFrontend::Tsx);
3131        create_project(&options).unwrap();
3132
3133        let cargo = fs::read_to_string(root.join("Cargo.toml")).unwrap();
3134        assert!(cargo.contains("default = []"));
3135        assert!(!cargo.contains("toasty ="));
3136        assert!(!root.join("config/database.toml").exists());
3137        assert!(!root.join("src/bin/phoenix-manage.rs").exists());
3138        assert!(
3139            fs::read_to_string(root.join("app/controllers/home_controller.rs"))
3140                .unwrap()
3141                .contains(".islands()")
3142        );
3143        assert!(!root.join(".git").exists());
3144
3145        let cargo = env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
3146        let status = Command::new(cargo)
3147            .args(["check", "--quiet"])
3148            .current_dir(&root)
3149            .status()
3150            .unwrap();
3151        assert!(status.success());
3152        fs::remove_dir_all(root).unwrap();
3153    }
3154
3155    #[test]
3156    fn project_options_select_jsx_tailwind_and_database() {
3157        let root = temporary_directory("options");
3158        create_project(
3159            &NewProjectOptions::new(&root)
3160                .dependencies(DependencySource::Local(framework_root()))
3161                .database(Some(ProjectDatabase::Pgsql))
3162                .render_mode(ProjectRenderMode::Ssr)
3163                .frontend(ProjectFrontend::Jsx)
3164                .tailwind(true)
3165                .install_dependencies(false),
3166        )
3167        .unwrap();
3168
3169        let cargo = fs::read_to_string(root.join("Cargo.toml")).unwrap();
3170        assert!(cargo.contains("default = [\"pgsql\"]"));
3171        assert!(cargo.contains("\"postgresql\""));
3172        assert!(root.join("views/pages/home.jsx").is_file());
3173        assert!(!root.join("views/pages/home.tsx").exists());
3174        assert!(
3175            fs::read_to_string(root.join("app/controllers/home_controller.rs"))
3176                .unwrap()
3177                .contains(".ssr()")
3178        );
3179        assert!(
3180            fs::read_to_string(root.join("package.json"))
3181                .unwrap()
3182                .contains("@tailwindcss/vite")
3183        );
3184        assert!(
3185            fs::read_to_string(root.join("views/styles.css"))
3186                .unwrap()
3187                .starts_with("@import \"tailwindcss\";")
3188        );
3189        assert!(
3190            fs::read_to_string(root.join("vite.config.ts"))
3191                .unwrap()
3192                .contains("tailwindcss()")
3193        );
3194        let generator = ProjectGenerator::discover(&root).unwrap();
3195        generator
3196            .page("reports/index", GenerateOptions::default())
3197            .unwrap();
3198        generator
3199            .island("Counter", GenerateOptions::default())
3200            .unwrap();
3201        assert!(root.join("views/pages/reports/index.jsx").is_file());
3202        assert!(root.join("views/islands/counter.jsx").is_file());
3203        assert!(
3204            !fs::read_to_string(root.join("views/pages/reports/index.jsx"))
3205                .unwrap()
3206                .contains("import type")
3207        );
3208        fs::remove_dir_all(root).unwrap();
3209    }
3210
3211    #[test]
3212    fn all_database_option_writes_all_toasty_drivers() {
3213        let root = temporary_directory("all-databases");
3214        create_project(
3215            &NewProjectOptions::new(&root)
3216                .dependencies(DependencySource::Local(framework_root()))
3217                .database(Some(ProjectDatabase::All))
3218                .install_dependencies(false),
3219        )
3220        .unwrap();
3221        let cargo = fs::read_to_string(root.join("Cargo.toml")).unwrap();
3222        assert!(cargo.contains(
3223            "toasty = { version = \"0.8\", default-features = false, features = [\"migration\", \"mysql\", \"postgresql\", \"serde\", \"sqlite\"] }"
3224        ));
3225        assert!(cargo.contains("all-databases = [\"database\", \"phoenix/sqlite\", \"phoenix/pgsql\", \"phoenix/mysql\"]"));
3226        let cargo = env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
3227        let status = Command::new(cargo)
3228            .args(["check", "--quiet"])
3229            .current_dir(&root)
3230            .status()
3231            .unwrap();
3232        assert!(status.success());
3233        fs::remove_dir_all(root).unwrap();
3234    }
3235
3236    #[test]
3237    fn make_command_registers_async_handler() {
3238        let root = temporary_directory("command");
3239        create_project(
3240            &NewProjectOptions::new(&root)
3241                .dependencies(DependencySource::Local(framework_root()))
3242                .database(Some(ProjectDatabase::Sqlite))
3243                .initialize_git(false)
3244                .install_dependencies(false),
3245        )
3246        .unwrap();
3247        let generator = ProjectGenerator::discover(&root).unwrap();
3248        generator
3249            .command("Update", GenerateOptions::default())
3250            .unwrap();
3251
3252        assert!(root.join("app/commands/update.rs").is_file());
3253        let module = fs::read_to_string(root.join("app/commands/mod.rs")).unwrap();
3254        assert!(module.contains("pub mod update;"));
3255        assert!(module.contains("pub use update::update;"));
3256        assert!(module.contains("update,"));
3257        let command = fs::read_to_string(root.join("app/commands/update.rs")).unwrap();
3258        assert!(command.contains("pub async fn update"));
3259        assert!(command.contains("CommandContext<'_>"));
3260        fs::remove_dir_all(root).unwrap();
3261    }
3262
3263    #[test]
3264    fn model_all_registers_every_supported_business_artifact() {
3265        let root = temporary_directory("model-all");
3266        create_project(
3267            &NewProjectOptions::new(&root)
3268                .dependencies(DependencySource::Local(framework_root()))
3269                .database(Some(ProjectDatabase::Sqlite))
3270                .initialize_git(false)
3271                .install_dependencies(false),
3272        )
3273        .unwrap();
3274        let generator = ProjectGenerator::discover(&root).unwrap();
3275        generator
3276            .model(
3277                "Admin/Post",
3278                ModelOptions {
3279                    all: true,
3280                    ..ModelOptions::default()
3281                },
3282            )
3283            .unwrap();
3284        generator.model("Comment", ModelOptions::default()).unwrap();
3285
3286        assert!(root.join("app/models/admin/post.rs").is_file());
3287        assert!(
3288            root.join("app/controllers/admin/post_controller.rs")
3289                .is_file()
3290        );
3291        assert!(
3292            root.join("app/requests/admin/store_post_request.rs")
3293                .is_file()
3294        );
3295        assert!(root.join("app/resources/admin/post_resource.rs").is_file());
3296        assert!(root.join("routes/admin_posts.rs").is_file());
3297        assert!(root.join("views/pages/admin/posts/index.tsx").is_file());
3298        let routes = fs::read_to_string(root.join("routes/admin_posts.rs")).unwrap();
3299        assert!(routes.contains(".name(\"admin.posts.index\")"));
3300        assert!(routes.contains(".name(\"admin.posts.destroy\")"));
3301        assert!(routes.contains("typed(PostController::store)"));
3302        assert!(routes.contains(".action::<StorePostRequest, PostResource>()"));
3303        let controller =
3304            fs::read_to_string(root.join("app/controllers/admin/post_controller.rs")).unwrap();
3305        assert!(controller.contains("Validated(Json(input))"));
3306        assert!(controller.contains("Page::new(\"admin/posts/index\""));
3307        let page = fs::read_to_string(root.join("views/pages/admin/posts/index.tsx")).unwrap();
3308        assert!(page.contains("../../../generated/contracts.js"));
3309        let models = fs::read_to_string(root.join("app/models/mod.rs")).unwrap();
3310        assert!(models.contains("admin::Post"));
3311        assert!(models.contains("Comment"));
3312        let migrations = fs::read_to_string(root.join("database/migrations/mod.rs")).unwrap();
3313        assert!(migrations.contains("pub fn all()"));
3314        fs::remove_dir_all(root).unwrap();
3315    }
3316
3317    #[test]
3318    fn update_core_refreshes_framework_files_only() {
3319        let root = temporary_directory("update-core");
3320        create_project(
3321            &NewProjectOptions::new(&root)
3322                .dependencies(DependencySource::Local(framework_root()))
3323                .initialize_git(false)
3324                .install_dependencies(false),
3325        )
3326        .unwrap();
3327
3328        let route_before = fs::read_to_string(root.join("routes/web.rs")).unwrap();
3329        let page_before = fs::read_to_string(root.join("views/pages/home.tsx")).unwrap();
3330        fs::write(root.join("src/lib.rs"), "// stale core\n").unwrap();
3331        fs::write(
3332            root.join("routes/web.rs"),
3333            format!("{route_before}\n// business marker\n"),
3334        )
3335        .unwrap();
3336
3337        let generator = ProjectGenerator::discover(&root).unwrap();
3338        let changed = generator
3339            .update_core(
3340                &UpdateProjectOptions::new()
3341                    .dependencies(DependencySource::Local(framework_root()))
3342                    .install_dependencies(false),
3343            )
3344            .unwrap();
3345        assert!(
3346            changed
3347                .iter()
3348                .any(|path| path.ends_with("src/lib.rs")),
3349            "expected src/lib.rs to be refreshed"
3350        );
3351
3352        let lib = fs::read_to_string(root.join("src/lib.rs")).unwrap();
3353        assert!(lib.contains("let vite_dev_server = std::env::var_os(\"PHOENIX_VITE_DEV\").is_some()"));
3354        assert!(!lib.contains("// stale core"));
3355        let route_after = fs::read_to_string(root.join("routes/web.rs")).unwrap();
3356        assert!(route_after.contains("// business marker"));
3357        assert_eq!(
3358            fs::read_to_string(root.join("views/pages/home.tsx")).unwrap(),
3359            page_before
3360        );
3361        let options = fs::read_to_string(root.join(".phoenix")).unwrap();
3362        assert!(options.contains("render_mode=islands"));
3363        assert!(options.contains("database=none"));
3364        fs::remove_dir_all(root).unwrap();
3365    }
3366
3367    #[test]
3368    fn generators_refuse_overwrites_and_path_traversal() {
3369        let root = temporary_directory("safety");
3370        create_project(
3371            &NewProjectOptions::new(&root)
3372                .dependencies(DependencySource::Local(framework_root()))
3373                .database(Some(ProjectDatabase::Sqlite))
3374                .initialize_git(false)
3375                .install_dependencies(false),
3376        )
3377        .unwrap();
3378        let generator = ProjectGenerator::discover(&root).unwrap();
3379        generator
3380            .controller("Report", ControllerOptions::default())
3381            .unwrap();
3382        assert!(matches!(
3383            generator.controller("Report", ControllerOptions::default()),
3384            Err(ScaffoldError::AlreadyExists(_))
3385        ));
3386        assert!(matches!(
3387            generator.page("../outside", GenerateOptions::default()),
3388            Err(ScaffoldError::InvalidName(_))
3389        ));
3390        fs::remove_dir_all(root).unwrap();
3391    }
3392}