use crate::docker::{self, discover_files, handle_dockerignore};
use inquire::Text;
use std::fs;
use std::path::Path;
fn ask_for_tools(
existing_tools: &[String],
) -> Result<(Vec<String>, bool), Box<dyn std::error::Error>> {
let mut selected = existing_tools.to_vec();
let mut new_tools_added = false;
println!("\nAdditional tools installation:");
println!(
" (Already configured: {})",
if existing_tools.is_empty() {
"none".to_string()
} else {
existing_tools.join(", ")
}
);
let should_add_tools =
inquire::Confirm::new("Do you want to add additional tools to install in the Dockerfile?")
.with_default(false)
.with_help_message(
"These are apt packages that will be installed during the build stage",
)
.prompt()?;
if should_add_tools {
loop {
let tool: String = Text::new("Enter tool name (or press Enter to finish):")
.with_help_message("Example: curl, jq, vim, git")
.prompt()?;
let tool = tool.trim();
if tool.is_empty() {
break;
}
if !selected.contains(&tool.to_string()) {
selected.push(tool.to_string());
new_tools_added = true;
println!(" [OK] Added '{}' to additional tools", tool);
} else {
println!(" '{}' is already in the list", tool);
}
let add_more = inquire::Confirm::new("Add another tool?")
.with_default(true)
.prompt()?;
if !add_more {
break;
}
}
}
Ok((selected, new_tools_added))
}
fn ask_for_runtime_deps(
existing_deps: &[String],
) -> Result<(Vec<String>, bool), Box<dyn std::error::Error>> {
let mut selected = existing_deps.to_vec();
let mut new_deps_added = false;
println!("\nAdditional runtime dependencies:");
println!(
" (Already configured: {})",
if existing_deps.is_empty() {
"none".to_string()
} else {
existing_deps.join(", ")
}
);
let should_add_deps = inquire::Confirm::new(
"Do you want to add additional runtime dependencies?",
)
.with_default(false)
.with_help_message(
"These are apt packages that will be installed in the runtime stage (not build stage)",
)
.prompt()?;
if should_add_deps {
loop {
let dep: String = Text::new("Enter runtime dependency (or press Enter to finish):")
.with_help_message("Example: postgresql-client, redis-tools")
.prompt()?;
let dep = dep.trim();
if dep.is_empty() {
break;
}
if !selected.contains(&dep.to_string()) {
selected.push(dep.to_string());
new_deps_added = true;
println!(" [OK] Added '{}' to runtime dependencies", dep);
} else {
println!(" '{}' is already in the list", dep);
}
let add_more = inquire::Confirm::new("Add another runtime dependency?")
.with_default(true)
.prompt()?;
if !add_more {
break;
}
}
}
Ok((selected, new_deps_added))
}
fn ask_for_files(
existing_includes: &[String],
existing_excludes: &[String],
) -> Result<(Vec<String>, Vec<String>), Box<dyn std::error::Error>> {
let (available_includes, _) = discover_files();
let mut selected = existing_includes.to_vec();
let mut excludes = existing_excludes.to_vec();
let new_files: Vec<String> = available_includes
.into_iter()
.filter(|f| !existing_includes.contains(f) && !existing_excludes.contains(f))
.collect();
if new_files.is_empty() {
println!(" No new files to add.");
return Ok((selected, excludes));
}
println!("\nSelect files/directories to include in the Docker build:");
println!(" (Files you skip will be excluded from the Docker build context)");
println!(
" (Already included: {})",
if existing_includes.is_empty() {
"none".to_string()
} else {
existing_includes.join(", ")
}
);
println!();
for file in new_files {
let should_include = inquire::Confirm::new(&format!("Include '{}'?", file))
.with_default(false)
.prompt()?;
if should_include {
selected.push(file);
} else {
excludes.push(file);
}
}
let add_custom = inquire::Confirm::new("Add custom exclude patterns?")
.with_default(false)
.with_help_message("Add patterns like '*.log', 'tests/', etc.")
.prompt()?;
if add_custom {
loop {
let pattern: String = Text::new("Enter exclude pattern (or press Enter to finish):")
.with_help_message("Examples: *.log, tests/, *.tmp, docs/")
.prompt()?;
let pattern = pattern.trim();
if pattern.is_empty() {
break;
}
if !excludes.contains(&pattern.to_string()) {
excludes.push(pattern.to_string());
println!(" [OK] Added '{}' to excludes", pattern);
} else {
println!(" '{}' is already in the list", pattern);
}
let add_more = inquire::Confirm::new("Add another exclude pattern?")
.with_default(true)
.prompt()?;
if !add_more {
break;
}
}
}
excludes.sort();
excludes.dedup();
Ok((selected, excludes))
}
pub fn init(namespace: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
if !Path::new("Cargo.toml").exists() {
eprintln!(
"Error: No Cargo.toml found in current directory. Please run this in a Rust project."
);
std::process::exit(1);
}
let cargo_content = fs::read_to_string("Cargo.toml")?;
let mut cargo_toml: toml::Value = cargo_content.parse()?;
let namespace = match namespace {
Some(ns) => ns,
None => {
let existing_namespace = cargo_toml
.get("spruce")
.and_then(|s| s.get("namespace"))
.and_then(|n| n.as_str())
.map(String::from);
match existing_namespace {
Some(ns) => {
println!("Using existing namespace from Cargo.toml: '{}'", ns);
ns
}
None => Text::new("Enter the Docker namespace (e.g., your-dockerhub-username):")
.with_help_message("This will be used as the registry namespace")
.prompt()?
.trim()
.to_string(),
}
}
};
if namespace.is_empty() {
eprintln!("Error: Namespace cannot be empty");
std::process::exit(1);
}
let project_name: String = cargo_toml["package"]["name"]
.as_str()
.ok_or("Could not find package name in Cargo.toml")?
.to_string();
let existing_includes: Vec<String> = cargo_toml
.get("spruce")
.and_then(|s| s.get("includes"))
.and_then(|i| i.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let existing_tools: Vec<String> = cargo_toml
.get("spruce")
.and_then(|s| s.get("additional_tools"))
.and_then(|t| t.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let existing_runtime_deps: Vec<String> = cargo_toml
.get("spruce")
.and_then(|s| s.get("runtime_deps"))
.and_then(|d| d.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let existing_excludes: Vec<String> = cargo_toml
.get("spruce")
.and_then(|s| s.get("excludes"))
.and_then(|e| e.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let (includes, excludes) = ask_for_files(&existing_includes, &existing_excludes)?;
let (additional_tools, _new_tools_added) = ask_for_tools(&existing_tools)?;
let (runtime_deps, _new_runtime_deps_added) = ask_for_runtime_deps(&existing_runtime_deps)?;
let vps_host: String = Text::new("Enter VPS host (e.g., user@myvps, leave empty to skip):")
.with_help_message("This is used by 'spruce deploy' to SSH into your VPS")
.with_default("")
.prompt()?
.trim()
.to_string();
let domain: String = Text::new("Enter domain (e.g., silenlocatelli.ch, leave empty to skip):")
.with_help_message("Used by 'spruce deploy' to construct the app hostname as <name>.<domain>")
.with_default("")
.prompt()?
.trim()
.to_string();
let mut spruce_table = toml::map::Map::new();
spruce_table.insert(
"namespace".to_string(),
toml::Value::String(namespace.clone()),
);
spruce_table.insert("mode".to_string(), toml::Value::String("alpha".to_string()));
if !vps_host.is_empty() {
spruce_table.insert("host".to_string(), toml::Value::String(vps_host.clone()));
}
if !domain.is_empty() {
spruce_table.insert("domain".to_string(), toml::Value::String(domain.clone()));
}
let includes_array: Vec<toml::Value> = includes
.iter()
.map(|s| toml::Value::String(s.clone()))
.collect();
spruce_table.insert("includes".to_string(), toml::Value::Array(includes_array));
let tools_array: Vec<toml::Value> = additional_tools
.iter()
.map(|s| toml::Value::String(s.clone()))
.collect();
spruce_table.insert(
"additional_tools".to_string(),
toml::Value::Array(tools_array),
);
let runtime_deps_array: Vec<toml::Value> = runtime_deps
.iter()
.map(|s| toml::Value::String(s.clone()))
.collect();
spruce_table.insert(
"runtime_deps".to_string(),
toml::Value::Array(runtime_deps_array),
);
let excludes_array: Vec<toml::Value> = excludes
.iter()
.map(|s| toml::Value::String(s.clone()))
.collect();
spruce_table.insert("excludes".to_string(), toml::Value::Array(excludes_array));
if let toml::Value::Table(ref mut table) = cargo_toml {
table.insert("spruce".to_string(), toml::Value::Table(spruce_table));
}
let updated_cargo = toml::to_string_pretty(&cargo_toml)?;
fs::write("Cargo.toml", updated_cargo)?;
println!("[OK] Added [spruce] section to Cargo.toml");
println!(" namespace = '{}'", namespace);
println!(" mode = 'alpha'");
if !vps_host.is_empty() {
println!(" host = '{}'", vps_host);
}
if !domain.is_empty() {
println!(" domain = '{}'", domain);
}
if !includes.is_empty() {
println!(
" includes = [{}]",
includes
.iter()
.map(|s| format!("'{}'", s))
.collect::<Vec<_>>()
.join(", ")
);
}
if !additional_tools.is_empty() {
println!(
" additional_tools = [{}]",
additional_tools
.iter()
.map(|s| format!("'{}'", s))
.collect::<Vec<_>>()
.join(", ")
);
}
if !excludes.is_empty() {
println!(
" excludes = [{}]",
excludes
.iter()
.map(|s| format!("'{}'", s))
.collect::<Vec<_>>()
.join(", ")
);
}
let dockerfile_exists = Path::new("Dockerfile").exists();
if dockerfile_exists {
let overwrite = inquire::Confirm::new("Dockerfile already exists. Overwrite it?")
.with_default(false)
.prompt()?;
if overwrite {
let dockerfile_content =
docker::generate_dockerfile(&project_name, &includes, &additional_tools, &runtime_deps);
fs::write("Dockerfile", dockerfile_content)?;
println!("[OK] Overwrote Dockerfile");
} else {
println!(" Skipped Dockerfile (kept existing)");
}
} else {
let dockerfile_content =
docker::generate_dockerfile(&project_name, &includes, &additional_tools, &runtime_deps);
fs::write("Dockerfile", dockerfile_content)?;
println!("[OK] Created Dockerfile");
}
handle_dockerignore(&excludes)?;
println!("\nInitialization complete!");
println!(" Image name: {}/{}\n", namespace, project_name);
println!("The [spruce] section in Cargo.toml now contains:");
println!(
" namespace = \"{}\" # Docker registry namespace",
namespace
);
println!(" mode = \"alpha\" # version bump mode (alpha/beta/normal)");
if !includes.is_empty() {
println!(
" includes = {:?} # files to include in Docker build",
includes
);
}
if !additional_tools.is_empty() {
println!(
" additional_tools = {:?} # tools to install in Dockerfile",
additional_tools
);
}
if !excludes.is_empty() {
println!(
" excludes = {:?} # files excluded from Docker build context",
excludes
);
}
println!("\nNext steps:");
println!(" - Run 'spruce build' to build the image");
println!(" - Run 'spruce push' to push to the registry");
println!(" - Run 'spruce deploy' to deploy on your VPS");
println!(" - Run 'spruce mode <alpha|beta|normal>' to change mode");
println!(" - Run 'spruce init' again to add more files or tools");
Ok(())
}