use crate::external_generators;
use crate::features::ci::CIPlugin;
use crate::features::database::DatabasePlugin;
use crate::features::docker::{DockerBuildStage, DockerPlugin};
use crate::features::{PluginManager, ProjectContext as FeatureContext};
use anyhow::{anyhow, Result};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct ProjectConfig {
pub name: String,
pub project_type: String,
pub author: String,
pub description: Option<String>,
pub license: Option<String>,
pub features: Vec<String>,
pub target: Option<String>,
pub esp32_chip: Option<String>,
}
pub struct Generator;
fn toml_escape(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for c in value.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 || c as u32 == 0x7f => {
out.push_str(&format!("\\u{:04X}", c as u32))
}
c => out.push(c),
}
}
out
}
impl Generator {
pub fn new() -> Self {
Self
}
pub fn generate(&self, config: &ProjectConfig, output_dir: &Path) -> Result<()> {
if let Some(target) = &config.target {
if target == "esp32" {
if let Some(parent) = output_dir.parent() {
if !parent.exists() {
fs::create_dir_all(parent)?;
}
}
return self.generate_embedded(config, output_dir);
}
}
if output_dir.exists() {
if !output_dir.is_dir() {
return Err(anyhow!(
"Output path exists but is not a directory: {}",
output_dir.display()
));
}
if output_dir.read_dir()?.next().is_some() {
return Err(anyhow!("Directory '{}' is not empty", output_dir.display()));
}
} else {
fs::create_dir_all(output_dir)?;
}
struct ClaimGuard(PathBuf);
impl Drop for ClaimGuard {
fn drop(&mut self) {
let _ = fs::remove_file(&self.0);
}
}
let claim_path = output_dir.join(".cargo-forge-claim");
let _claim = match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&claim_path)
{
Ok(_) => ClaimGuard(claim_path.clone()),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
return Err(anyhow!(
"Directory '{}' is already being generated by another cargo-forge process",
output_dir.display()
));
}
Err(e) => {
return Err(anyhow!(
"Failed to claim generation directory '{}': {}",
output_dir.display(),
e
));
}
};
ensure_empty_except_claim(output_dir, &claim_path)?;
for feature in &config.features {
if !feature_supported(feature, &config.project_type) {
return Err(anyhow!(
"Feature '{}' is not supported for '{}' projects",
feature,
config.project_type
));
}
}
if config.project_type != "workspace" {
fs::create_dir_all(output_dir.join("src"))?;
}
match config.project_type.as_str() {
"api-server" => self.generate_api_server(config, output_dir)?,
"cli-tool" => self.generate_cli_tool(config, output_dir)?,
"library" => self.generate_library(config, output_dir)?,
"wasm-app" => self.generate_wasm_app(config, output_dir)?,
"game-engine" => self.generate_game_engine(config, output_dir)?,
"embedded" => self.generate_embedded(config, output_dir)?,
"workspace" => self.generate_workspace(config, output_dir)?,
_ => return Err(anyhow!("Unknown project type: {}", config.project_type)),
}
let mut feature_context = FeatureContext::new(&config.name);
if !config.features.is_empty() {
let mut plugin_manager = PluginManager::new();
let mut database_registered = false;
for feature in &config.features {
match feature.as_str() {
"docker" => {
let port = match config.project_type.as_str() {
"api-server" => Some(3000),
"wasm-app" => Some(8080),
_ => None,
};
let mut docker_plugin =
DockerPlugin::new().with_build_stage(DockerBuildStage::MultiStage);
if let Some(p) = port {
docker_plugin = docker_plugin.expose_port(p);
}
plugin_manager.register(Box::new(docker_plugin));
}
"ci" | "github-actions" => {
use crate::features::ci::CIPlatform;
plugin_manager.register(Box::new(CIPlugin::new(CIPlatform::GitHubActions)));
}
"database" | "postgres" => {
use crate::features::database::DatabaseType;
if database_registered {
eprintln!(
"⚠️ Only one database flavor is supported — keeping the first, skipping '{}'.",
feature
);
} else {
plugin_manager
.register(Box::new(DatabasePlugin::new(DatabaseType::PostgreSQL)));
database_registered = true;
}
}
"sqlite" => {
use crate::features::database::DatabaseType;
if database_registered {
eprintln!(
"⚠️ Only one database flavor is supported — keeping the first, skipping '{}'.",
feature
);
} else {
plugin_manager
.register(Box::new(DatabasePlugin::new(DatabaseType::SQLite)));
database_registered = true;
}
}
"mysql" => {
use crate::features::database::DatabaseType;
if database_registered {
eprintln!(
"⚠️ Only one database flavor is supported — keeping the first, skipping '{}'.",
feature
);
} else {
plugin_manager
.register(Box::new(DatabasePlugin::new(DatabaseType::MySQL)));
database_registered = true;
}
}
_ => {
eprintln!(
"⚠️ Feature '{}' is not implemented yet — skipping it.",
feature
);
}
}
}
plugin_manager
.configure_all(&mut feature_context)
.map_err(|e| anyhow!("Plugin configuration failed: {}", e))?;
}
self.generate_cargo_toml(config, output_dir, &feature_context)?;
self.generate_gitignore_with_features(config, output_dir, &feature_context)?;
self.generate_readme_with_features(config, output_dir, &feature_context)?;
if !config.features.is_empty() {
self.generate_feature_files(&feature_context, output_dir)?;
}
drop(_claim);
Ok(())
}
fn generate_feature_files(
&self,
feature_context: &FeatureContext,
output_dir: &Path,
) -> Result<()> {
for dir in &feature_context.directories {
fs::create_dir_all(output_dir.join(dir))?;
}
for (path, content) in &feature_context.template_files {
let file_path = output_dir.join(path);
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&file_path, content)?;
#[cfg(unix)]
if path.starts_with("scripts/") && path.ends_with(".sh") {
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&file_path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&file_path, perms)?;
}
}
Ok(())
}
fn generate_gitignore_with_features(
&self,
config: &ProjectConfig,
output_dir: &Path,
feature_context: &FeatureContext,
) -> Result<()> {
let mut content = String::from("/target\n**/*.rs.bk\n.DS_Store\n");
if config.project_type == "library" {
content.push_str("Cargo.lock\n");
}
match config.project_type.as_str() {
"wasm-app" => {
content.push_str("node_modules\n");
content.push_str("dist/\n");
content.push_str("pkg/\n");
}
"game-engine" => {
content.push_str("wasm/\n");
content.push_str("*.wasm\n");
content.push_str(".DS_Store\n");
}
"embedded" => {
content.push_str("*.bin\n");
content.push_str("*.hex\n");
content.push_str("*.elf\n");
content.push_str(".vscode/\n");
}
"workspace" => {
content.push_str("Cargo.lock\n");
}
_ => {}
}
for entry in &feature_context.gitignore_entries {
content.push_str(entry);
content.push('\n');
}
fs::write(output_dir.join(".gitignore"), content)?;
Ok(())
}
fn generate_readme_with_features(
&self,
config: &ProjectConfig,
output_dir: &Path,
feature_context: &FeatureContext,
) -> Result<()> {
let mut content = format!("# {}\n\n", config.name);
if let Some(desc) = &config.description {
content.push_str(desc);
content.push_str("\n\n");
}
match config.project_type.as_str() {
"api-server" => {
content.push_str("## API Server\n\n");
content.push_str("This is a REST API server built with Axum.\n\n");
content.push_str("### Endpoints\n\n");
content.push_str("- `GET /` - Health check endpoint\n");
content.push_str("- More endpoints coming soon...\n\n");
content.push_str("### Running\n\n");
content.push_str("```bash\ncargo run\n```\n\n");
content.push_str("The server will start on `http://localhost:3000`\n");
}
"cli-tool" => {
content.push_str("## CLI Tool\n\n");
content.push_str("### Usage\n\n");
content.push_str("```bash\ncargo run -- --help\n```\n\n");
content.push_str("### Commands\n\n");
content.push_str("Available commands and arguments will be shown in help.\n");
}
"library" => {
content.push_str("## Library\n\n");
content.push_str("### Usage\n\n");
content.push_str("Add this to your `Cargo.toml`:\n\n");
content.push_str("```toml\n[dependencies]\n");
content.push_str(&format!("{} = \"0.1.0\"\n", config.name));
content.push_str("```\n\n");
content.push_str("### Example\n\n");
content.push_str("```rust\n// Example usage\n```\n\n");
content.push_str("### API Documentation\n\n");
content.push_str("Run `cargo doc --open` to view the documentation.\n");
}
"wasm-app" => {
content.push_str("## WebAssembly App\n\n");
content.push_str("This project compiles Rust to WebAssembly for the browser.\n\n");
content.push_str("### Building\n\n");
content.push_str("```bash\nwasm-pack build --target web\n```\n\n");
content.push_str("### Running locally\n\n");
content.push_str("Serve the project directory and open `index.html`:\n\n");
content.push_str("```bash\npython3 -m http.server 8080\n```\n\n");
content.push_str("### Testing\n\n");
content.push_str("```bash\nwasm-pack test --headless --firefox\n```\n");
}
"game-engine" => {
content.push_str("## Game Engine Project\n\n");
content.push_str("A game built on the Bevy engine.\n\n");
content.push_str("### Prerequisites (Linux)\n\n");
content.push_str(
"Bevy's windowing stack needs system libraries. On Debian/Ubuntu:\n\n",
);
content.push_str(
"```bash\nsudo apt install libwayland-dev libxkbcommon-dev pkg-config\n```\n\n",
);
content.push_str("### Running\n\n");
content.push_str("```bash\ncargo run\n```\n\n");
content.push_str("### Assets\n\n");
content.push_str(
"Place sprites, sounds, and other assets in the `assets/` directory.\n\n",
);
content.push_str("### Release builds\n\n");
content.push_str("```bash\ncargo build --release\n```\n\n");
content.push_str("Release builds enable optimizations for smooth frame rates.\n");
}
"embedded" => {
content.push_str("## Embedded Project\n\n");
content.push_str("Bare-metal firmware for microcontrollers (no_std).\n\n");
content.push_str("### Building\n\n");
content.push_str("```bash\ncargo build --release\n```\n\n");
content.push_str("### Flashing\n\n");
content.push_str("```bash\ncargo run --release # via probe-rs runner\n```\n\n");
content.push_str("### Target\n\n");
content.push_str(
"See `.cargo/config.toml` for the configured target triple and runner.\n",
);
}
"workspace" => {
content.push_str("## Cargo Workspace\n\n");
content.push_str("A multi-crate workspace layout.\n\n");
content.push_str("### Structure\n\n");
content.push_str("- `crates/` — member crates\n");
content.push_str("- `Cargo.toml` — workspace manifest\n\n");
content.push_str("### Building everything\n\n");
content.push_str("```bash\ncargo build --workspace\n```\n\n");
content.push_str("### Testing everything\n\n");
content.push_str("```bash\ncargo test --workspace\n```\n");
}
_ => {}
}
for section in &feature_context.readme_sections {
content.push_str(section);
content.push_str("\n");
}
fs::write(output_dir.join("README.md"), content)?;
Ok(())
}
fn generate_api_server(&self, _config: &ProjectConfig, output_dir: &Path) -> Result<()> {
let main_rs = r#"use tokio::net::TcpListener;
mod handlers;
mod models;
mod routes;
#[tokio::main]
async fn main() {
let app = routes::routes();
let port: u16 = std::env::var("PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(3000);
let listener = TcpListener::bind(("0.0.0.0", port))
.await
.expect("failed to bind listener");
println!("API server listening on http://localhost:{port}");
axum::serve(listener, app).await.expect("server error");
}
"#;
fs::write(output_dir.join("src/main.rs"), main_rs)?;
let routes_rs = r#"use axum::{routing::get, Router};
use crate::handlers;
pub fn routes() -> Router {
Router::new().route("/", get(handlers::health))
}
"#;
fs::write(output_dir.join("src/routes.rs"), routes_rs)?;
let handlers_rs = r#"use axum::Json;
use crate::models::Health;
/// GET / — health check
pub async fn health() -> Json<Health> {
Json(Health::ok())
}
"#;
fs::write(output_dir.join("src/handlers.rs"), handlers_rs)?;
let models_rs = r#"use serde::Serialize;
#[derive(Serialize)]
pub struct Health {
pub status: String,
}
impl Health {
pub fn ok() -> Self {
Self {
status: "ok".to_string(),
}
}
}
"#;
fs::write(output_dir.join("src/models.rs"), models_rs)?;
fs::create_dir_all(output_dir.join("config"))?;
fs::write(
output_dir.join("config/default.toml"),
"# Server configuration (loaded by your config code as needed)\n[server]\nport = 3000\n",
)?;
fs::write(
output_dir.join(".env.example"),
"# The server binds to the PORT environment variable when set\n# (default 3000). Nothing else reads this file; the starter has no\n# dotenv dependency, so exporting PORT is what takes effect.\n# PORT=3000\n",
)?;
Ok(())
}
fn generate_cli_tool(&self, config: &ProjectConfig, output_dir: &Path) -> Result<()> {
let main_rs = r#"mod cli;
mod commands;
use clap::Parser;
use crate::cli::{Cli, Commands};
fn main() {
let cli = Cli::parse();
match cli.command {
Some(Commands::Hello { name }) => commands::hello(name.as_deref()),
None => {
println!(
"{} — run with --help to see available commands.",
env!("CARGO_PKG_NAME")
);
}
}
}
"#;
fs::write(output_dir.join("src/main.rs"), main_rs)?;
let cli_rs = r#"use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = env!("CARGO_PKG_NAME"), version, about = env!("CARGO_PKG_DESCRIPTION"))]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand)]
pub enum Commands {
/// Print a greeting
Hello {
/// Name to greet (defaults to "world")
#[arg(short, long)]
name: Option<String>,
},
}
"#;
fs::write(output_dir.join("src/cli.rs"), cli_rs)?;
let commands_rs = r#"pub fn hello(name: Option<&str>) {
println!("Hello, {}!", name.unwrap_or("world"));
}
"#;
fs::write(output_dir.join("src/commands.rs"), commands_rs)?;
fs::create_dir_all(output_dir.join("tests"))?;
fs::write(
output_dir.join("tests/cli.rs"),
&format!(
r#"#[test]
fn hello_command_greets_by_name() {{
let output = std::process::Command::new(env!("CARGO_BIN_EXE_{}"))
.args(["hello", "--name", "forge"])
.output()
.expect("failed to run binary");
assert!(output.status.success());
assert!(String::from_utf8_lossy(&output.stdout).contains("Hello, forge!"));
}}
"#,
config.name
),
)?;
Ok(())
}
fn generate_library(&self, config: &ProjectConfig, output_dir: &Path) -> Result<()> {
let lib_name = config.name.replace('-', "_");
let doc_header: String = config
.description
.as_deref()
.unwrap_or("A Rust library")
.replace('\r', "\n") .lines()
.map(|line| format!("//! {}\n", line))
.collect();
let lib_content = format!(
"{doc_header}\
/// Return a greeting for `name`.
pub fn greet(name: &str) -> String {{
format!(\"Hello, {{}}!\", name)
}}
#[cfg(test)]
mod tests {{
use super::greet;
#[test]
fn greets_by_name() {{
assert_eq!(greet(\"forge\"), \"Hello, forge!\");
}}
}}
"
);
fs::write(output_dir.join("src/lib.rs"), lib_content)?;
fs::create_dir_all(output_dir.join("examples"))?;
fs::write(
output_dir.join("examples/basic.rs"),
&format!(
"fn main() {{\n println!(\"{{}}\", {}::greet(\"world\"));\n}}\n",
lib_name
),
)?;
Ok(())
}
fn generate_wasm_app(&self, config: &ProjectConfig, output_dir: &Path) -> Result<()> {
let lib_rs = r#"use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
"#;
fs::write(output_dir.join("src/lib.rs"), lib_rs)?;
let index_html = format!(
r#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>{name}</title>
</head>
<body>
<script type="module">
import init, {{ greet }} from "./pkg/{pkg}.js";
init().then(() => {{
document.body.textContent = greet("world");
}});
</script>
</body>
</html>
"#,
name = config.name,
pkg = config.name.replace('-', "_")
);
fs::write(output_dir.join("index.html"), index_html)?;
Ok(())
}
fn generate_game_engine(&self, _config: &ProjectConfig, output_dir: &Path) -> Result<()> {
let main_rs = r#"use bevy::prelude::*;
fn main() {
App::new().add_plugins(DefaultPlugins).run();
}
"#;
fs::write(output_dir.join("src/main.rs"), main_rs)?;
fs::create_dir_all(output_dir.join("assets/models"))?;
fs::create_dir_all(output_dir.join("assets/textures"))?;
fs::create_dir_all(output_dir.join("assets/sounds"))?;
fs::create_dir_all(output_dir.join("assets/shaders"))?;
fs::write(
output_dir.join("assets/README.md"),
"# Assets\n\nPlace your game assets here.",
)?;
Ok(())
}
fn generate_embedded(&self, config: &ProjectConfig, output_dir: &Path) -> Result<()> {
if let Some(target) = &config.target {
if target == "esp32" {
let chip = config.esp32_chip.as_deref().unwrap_or("esp32");
println!("🔧 Generating project for chip : {}", chip);
return external_generators::generate_esp32_project(&config.name, chip, output_dir);
}
}
println!("🔧 Generating Cortex-M embedded project");
self.generate_cortex_m_embedded(config, output_dir)
}
fn generate_cortex_m_embedded(&self, _config: &ProjectConfig, output_dir: &Path) -> Result<()> {
let main_content = r#"#![no_std]
#![no_main]
use panic_halt as _; // panic handler
use cortex_m_rt::entry;
#[entry]
fn main() -> ! {
// Initialize the allocator BEFORE you use the heap
// Main application logic
loop {
// Your code here
}
}
"#;
fs::write(output_dir.join("src/main.rs"), main_content)?;
fs::create_dir_all(output_dir.join(".cargo"))?;
let cargo_config_content = r#"[target.thumbv7em-none-eabihf]
runner = "probe-rs-cli run --chip STM32F401RETx"
[build]
target = "thumbv7em-none-eabihf"
[env]
DEFMT_LOG = "debug"
"#;
fs::write(output_dir.join(".cargo/config.toml"), cargo_config_content)?;
let memory_x_content = r#"/* Linker script for the STM32F401RET6 */
MEMORY
{
/* NOTE 1 K = 1 KiBi = 1024 bytes */
FLASH : ORIGIN = 0x08000000, LENGTH = 512K
RAM : ORIGIN = 0x20000000, LENGTH = 96K
}
/* This is where the call stack will be allocated. */
/* The stack is of the full descending type. */
/* You may want to use this variable to locate the call stack and static
variables in different memory regions. Below is shown the default value */
/* _stack_start = ORIGIN(RAM) + LENGTH(RAM); */
/* You can use this symbol to customize the location of the .text section */
/* If omitted the .text section will be placed right after the .vector_table
section */
/* This can be useful if you want to move the firmware to some address other
than the default one (= 0x00000000 in thumb mode, 0x00000008 in non-thumb mode) */
/* ENTRY_POINT = 0x08000000; */
"#;
fs::write(output_dir.join("memory.x"), memory_x_content)?;
let embed_toml_content = r#"[default.probe]
protocol = "Swd"
[default.flashing]
enabled = true
[default.reset]
enabled = true
[default.general]
chip = "STM32F401RETx"
[default.rtt]
enabled = true
up_mode = "NoBlockSkip"
"#;
fs::write(output_dir.join("Embed.toml"), embed_toml_content)?;
Ok(())
}
fn generate_workspace(&self, config: &ProjectConfig, output_dir: &Path) -> Result<()> {
let author = toml_escape(&config.author);
let license_line = config
.license
.as_ref()
.map(|l| format!(r#"license = "{}""#, toml_escape(l)))
.unwrap_or_default();
fs::create_dir_all(output_dir.join("crates/core/src"))?;
fs::create_dir_all(output_dir.join("crates/api/src"))?;
fs::create_dir_all(output_dir.join("crates/cli/src"))?;
fs::write(
output_dir.join("crates/core/src/lib.rs"),
"//! Core library\n\npub fn hello() {\n println!(\"Hello from core!\");\n}\n",
)?;
fs::write(output_dir.join("crates/core/src/error.rs"), "//! Error types for the core library\n\nuse std::fmt;\n\n#[derive(Debug)]\npub enum CoreError {\n Generic(String),\n}\n\nimpl fmt::Display for CoreError {\n fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {\n match self {\n CoreError::Generic(msg) => write!(f, \"Core error: {}\", msg),\n }\n }\n}\n\nimpl std::error::Error for CoreError {}\n")?;
fs::write(output_dir.join("crates/core/src/lib.rs"), "//! Core library\n\npub mod error;\n\npub use error::CoreError;\n\npub fn hello() {\n println!(\"Hello from core!\");\n}\n")?;
fs::write(output_dir.join("crates/core/src/models.rs"), "//! Data models\n\n#[derive(Debug, Clone)]\npub struct User {\n pub id: u64,\n pub name: String,\n pub email: String,\n}\n\nimpl User {\n pub fn new(id: u64, name: String, email: String) -> Self {\n Self { id, name, email }\n }\n}\n")?;
fs::write(output_dir.join("crates/core/src/utils.rs"), "//! Utility functions\n\npub fn format_name(first: &str, last: &str) -> String {\n format!(\"{} {}\", first, last)\n}\n\npub fn validate_email(email: &str) -> bool {\n email.contains('@') && email.contains('.')\n}\n")?;
let core_cargo_toml = format!(
r#"[package]
name = "{}-core"
version = "0.1.0"
edition = "2021"
authors = ["{}"]
{license_line}
[dependencies]
serde = {{ version = "1.0", features = ["derive"] }}
anyhow = "1.0"
"#,
config.name,
author,
license_line = if license_line.is_empty() {
String::new()
} else {
format!("{}\n", license_line)
}
);
fs::write(output_dir.join("crates/core/Cargo.toml"), core_cargo_toml)?;
fs::write(output_dir.join("crates/api/src/lib.rs"), "//! API library\n\nuse anyhow::Result;\n\npub fn start_server() -> Result<()> {\n println!(\"Starting API server...\");\n Ok(())\n}\n")?;
fs::write(output_dir.join("crates/api/src/state.rs"), "//! Application state management\n\nuse std::sync::Arc;\nuse tokio::sync::RwLock;\n\n#[derive(Clone)]\npub struct AppState {\n pub counter: Arc<RwLock<u64>>,\n}\n\nimpl AppState {\n pub fn new() -> Self {\n Self {\n counter: Arc::new(RwLock::new(0)),\n }\n }\n}\n\nimpl Default for AppState {\n fn default() -> Self {\n Self::new()\n }\n}\n")?;
let api_cargo_toml = format!(
r#"[package]
name = "{}-api"
version = "0.1.0"
edition = "2021"
authors = ["{}"]
{license_line}
[dependencies]
{}-core = {{ path = "../core" }}
tokio = {{ version = "1", features = ["full"] }}
anyhow = "1.0"
serde = {{ version = "1.0", features = ["derive"] }}
[lib]
name = "{}_api"
"#,
config.name,
author,
config.name,
config.name.replace('-', "_"),
license_line = if license_line.is_empty() {
String::new()
} else {
format!("{}\n", license_line)
}
);
fs::write(output_dir.join("crates/api/Cargo.toml"), api_cargo_toml)?;
fs::write(output_dir.join("crates/cli/src/main.rs"), &format!("use {}_core::hello;\nuse anyhow::Result;\n\nfn main() -> Result<()> {{\n println!(\"Welcome to {}!\");\n hello();\n Ok(())\n}}\n", config.name.replace('-', "_"), config.name))?;
let cli_cargo_toml = format!(
r#"[package]
name = "{}-cli"
version = "0.1.0"
edition = "2021"
authors = ["{}"]
{license_line}
[[bin]]
name = "{}"
path = "src/main.rs"
[dependencies]
{}-core = {{ path = "../core" }}
clap = {{ version = "4", features = ["derive"] }}
anyhow = "1.0"
"#,
config.name,
author,
config.name,
config.name,
license_line = if license_line.is_empty() {
String::new()
} else {
format!("{}\n", license_line)
}
);
fs::write(output_dir.join("crates/cli/Cargo.toml"), cli_cargo_toml)?;
Ok(())
}
fn generate_cargo_toml(
&self,
config: &ProjectConfig,
output_dir: &Path,
feature_context: &FeatureContext,
) -> Result<()> {
if let Some(target) = &config.target {
if target == "esp32" {
return Ok(());
}
}
let mut content = String::new();
if config.project_type == "workspace" {
content.push_str("[workspace]\n");
content.push_str("resolver = \"2\"\n");
content.push_str("members = [\n");
content.push_str(" \"crates/core\",\n");
content.push_str(" \"crates/api\",\n");
content.push_str(" \"crates/cli\",\n");
content.push_str("]\n\n");
content.push_str("[workspace.package]\n");
content.push_str(&format!(r#"version = "0.1.0""#));
content.push('\n');
content.push_str(&format!(r#"authors = ["{}"]"#, toml_escape(&config.author)));
content.push('\n');
content.push_str(r#"edition = "2021""#);
content.push('\n');
if let Some(desc) = &config.description {
content.push_str(&format!(r#"description = "{}""#, toml_escape(desc)));
content.push('\n');
}
if let Some(license) = &config.license {
content.push_str(&format!(r#"license = "{}""#, toml_escape(license)));
content.push('\n');
}
content.push_str("\n");
content.push_str("[workspace.dependencies]\n");
content.push_str("tokio = { version = \"1\", features = [\"full\"] }\n");
content.push_str("serde = { version = \"1\", features = [\"derive\"] }\n");
content.push_str("anyhow = \"1\"\n");
content.push_str("clap = { version = \"4\", features = [\"derive\"] }\n");
fs::write(output_dir.join("Cargo.toml"), content)?;
return Ok(());
}
content.push_str("[package]\n");
content.push_str(&format!(r#"name = "{}""#, config.name));
content.push('\n');
content.push_str(r#"version = "0.1.0""#);
content.push('\n');
content.push_str(&format!(r#"authors = ["{}"]"#, toml_escape(&config.author)));
content.push('\n');
content.push_str(r#"edition = "2021""#);
content.push('\n');
if let Some(desc) = &config.description {
content.push_str(&format!(r#"description = "{}""#, toml_escape(desc)));
content.push('\n');
}
if let Some(license) = &config.license {
content.push_str(&format!(r#"license = "{}""#, toml_escape(license)));
content.push('\n');
}
content.push_str("\n[dependencies]\n");
match config.project_type.as_str() {
"api-server" => {
content.push_str("axum = \"0.7\"\n");
content.push_str("tokio = { version = \"1\", features = [\"full\"] }\n");
content.push_str("serde = { version = \"1\", features = [\"derive\"] }\n");
content.push_str("serde_json = \"1\"\n");
content.push_str("tower = \"0.4\"\n");
}
"cli-tool" => {
content.push_str("clap = { version = \"4\", features = [\"derive\"] }\n");
content.push_str("anyhow = \"1\"\n");
content.push_str("env_logger = \"0.10\"\n");
content.push_str("\n[[bin]]\n");
content.push_str(&format!(r#"name = "{}""#, config.name));
content.push('\n');
content.push_str("path = \"src/main.rs\"\n");
}
"library" => {
content.push_str("\n[lib]\n");
content.push_str(&format!(r#"name = "{}""#, config.name.replace('-', "_")));
content.push('\n');
}
"wasm-app" => {
content.push_str("wasm-bindgen = \"0.2\"\n");
content.push_str("web-sys = \"0.3\"\n");
content.push_str("js-sys = \"0.3\"\n");
content.push_str("\n[lib]\n");
content.push_str(r#"crate-type = ["cdylib"]"#);
content.push('\n');
}
"game-engine" => {
content.push_str("bevy = \"0.20\"\n");
content.push_str("\n[target.'cfg(target_arch = \"wasm32\")'.dependencies]\n");
content.push_str("wasm-bindgen = \"0.2\"\n");
content.push_str("web-sys = \"0.3\"\n");
content.push_str("console_error_panic_hook = \"0.1\"\n");
content.push_str("\n[profile.dev]\n");
content.push_str("opt-level = 1\n");
content.push_str("\n[profile.dev.package.\"*\"]\n");
content.push_str("opt-level = 3\n");
}
"embedded" => {
content.push_str("cortex-m = \"0.7\"\n");
content.push_str("cortex-m-rt = \"0.7\"\n");
content.push_str("panic-halt = \"0.2\"\n");
content.push_str("\n[profile.dev]\n");
content.push_str("opt-level = 1\n");
content.push_str("\n[profile.release]\n");
content.push_str("lto = \"fat\"\n");
content.push_str("opt-level = 3\n");
}
"workspace" => {
}
_ => {}
}
if config.project_type != "workspace" {
let dep_lines: Vec<String> = feature_context
.dependencies
.iter()
.filter(|(name, _)| !dep_already_present(&content, "dependencies", name))
.map(|(name, spec)| format!("{} = {}\n", name, spec))
.collect();
let dev_dep_lines: Vec<String> = feature_context
.dev_dependencies
.iter()
.filter(|(name, _)| !dep_already_present(&content, "dev-dependencies", name))
.map(|(name, spec)| format!("{} = {}\n", name, spec))
.collect();
if !dep_lines.is_empty() {
insert_in_table(&mut content, "[dependencies]", &dep_lines);
}
if !dev_dep_lines.is_empty() {
insert_in_table(&mut content, "[dev-dependencies]", &dev_dep_lines);
}
}
fs::write(output_dir.join("Cargo.toml"), content)?;
Ok(())
}
}
fn dep_already_present(content: &str, table: &str, name: &str) -> bool {
toml::from_str::<toml::Value>(content)
.ok()
.and_then(|v| v.get(table).map(|t| t.get(name).is_some()))
.unwrap_or(false)
}
pub fn feature_supported(feature: &str, project_type: &str) -> bool {
match feature {
"database" | "postgres" | "sqlite" | "mysql" => {
matches!(project_type, "api-server" | "cli-tool" | "library")
}
_ => true,
}
}
fn ensure_empty_except_claim(output_dir: &Path, claim_path: &Path) -> Result<()> {
for entry in fs::read_dir(output_dir)? {
let entry = entry?;
if entry.path() == claim_path {
continue;
}
anyhow::bail!("Directory '{}' is not empty", output_dir.display());
}
Ok(())
}
#[cfg(test)]
mod claim_tests {
use super::*;
#[test]
fn stray_file_beside_the_claim_is_rejected() {
let temp = tempfile::TempDir::new().unwrap();
let dir = temp.path().join("proj");
fs::create_dir_all(&dir).unwrap();
let claim = dir.join(".cargo-forge-claim");
fs::write(&claim, "").unwrap();
fs::write(dir.join("src"), "").unwrap(); assert!(ensure_empty_except_claim(&dir, &claim).is_err());
}
#[test]
fn claim_only_directory_passes() {
let temp = tempfile::TempDir::new().unwrap();
let dir = temp.path().join("proj");
fs::create_dir_all(&dir).unwrap();
let claim = dir.join(".cargo-forge-claim");
fs::write(&claim, "").unwrap();
assert!(ensure_empty_except_claim(&dir, &claim).is_ok());
}
}
fn insert_in_table(content: &mut String, header: &str, lines: &[String]) {
if let Some(idx) = content.lines().position(|l| l.trim() == header) {
let mut out = String::with_capacity(content.len() + 64);
for (i, line) in content.lines().enumerate() {
out.push_str(line);
out.push('\n');
if i == idx {
for l in lines {
out.push_str(l.trim_end_matches('\n'));
out.push('\n');
}
}
}
*content = out;
} else {
content.push('\n');
content.push_str(header);
content.push('\n');
for line in lines {
content.push_str(line);
}
}
}
impl Default for Generator {
fn default() -> Self {
Self::new()
}
}