mod commands;
mod config;
mod docker;
mod version;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "spruce")]
#[command(about = "A CLI tool to build and push Docker images for Rust projects")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Initialize the project with a Dockerfile and [spruce] section in Cargo.toml
Init {
/// Docker namespace/username (optional, will prompt if not provided)
#[arg(short, long)]
namespace: Option<String>,
},
/// Build the Docker image using mode from Cargo.toml [spruce] section
Build,
/// Push the Docker image to the registry
Push,
/// Change the version bump mode in Cargo.toml
Mode {
/// The mode to set: alpha, beta, or normal
mode: String,
},
/// Build, commit, git push, and push Docker image in one command
Release,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
match cli.command {
Commands::Init { namespace } => commands::init::init(namespace),
Commands::Build => commands::build::build(),
Commands::Push => commands::push::push(),
Commands::Mode { mode } => commands::mode::set_mode(&mode),
Commands::Release => commands::release::release(),
}
}
#[cfg(test)]
mod toml_tests {
#[test]
fn test_parse_simple_cargo_toml() {
let cargo_toml = r#"
[package]
name = "test"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = "1.0"
"#;
let parsed: toml::Value = cargo_toml.parse().expect("Should parse simple TOML");
assert_eq!(parsed["package"]["name"].as_str(), Some("test"));
let serialized = toml::to_string_pretty(&parsed).expect("Should serialize");
let reparsed: toml::Value = serialized.parse().expect("Should reparse serialized");
assert_eq!(reparsed["package"]["name"].as_str(), Some("test"));
}
#[test]
fn test_parse_cargo_toml_with_dependencies_first() {
let cargo_toml = r#"[dependencies]
serde = "1.0"
toml = "1"
[package]
name = "spruce"
version = "0.1.0"
edition = "2024"
"#;
let parsed: toml::Value = cargo_toml
.parse()
.expect("Should parse TOML with deps first");
assert_eq!(parsed["package"]["name"].as_str(), Some("spruce"));
let serialized = toml::to_string_pretty(&parsed).expect("Should serialize");
let reparsed: toml::Value = serialized.parse().expect("Should reparse serialized");
assert_eq!(reparsed["package"]["name"].as_str(), Some("spruce"));
}
#[test]
fn test_parse_cargo_toml_with_spruce_section() {
let cargo_toml = r#"[dependencies]
actix-web = "4"
[package]
name = "fig"
version = "0.1.3"
edition = "2024"
[spruce]
mode = "alpha"
namespace = "silenloc"
"#;
let parsed: toml::Value = cargo_toml
.parse()
.expect("Should parse TOML with spruce section");
let spruce = parsed.get("spruce").expect("Should have spruce section");
assert_eq!(spruce["mode"].as_str(), Some("alpha"));
assert_eq!(spruce["namespace"].as_str(), Some("silenloc"));
let serialized = toml::to_string_pretty(&parsed).expect("Should serialize");
println!("Serialized with spruce:\n{}", serialized);
}
#[test]
fn test_parse_cargo_toml_with_table_dependencies() {
let cargo_toml = r#"[dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
[package]
name = "test"
version = "0.1.0"
edition = "2024"
"#;
let parsed: toml::Value = cargo_toml
.parse()
.expect("Should parse TOML with table deps");
let serde_dep = &parsed["dependencies"]["serde"];
assert_eq!(serde_dep["version"].as_str(), Some("1.0"));
let serialized = toml::to_string_pretty(&parsed).expect("Should serialize");
println!("Serialized with table deps:\n{}", serialized);
}
#[test]
fn test_parse_spruce_project_cargo_toml() {
let cargo_toml = r#"[dependencies]
inquire = "0.9"
toml = "1"
xshell = "0.2"
[dependencies.clap]
features = ["derive"]
version = "4"
[dependencies.serde]
features = ["derive"]
version = "1.0"
[package]
edition = "2024"
name = "spruce"
version = "0.1.0"
"#;
let parsed: toml::Value = cargo_toml.parse().expect("Should parse spruce TOML");
assert_eq!(parsed["package"]["name"].as_str(), Some("spruce"));
assert_eq!(parsed["package"]["version"].as_str(), Some("0.1.0"));
let deps = parsed["dependencies"]
.as_table()
.expect("Should have deps table");
assert!(deps.contains_key("inquire"));
assert!(deps.contains_key("clap"));
let serialized = toml::to_string_pretty(&parsed).expect("Should serialize");
println!("Serialized spruce Cargo.toml:\n{}", serialized);
let reparsed: toml::Value = serialized.parse().expect("Should reparse serialized");
assert_eq!(reparsed["package"]["name"].as_str(), Some("spruce"));
}
#[test]
fn test_parse_fig_project_cargo_toml() {
let cargo_toml = r#"[dependencies]
actix-multipart = "0.7"
actix-web = "4"
argon2 = "0.5"
base64 = "0.22"
chrono = "0.4"
env_logger = "0.11"
git2 = "0.18"
hex = "0.4"
libsql = "0.9"
log = "0.4"
pulldown-cmark = "0.12"
rand = "0.8"
xshell = "0.2.7"
[dependencies.maud]
features = ["actix-web"]
version = "0.27.0"
[dependencies.reqwest]
default-features = false
features = ["rustls-tls"]
version = "0.12"
[dependencies.serde]
features = ["derive"]
version = "1.0"
[dependencies.tokio]
features = ["full"]
version = "1"
[dependencies.uuid]
features = ["v4"]
version = "1.23.0"
[dev-dependencies.tokio]
features = ["full"]
version = "1"
[package]
edition = "2024"
name = "fig"
version = "0.1.3"
[spruce]
mode = "alpha"
namespace = "silenloc"
"#;
let parsed: toml::Value = cargo_toml.parse().expect("Should parse fig TOML");
assert_eq!(parsed["package"]["name"].as_str(), Some("fig"));
let spruce = parsed.get("spruce").expect("Should have spruce section");
assert_eq!(spruce["mode"].as_str(), Some("alpha"));
let serialized = toml::to_string_pretty(&parsed).expect("Should serialize");
println!("Serialized fig Cargo.toml:\n{}", serialized);
let reparsed: toml::Value = serialized.parse().expect("Should reparse serialized");
assert_eq!(reparsed["package"]["name"].as_str(), Some("fig"));
}
#[test]
fn test_update_version_in_cargo_toml() {
let cargo_toml = r#"[dependencies]
serde = "1.0"
[package]
name = "test"
version = "0.1.0"
edition = "2024"
[spruce]
mode = "alpha"
namespace = "test"
"#;
let mut parsed: toml::Value = cargo_toml.parse().expect("Should parse");
parsed["package"]["version"] = toml::Value::String("0.1.1".to_string());
let serialized = toml::to_string_pretty(&parsed).expect("Should serialize");
println!("Updated Cargo.toml:\n{}", serialized);
assert!(serialized.contains("version = \"0.1.1\""));
let reparsed: toml::Value = serialized.parse().expect("Should reparse");
assert_eq!(reparsed["package"]["version"].as_str(), Some("0.1.1"));
}
#[test]
fn test_cargo_toml_without_spruce_section() {
let cargo_toml = r#"[package]
name = "test"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = "1.0"
"#;
let parsed: toml::Value = cargo_toml.parse().expect("Should parse");
assert!(parsed.get("spruce").is_none());
let serialized = toml::to_string_pretty(&parsed).expect("Should serialize");
println!("Without spruce section:\n{}", serialized);
}
#[test]
fn test_parse_cargo_toml_with_additional_tools() {
let cargo_toml = r#"[dependencies]
serde = "1.0"
[package]
name = "test"
version = "0.1.0"
edition = "2024"
[spruce]
mode = "alpha"
namespace = "test"
additional_tools = ["curl", "jq", "vim"]
"#;
let parsed: toml::Value = cargo_toml.parse().expect("Should parse");
let spruce = parsed.get("spruce").expect("Should have spruce section");
let tools = spruce
.get("additional_tools")
.and_then(|t| t.as_array())
.expect("Should have additional_tools array");
assert_eq!(tools.len(), 3);
assert_eq!(tools[0].as_str(), Some("curl"));
assert_eq!(tools[1].as_str(), Some("jq"));
assert_eq!(tools[2].as_str(), Some("vim"));
let serialized = toml::to_string_pretty(&parsed).expect("Should serialize");
assert!(serialized.contains("additional_tools"));
let reparsed: toml::Value = serialized.parse().expect("Should reparse serialized");
let spruce = reparsed.get("spruce").expect("Should have spruce section");
let tools = spruce
.get("additional_tools")
.and_then(|t| t.as_array())
.expect("Should have additional_tools array");
assert_eq!(tools.len(), 3);
}
#[test]
fn test_parse_cargo_toml_with_excludes() {
let cargo_toml = r#"[dependencies]
serde = "1.0"
[package]
name = "test"
version = "0.1.0"
edition = "2024"
[spruce]
mode = "alpha"
namespace = "test"
excludes = ["tests", "*.log", "docs"]
"#;
let parsed: toml::Value = cargo_toml.parse().expect("Should parse");
let spruce = parsed.get("spruce").expect("Should have spruce section");
let excludes = spruce
.get("excludes")
.and_then(|e| e.as_array())
.expect("Should have excludes array");
assert_eq!(excludes.len(), 3);
assert_eq!(excludes[0].as_str(), Some("tests"));
assert_eq!(excludes[1].as_str(), Some("*.log"));
assert_eq!(excludes[2].as_str(), Some("docs"));
let serialized = toml::to_string_pretty(&parsed).expect("Should serialize");
assert!(serialized.contains("excludes"));
let reparsed: toml::Value = serialized.parse().expect("Should reparse serialized");
let spruce = reparsed.get("spruce").expect("Should have spruce section");
let excludes = spruce
.get("excludes")
.and_then(|e| e.as_array())
.expect("Should have excludes array");
assert_eq!(excludes.len(), 3);
}
}