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