diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 210e1e5..a580660 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,3 +72,37 @@ jobs: working-directory: ${{ runner.temp }}/foundry-web - run: npm run check working-directory: ${{ runner.temp }}/foundry-web + + rust-template: + name: Rust template (${{ matrix.os }}, ${{ matrix.toolchain }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + toolchain: [stable] + include: + - os: ubuntu-latest + toolchain: '1.85.0' + env: + RUSTDOCFLAGS: -D warnings + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.23.x' + cache: true + - name: Install Rust tooling + run: rustup toolchain install ${{ matrix.toolchain }} --profile minimal --component rustfmt --component clippy + - name: Generate the bundled template + run: go run ./cmd/foundry new rust-check --template rust --destination "${{ runner.temp }}/foundry-rust" --no-hooks + - run: cargo +${{ matrix.toolchain }} fmt --all -- --check + working-directory: ${{ runner.temp }}/foundry-rust + - run: cargo +${{ matrix.toolchain }} test --locked --offline + working-directory: ${{ runner.temp }}/foundry-rust + - run: cargo +${{ matrix.toolchain }} clippy --locked --offline --all-targets -- -D warnings + working-directory: ${{ runner.temp }}/foundry-rust + - run: cargo +${{ matrix.toolchain }} doc --locked --offline --no-deps + working-directory: ${{ runner.temp }}/foundry-rust + - run: cargo +${{ matrix.toolchain }} build --release --locked --offline + working-directory: ${{ runner.temp }}/foundry-rust diff --git a/CHANGELOG.md b/CHANGELOG.md index f2f35a6..9b945e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Add embedded Rust 2024 foundations with a thin CLI, reusable library, feature modules, typed errors, unit/integration/doc tests, rustfmt, and Clippy. +- Validate generated Rust projects across Linux, macOS, Windows, and the minimum Rust version. + - Complete the project metadata package rename from `internal/projectconfig` to `internal/config`. - Add embedded Vanilla JavaScript and React foundations with Vite, feature-first structure, reproducible npm installs, linting, formatting, behavior tests, and production builds. diff --git a/README.md b/README.md index 7bb547c..3d21e90 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Foundry is a single Go CLI for creating and extending projects from reusable fou ## Highlights -- Go, Flutter, Vanilla JavaScript, and React templates bundled directly as source files in one executable +- Go, Flutter, Vanilla JavaScript, React, and Rust templates bundled directly as source files in one executable - offline project generation with no template downloads or packaging step - `foundry template list` to inspect the bundled templates - `foundry add service` and `foundry add migration` for Go projects @@ -16,6 +16,7 @@ Foundry is a single Go CLI for creating and extending projects from reusable fou - `flutter-foundations` (`flutter`, `mobile`, `app`) — Flutter, Riverpod, go_router, Dio, Melos, Mason-ready feature generation and reusable foundation packages. - `vanilla-js-foundations` (`vanilla-js`, `vanilla`, `javascript`, `js`) — JavaScript ES modules, Vite, feature-first DOM components, ESLint, Prettier, Vitest, and Testing Library. - `react-foundations` (`react`) — React with JavaScript, Vite, feature-first components, Hooks linting, ESLint, Prettier, Vitest, and Testing Library. +- `rust-foundations` (`rust`, `rs`) — Rust 2024, a thin CLI and reusable library, feature modules, validated domain types, typed errors, unit/integration/doc tests, rustfmt, and Clippy. ## Install @@ -95,6 +96,18 @@ npm run dev The web templates use Node.js 22.18+ on the 22.x line, Node 24, or a newer supported even-numbered release. They include a feature-first `src/app`, `src/pages`, `src/features`, and `src/shared` structure, colocated tests, public environment configuration, reusable UI/styles, and an HTTP helper. Run `npm run check` for formatting, lint, tests, and a production build. Dependency installation is explicit; Foundry does not run npm automatically. +Rust application: + +```bash +foundry new command-tool --template rust +cd command-tool +cargo run --locked -- greet "Ada Lovelace" +cargo test --locked +cargo lint +``` + +The Rust starter has no third-party dependencies. Its toolchain file selects stable Rust with rustfmt and Clippy, and its minimum supported version is Rust 1.85. Domain rules live in feature modules, with CLI input/output at the boundary. Foundry does not run Cargo automatically. + All templates are available offline. Optional generation hooks may invoke installed SDKs and their package managers; use `--no-hooks` to skip them. ## Extend an existing project @@ -129,6 +142,7 @@ All templates are maintained under `templates/` and embedded directly into the F | Flutter Foundations | `templates/flutter_foundations/` | Embedded source files | | Vanilla JavaScript Foundations | `templates/vanilla-js-foundations/` | Embedded source files | | React Foundations | `templates/react-foundations/` | Embedded source files | +| Rust Foundations | `templates/rust-foundations/` | Embedded source files | Go source files use `.go.tmpl` and its module manifest uses `go.mod.tmpl` to keep the template separate from Foundry's Go module. Generated projects receive ordinary `.go` files and `go.mod`. @@ -140,7 +154,7 @@ To update a template, edit its source files and rebuild Foundry with `make build ```text foundry -foundry new --template +foundry new --template foundry add service foundry add migration foundry add feature @@ -174,7 +188,8 @@ Foundry CLI | +-- Go foundation | +-- Flutter foundation | +-- Vanilla JavaScript foundation - | `-- React foundation + | +-- React foundation + | `-- Rust foundation | +-- add generators | +-- Go service @@ -196,7 +211,7 @@ make build make smoke ``` -`go test ./...` checks generation of all four bundled templates, project setting substitution, CLI behavior, and the Go/Flutter add generators. Web-template CI generates fresh projects and runs `npm ci` and `npm run check` on Node 22 and 24. +`go test ./...` checks generation of all five bundled templates, project setting substitution, CLI behavior, and the Go/Flutter add generators. Web-template CI generates fresh projects and runs `npm ci` and `npm run check` on Node 22 and 24. Rust-template CI runs formatting, tests, Clippy, documentation, and release builds on stable Rust across Linux/macOS/Windows and on Rust 1.85 for minimum-version compatibility. ### Releases diff --git a/assets.go b/assets.go index 7fd14ba..c5db468 100644 --- a/assets.go +++ b/assets.go @@ -46,4 +46,7 @@ import "embed" //go:embed templates/react-foundations/*.config.js templates/react-foundations/README.md all:templates/react-foundations/public //go:embed templates/react-foundations/.editorconfig templates/react-foundations/.env.example templates/react-foundations/.gitignore //go:embed templates/react-foundations/.nvmrc templates/react-foundations/.prettierignore templates/react-foundations/.prettierrc.json +//go:embed templates/rust-foundations/src templates/rust-foundations/tests templates/rust-foundations/Cargo.* +//go:embed templates/rust-foundations/rust-toolchain.toml templates/rust-foundations/README.md +//go:embed templates/rust-foundations/.gitignore templates/rust-foundations/.editorconfig templates/rust-foundations/.cargo var Templates embed.FS diff --git a/internal/cli/cli.go b/internal/cli/cli.go index cbe9576..a5ee399 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -79,7 +79,7 @@ func (a App) interactive() int { fmt.Fprintln(os.Stderr, "project name required") return 2 } - fmt.Println("Template:\n 1) Go Foundations\n 2) Flutter Foundations\n 3) Vanilla JavaScript Foundations\n 4) React Foundations") + fmt.Println("Template:\n 1) Go Foundations\n 2) Flutter Foundations\n 3) Vanilla JavaScript Foundations\n 4) React Foundations\n 5) Rust Foundations") fmt.Print("Select [1]: ") choice, _ := r.ReadString('\n') choice = strings.TrimSpace(choice) @@ -92,8 +92,10 @@ func (a App) interactive() int { tmpl = "vanilla-js" case "4": tmpl = "react" + case "5": + tmpl = "rust" default: - fmt.Fprintln(os.Stderr, "select a template from 1 to 4") + fmt.Fprintln(os.Stderr, "select a template from 1 to 5") return 2 } return a.newCmd([]string{name, "--template", tmpl}) @@ -107,7 +109,7 @@ func (a App) newCmd(args []string) int { } f := flag.NewFlagSet("new", flag.ContinueOnError) f.SetOutput(os.Stderr) - tmpl := f.String("template", "", "template: go, flutter, vanilla-js, or react") + tmpl := f.String("template", "", "template: go, flutter, vanilla-js, react, or rust") version := f.String("version", "", "template version; defaults to bundled version") module := f.String("module", "", "Go module path") org := f.String("org", "", "Flutter organization, e.g. ug.go.health") @@ -122,11 +124,11 @@ func (a App) newCmd(args []string) int { name = f.Args()[0] } if name == "" { - fmt.Fprintln(os.Stderr, "usage: foundry new --template ") + fmt.Fprintln(os.Stderr, "usage: foundry new --template ") return 2 } if *tmpl == "" { - fmt.Fprintln(os.Stderr, "--template is required (go, flutter, vanilla-js, or react)") + fmt.Fprintln(os.Stderr, "--template is required (go, flutter, vanilla-js, react, or rust)") return 2 } @@ -148,6 +150,8 @@ func (a App) newCmd(args []string) int { fmt.Println("Template source: bundled files") if selected.Kind == "javascript" || selected.Kind == "react" { fmt.Println("Next: run npm ci, then npm run dev in the generated project.") + } else if selected.Kind == "rust" { + fmt.Println("Next: run cargo run --locked -- greet World in the generated project.") } return 0 } @@ -282,6 +286,7 @@ Usage: foundry new --template go [--module path] [--version version] foundry new --template flutter [--org com.example] [--version version] foundry new --template [--version version] + foundry new --template rust [--version version] foundry add service [--project path] foundry add repository [--project path] foundry add usecase [--project path] diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index 84fde55..a668d03 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -14,7 +14,7 @@ type Check struct { func Run() []Check { checks := []Check{{Name: "OS", Detail: runtime.GOOS + "/" + runtime.GOARCH, OK: true}} for _, c := range []struct{ Name, Bin string }{ - {"Git", "git"}, {"Node.js", "node"}, {"npm", "npm"}, {"Go", "go"}, {"Docker", "docker"}, {"Buf", "buf"}, + {"Git", "git"}, {"Rust", "rustc"}, {"Cargo", "cargo"}, {"Node.js", "node"}, {"npm", "npm"}, {"Go", "go"}, {"Docker", "docker"}, {"Buf", "buf"}, {"grpcurl", "grpcurl"}, {"Flutter", "flutter"}, {"Dart", "dart"}, {"Melos", "melos"}, {"Mason", "mason"}, } { p, err := exec.LookPath(c.Bin) diff --git a/internal/generator/generator.go b/internal/generator/generator.go index 5589f50..e5a6b0f 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -52,6 +52,7 @@ func (g Generator) Generate(t registry.Template, o Options) error { vars := map[string]string{ "go-foundations": defaultString(o.Module, sanitize(o.Name)), "example.com/go-foundations": defaultString(o.Module, sanitize(o.Name)), + "__RUST_CRATE__": rustCrateName(o.Name), "__PROJECT_NAME__": sanitize(o.Name), "__ORG__": defaultString(o.Org, "com.example"), "__PACKAGE__": defaultString(o.Package, sanitize(o.Name)), @@ -107,10 +108,11 @@ func copyTree(src fs.FS, root, dst string) error { } func replaceText(root string, vars map[string]string) error { - exts := map[string]bool{".html": true, ".js": true, ".jsx": true, ".mjs": true, ".css": true, ".go": true, ".mod": true, ".sum": true, ".proto": true, ".yaml": true, ".yml": true, ".json": true, ".md": true, ".sql": true, ".env": true, ".example": true, ".dart": true, ".xml": true, ".gradle": true, ".properties": true, ".toml": true, ".makefile": true, ".tmpl": true} + exts := map[string]bool{".rs": true, ".lock": true, ".html": true, ".js": true, ".jsx": true, ".mjs": true, ".css": true, ".go": true, ".mod": true, ".sum": true, ".proto": true, ".yaml": true, ".yml": true, ".json": true, ".md": true, ".sql": true, ".env": true, ".example": true, ".dart": true, ".xml": true, ".gradle": true, ".properties": true, ".toml": true, ".makefile": true, ".tmpl": true} ordered := [][2]string{ {"example.com/go-foundations", vars["example.com/go-foundations"]}, {"go-foundations", vars["go-foundations"]}, + {"__RUST_CRATE__", vars["__RUST_CRATE__"]}, {"__PROJECT_NAME__", vars["__PROJECT_NAME__"]}, {"__ORG__", vars["__ORG__"]}, {"__PACKAGE__", vars["__PACKAGE__"]}, @@ -200,6 +202,17 @@ func sanitize(s string) string { } return b.String() } + +// rustCrateName produces an identifier usable in package, library, and binary names. +func rustCrateName(name string) string { + name = sanitize(name) + reserved := " as async await break const continue crate dyn else enum extern false fn for if impl in let loop match mod move mut pub ref return self static struct super trait true type unsafe use where while abstract become box do final gen macro override priv typeof unsized virtual yield try union std core alloc test proc_macro build deps examples incremental con prn aux nul com1 com2 com3 com4 com5 com6 com7 com8 com9 lpt1 lpt2 lpt3 lpt4 lpt5 lpt6 lpt7 lpt8 lpt9 " + if name == "_" || (name[0] >= '0' && name[0] <= '9') || strings.Contains(reserved, " "+name+" ") { + return "app_" + name + } + return name +} + func defaultString(v, d string) string { if strings.TrimSpace(v) == "" { return d diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go index aaa6523..32f5205 100644 --- a/internal/generator/generator_test.go +++ b/internal/generator/generator_test.go @@ -16,7 +16,7 @@ import ( ) func TestBundledTemplatesGenerate(t *testing.T) { - for _, name := range []string{"go", "flutter", "vanilla-js", "react"} { + for _, name := range []string{"go", "flutter", "vanilla-js", "react", "rust"} { t.Run(name, func(t *testing.T) { tmpl, err := registry.Find(name) if err != nil { @@ -34,7 +34,7 @@ func TestBundledTemplatesGenerate(t *testing.T) { if strings.HasSuffix(path, ".tmpl") { t.Errorf("unrestored template filename: %s", path) } - if d.IsDir() && (d.Name() == ".dart_tool" || d.Name() == ".fvm" || d.Name() == ".mason" || d.Name() == "node_modules" || d.Name() == "dist") { + if d.IsDir() && (d.Name() == ".dart_tool" || d.Name() == ".fvm" || d.Name() == ".mason" || d.Name() == "node_modules" || d.Name() == "dist" || d.Name() == "target") { t.Errorf("local tool cache delivered: %s", path) } return nil @@ -65,6 +65,8 @@ func TestBundledTemplatesGenerate(t *testing.T) { file, expected := "pubspec.yaml", "name: flutter_foundation_workspace" if name == "go" { file, expected = "go.mod", "module demo\n" + } else if name == "rust" { + file, expected = "Cargo.toml", `name = "demo"` } else if name == "vanilla-js" || name == "react" { file, expected = "package.json", `"name": "demo"` } @@ -171,3 +173,47 @@ func TestWebTemplatesSubstituteProjectSettings(t *testing.T) { }) } } + +func TestRustCrateName(t *testing.T) { + cases := map[string]string{ + "My Service": "my_service", "123 App": "app_123_app", "type": "app_type", "gen": "app_gen", + "Self": "app_self", "_": "app__", "std": "app_std", "COM1": "app_com1", "": "app", + } + for name, want := range cases { + if got := rustCrateName(name); got != want { + t.Errorf("rustCrateName(%q) = %q, want %q", name, got, want) + } + } +} + +func TestRustTemplateSubstitutesCrateName(t *testing.T) { + tmpl, err := registry.Find("rust") + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"My Service", "type", "123 App"} { + t.Run(name, func(t *testing.T) { + dest := t.TempDir() + if err := (Generator{FS: foundry.Templates}).Generate(tmpl, Options{Name: name, Destination: dest, NoHooks: true}); err != nil { + t.Fatal(err) + } + want := rustCrateName(name) + for _, file := range []string{"Cargo.toml", "Cargo.lock", "src/main.rs", "src/features/greeting/service.rs", "tests/cli.rs", "README.md"} { + data, err := os.ReadFile(filepath.Join(dest, file)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), want) || strings.Contains(string(data), "__RUST_CRATE__") { + t.Errorf("crate name not substituted in %s", file) + } + } + metadata, err := config.Read(dest) + if err != nil { + t.Fatal(err) + } + if metadata.Kind != "rust" || metadata.Template != "rust-foundations" { + t.Fatalf("unexpected metadata: %#v", metadata) + } + }) + } +} diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 1ab3063..6a80f64 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -37,6 +37,11 @@ var templates = []Template{ Description: "React + JavaScript + Vite + feature-first components + Hooks linting + Testing Library + Vitest", Aliases: []string{"react"}, EmbeddedDir: "templates/react-foundations", Kind: "react", }, + { + ID: "rust-foundations", Name: "Rust Foundations", Version: "1.0.0", + Description: "Rust 2024 + reusable library + CLI adapter + feature modules + typed errors + Cargo tests + Clippy", + Aliases: []string{"rust", "rs"}, EmbeddedDir: "templates/rust-foundations", Kind: "rust", + }, } func All() []Template { diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index a0af007..ca4aa4b 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -3,7 +3,7 @@ package registry import "testing" func TestFindAliases(t *testing.T) { - for _, name := range []string{"go-foundations", "go", "grpc", "flutter", "mobile", "vanilla-js-foundations", "vanilla", "vanilla-js", "javascript", "js", "react-foundations", "react"} { + for _, name := range []string{"go-foundations", "go", "grpc", "flutter", "mobile", "vanilla-js-foundations", "vanilla", "vanilla-js", "javascript", "js", "react-foundations", "react", "rust-foundations", "rust", "rs"} { if _, err := Find(name); err != nil { t.Fatalf("Find(%q): %v", name, err) } diff --git a/templates/rust-foundations/.cargo/config.toml b/templates/rust-foundations/.cargo/config.toml new file mode 100644 index 0000000..c2f63db --- /dev/null +++ b/templates/rust-foundations/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +lint = "clippy --locked --all-targets -- -D warnings" diff --git a/templates/rust-foundations/.editorconfig b/templates/rust-foundations/.editorconfig new file mode 100644 index 0000000..c4eca09 --- /dev/null +++ b/templates/rust-foundations/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{toml,md}] +indent_size = 2 diff --git a/templates/rust-foundations/.gitignore b/templates/rust-foundations/.gitignore new file mode 100644 index 0000000..9d20466 --- /dev/null +++ b/templates/rust-foundations/.gitignore @@ -0,0 +1,4 @@ +/target/ +.env +.env.* +.DS_Store diff --git a/templates/rust-foundations/Cargo.lock b/templates/rust-foundations/Cargo.lock new file mode 100644 index 0000000..5acc907 --- /dev/null +++ b/templates/rust-foundations/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "__RUST_CRATE__" +version = "0.1.0" diff --git a/templates/rust-foundations/Cargo.toml b/templates/rust-foundations/Cargo.toml new file mode 100644 index 0000000..f9321a5 --- /dev/null +++ b/templates/rust-foundations/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "__RUST_CRATE__" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +publish = false +description = "A Rust application created with Foundry" + +[dependencies] + +[lints.rust] +unsafe_code = "forbid" + +[profile.release] +lto = "thin" +strip = "debuginfo" diff --git a/templates/rust-foundations/README.md b/templates/rust-foundations/README.md new file mode 100644 index 0000000..0b776f6 --- /dev/null +++ b/templates/rust-foundations/README.md @@ -0,0 +1,53 @@ +# `__PROJECT_NAME__` + +A Rust 2024 application foundation with a thin executable, reusable library, feature modules, typed errors, and no third-party dependencies. + +## Run + +Install Rust with rustup from https://rust-lang.org/tools/install/. The included toolchain file selects stable Rust with rustfmt and Clippy. The minimum supported compiler is Rust 1.85; CI checks both that version and stable. + +```sh +cargo run --locked -- greet "Ada Lovelace" +cargo run --locked -- --help +``` + +The generated Cargo package and library are named `__RUST_CRATE__`. Foundry normalizes the project name to a valid crate identifier and prefixes reserved names or names beginning with a digit with `app_`. + +## Structure + +```text +src/ + main.rs Process startup, exit status, and error reporting + lib.rs Reusable library root and public module tree + cli.rs Argument parsing and output adapter + features/ + greeting/ + mod.rs Feature's public API + domain.rs Validated input and domain errors + service.rs Application operation and documentation example +tests/ + cli.rs Integration tests of the actual executable +.cargo/config.toml Cargo convenience aliases +``` + +Replace the example greeting with your first feature. Keep business rules independent of command-line or transport details. Add another module under `features/` when it owns a separate capability. Share code only when multiple features need it. Grow into a Cargo workspace when independently reusable crates justify it. + +The executable consumes the same public library API as other callers. Domain values enforce invariants at construction; typed errors propagate to the boundary. Command output uses an injected writer, making it testable without global output capture. External I/O and configuration belong at application boundaries. Introduce a CLI parser, async runtime, HTTP framework, or persistence library when the product requires them. + +## Checks + +```sh +cargo fmt --all -- --check +cargo test --locked +cargo lint +cargo doc --locked --no-deps +cargo build --release --locked +``` + +`cargo test` runs unit, executable integration, and documentation tests. Tests cover Unicode input, validation, argument handling, output failures, and user-visible CLI behavior. `cargo lint` runs Clippy for every target with warnings treated as errors. Unsafe Rust is forbidden by the manifest. Release builds use thin LTO and remove debug information. + +Commit `Cargo.lock` for this application. Review dependency and toolchain changes together with test results. CI checks Linux, macOS, Windows, and the minimum compiler version. For deployment, build a release executable on the target platform; configure additional cross-compilation targets as needed. + +## Foundry delivery + +Foundry embeds these source files directly. Generating this project needs no network access. Once its Rust toolchain is installed, the initial application can also build with `cargo build --locked --offline` because it has no external crates. Installing or updating Rust itself can require network access. Foundry does not invoke Cargo automatically. diff --git a/templates/rust-foundations/rust-toolchain.toml b/templates/rust-foundations/rust-toolchain.toml new file mode 100644 index 0000000..05e6ca1 --- /dev/null +++ b/templates/rust-foundations/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "stable" +profile = "minimal" +components = ["rustfmt", "clippy"] diff --git a/templates/rust-foundations/src/cli.rs b/templates/rust-foundations/src/cli.rs new file mode 100644 index 0000000..f95e77e --- /dev/null +++ b/templates/rust-foundations/src/cli.rs @@ -0,0 +1,90 @@ +//! Command-line parsing and output, kept separate from business rules. + +use std::error::Error; +use std::fmt; +use std::io::{self, Write}; + +use crate::features::greeting::{NameError, greet}; + +/// Failures at the command-line boundary. +#[derive(Debug)] +pub enum CliError { + /// The supplied arguments do not match a supported command. + Usage, + /// A feature rejected its input. + InvalidName(NameError), + /// Writing command output failed. + Io(io::Error), +} + +impl fmt::Display for CliError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Usage => write!(formatter, "expected 'greet '; use --help for usage"), + Self::InvalidName(error) => error.fmt(formatter), + Self::Io(error) => error.fmt(formatter), + } + } +} + +impl Error for CliError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Usage => None, + Self::InvalidName(error) => Some(error), + Self::Io(error) => Some(error), + } + } +} + +/// Run a command using explicit arguments and an output writer. +/// +/// # Errors +/// Returns [`CliError`] for unsupported arguments, invalid names, or output errors. +pub fn run( + args: impl IntoIterator, + output: &mut impl Write, +) -> Result<(), CliError> { + let args: Vec = args.into_iter().collect(); + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + let message = match args.as_slice() { + [] | ["--help"] | ["-h"] | ["greet", "--help"] => { + format!( + "Usage: {} greet \n {} --help", + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_NAME") + ) + } + ["greet", name] if !name.starts_with('-') => greet(name).map_err(CliError::InvalidName)?, + _ => return Err(CliError::Usage), + }; + writeln!(output, "{message}").map_err(CliError::Io) +} + +#[cfg(test)] +mod tests { + use super::{CliError, run}; + use std::io::{self, Write}; + + #[test] + fn accepts_a_quoted_multi_word_name() { + let mut output = Vec::new(); + run(["greet".to_owned(), "Ada Lovelace".to_owned()], &mut output).expect("valid command"); + assert_eq!(output, b"Hello, Ada Lovelace!\n"); + } + + #[test] + fn returns_writer_errors_without_panicking() { + struct BrokenWriter; + impl Write for BrokenWriter { + fn write(&mut self, _: &[u8]) -> io::Result { + Err(io::ErrorKind::BrokenPipe.into()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + let error = run(["--help".to_owned()], &mut BrokenWriter).expect_err("writer fails"); + assert!(matches!(error, CliError::Io(error) if error.kind() == io::ErrorKind::BrokenPipe)); + } +} diff --git a/templates/rust-foundations/src/features/greeting/domain.rs b/templates/rust-foundations/src/features/greeting/domain.rs new file mode 100644 index 0000000..7fd7bde --- /dev/null +++ b/templates/rust-foundations/src/features/greeting/domain.rs @@ -0,0 +1,78 @@ +use std::error::Error; +use std::fmt; + +/// A trimmed name with no control characters and at most 80 Unicode characters. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PersonName(String); + +impl PersonName { + /// Trim surrounding whitespace and validate a name. + /// + /// # Errors + /// Returns [`NameError`] for empty, excessively long, or control-containing names. + pub fn parse(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err(NameError::Empty); + } + if value.chars().count() > 80 { + return Err(NameError::TooLong); + } + if value.chars().any(char::is_control) { + return Err(NameError::ControlCharacter); + } + Ok(Self(value.to_owned())) + } + + /// Borrow the validated name without exposing mutation. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Reasons why a name cannot be used in a greeting. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NameError { + /// The trimmed name is empty. + Empty, + /// The name contains more than 80 Unicode characters. + TooLong, + /// The name includes a control character. + ControlCharacter, +} + +impl fmt::Display for NameError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::Empty => "name must not be empty", + Self::TooLong => "name must be at most 80 characters", + Self::ControlCharacter => "name must not contain control characters", + }; + formatter.write_str(message) + } +} + +impl Error for NameError {} + +#[cfg(test)] +mod tests { + use super::{NameError, PersonName}; + + #[test] + fn accepts_unicode_and_normalizes_surrounding_whitespace() { + let name = PersonName::parse(" Zoë 李 ").expect("valid name"); + assert_eq!(name.as_str(), "Zoë 李"); + assert!(PersonName::parse(&"界".repeat(80)).is_ok()); + } + + #[test] + fn rejects_empty_long_and_control_containing_names() { + for (input, expected) in [ + (" ".to_owned(), NameError::Empty), + ("界".repeat(81), NameError::TooLong), + ("Ada\nLovelace".to_owned(), NameError::ControlCharacter), + ] { + assert_eq!(PersonName::parse(&input), Err(expected)); + } + } +} diff --git a/templates/rust-foundations/src/features/greeting/mod.rs b/templates/rust-foundations/src/features/greeting/mod.rs new file mode 100644 index 0000000..2d2727a --- /dev/null +++ b/templates/rust-foundations/src/features/greeting/mod.rs @@ -0,0 +1,7 @@ +//! A small example feature with validated input and typed errors. + +mod domain; +mod service; + +pub use domain::{NameError, PersonName}; +pub use service::greet; diff --git a/templates/rust-foundations/src/features/greeting/service.rs b/templates/rust-foundations/src/features/greeting/service.rs new file mode 100644 index 0000000..ee6007d --- /dev/null +++ b/templates/rust-foundations/src/features/greeting/service.rs @@ -0,0 +1,16 @@ +use super::{NameError, PersonName}; + +/// Build a greeting after validating its recipient. +/// +/// ``` +/// use __RUST_CRATE__::features::greeting::greet; +/// +/// assert_eq!(greet("Ada").expect("valid name"), "Hello, Ada!"); +/// ``` +/// +/// # Errors +/// Returns [`NameError`] when the recipient is not a valid name. +pub fn greet(recipient: &str) -> Result { + let name = PersonName::parse(recipient)?; + Ok(format!("Hello, {}!", name.as_str())) +} diff --git a/templates/rust-foundations/src/features/mod.rs b/templates/rust-foundations/src/features/mod.rs new file mode 100644 index 0000000..b2e2113 --- /dev/null +++ b/templates/rust-foundations/src/features/mod.rs @@ -0,0 +1,3 @@ +//! Feature modules own their business rules and application operations. + +pub mod greeting; diff --git a/templates/rust-foundations/src/lib.rs b/templates/rust-foundations/src/lib.rs new file mode 100644 index 0000000..ce8d4cc --- /dev/null +++ b/templates/rust-foundations/src/lib.rs @@ -0,0 +1,7 @@ +//! Reusable application features and their command-line adapter. +//! +//! Keep business rules in feature modules; the executable only connects I/O. +#![warn(missing_docs)] + +pub mod cli; +pub mod features; diff --git a/templates/rust-foundations/src/main.rs b/templates/rust-foundations/src/main.rs new file mode 100644 index 0000000..ab3fa8e --- /dev/null +++ b/templates/rust-foundations/src/main.rs @@ -0,0 +1,13 @@ +use std::env; +use std::io; +use std::process::ExitCode; + +fn main() -> ExitCode { + match __RUST_CRATE__::cli::run(env::args().skip(1), &mut io::stdout().lock()) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("error: {error}"); + ExitCode::FAILURE + } + } +} diff --git a/templates/rust-foundations/tests/cli.rs b/templates/rust-foundations/tests/cli.rs new file mode 100644 index 0000000..041c1aa --- /dev/null +++ b/templates/rust-foundations/tests/cli.rs @@ -0,0 +1,49 @@ +use std::process::Command; + +fn command() -> Command { + Command::new(env!("CARGO_BIN_EXE___RUST_CRATE__")) +} + +#[test] +fn greets_a_recipient_from_the_executable() { + let output = command() + .args(["greet", "Ada"]) + .output() + .expect("run binary"); + assert!(output.status.success()); + assert_eq!( + String::from_utf8(output.stdout).expect("UTF-8 output"), + "Hello, Ada!\n" + ); + assert!(output.stderr.is_empty()); +} + +#[test] +fn displays_help_successfully() { + let output = command().arg("--help").output().expect("run binary"); + assert!(output.status.success()); + assert!( + String::from_utf8(output.stdout) + .expect("UTF-8 output") + .contains("greet ") + ); +} + +#[test] +fn rejects_bad_commands_and_invalid_names() { + for args in [ + vec!["unknown"], + vec!["greet"], + vec!["greet", " "], + vec!["greet", "Ada", "extra"], + ] { + let output = command().args(args).output().expect("run binary"); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + assert!( + String::from_utf8(output.stderr) + .expect("UTF-8 error") + .contains("error:") + ); + } +}