rmcl 0.3.1

A fully featured Minecraft TUI launcher
// global tracking for running minecraft instances. everything is behind
// Arc<Mutex<>> because the launch/monitor tasks live on separate tokio threads
// and the TUI render loop needs to read state every frame.

use chrono::{DateTime, Utc};
use std::collections::HashMap;
use std::sync::LazyLock;
use std::sync::{Arc, Mutex};

#[derive(Debug, Clone, PartialEq)]
pub enum RunState {
    Authenticating,
    Starting,
    Running,
    Crashed(Option<i32>),
}

pub static RUNNING: LazyLock<Arc<Mutex<HashMap<String, RunState>>>> =
    LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));

// queued up so the TUI event loop can flush these to disk in batch,
// the child process monitor shouldn't be writing config files directly
type PendingLastPlayed = Arc<Mutex<Vec<(String, DateTime<Utc>)>>>;
pub static PENDING_LAST_PLAYED: LazyLock<PendingLastPlayed> =
    LazyLock::new(|| Arc::new(Mutex::new(Vec::new())));

// oneshot channels to signal a running instance to stop.
// send_kill fires the channel, the launch task receives it and kills the child process.
type KillSenders = Arc<Mutex<HashMap<String, tokio::sync::oneshot::Sender<()>>>>;
pub static KILL_SENDERS: LazyLock<KillSenders> =
    LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));

pub fn set_state(name: &str, state: RunState) {
    if let Ok(mut map) = RUNNING.lock() {
        map.insert(name.to_string(), state);
    }
}

pub fn remove(name: &str) {
    if let Ok(mut map) = RUNNING.lock() {
        map.remove(name);
    }
}

#[must_use]
pub fn get(name: &str) -> Option<RunState> {
    RUNNING.lock().ok().and_then(|map| map.get(name).cloned())
}

#[must_use]
pub fn all() -> Vec<(String, RunState)> {
    RUNNING
        .lock()
        .ok()
        .map(|map| map.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
        .unwrap_or_default()
}

pub fn push_last_played(name: &str, time: DateTime<Utc>) {
    if let Ok(mut q) = PENDING_LAST_PLAYED.lock() {
        q.push((name.to_string(), time));
    }
}

pub fn drain_last_played() -> Vec<(String, DateTime<Utc>)> {
    PENDING_LAST_PLAYED
        .lock()
        .ok()
        .map(|mut q| q.drain(..).collect())
        .unwrap_or_default()
}

pub fn register_kill(name: &str, tx: tokio::sync::oneshot::Sender<()>) {
    if let Ok(mut map) = KILL_SENDERS.lock() {
        map.insert(name.to_string(), tx);
    }
}

pub fn send_kill(name: &str) -> bool {
    if let Ok(mut map) = KILL_SENDERS.lock()
        && let Some(tx) = map.remove(name)
    {
        let _ = tx.send(());
        return true;
    }
    false
}

pub fn cleanup_kill_sender(name: &str) {
    if let Ok(mut map) = KILL_SENDERS.lock() {
        map.remove(name);
    }
}

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

    #[test]
    fn set_and_get_state() {
        set_state("run_test_1", RunState::Starting);
        assert_eq!(get("run_test_1"), Some(RunState::Starting));
    }

    #[test]
    fn get_missing_returns_none() {
        assert_eq!(get("run_never_set_xyz"), None);
    }

    #[test]
    fn remove_clears_state() {
        set_state("run_test_2", RunState::Running);
        remove("run_test_2");
        assert_eq!(get("run_test_2"), None);
    }

    #[test]
    fn set_state_overwrites() {
        set_state("run_test_3", RunState::Starting);
        set_state("run_test_3", RunState::Running);
        assert_eq!(get("run_test_3"), Some(RunState::Running));
    }

    #[test]
    fn all_returns_entries() {
        set_state("run_test_all_a", RunState::Running);
        let entries = all();
        assert!(entries.iter().any(|(k, _)| k == "run_test_all_a"));
    }

    #[test]
    fn crashed_state_stores_exit_code() {
        set_state("run_test_crash", RunState::Crashed(Some(1)));
        assert_eq!(get("run_test_crash"), Some(RunState::Crashed(Some(1))));
    }

    #[test]
    fn push_and_drain_last_played() {
        let time = Utc::now();
        push_last_played("run_test_lp", time);
        let drained = drain_last_played();
        assert!(drained.iter().any(|(k, _)| k == "run_test_lp"));
    }

    // Removed drain_empty_returns_empty: it relied on no other test pushing
    // to LAST_PLAYED between the two drain calls, which races with the
    // parallel push_and_drain_last_played test. The drain semantics are
    // already covered by push_and_drain_last_played, which asserts a
    // specific entry is present, and the empty-result path is exercised
    // implicitly any time drain runs after that test's cleanup.

    #[test]
    fn send_kill_returns_false_for_missing() {
        assert!(!send_kill("run_never_registered_xyz"));
    }

    #[test]
    fn register_and_send_kill() {
        let (tx, mut rx) = tokio::sync::oneshot::channel::<()>();
        register_kill("run_test_kill", tx);
        assert!(send_kill("run_test_kill"));
        let _ = rx.try_recv();
    }

    #[test]
    fn cleanup_kill_sender_removes() {
        let (tx, _rx) = tokio::sync::oneshot::channel::<()>();
        register_kill("run_test_cleanup", tx);
        cleanup_kill_sender("run_test_cleanup");
        assert!(!send_kill("run_test_cleanup"));
    }
}