Skip to content
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);
    }
}