diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index fa80b8e..533eb0c 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -9,6 +9,9 @@ concurrency: group: rust-ci-${{ github.ref }} cancel-in-progress: true +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: lint: name: Lint (ubuntu) diff --git a/.gitignore b/.gitignore index ea8c4bf..9317fa9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ /target +/openspec +.vscode +.idea diff --git a/Cargo.lock b/Cargo.lock index d37e0ae..32bb96a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -299,6 +299,22 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memo-map" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" + +[[package]] +name = "minijinja" +version = "2.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328251e58ad8e415be6198888fc207502727dc77945806421ab34f35bf012e7d" +dependencies = [ + "memo-map", + "serde", +] + [[package]] name = "ngseed" version = "0.1.2" @@ -308,6 +324,8 @@ dependencies = [ "console", "dialoguer", "indicatif", + "minijinja", + "serde", "serde_json", "tempfile", ] @@ -402,6 +420,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 061f42a..0a3a53e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,8 @@ clap = { version = "4.5", features = ["derive"] } console = "0.15" dialoguer = "0.11" indicatif = "0.17" +minijinja = "2.0" +serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" [dev-dependencies] diff --git a/README.md b/README.md index baa867e..955f37d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ngseed -`ngseed` is a Rust-powered CLI to scaffold production-ready Angular apps. Get a layered Clean Architecture baseline with optional UI integrations in seconds. +`ngseed` is a Rust-powered CLI to scaffold production-ready Angular apps. Generate a Clean or CDP (Core-Data-Presentation) architecture baseline with optional UI integrations in seconds. ## Features @@ -10,7 +10,10 @@ - SCSS styles - SSR disabled - npm package manager -- Applies a Clean Architecture starter structure: +- Supports architecture profiles: + - `clean`: domain/application/infrastructure/presentation + - `cdp`: core/data/presentation +- Clean profile starter structure: - `domain` - `application` - `infrastructure` @@ -29,13 +32,14 @@ ngseed new my-app Non-interactive mode: ```bash -ngseed new my-app --yes --ui material --package-manager pnpm +ngseed new my-app --yes --architecture cdp --ui material --package-manager pnpm ``` Flags: - `--ui ` - `--package-manager ` +- `--architecture ` - `--skip-install` - `--yes` diff --git a/src/application/ports.rs b/src/application/ports.rs index 0905045..c0898f3 100644 --- a/src/application/ports.rs +++ b/src/application/ports.rs @@ -3,33 +3,50 @@ use std::path::PathBuf; use anyhow::Result; +use crate::domain::project::ArchitectureProfile; use crate::domain::project::PackageManager; use crate::domain::project::ResolvedOptions; use crate::domain::project::UiChoice; +use crate::domain::styles_choice::StylesChoice; pub trait UiSelector { fn select_ui(&self) -> Result; + fn select_styles(&self) -> Result; fn select_package_manager(&self) -> Result; + fn select_architecture(&self) -> Result; } pub trait Environment { fn project_exists(&self, project_name: &str) -> bool; fn current_dir(&self) -> Result; + fn is_ci(&self) -> bool; + fn is_interactive_terminal(&self) -> bool; } pub trait Seeder { fn ensure_required_tools(&self, package_manager: PackageManager) -> Result<()>; fn scaffold_angular_project(&self, project_name: &str, options: ResolvedOptions) -> Result<()>; - fn apply_clean_architecture_template(&self, project_dir: &Path) -> Result<()>; + fn apply_architecture_template( + &self, + project_dir: &Path, + architecture: ArchitectureProfile, + ) -> Result<()>; fn apply_ui_integration( &self, project_dir: &Path, ui: UiChoice, package_manager: PackageManager, ) -> Result<()>; + fn apply_styles( + &self, + project_dir: &Path, + styles: StylesChoice, + package_manager: PackageManager, + ) -> Result<()>; } pub trait ProgressReporter { + fn show_banner(&self); fn stage_start(&self, stage: &str, message: &str); fn stage_ok(&self, stage: &str, message: &str); fn stage_error(&self, stage: &str, message: &str); diff --git a/src/application/use_cases/new_project.rs b/src/application/use_cases/new_project.rs index 8aa4938..26d2992 100644 --- a/src/application/use_cases/new_project.rs +++ b/src/application/use_cases/new_project.rs @@ -4,9 +4,12 @@ use crate::application::ports::Environment; use crate::application::ports::ProgressReporter; use crate::application::ports::Seeder; use crate::application::ports::UiSelector; +use crate::domain::project::ArchitectureProfile; use crate::domain::project::NewProjectRequest; +use crate::domain::project::PackageManager; use crate::domain::project::ResolvedOptions; use crate::domain::project::UiChoice; +use crate::domain::styles_choice::StylesChoice; pub struct NewProjectUseCase<'a> { env: &'a dyn Environment, @@ -34,10 +37,16 @@ impl<'a> NewProjectUseCase<'a> { let options = self.resolve_options( request.ui, request.package_manager, + request.styles, + request.architecture, request.skip_install, request.yes, )?; + if !request.yes && !self.env.is_ci() && self.env.is_interactive_terminal() { + self.reporter.show_banner(); + } + self.reporter .stage_start("preflight", "checking required tools"); if let Err(err) = self.seeder.ensure_required_tools(options.package_manager) { @@ -71,17 +80,17 @@ impl<'a> NewProjectUseCase<'a> { let absolute_project_dir = self.env.current_dir()?.join(&request.project_name); self.reporter - .stage_start("template", "applying clean architecture template"); + .stage_start("template", "applying architecture template"); if let Err(err) = self .seeder - .apply_clean_architecture_template(&absolute_project_dir) + .apply_architecture_template(&absolute_project_dir, options.architecture) { self.reporter - .stage_error("template", "clean template setup failed"); + .stage_error("template", "template setup failed"); return Err(err); } self.reporter - .stage_ok("template", "clean architecture template applied"); + .stage_ok("template", "architecture template applied"); self.reporter .stage_start("ui setup", "applying selected UI integration"); @@ -97,6 +106,19 @@ impl<'a> NewProjectUseCase<'a> { self.reporter .stage_ok("ui setup", "UI integration completed"); + if options.styles != StylesChoice::None { + self.reporter.stage_start("styles", "applying styles setup"); + if let Err(err) = self.seeder.apply_styles( + &absolute_project_dir, + options.styles, + options.package_manager, + ) { + self.reporter.stage_error("styles", "styles setup failed"); + return Err(err); + } + self.reporter.stage_ok("styles", "styles setup completed"); + } + self.reporter .summary(&request.project_name, &absolute_project_dir, options); @@ -106,22 +128,34 @@ impl<'a> NewProjectUseCase<'a> { fn resolve_options( &self, cli_ui: Option, - cli_package_manager: Option, + cli_package_manager: Option, + cli_styles: Option, + cli_architecture: Option, skip_install: bool, yes: bool, ) -> Result { let package_manager = if let Some(value) = cli_package_manager { value } else if yes { - crate::domain::project::PackageManager::Npm + PackageManager::Npm } else { self.ui_selector.select_package_manager()? }; + let architecture = if let Some(value) = cli_architecture { + value + } else if yes { + ArchitectureProfile::Clean + } else { + self.ui_selector.select_architecture()? + }; + if yes { return Ok(ResolvedOptions { ui: cli_ui.unwrap_or(UiChoice::None), + styles: cli_styles.unwrap_or(StylesChoice::None), package_manager, + architecture, skip_install, }); } @@ -132,9 +166,17 @@ impl<'a> NewProjectUseCase<'a> { self.ui_selector.select_ui()? }; + let styles = if let Some(value) = cli_styles { + value + } else { + self.ui_selector.select_styles()? + }; + Ok(ResolvedOptions { ui, + styles, package_manager, + architecture, skip_install, }) } @@ -156,6 +198,7 @@ mod tests { struct FakeUiSelector { ui: UiChoice, + styles: StylesChoice, } impl UiSelector for FakeUiSelector { @@ -163,9 +206,17 @@ mod tests { Ok(self.ui) } + fn select_styles(&self) -> Result { + Ok(self.styles) + } + fn select_package_manager(&self) -> Result { Ok(PackageManager::Npm) } + + fn select_architecture(&self) -> Result { + Ok(ArchitectureProfile::Clean) + } } struct FakeEnvironment { @@ -181,6 +232,14 @@ mod tests { fn current_dir(&self) -> Result { Ok(self.cwd.clone()) } + + fn is_ci(&self) -> bool { + false + } + + fn is_interactive_terminal(&self) -> bool { + true + } } #[derive(Default)] @@ -207,10 +266,14 @@ mod tests { Ok(()) } - fn apply_clean_architecture_template(&self, _project_dir: &Path) -> Result<()> { + fn apply_architecture_template( + &self, + _project_dir: &Path, + _architecture: ArchitectureProfile, + ) -> Result<()> { self.calls .borrow_mut() - .push("apply_clean_architecture_template".to_string()); + .push("apply_architecture_template".to_string()); Ok(()) } @@ -225,12 +288,23 @@ mod tests { .push("apply_ui_integration".to_string()); Ok(()) } + + fn apply_styles( + &self, + _project_dir: &Path, + _styles: StylesChoice, + _package_manager: PackageManager, + ) -> Result<()> { + self.calls.borrow_mut().push("apply_styles".to_string()); + Ok(()) + } } #[derive(Default)] struct FakeReporter; impl ProgressReporter for FakeReporter { + fn show_banner(&self) {} fn stage_start(&self, _stage: &str, _message: &str) {} fn stage_ok(&self, _stage: &str, _message: &str) {} fn stage_error(&self, _stage: &str, _message: &str) {} @@ -243,7 +317,10 @@ mod tests { exists: false, cwd: PathBuf::from("/tmp"), }; - let ui_selector = FakeUiSelector { ui: UiChoice::None }; + let ui_selector = FakeUiSelector { + ui: UiChoice::None, + styles: StylesChoice::None, + }; let seeder = FakeSeeder::default(); let reporter = FakeReporter; let use_case = NewProjectUseCase::new(&env, &ui_selector, &seeder, &reporter); @@ -252,7 +329,9 @@ mod tests { .execute(NewProjectRequest { project_name: "demo-app".to_string(), ui: None, + styles: None, package_manager: Some(PackageManager::Npm), + architecture: Some(ArchitectureProfile::Clean), skip_install: true, yes: true, }) @@ -263,7 +342,7 @@ mod tests { vec![ "ensure_required_tools", "scaffold_angular_project", - "apply_clean_architecture_template", + "apply_architecture_template", "apply_ui_integration" ] ); diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 36df406..bd803d0 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -1 +1,3 @@ pub mod project; +pub mod styles_choice; +pub mod tui_input; diff --git a/src/domain/project.rs b/src/domain/project.rs index 0aed6a1..295820a 100644 --- a/src/domain/project.rs +++ b/src/domain/project.rs @@ -1,3 +1,5 @@ +use crate::domain::styles_choice::StylesChoice; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum UiChoice { Material, @@ -13,10 +15,18 @@ pub enum PackageManager { Bun, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ArchitectureProfile { + Clean, + Cdp, +} + #[derive(Debug, Clone, Copy)] pub struct ResolvedOptions { pub ui: UiChoice, + pub styles: StylesChoice, pub package_manager: PackageManager, + pub architecture: ArchitectureProfile, pub skip_install: bool, } @@ -24,7 +34,9 @@ pub struct ResolvedOptions { pub struct NewProjectRequest { pub project_name: String, pub ui: Option, + pub styles: Option, pub package_manager: Option, + pub architecture: Option, pub skip_install: bool, pub yes: bool, } diff --git a/src/domain/styles_choice.rs b/src/domain/styles_choice.rs new file mode 100644 index 0000000..f2f05f6 --- /dev/null +++ b/src/domain/styles_choice.rs @@ -0,0 +1,6 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum StylesChoice { + #[default] + None, + TailwindCSS, +} diff --git a/src/domain/tui_input.rs b/src/domain/tui_input.rs new file mode 100644 index 0000000..f4ceb5b --- /dev/null +++ b/src/domain/tui_input.rs @@ -0,0 +1,57 @@ +#![allow(dead_code)] + +use crate::domain::project::PackageManager; +use crate::domain::project::UiChoice; + +pub struct TuiInput { + pub project_name: Option, + pub package_manager: Option, + pub ui_choice: Option, + pub styles_choice: Option, + pub skip_install: bool, +} + +impl TuiInput { + pub fn new() -> Self { + Self { + project_name: None, + package_manager: None, + ui_choice: None, + styles_choice: None, + skip_install: false, + } + } + + pub fn with_project_name(mut self, name: String) -> Self { + self.project_name = Some(name); + self + } + + pub fn with_package_manager(mut self, pm: PackageManager) -> Self { + self.package_manager = Some(pm); + self + } + + pub fn with_ui_choice(mut self, ui: UiChoice) -> Self { + self.ui_choice = Some(ui); + self + } + + pub fn with_styles_choice(mut self, styles: StylesChoice) -> Self { + self.styles_choice = Some(styles); + self + } + + pub fn with_skip_install(mut self, skip: bool) -> Self { + self.skip_install = skip; + self + } +} + +impl Default for TuiInput { + fn default() -> Self { + Self::new() + } +} + +use crate::domain::styles_choice::StylesChoice; diff --git a/src/infrastructure/console_progress_reporter.rs b/src/infrastructure/console_progress_reporter.rs index d3dd6cc..e52f61f 100644 --- a/src/infrastructure/console_progress_reporter.rs +++ b/src/infrastructure/console_progress_reporter.rs @@ -6,7 +6,7 @@ use console::style; use indicatif::{ProgressBar, ProgressStyle}; use crate::application::ports::ProgressReporter; -use crate::domain::project::{PackageManager, ResolvedOptions, UiChoice}; +use crate::domain::project::{ArchitectureProfile, PackageManager, ResolvedOptions, UiChoice}; pub struct ConsoleProgressReporter { spinner: Mutex>, @@ -21,6 +21,39 @@ impl Default for ConsoleProgressReporter { } impl ProgressReporter for ConsoleProgressReporter { + fn show_banner(&self) { + println!( + "{}", + style(" _ _ ____ ____ _____ _____ ____ ").cyan().bold() + ); + println!( + "{}", + style("| \\ | |/ ___/ ___|| ____| ____| _ \\ ") + .cyan() + .bold() + ); + println!( + "{}", + style("| \\| | | _\\___ \\| _| | _| | | | |") + .cyan() + .bold() + ); + println!( + "{}", + style("| |\\ | |_| |___) | |___| |___| |_| |") + .cyan() + .bold() + ); + println!( + "{}", + style("|_| \\_|\\____|____/|_____|_____|____/ ") + .cyan() + .bold() + ); + println!("{}", style("Angular project bootstrap CLI").dim()); + println!(); + } + fn stage_start(&self, stage: &str, message: &str) { let spinner = ProgressBar::new_spinner(); spinner.set_style( @@ -89,6 +122,11 @@ impl ProgressReporter for ConsoleProgressReporter { style("package manager:").bold(), style(package_manager_label(options.package_manager)).yellow() ); + println!( + "{} {}", + style("architecture:").bold(), + style(architecture_label(options.architecture)).yellow() + ); println!( "{} {}", style("skip install:").bold(), @@ -117,3 +155,10 @@ fn package_manager_label(pm: PackageManager) -> &'static str { PackageManager::Bun => "bun", } } + +fn architecture_label(profile: ArchitectureProfile) -> &'static str { + match profile { + ArchitectureProfile::Clean => "clean", + ArchitectureProfile::Cdp => "cdp", + } +} diff --git a/src/infrastructure/dialoguer_ui_selector.rs b/src/infrastructure/dialoguer_ui_selector.rs index 726daa7..16de774 100644 --- a/src/infrastructure/dialoguer_ui_selector.rs +++ b/src/infrastructure/dialoguer_ui_selector.rs @@ -2,8 +2,10 @@ use anyhow::{Context, Result}; use dialoguer::{Select, theme::ColorfulTheme}; use crate::application::ports::UiSelector; +use crate::domain::project::ArchitectureProfile; use crate::domain::project::PackageManager; use crate::domain::project::UiChoice; +use crate::domain::styles_choice::StylesChoice; pub struct DialoguerUiSelector; @@ -27,6 +29,24 @@ impl UiSelector for DialoguerUiSelector { Ok(ui) } + fn select_styles(&self) -> Result { + let choices = ["None", "TailwindCSS"]; + let selected = Select::with_theme(&ColorfulTheme::default()) + .with_prompt("Select styles option") + .items(&choices) + .default(0) + .interact() + .context("failed to read styles selection")?; + + let styles = match selected { + 0 => StylesChoice::None, + 1 => StylesChoice::TailwindCSS, + _ => StylesChoice::None, + }; + + Ok(styles) + } + fn select_package_manager(&self) -> Result { let choices = ["npm", "pnpm", "yarn", "bun"]; let selected = Select::with_theme(&ColorfulTheme::default()) @@ -46,4 +66,22 @@ impl UiSelector for DialoguerUiSelector { Ok(manager) } + + fn select_architecture(&self) -> Result { + let choices = ["clean", "cdp"]; + let selected = Select::with_theme(&ColorfulTheme::default()) + .with_prompt("Select architecture profile") + .items(&choices) + .default(0) + .interact() + .context("failed to read architecture selection")?; + + let profile = match selected { + 0 => ArchitectureProfile::Clean, + 1 => ArchitectureProfile::Cdp, + _ => ArchitectureProfile::Clean, + }; + + Ok(profile) + } } diff --git a/src/infrastructure/system_environment.rs b/src/infrastructure/system_environment.rs index 6adf67c..109ae33 100644 --- a/src/infrastructure/system_environment.rs +++ b/src/infrastructure/system_environment.rs @@ -14,4 +14,13 @@ impl Environment for SystemEnvironment { fn current_dir(&self) -> Result { std::env::current_dir().context("unable to resolve current directory") } + + fn is_ci(&self) -> bool { + std::env::var_os("CI").is_some() + } + + fn is_interactive_terminal(&self) -> bool { + use std::io::IsTerminal; + std::io::stdout().is_terminal() + } } diff --git a/src/infrastructure/system_seeder.rs b/src/infrastructure/system_seeder.rs index 0c94fc8..b9121bc 100644 --- a/src/infrastructure/system_seeder.rs +++ b/src/infrastructure/system_seeder.rs @@ -1,14 +1,55 @@ +#![allow(dead_code)] + use std::fs; use std::path::Path; use std::process::Command; use anyhow::{Context, Result, bail}; +use minijinja::Environment; use serde_json::Value; use crate::application::ports::Seeder; +use crate::domain::project::ArchitectureProfile; use crate::domain::project::PackageManager; use crate::domain::project::ResolvedOptions; use crate::domain::project::UiChoice; +use crate::domain::styles_choice::StylesChoice; + +pub struct TemplateContext { + pub project_name: String, + pub template_url: String, + pub style_url: String, + pub component_class: String, +} + +pub struct TemplateLoader { + env: Environment<'static>, +} + +impl TemplateLoader { + pub fn new() -> Result { + let mut env = Environment::new(); + let templates_dir = std::env::current_dir()?.join("templates").join("angular"); + env.set_loader(minijinja::path_loader(templates_dir)); + Ok(Self { env }) + } + + pub fn render(&self, template_name: &str, context: impl serde::Serialize) -> Result { + let template = self + .env + .get_template(template_name) + .with_context(|| format!("failed to load template {}", template_name))?; + template + .render(context) + .with_context(|| format!("failed to render template {}", template_name)) + } +} + +impl Default for TemplateLoader { + fn default() -> Self { + Self::new().expect("failed to initialize template loader") + } +} pub trait CommandRunner { fn run(&mut self, program: &str, args: &[String], cwd: Option<&Path>) -> Result<()>; @@ -41,12 +82,6 @@ impl CommandRunner for SystemCommandRunner { pub struct SystemSeeder; -impl Default for SystemSeeder { - fn default() -> Self { - Self - } -} - impl Seeder for SystemSeeder { fn ensure_required_tools(&self, package_manager: PackageManager) -> Result<()> { let mut runner = SystemCommandRunner; @@ -58,8 +93,12 @@ impl Seeder for SystemSeeder { scaffold_angular_project(&mut runner, project_name, options) } - fn apply_clean_architecture_template(&self, project_dir: &Path) -> Result<()> { - apply_clean_architecture_template(project_dir) + fn apply_architecture_template( + &self, + project_dir: &Path, + architecture: ArchitectureProfile, + ) -> Result<()> { + apply_architecture_template(project_dir, architecture) } fn apply_ui_integration( @@ -71,6 +110,16 @@ impl Seeder for SystemSeeder { let mut runner = SystemCommandRunner; apply_ui_integration(&mut runner, project_dir, ui, package_manager) } + + fn apply_styles( + &self, + project_dir: &Path, + styles: StylesChoice, + package_manager: PackageManager, + ) -> Result<()> { + let mut runner = SystemCommandRunner; + apply_styles(&mut runner, project_dir, styles, package_manager) + } } fn ensure_required_tools( @@ -112,6 +161,16 @@ fn scaffold_angular_project( runner.run("ng", &args, None) } +fn apply_architecture_template( + project_dir: &Path, + architecture: ArchitectureProfile, +) -> Result<()> { + match architecture { + ArchitectureProfile::Clean => apply_clean_architecture_template(project_dir), + ArchitectureProfile::Cdp => apply_cdp_architecture_template(project_dir), + } +} + fn apply_clean_architecture_template(project_dir: &Path) -> Result<()> { let app_dir = project_dir.join("src/app"); if !app_dir.exists() { @@ -121,103 +180,211 @@ fn apply_clean_architecture_template(project_dir: &Path) -> Result<()> { ); } + let loader = TemplateLoader::new()?; + write_file( &app_dir.join("domain/entities/greeting.entity.ts"), - r#"export interface Greeting { - value: string; + &loader.render("greeting.entity.ts.j2", ())?, + )?; + + write_file( + &app_dir.join("domain/ports/greeting-repository.port.ts"), + &loader.render("greeting-repository.port.ts.j2", ())?, + )?; + + write_file( + &app_dir.join("application/use-cases/get-greeting.use-case.ts"), + &loader.render("get-greeting.use-case.ts.j2", ())?, + )?; + + write_file( + &app_dir.join("infrastructure/adapters/static-greeting.repository.ts"), + &loader.render("static-greeting.repository.ts.j2", ())?, + )?; + + write_file( + &app_dir.join("infrastructure/providers/greeting.provider.ts"), + &loader.render("greeting.provider.ts.j2", ())?, + )?; + + write_file( + &app_dir.join("presentation/facades/home.facade.ts"), + &loader.render("home.facade.ts.j2", ())?, + )?; + + patch_app_component_for_clean(&app_dir)?; + patch_app_config_for_clean(&app_dir)?; + + Ok(()) +} + +fn apply_cdp_architecture_template(project_dir: &Path) -> Result<()> { + let app_dir = project_dir.join("src/app"); + if !app_dir.exists() { + bail!( + "could not find Angular app directory at `{}`", + app_dir.display() + ); + } + + write_file( + &app_dir.join("core/models/health-status.model.ts"), + r#"export interface HealthStatus { + service: string; + status: 'ok' | 'degraded'; + checkedAt: string; } "#, )?; write_file( - &app_dir.join("domain/ports/greeting-repository.port.ts"), - r#"import { InjectionToken } from '@angular/core'; -import { Greeting } from '../entities/greeting.entity'; + &app_dir.join("core/environment/app-environment.ts"), + r#"export const appEnvironment = { + appName: 'ngseed-cdp-app', + apiBaseUrl: '/api', +}; +"#, + )?; -export interface GreetingRepository { - getGreeting(): Greeting; + write_file( + &app_dir.join("core/commons/logger.ts"), + r#"export function logInfo(message: string): void { + console.info(`[CDP] ${message}`); } +"#, + )?; -export const GREETING_REPOSITORY = new InjectionToken('GREETING_REPOSITORY'); + write_file( + &app_dir.join("core/auth/auth.types.ts"), + r#"export interface AuthUser { + id: string; + role: string; +} "#, )?; write_file( - &app_dir.join("application/use-cases/get-greeting.use-case.ts"), - r#"import { Inject, Injectable } from '@angular/core'; -import { - GREETING_REPOSITORY, - GreetingRepository, -} from '../../domain/ports/greeting-repository.port'; + &app_dir.join("data/datasource/remote/health.datasource.ts"), + r#"import { Injectable } from '@angular/core'; +import { HealthStatus } from '../../../core/models/health-status.model'; @Injectable({ providedIn: 'root' }) -export class GetGreetingUseCase { - constructor( - @Inject(GREETING_REPOSITORY) - private readonly greetingRepository: GreetingRepository, - ) {} - - execute(): string { - return this.greetingRepository.getGreeting().value; +export class HealthRemoteDataSource { + getStatus(): HealthStatus { + return { + service: 'ngseed-cdp', + status: 'ok', + checkedAt: new Date().toISOString(), + }; } } "#, )?; write_file( - &app_dir.join("infrastructure/adapters/static-greeting.repository.ts"), + &app_dir.join("data/datasource/local/preferences.datasource.ts"), r#"import { Injectable } from '@angular/core'; -import { Greeting } from '../../domain/entities/greeting.entity'; -import { GreetingRepository } from '../../domain/ports/greeting-repository.port'; -@Injectable() -export class StaticGreetingRepository implements GreetingRepository { - getGreeting(): Greeting { - return { value: 'Angular project seeded with Clean Architecture' }; +@Injectable({ providedIn: 'root' }) +export class PreferencesLocalDataSource { + private readonly key = 'ngseed:theme'; + + getTheme(): string { + return localStorage.getItem(this.key) ?? 'light'; } } "#, )?; write_file( - &app_dir.join("infrastructure/providers/greeting.provider.ts"), - r#"import { Provider } from '@angular/core'; -import { GREETING_REPOSITORY } from '../../domain/ports/greeting-repository.port'; -import { StaticGreetingRepository } from '../adapters/static-greeting.repository'; - -export function provideGreetingRepository(): Provider[] { - return [ - StaticGreetingRepository, - { - provide: GREETING_REPOSITORY, - useExisting: StaticGreetingRepository, - }, - ]; + &app_dir.join("presentation/pages/health/health.page.ts"), + r#"import { CommonModule } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { HealthRemoteDataSource } from '../../../data/datasource/remote/health.datasource'; +import { PreferencesLocalDataSource } from '../../../data/datasource/local/preferences.datasource'; + +@Component({ + selector: 'app-health-page', + standalone: true, + imports: [CommonModule], + templateUrl: './health.page.html', +}) +export class HealthPage { + private readonly remote = inject(HealthRemoteDataSource); + private readonly local = inject(PreferencesLocalDataSource); + + readonly health = this.remote.getStatus(); + readonly theme = this.local.getTheme(); } "#, )?; write_file( - &app_dir.join("presentation/facades/home.facade.ts"), - r#"import { Injectable, inject } from '@angular/core'; -import { GetGreetingUseCase } from '../../application/use-cases/get-greeting.use-case'; - -@Injectable({ providedIn: 'root' }) -export class HomeFacade { - private readonly getGreetingUseCase = inject(GetGreetingUseCase); + &app_dir.join("presentation/pages/health/health.page.html"), + r#"
+

CDP Architecture Ready

+

Status: {{ health.status }} ({{ health.service }})

+

Theme preference: {{ theme }}

+
+"#, + )?; - readonly message = this.getGreetingUseCase.execute(); -} + write_file( + &app_dir.join("app.routes.ts"), + r#"import { Routes } from '@angular/router'; +import { HealthPage } from './presentation/pages/health/health.page'; + +export const routes: Routes = [ + { + path: '', + component: HealthPage, + }, +]; "#, )?; - patch_app_component(&app_dir)?; - patch_app_config(&app_dir)?; + patch_app_component_for_cdp(&app_dir)?; + patch_app_config_for_cdp(&app_dir)?; + + Ok(()) +} + +fn patch_app_component_for_clean(app_dir: &Path) -> Result<()> { + let loader = TemplateLoader::new()?; + + let (app_ts, app_html, template_url, style_url, component_class) = + if app_dir.join("app.ts").exists() { + ( + app_dir.join("app.ts"), + app_dir.join("app.html"), + "./app.html", + "./app.scss", + "App", + ) + } else { + ( + app_dir.join("app.component.ts"), + app_dir.join("app.component.html"), + "./app.component.html", + "./app.scss", + "AppComponent", + ) + }; + + let context = serde_json::json!({ + "template_url": template_url, + "style_url": style_url, + "component_class": component_class + }); + + write_file(&app_ts, &loader.render("app.component.ts.j2", context)?)?; + + write_file(&app_html, &loader.render("app.component.html.j2", ())?)?; Ok(()) } -fn patch_app_component(app_dir: &Path) -> Result<()> { +fn patch_app_component_for_cdp(app_dir: &Path) -> Result<()> { let (app_ts, app_html, template_url, style_property, component_class) = if app_dir.join("app.ts").exists() { ( @@ -238,9 +405,8 @@ fn patch_app_component(app_dir: &Path) -> Result<()> { }; write_file(&app_ts, &{ - let template = r#"import { Component, inject } from '@angular/core'; + let template = r#"import { Component } from '@angular/core'; import { RouterOutlet } from '@angular/router'; -import { HomeFacade } from './presentation/facades/home.facade'; @Component({ selector: 'app-root', @@ -248,10 +414,7 @@ import { HomeFacade } from './presentation/facades/home.facade'; templateUrl: '__TEMPLATE_URL__', __STYLE_PROPERTY__: ['./app.scss'], }) -export class __COMPONENT_CLASS__ { - private readonly homeFacade = inject(HomeFacade); - readonly message = this.homeFacade.message; -} +export class __COMPONENT_CLASS__ {} "#; template .replace("__TEMPLATE_URL__", template_url) @@ -261,18 +424,21 @@ export class __COMPONENT_CLASS__ { write_file( &app_html, - r#"
-

{{ message }}

-

Start building features in domain/application/infrastructure/presentation.

-
- + r#" "#, )?; Ok(()) } -fn patch_app_config(app_dir: &Path) -> Result<()> { +fn patch_app_config_for_clean(app_dir: &Path) -> Result<()> { + let loader = TemplateLoader::new()?; + let app_config = app_dir.join("app.config.ts"); + + write_file(&app_config, &loader.render("app.config.ts.j2", ())?) +} + +fn patch_app_config_for_cdp(app_dir: &Path) -> Result<()> { let app_config = app_dir.join("app.config.ts"); write_file( @@ -281,10 +447,9 @@ fn patch_app_config(app_dir: &Path) -> Result<()> { import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; -import { provideGreetingRepository } from './infrastructure/providers/greeting.provider'; export const appConfig: ApplicationConfig = { - providers: [provideRouter(routes), ...provideGreetingRepository()], + providers: [provideRouter(routes)], }; "#, ) @@ -325,6 +490,48 @@ fn apply_ui_integration( } } +fn apply_styles( + runner: &mut dyn CommandRunner, + project_dir: &Path, + styles: StylesChoice, + package_manager: PackageManager, +) -> Result<()> { + match styles { + StylesChoice::None => Ok(()), + StylesChoice::TailwindCSS => { + let (program, install_args) = package_manager_install_command( + package_manager, + &["tailwindcss", "postcss", "autoprefixer"], + ); + runner.run(program, &install_args, Some(project_dir))?; + + runner.run( + "npx", + &[ + "tailwindcss".to_string(), + "init".to_string(), + "-p".to_string(), + ], + Some(project_dir), + )?; + + let tailwind_config = project_dir.join("tailwind.config.js"); + let loader = TemplateLoader::new()?; + fs::write( + &tailwind_config, + loader.render("tailwind.config.js.j2", ())?, + ) + .with_context(|| format!("failed to write {}", tailwind_config.display()))?; + + let styles_scss = project_dir.join("src/styles.scss"); + fs::write(&styles_scss, loader.render("styles.scss.j2", ())?) + .with_context(|| format!("failed to write {}", styles_scss.display()))?; + + Ok(()) + } + } +} + fn package_manager_cli_name(package_manager: PackageManager) -> &'static str { match package_manager { PackageManager::Npm => "npm", @@ -449,7 +656,9 @@ mod tests { "demo-app", ResolvedOptions { ui: UiChoice::None, + styles: StylesChoice::None, package_manager: PackageManager::Pnpm, + architecture: ArchitectureProfile::Clean, skip_install: true, }, ) @@ -541,4 +750,29 @@ mod tests { ); assert!(app_dir.join("presentation/facades/home.facade.ts").exists()); } + + #[test] + fn cdp_template_creates_layered_files() { + let tmp = tempdir().unwrap(); + let app_dir = tmp.path().join("demo/src/app"); + fs::create_dir_all(&app_dir).unwrap(); + fs::write(app_dir.join("app.ts"), "").unwrap(); + fs::write(app_dir.join("app.html"), "").unwrap(); + fs::write(app_dir.join("app.config.ts"), "").unwrap(); + fs::write(app_dir.join("app.routes.ts"), "").unwrap(); + + apply_cdp_architecture_template(&tmp.path().join("demo")).unwrap(); + + assert!(app_dir.join("core/models/health-status.model.ts").exists()); + assert!( + app_dir + .join("data/datasource/remote/health.datasource.ts") + .exists() + ); + assert!( + app_dir + .join("presentation/pages/health/health.page.ts") + .exists() + ); + } } diff --git a/src/interfaces/cli.rs b/src/interfaces/cli.rs index 632d01d..d8d804d 100644 --- a/src/interfaces/cli.rs +++ b/src/interfaces/cli.rs @@ -1,17 +1,19 @@ use anyhow::Result; use clap::{Parser, Subcommand, ValueEnum}; +use crate::domain::project::ArchitectureProfile; use crate::domain::project::NewProjectRequest; use crate::domain::project::PackageManager; use crate::domain::project::UiChoice; +use crate::domain::styles_choice::StylesChoice; #[derive(Parser, Debug)] #[command( name = "ngseed", version, about = "Initialize Angular projects with a clean architecture baseline", - long_about = "A modern CLI to scaffold Angular projects, apply clean architecture structure, and integrate a UI stack.", - after_help = "Examples:\n ngseed new my-app\n ngseed new my-app --yes --ui material --package-manager pnpm\n ngseed new my-app --skip-install --ui none", + long_about = "A modern CLI to scaffold Angular projects, apply architecture templates, and integrate a UI stack.", + after_help = "Examples:\n ngseed new my-app --architecture clean\n ngseed new my-app --architecture cdp --ui none\n ngseed new my-app --yes --ui material --package-manager pnpm", arg_required_else_help = true )] struct Cli { @@ -31,9 +33,15 @@ struct NewCommand { #[arg(long, value_enum)] ui: Option, + #[arg(long, value_enum)] + styles: Option, + #[arg(long, value_enum)] package_manager: Option, + #[arg(long, value_enum)] + architecture: Option, + #[arg(long)] skip_install: bool, @@ -48,6 +56,12 @@ enum CliUiChoice { None, } +#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] +enum CliStylesChoice { + Tailwindcss, + None, +} + #[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] enum CliPackageManager { Npm, @@ -56,6 +70,12 @@ enum CliPackageManager { Bun, } +#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] +enum CliArchitectureProfile { + Clean, + Cdp, +} + pub enum AppCommand { New(NewProjectRequest), } @@ -78,7 +98,9 @@ fn map_cli_to_command(cli: Cli) -> AppCommand { Commands::New(cmd) => AppCommand::New(NewProjectRequest { project_name: cmd.project_name, ui: cmd.ui.map(Into::into), + styles: cmd.styles.map(Into::into), package_manager: cmd.package_manager.map(Into::into), + architecture: cmd.architecture.map(Into::into), skip_install: cmd.skip_install, yes: cmd.yes, }), @@ -95,6 +117,15 @@ impl From for UiChoice { } } +impl From for StylesChoice { + fn from(value: CliStylesChoice) -> Self { + match value { + CliStylesChoice::Tailwindcss => StylesChoice::TailwindCSS, + CliStylesChoice::None => StylesChoice::None, + } + } +} + impl From for PackageManager { fn from(value: CliPackageManager) -> Self { match value { @@ -106,6 +137,15 @@ impl From for PackageManager { } } +impl From for ArchitectureProfile { + fn from(value: CliArchitectureProfile) -> Self { + match value { + CliArchitectureProfile::Clean => ArchitectureProfile::Clean, + CliArchitectureProfile::Cdp => ArchitectureProfile::Cdp, + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -122,6 +162,8 @@ mod tests { "primeng", "--package-manager", "pnpm", + "--architecture", + "cdp", ]) .unwrap(); @@ -129,6 +171,7 @@ mod tests { assert_eq!(request.project_name, "demo"); assert_eq!(request.ui, Some(UiChoice::Primeng)); assert_eq!(request.package_manager, Some(PackageManager::Pnpm)); + assert_eq!(request.architecture, Some(ArchitectureProfile::Cdp)); assert!(request.skip_install); assert!(request.yes); } diff --git a/src/lib.rs b/src/lib.rs index 623c38d..88a8373 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,7 +11,18 @@ use infrastructure::dialoguer_ui_selector::DialoguerUiSelector; use infrastructure::system_environment::SystemEnvironment; use infrastructure::system_seeder::SystemSeeder; +const BANNER: &str = r#" + _ __ _____ __ + / | / /___ _/ ___/___ ___ ____/ / + / |/ / __ `/\__ \/ _ \/ _ \/ __ / + / /| / /_/ /___/ / __/ __/ /_/ / + /_/ |_/\__, //____/\___/\___/\__,_/ + /____/ +"#; + pub fn run() -> Result<()> { + println!("{}", BANNER); + let command = interfaces::cli::parse()?; match command { diff --git a/templates/angular/app.component.html.j2 b/templates/angular/app.component.html.j2 new file mode 100644 index 0000000..1652092 --- /dev/null +++ b/templates/angular/app.component.html.j2 @@ -0,0 +1,5 @@ +
+

{{ message }}

+

Start building features in domain/application/infrastructure/presentation.

+
+ \ No newline at end of file diff --git a/templates/angular/app.component.ts.j2 b/templates/angular/app.component.ts.j2 new file mode 100644 index 0000000..f165b7f --- /dev/null +++ b/templates/angular/app.component.ts.j2 @@ -0,0 +1,14 @@ +import { Component, inject } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; +import { HomeFacade } from './presentation/facades/home.facade'; + +@Component({ + selector: 'app-root', + imports: [RouterOutlet], + templateUrl: '{{ template_url }}', + styleUrl: '{{ style_url }}', +}) +export class {{ component_class }} { + private readonly homeFacade = inject(HomeFacade); + readonly message = this.homeFacade.message; +} \ No newline at end of file diff --git a/templates/angular/app.config.ts.j2 b/templates/angular/app.config.ts.j2 new file mode 100644 index 0000000..926121c --- /dev/null +++ b/templates/angular/app.config.ts.j2 @@ -0,0 +1,9 @@ +import { ApplicationConfig } from '@angular/core'; +import { provideRouter } from '@angular/router'; + +import { routes } from './app.routes'; +import { provideGreetingRepository } from './infrastructure/providers/greeting.provider'; + +export const appConfig: ApplicationConfig = { + providers: [provideRouter(routes), ...provideGreetingRepository()], +}; \ No newline at end of file diff --git a/templates/angular/get-greeting.use-case.ts.j2 b/templates/angular/get-greeting.use-case.ts.j2 new file mode 100644 index 0000000..74f883b --- /dev/null +++ b/templates/angular/get-greeting.use-case.ts.j2 @@ -0,0 +1,17 @@ +import { Inject, Injectable } from '@angular/core'; +import { + GREETING_REPOSITORY, + GreetingRepository, +} from '../../domain/ports/greeting-repository.port'; + +@Injectable({ providedIn: 'root' }) +export class GetGreetingUseCase { + constructor( + @Inject(GREETING_REPOSITORY) + private readonly greetingRepository: GreetingRepository, + ) {} + + execute(): string { + return this.greetingRepository.getGreeting().value; + } +} \ No newline at end of file diff --git a/templates/angular/greeting-repository.port.ts.j2 b/templates/angular/greeting-repository.port.ts.j2 new file mode 100644 index 0000000..1b63bb0 --- /dev/null +++ b/templates/angular/greeting-repository.port.ts.j2 @@ -0,0 +1,8 @@ +import { InjectionToken } from '@angular/core'; +import { Greeting } from '../entities/greeting.entity'; + +export interface GreetingRepository { + getGreeting(): Greeting; +} + +export const GREETING_REPOSITORY = new InjectionToken('GREETING_REPOSITORY'); \ No newline at end of file diff --git a/templates/angular/greeting.entity.ts.j2 b/templates/angular/greeting.entity.ts.j2 new file mode 100644 index 0000000..118d2c4 --- /dev/null +++ b/templates/angular/greeting.entity.ts.j2 @@ -0,0 +1,3 @@ +export interface Greeting { + value: string; +} \ No newline at end of file diff --git a/templates/angular/greeting.provider.ts.j2 b/templates/angular/greeting.provider.ts.j2 new file mode 100644 index 0000000..61c5b70 --- /dev/null +++ b/templates/angular/greeting.provider.ts.j2 @@ -0,0 +1,13 @@ +import { Provider } from '@angular/core'; +import { GREETING_REPOSITORY } from '../../domain/ports/greeting-repository.port'; +import { StaticGreetingRepository } from '../adapters/static-greeting.repository'; + +export function provideGreetingRepository(): Provider[] { + return [ + StaticGreetingRepository, + { + provide: GREETING_REPOSITORY, + useExisting: StaticGreetingRepository, + }, + ]; +} \ No newline at end of file diff --git a/templates/angular/home.facade.ts.j2 b/templates/angular/home.facade.ts.j2 new file mode 100644 index 0000000..170dd2a --- /dev/null +++ b/templates/angular/home.facade.ts.j2 @@ -0,0 +1,9 @@ +import { Injectable, inject } from '@angular/core'; +import { GetGreetingUseCase } from '../../application/use-cases/get-greeting.use-case'; + +@Injectable({ providedIn: 'root' }) +export class HomeFacade { + private readonly getGreetingUseCase = inject(GetGreetingUseCase); + + readonly message = this.getGreetingUseCase.execute(); +} \ No newline at end of file diff --git a/templates/angular/static-greeting.repository.ts.j2 b/templates/angular/static-greeting.repository.ts.j2 new file mode 100644 index 0000000..bc38177 --- /dev/null +++ b/templates/angular/static-greeting.repository.ts.j2 @@ -0,0 +1,10 @@ +import { Injectable } from '@angular/core'; +import { Greeting } from '../../domain/entities/greeting.entity'; +import { GreetingRepository } from '../../domain/ports/greeting-repository.port'; + +@Injectable() +export class StaticGreetingRepository implements GreetingRepository { + getGreeting(): Greeting { + return { value: 'Angular project seeded with Clean Architecture' }; + } +} \ No newline at end of file diff --git a/templates/angular/styles.scss.j2 b/templates/angular/styles.scss.j2 new file mode 100644 index 0000000..bd6213e --- /dev/null +++ b/templates/angular/styles.scss.j2 @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/templates/angular/tailwind.config.js.j2 b/templates/angular/tailwind.config.js.j2 new file mode 100644 index 0000000..1dfb857 --- /dev/null +++ b/templates/angular/tailwind.config.js.j2 @@ -0,0 +1,10 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./src/**/*.{html,ts}", + ], + theme: { + extend: {}, + }, + plugins: [], +} \ No newline at end of file