Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 19 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`.

Expand All @@ -140,7 +154,7 @@ To update a template, edit its source files and rebuild Foundry with `make build

```text
foundry
foundry new <name> --template <go|flutter|vanilla-js|react>
foundry new <name> --template <go|flutter|vanilla-js|react|rust>
foundry add service <name>
foundry add migration <name>
foundry add feature <name>
Expand Down Expand Up @@ -174,7 +188,8 @@ Foundry CLI
| +-- Go foundation
| +-- Flutter foundation
| +-- Vanilla JavaScript foundation
| `-- React foundation
| +-- React foundation
| `-- Rust foundation
|
+-- add generators
| +-- Go service
Expand All @@ -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

Expand Down
3 changes: 3 additions & 0 deletions assets.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 10 additions & 5 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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})
Expand All @@ -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")
Expand All @@ -122,11 +124,11 @@ func (a App) newCmd(args []string) int {
name = f.Args()[0]
}
if name == "" {
fmt.Fprintln(os.Stderr, "usage: foundry new <name> --template <go|flutter|vanilla-js|react>")
fmt.Fprintln(os.Stderr, "usage: foundry new <name> --template <go|flutter|vanilla-js|react|rust>")
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
}

Expand All @@ -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
}
Expand Down Expand Up @@ -282,6 +286,7 @@ Usage:
foundry new <name> --template go [--module path] [--version version]
foundry new <name> --template flutter [--org com.example] [--version version]
foundry new <name> --template <vanilla-js|react> [--version version]
foundry new <name> --template rust [--version version]
foundry add service <name> [--project path]
foundry add repository <name> [--project path]
foundry add usecase <domain> <name> [--project path]
Expand Down
2 changes: 1 addition & 1 deletion internal/doctor/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 14 additions & 1 deletion internal/generator/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down Expand Up @@ -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__"]},
Expand Down Expand Up @@ -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
Expand Down
50 changes: 48 additions & 2 deletions internal/generator/generator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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"`
}
Expand Down Expand Up @@ -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)
}
})
}
}
5 changes: 5 additions & 0 deletions internal/registry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion internal/registry/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 2 additions & 0 deletions templates/rust-foundations/.cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[alias]
lint = "clippy --locked --all-targets -- -D warnings"
12 changes: 12 additions & 0 deletions templates/rust-foundations/.editorconfig
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions templates/rust-foundations/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/target/
.env
.env.*
.DS_Store
7 changes: 7 additions & 0 deletions templates/rust-foundations/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions templates/rust-foundations/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading