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