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