use anyhow::{Context, Result};
use clap::Args;
use console::style;
use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select};
use indicatif::{ProgressBar, ProgressStyle};
use std::path::Path;
use std::time::Duration;
use tokio::fs;
use crate::templates::{self, ProjectPreset, ProjectTemplate};
#[derive(Args, Debug)]
pub struct NewArgs {
pub name: Option<String>,
#[arg(short, long, value_enum)]
pub template: Option<ProjectTemplate>,
#[arg(long, value_enum)]
pub preset: Option<ProjectPreset>,
#[arg(short, long, value_delimiter = ',')]
pub features: Option<Vec<String>>,
#[arg(long)]
pub yes: bool,
#[arg(long, default_value = "true")]
pub git: bool,
}
pub async fn new_project(mut args: NewArgs) -> Result<()> {
let theme = ColorfulTheme::default();
let name = if let Some(name) = args.name.take() {
name
} else {
Input::with_theme(&theme)
.with_prompt("Project name")
.default("my-rustapi-app".to_string())
.interact_text()?
};
validate_project_name(&name)?;
let project_path = Path::new(&name);
if project_path.exists() {
anyhow::bail!("Directory '{}' already exists", name);
}
let preset = if let Some(preset) = args.preset {
Some(preset)
} else if args.yes {
None
} else {
let presets = [
"none - choose template/features manually",
"prod-api - production-oriented HTTP API defaults",
"ai-api - TOON-ready API defaults",
"realtime-api - WebSocket-ready API defaults",
];
let selection = Select::with_theme(&theme)
.with_prompt("Select an optional preset")
.items(&presets)
.default(0)
.interact()?;
match selection {
1 => Some(ProjectPreset::Production),
2 => Some(ProjectPreset::Ai),
3 => Some(ProjectPreset::Realtime),
_ => None,
}
};
let template = if let Some(template) = args.template {
template
} else if let Some(preset) = preset {
preset.default_template()
} else if args.yes {
ProjectTemplate::Minimal
} else {
let templates = [
"minimal - Bare minimum app",
"api - REST API with CRUD",
"web - Web app with templates",
"full - Full-featured app",
];
let selection = Select::with_theme(&theme)
.with_prompt("Select a template")
.items(&templates)
.default(0)
.interact()?;
match selection {
0 => ProjectTemplate::Minimal,
1 => ProjectTemplate::Api,
2 => ProjectTemplate::Web,
3 => ProjectTemplate::Full,
_ => ProjectTemplate::Minimal,
}
};
let features = if let Some(features) = args.features {
merge_unique_features(
preset
.map(ProjectPreset::recommended_features)
.unwrap_or_default(),
features,
)
} else if args.yes {
preset
.map(ProjectPreset::recommended_features)
.unwrap_or_default()
} else {
let available = [
"extras-jwt",
"extras-cors",
"extras-rate-limit",
"extras-config",
"extras-security-headers",
"extras-structured-logging",
"extras-timeout",
"protocol-toon",
"protocol-ws",
"protocol-view",
"protocol-grpc",
];
let preset_features = preset
.map(ProjectPreset::recommended_features)
.unwrap_or_default();
let defaults = match template {
ProjectTemplate::Full => vec![
true, true, true, true, false, false, false, false, false, false, false,
],
ProjectTemplate::Web => vec![
false, false, false, false, false, false, false, false, false, true, false,
],
_ => vec![false; available.len()],
};
let defaults = defaults
.into_iter()
.enumerate()
.map(|(index, default)| {
default
|| preset_features
.iter()
.any(|feature| feature == available[index])
})
.collect::<Vec<_>>();
let selections = dialoguer::MultiSelect::with_theme(&theme)
.with_prompt("Select features (space to toggle)")
.items(&available)
.defaults(&defaults)
.interact()?;
selections
.iter()
.map(|&i| available[i].to_string())
.collect()
};
if !args.yes {
println!();
println!("{}", style("Project configuration:").bold());
println!(" Name: {}", style(&name).cyan());
println!(" Template: {}", style(format!("{:?}", template)).cyan());
println!(
" Preset: {}",
style(
preset
.map(|preset| format!("{:?}", preset))
.unwrap_or_else(|| "none".to_string())
)
.cyan()
);
println!(
" Features: {}",
style(if features.is_empty() {
"none".to_string()
} else {
features.join(", ")
})
.cyan()
);
println!();
if !Confirm::with_theme(&theme)
.with_prompt("Create project?")
.default(true)
.interact()?
{
println!("{}", style("Aborted").yellow());
return Ok(());
}
}
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.green} {msg}")
.unwrap(),
);
pb.enable_steady_tick(Duration::from_millis(80));
pb.set_message("Creating project directory...");
fs::create_dir_all(&name).await?;
pb.set_message("Generating project files...");
templates::generate_project(&name, template, &features).await?;
if args.git {
pb.set_message("Initializing git repository...");
init_git(&name).await.ok(); }
pb.finish_and_clear();
println!();
println!(
"{}",
style("✨ Project created successfully!").green().bold()
);
println!();
println!("Next steps:");
println!(" {} {}", style("cd").cyan(), name);
println!(" {} run", style("cargo").cyan());
println!();
println!(
"Then open {} in your browser.",
style("http://localhost:8080").cyan()
);
println!(
"API docs available at {}",
style("http://localhost:8080/docs").cyan()
);
Ok(())
}
fn merge_unique_features(mut base: Vec<String>, extras: Vec<String>) -> Vec<String> {
for feature in extras {
if !base.contains(&feature) {
base.push(feature);
}
}
base
}
fn validate_project_name(name: &str) -> Result<()> {
if name.is_empty() {
anyhow::bail!("Project name cannot be empty");
}
if name.contains('/') || name.contains('\\') {
anyhow::bail!("Project name cannot contain path separators");
}
if !name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
anyhow::bail!(
"Project name can only contain alphanumeric characters, hyphens, and underscores"
);
}
if name.starts_with('-') || name.starts_with('_') {
anyhow::bail!("Project name cannot start with a hyphen or underscore");
}
Ok(())
}
async fn init_git(path: &str) -> Result<()> {
let output = tokio::process::Command::new("git")
.args(["init"])
.current_dir(path)
.output()
.await
.context("Failed to run git init")?;
if !output.status.success() {
anyhow::bail!("git init failed");
}
Ok(())
}