cobble-lang 0.6.2

A modern, Python-like language for creating Minecraft Data Packs
Documentation
use crate::commands::validate::SUPPORTED_COMMANDS_JSON_SHA1;
use crate::config::CobbleConfig;
use crate::pack_format::{COBBLE_VERSION, SUPPORTED_MINECRAFT_VERSION, SUPPORTED_PACK_FORMAT};
use sha1::{Digest, Sha1};
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Command;

pub struct DoctorOptions {
    pub path: Option<PathBuf>,
    pub commands_json: PathBuf,
}

pub fn doctor(options: DoctorOptions) -> Result<(), String> {
    let project_root = options.path.unwrap_or_else(|| PathBuf::from("."));
    let config_search_root = if project_root.is_file() {
        project_root.parent().unwrap_or_else(|| Path::new("."))
    } else {
        project_root.as_path()
    };

    println!("Cobble doctor");
    println!("  Cobble version: {COBBLE_VERSION}");
    println!("  Minecraft target: Java Edition {SUPPORTED_MINECRAFT_VERSION}");
    println!("  Pack format: {SUPPORTED_PACK_FORMAT}");

    print_tool_status("Java", "java", &["-version"]);
    print_tool_status("curl", "curl", &["--version"]);
    print_config_status(config_search_root);
    print_commands_json_status(&options.commands_json)?;

    Ok(())
}

fn print_tool_status(label: &str, command: &str, args: &[&str]) {
    let status = Command::new(command).args(args).output();
    match status {
        Ok(output) if output.status.success() => println!("{label}: available"),
        Ok(output) => println!(
            "  ! {label}: found but exited with {}",
            output
                .status
                .code()
                .map_or_else(|| "signal".to_string(), |code| code.to_string())
        ),
        Err(error) => println!("  ! {label}: not available ({error})"),
    }
}

fn print_config_status(search_root: &Path) {
    match CobbleConfig::find_in_path(search_root) {
        Some(config_path) => match CobbleConfig::load(&config_path) {
            Ok(config) => {
                println!("  ✓ Config: {}", config_path.display());
                println!("    Project: {}", config.project.name);
                println!("    Namespace: {}", config.project.namespace);
                println!("    Source: {}", config.build.source);
                println!("    Output: {}", config.build.output);
            }
            Err(error) => {
                println!("  ! Config: {} ({error})", config_path.display());
            }
        },
        None => println!(
            "  ! Config: no cobble.toml found from {}",
            search_root.display()
        ),
    }
}

fn print_commands_json_status(commands_json: &Path) -> Result<(), String> {
    match inspect_commands_json(commands_json)? {
        CommandsJsonStatus::Missing => {
            println!("  ! Command tree: missing ({})", commands_json.display());
            if is_default_commands_json_path(commands_json) {
                println!("    Default validation will auto-generate it when needed.");
            }
        }
        CommandsJsonStatus::Present {
            sha1,
            matches_supported,
        } => {
            println!("  ✓ Command tree: {}", commands_json.display());
            println!("    SHA-1: {sha1}");
            if let Some(matches_supported) = matches_supported {
                if matches_supported {
                    println!("    Target match: Minecraft {SUPPORTED_MINECRAFT_VERSION}");
                } else {
                    println!(
                        "    Target mismatch: expected {SUPPORTED_COMMANDS_JSON_SHA1} for Minecraft {SUPPORTED_MINECRAFT_VERSION}"
                    );
                }
            }
        }
    }

    Ok(())
}

#[derive(Debug, PartialEq, Eq)]
enum CommandsJsonStatus {
    Missing,
    Present {
        sha1: String,
        matches_supported: Option<bool>,
    },
}

fn inspect_commands_json(commands_json: &Path) -> Result<CommandsJsonStatus, String> {
    if !commands_json.exists() {
        return Ok(CommandsJsonStatus::Missing);
    }

    let sha1 = sha1_file(commands_json)?;
    let matches_supported = if is_default_commands_json_path(commands_json) {
        Some(sha1.eq_ignore_ascii_case(SUPPORTED_COMMANDS_JSON_SHA1))
    } else {
        None
    };

    Ok(CommandsJsonStatus::Present {
        sha1,
        matches_supported,
    })
}

fn is_default_commands_json_path(commands_json: &Path) -> bool {
    commands_json == Path::new("data/commands.json")
        || commands_json.ends_with(Path::new("data/commands.json"))
}

fn sha1_file(path: &Path) -> Result<String, String> {
    let mut file = fs::File::open(path)
        .map_err(|error| format!("Failed to open {}: {error}", path.display()))?;
    let mut hasher = Sha1::new();
    let mut buffer = [0; 8192];
    loop {
        let bytes_read = file
            .read(&mut buffer)
            .map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
        if bytes_read == 0 {
            break;
        }
        hasher.update(&buffer[..bytes_read]);
    }
    Ok(format!("{:x}", hasher.finalize()))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn inspect_commands_json_reports_missing_default_tree() {
        let temp_dir = tempfile::TempDir::new().unwrap();
        let commands_json = temp_dir.path().join("data/commands.json");

        assert_eq!(
            inspect_commands_json(&commands_json).unwrap(),
            CommandsJsonStatus::Missing
        );
    }

    #[test]
    fn inspect_commands_json_reports_default_tree_fingerprint_mismatch() {
        let temp_dir = tempfile::TempDir::new().unwrap();
        let commands_json = temp_dir.path().join("data/commands.json");
        fs::create_dir_all(commands_json.parent().unwrap()).unwrap();
        fs::write(&commands_json, "{}").unwrap();

        let CommandsJsonStatus::Present {
            matches_supported, ..
        } = inspect_commands_json(&commands_json).unwrap()
        else {
            panic!("expected present command tree");
        };

        assert_eq!(matches_supported, Some(false));
    }

    #[test]
    fn doctor_runs_without_config_or_command_tree() {
        let temp_dir = tempfile::TempDir::new().unwrap();

        doctor(DoctorOptions {
            path: Some(temp_dir.path().to_path_buf()),
            commands_json: temp_dir.path().join("missing.json"),
        })
        .unwrap();
    }
}