use crate::onboarding::detection::{ProjectType, detect_project_type};
use crate::output::{ExitCode, Outputable};
use crate::tools::spec::iter_tools;
use inquire::{MultiSelect, Select};
use serde::Serialize;
use std::fs;
use std::io::{self, IsTerminal};
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StackCategory {
WebFrontend,
BackendApi,
FullStack,
Mobile,
DataScience,
DevOps,
Custom,
}
impl StackCategory {
pub fn display_name(&self) -> &'static str {
match self {
StackCategory::WebFrontend => "Web Frontend (React, Vue, Angular)",
StackCategory::BackendApi => "Backend API (Node, Go, Rust, Python)",
StackCategory::FullStack => "Full Stack (Frontend + Backend)",
StackCategory::Mobile => "Mobile Development",
StackCategory::DataScience => "Data Science / ML",
StackCategory::DevOps => "DevOps / Infrastructure",
StackCategory::Custom => "Custom (start from scratch)",
}
}
pub fn default_tools(&self) -> Vec<&'static str> {
match self {
StackCategory::WebFrontend => vec!["git", "node", "docker", "jq", "ripgrep", "fzf"],
StackCategory::BackendApi => vec!["git", "docker", "jq", "ripgrep", "httpie", "curl"],
StackCategory::FullStack => {
vec!["git", "node", "docker", "jq", "ripgrep", "httpie", "redis"]
}
StackCategory::Mobile => vec!["git", "node", "docker", "jq", "ripgrep"],
StackCategory::DataScience => vec!["git", "python", "docker", "jq", "duckdb"],
StackCategory::DevOps => {
vec!["git", "docker", "kubectl", "terraform", "aws", "jq", "yq"]
}
StackCategory::Custom => vec!["git"],
}
}
pub fn all() -> Vec<StackCategory> {
vec![
StackCategory::WebFrontend,
StackCategory::BackendApi,
StackCategory::FullStack,
StackCategory::Mobile,
StackCategory::DataScience,
StackCategory::DevOps,
StackCategory::Custom,
]
}
}
impl std::fmt::Display for StackCategory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.display_name())
}
}
#[derive(Debug, Clone, Default)]
pub struct InitOptions {
pub template: Option<String>,
pub non_interactive: bool,
pub stdout: bool,
pub output: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize)]
pub struct InitResult {
pub output_path: Option<String>,
pub tool_count: usize,
pub created: bool,
#[serde(skip)]
pub content: String,
#[serde(skip)]
pub stdout: bool,
}
impl Outputable for InitResult {
fn to_human(&self) -> String {
if self.stdout {
return self.content.clone();
}
let mut output = String::new();
if self.created {
if let Some(ref path) = self.output_path {
output.push_str(&format!(
"\n\x1b[32m✓\x1b[0m Created {} with {} tools\n\n",
path, self.tool_count
));
}
output.push_str("Next steps:\n");
output.push_str(" 1. Review your config: \x1b[36mcat jarvy.toml\x1b[0m\n");
output.push_str(" 2. Install tools: \x1b[36mjarvy setup\x1b[0m\n");
} else {
output.push_str("\n\x1b[33m!\x1b[0m jarvy.toml already exists. Use --output to specify a different path.\n");
}
output
}
fn to_json(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_else(|e| format!("{{\"error\":\"{}\"}}", e))
}
fn exit_code(&self) -> ExitCode {
if self.created || self.stdout {
ExitCode::Ok
} else {
ExitCode::Warning
}
}
}
pub fn run_init(options: InitOptions) -> InitResult {
if options.non_interactive && options.template.is_none() {
return InitResult {
output_path: None,
tool_count: 0,
created: false,
content: String::new(),
stdout: false,
};
}
let is_tty = io::stdin().is_terminal();
if let Some(ref template_name) = options.template {
return run_init_with_template(template_name, &options);
}
if !is_tty || options.non_interactive {
eprintln!("Error: Interactive mode requires a TTY. Use --template or --non-interactive.");
return InitResult {
output_path: None,
tool_count: 0,
created: false,
content: String::new(),
stdout: false,
};
}
run_init_interactive(options)
}
fn run_init_with_template(template_name: &str, options: &InitOptions) -> InitResult {
use crate::templates::builtin::get_builtin_template;
let template = match get_builtin_template(template_name) {
Some(t) => t,
None => {
eprintln!("Error: Template '{}' not found.", template_name);
eprintln!("Run 'jarvy templates' to see available templates.");
return InitResult {
output_path: None,
tool_count: 0,
created: false,
content: String::new(),
stdout: false,
};
}
};
let full_template = template.to_template();
let content = full_template.to_jarvy_toml();
write_config(&content, options)
}
fn run_init_interactive(options: InitOptions) -> InitResult {
println!();
println!("\x1b[1mWelcome to Jarvy!\x1b[0m Let's set up your development environment.");
println!();
let detected = detect_project_type(".");
if detected.primary != ProjectType::Unknown {
println!(
" \x1b[36mDetected:\x1b[0m {} project (found {})",
detected.primary.display_name(),
detected.detection_files.join(", ")
);
println!();
}
let stacks: Vec<StackCategory> = StackCategory::all();
let stack = match Select::new("What type of project is this?", stacks).prompt() {
Ok(s) => s,
Err(_) => {
return InitResult {
output_path: None,
tool_count: 0,
created: false,
content: String::new(),
stdout: false,
};
}
};
let available_tools: Vec<String> = iter_tools().map(|t| t.spec.name.to_string()).collect();
let default_tools = stack.default_tools();
let defaults: Vec<usize> = default_tools
.iter()
.filter_map(|t| available_tools.iter().position(|a| a == *t))
.collect();
let tool_options: Vec<&str> = available_tools.iter().map(|s| s.as_str()).collect();
let selected = match MultiSelect::new("Select tools to install:", tool_options)
.with_default(&defaults)
.with_page_size(15)
.prompt()
{
Ok(s) => s,
Err(_) => {
return InitResult {
output_path: None,
tool_count: 0,
created: false,
content: String::new(),
stdout: false,
};
}
};
let content = generate_config(&selected, None);
write_config(&content, &options)
}
fn generate_config(tools: &[&str], template_name: Option<&str>) -> String {
let mut content = String::new();
content.push_str("# Generated by jarvy init\n");
if let Some(template) = template_name {
content.push_str(&format!("# Template: {}\n", template));
}
content.push('\n');
content.push_str("[provisioner]\n");
for tool in tools {
content.push_str(&format!("{} = \"latest\"\n", tool));
}
content
}
fn write_config(content: &str, options: &InitOptions) -> InitResult {
if options.stdout {
print!("{}", content);
return InitResult {
output_path: None,
tool_count: content.lines().filter(|l| l.contains(" = ")).count(),
created: true,
content: content.to_string(),
stdout: true,
};
}
let output_path = options
.output
.clone()
.unwrap_or_else(|| PathBuf::from("jarvy.toml"));
if output_path.exists() && options.output.is_none() {
return InitResult {
output_path: Some(output_path.display().to_string()),
tool_count: 0,
created: false,
content: content.to_string(),
stdout: false,
};
}
match fs::write(&output_path, content) {
Ok(()) => InitResult {
output_path: Some(output_path.display().to_string()),
tool_count: content.lines().filter(|l| l.contains(" = ")).count(),
created: true,
content: content.to_string(),
stdout: false,
},
Err(e) => {
eprintln!("Error writing file: {}", e);
InitResult {
output_path: None,
tool_count: 0,
created: false,
content: String::new(),
stdout: false,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_stack_category_display() {
assert_eq!(
StackCategory::WebFrontend.display_name(),
"Web Frontend (React, Vue, Angular)"
);
assert_eq!(
StackCategory::Custom.display_name(),
"Custom (start from scratch)"
);
}
#[test]
fn test_stack_category_default_tools() {
let tools = StackCategory::WebFrontend.default_tools();
assert!(tools.contains(&"git"));
assert!(tools.contains(&"node"));
}
#[test]
fn test_generate_config() {
let tools = vec!["git", "node", "docker"];
let content = generate_config(&tools, None);
assert!(content.contains("[provisioner]"));
assert!(content.contains("git = \"latest\""));
assert!(content.contains("node = \"latest\""));
assert!(content.contains("docker = \"latest\""));
}
#[test]
fn test_generate_config_with_template() {
let tools = vec!["git"];
let content = generate_config(&tools, Some("react"));
assert!(content.contains("# Template: react"));
}
}