rmcl 0.6.0

A fully featured Minecraft TUI launcher
// SPDX-FileCopyrightText: 2026 Constantin Bauer
// SPDX-License-Identifier: GPL-3.0-only

use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;

use clap::ArgMatches;

use crate::cli::output::print_table;

type CliResult = Result<(), Box<dyn std::error::Error>>;

pub async fn handle_log(matches: &ArgMatches) -> CliResult {
    match matches.subcommand() {
        Some(("list", sub_matches)) => list_logs(required_arg(sub_matches, "instance")?),
        Some(("show", sub_matches)) => show_log(sub_matches).await,
        _ => Ok(()),
    }
}

fn list_logs(instance: &str) -> CliResult {
    let instances_dir = crate::config::SETTINGS.read().paths.resolve_instances_dir();
    require_instance(&instances_dir, instance)?;
    let rows = crate::instance::logs::files::scan_log_files(&instances_dir, instance)
        .into_iter()
        .map(|entry| {
            let size = std::fs::metadata(&entry.path)
                .map(|meta| meta.len())
                .unwrap_or(0);
            vec![entry.name, size.to_string()]
        })
        .collect::<Vec<_>>();

    print_table(&["File", "Size"], &rows);
    Ok(())
}

async fn show_log(matches: &ArgMatches) -> CliResult {
    let instance = required_arg(matches, "instance")?;
    let file = matches.get_one::<String>("file").map(String::as_str);
    let follow = matches.get_flag("follow");
    let instances_dir = crate::config::SETTINGS.read().paths.resolve_instances_dir();
    require_instance(&instances_dir, instance)?;
    let path = resolve_log_path(&instances_dir, instance, file)?;

    if follow {
        use std::io::Write;
        if path.extension().is_some_and(|extension| extension == "gz") {
            return Err(io::Error::other("Cannot follow a compressed log").into());
        }
        let mut offset = 0;
        loop {
            {
                let bytes = read_new_log_bytes(&path, &mut offset)?;
                let mut output = io::stdout().lock();
                output.write_all(&bytes)?;
                output.flush()?;
            }
            tokio::time::sleep(Duration::from_millis(500)).await;
        }
    }
    for line in crate::instance::logs::files::read_log_file(&path) {
        println!("{line}");
    }

    Ok(())
}

fn read_new_log_bytes(path: &Path, offset: &mut u64) -> io::Result<Vec<u8>> {
    use std::io::{Read, Seek};
    let mut file = std::fs::File::open(path)?;
    if file.metadata()?.len() < *offset {
        *offset = 0;
    }
    file.seek(io::SeekFrom::Start(*offset))?;
    let mut bytes = Vec::new();
    file.read_to_end(&mut bytes)?;
    *offset += bytes.len() as u64;
    Ok(bytes)
}

pub(crate) fn resolve_log_path(
    instances_dir: &Path,
    instance: &str,
    file: Option<&str>,
) -> Result<PathBuf, io::Error> {
    let log_dir = crate::instance::logs::files::log_dir(instances_dir, instance);
    let path = if let Some(name) = file {
        let filename = Path::new(name);
        if !crate::storage::safe_relative_path(filename) || filename.components().count() != 1 {
            return Err(io::Error::other("log file must be a single filename"));
        }
        log_dir.join(filename)
    } else {
        crate::instance::logs::files::scan_log_files(instances_dir, instance)
            .into_iter()
            .next()
            .map(|entry| entry.path)
            .ok_or_else(|| io::Error::other(format!("no log files found for '{}'", instance)))?
    };
    let log_dir = std::fs::canonicalize(log_dir)?;
    let path = std::fs::canonicalize(path)?;
    if path.parent() != Some(log_dir.as_path()) || !path.is_file() {
        return Err(io::Error::other(
            "log file is outside the instance log directory",
        ));
    }
    Ok(path)
}

use super::utils::{require_instance, required_arg};

#[cfg(test)]
#[path = "tests/log.rs"]
mod tests;