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