mecha10-cli 0.7.0

Mecha10 CLI tool
Documentation
//! Logs command handler
//!
//! Orchestrates viewing logs for a target - mecha10 dev's local node/framework/service log
//! files, or the remote target's Docker Compose stack (ported from the former
//! `mecha10 remote logs` - see `commands::logs::LogsCommand`'s doc comment for the
//! target-argument shape this mirrors).

use crate::commands::{LogSource, LogsArgs, LogsCommand, LogsRemoteArgs};
use crate::context::CliContext;
use crate::dev::termination::install_quit_signal;
use crate::paths;
use crate::services::{DockerService, LogContentFilters, LogFileFilters, LogsService};
use anyhow::Result;
use std::io::Write;
use std::sync::atomic::Ordering;
use std::time::Duration;

/// Handle the `logs` command
pub async fn handle_logs_command(ctx: &mut CliContext, command: &LogsCommand) -> Result<()> {
    match command {
        LogsCommand::Dev(args) => handle_logs(ctx, args).await,
        LogsCommand::Remote(args) => handle_logs_remote(ctx, args).await,
    }
}

/// Handle `logs remote` (ported from the former `mecha10 remote logs`)
///
/// Shows logs from the mecha10-remote container.
async fn handle_logs_remote(ctx: &mut CliContext, args: &LogsRemoteArgs) -> Result<()> {
    let compose_path = ctx.working_dir.join(paths::docker::COMPOSE_REMOTE_FILE);
    if !compose_path.exists() {
        return Err(anyhow::anyhow!("No remote compose file found."));
    }

    let compose_file_path = compose_path.to_string_lossy().to_string();
    let docker = DockerService::with_compose_file(&compose_file_path);
    docker.check_installation()?;

    if let Some(node) = &args.node {
        println!("Showing logs for node: {}", node);
        println!("(Note: All nodes run in single container, filtering by log content)");
        println!();
    }

    let tail = if args.tail > 0 { Some(args.tail as usize) } else { None };

    docker.compose_logs(Some("mecha10-remote"), args.follow, tail)?;

    Ok(())
}

/// Handle `logs dev`
///
/// Views and filters logs from multiple sources (nodes, framework, services).
///
/// # Arguments
///
/// * `ctx` - CLI execution context
/// * `args` - Logs command arguments
pub async fn handle_logs(ctx: &mut CliContext, args: &LogsArgs) -> Result<()> {
    println!();
    println!("📋 Viewing Logs");
    println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
    println!();

    let should_follow = args.follow && !args.no_follow;

    if !ctx.is_project_initialized() {
        return Err(anyhow::anyhow!(
            "Not in a Mecha10 project directory.\n\
             Run 'mecha10 init' to create a new project."
        ));
    }

    let project = ctx.project()?;
    let logs_dir = project.root().join(paths::project::LOGS_DIR);
    let logs_service = LogsService::new(logs_dir);

    let source = args.source.unwrap_or(LogSource::All);

    display_filters(args, source);

    let file_filters = LogFileFilters {
        node: args.node.clone(),
        source,
    };
    let log_sources = logs_service.collect_log_files(&file_filters)?;

    if log_sources.is_empty() {
        println!("No log files found.");
        println!();
        println!("Tip: Run 'mecha10 dev' to start nodes and generate logs");
        println!();
        return Ok(());
    }

    let content_filters = LogContentFilters {
        pattern: args.filter.clone(),
        level: args.level.clone(),
        lines: args.lines,
    };

    if should_follow {
        follow_logs(&logs_service, &log_sources, &content_filters, args.timestamps).await?;
    } else {
        display_logs(&logs_service, &log_sources, &content_filters, args.timestamps).await?;
    }

    Ok(())
}

/// Display active filters
fn display_filters(args: &LogsArgs, source: LogSource) {
    let mut filters = Vec::new();

    if let Some(node) = &args.node {
        filters.push(format!("Node: {}", node));
    }
    filters.push(format!("Source: {:?}", source));
    if let Some(pattern) = &args.filter {
        filters.push(format!("Pattern: {}", pattern));
    }
    if let Some(level) = &args.level {
        filters.push(format!("Level: {}", level));
    }
    if let Some(n) = args.lines {
        filters.push(format!("Lines: {}", n));
    }

    if !filters.is_empty() {
        println!("Filters: {}", filters.join(" | "));
        println!();
    }
}

/// Display logs from multiple sources
async fn display_logs(
    logs_service: &LogsService,
    log_sources: &std::collections::HashMap<String, std::path::PathBuf>,
    filters: &LogContentFilters,
    _show_timestamps: bool,
) -> Result<()> {
    let lines = logs_service.read_logs(log_sources, filters).await?;

    let line_count = lines.len();
    for log_line in lines {
        if log_sources.len() > 1 {
            println!("[{}] {}", log_line.source_name, log_line.content);
        } else {
            println!("{}", log_line.content);
        }
    }

    println!();
    println!("Showing {} lines from {} source(s)", line_count, log_sources.len());
    println!();

    Ok(())
}

/// Follow logs from multiple sources (watch mode)
async fn follow_logs(
    logs_service: &LogsService,
    log_sources: &std::collections::HashMap<String, std::path::PathBuf>,
    filters: &LogContentFilters,
    _show_timestamps: bool,
) -> Result<()> {
    display_logs(logs_service, log_sources, filters, _show_timestamps).await?;

    println!("Following logs (Ctrl+C to stop)...");
    println!();

    std::io::stdout().flush()?;

    let running = install_quit_signal()?;

    let mut readers = logs_service.open_for_follow(log_sources)?;

    while running.load(Ordering::SeqCst) {
        let mut had_activity = false;

        for (name, reader) in &mut readers {
            let new_lines = logs_service.read_new_lines(reader, filters)?;

            if !new_lines.is_empty() {
                had_activity = true;

                for line in new_lines {
                    if log_sources.len() > 1 {
                        print!("[{}] {}", name, line);
                    } else {
                        print!("{}", line);
                    }

                    let _ = std::io::stdout().flush();
                }
            }
        }

        if !had_activity {
            std::thread::sleep(Duration::from_millis(100));
        }
    }

    println!();
    println!("Stopped following logs");
    println!();

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::context::CliContextBuilder;
    use tempfile::tempdir;

    fn ctx_in(dir: &std::path::Path) -> CliContext {
        CliContextBuilder::new()
            .working_dir(dir.to_path_buf())
            .config_path(Some(dir.join("mecha10.json")))
            .build()
            .unwrap()
    }

    #[tokio::test]
    async fn handle_logs_remote_errors_when_no_compose_file() {
        let dir = tempdir().unwrap();
        let mut ctx = ctx_in(dir.path());

        let args = LogsRemoteArgs {
            node: None,
            follow: false,
            tail: 0,
        };

        let err = handle_logs_remote(&mut ctx, &args).await.unwrap_err();
        assert!(err.to_string().contains("No remote compose file found"));
    }

    #[tokio::test]
    async fn handle_logs_command_dispatches_to_remote() {
        let dir = tempdir().unwrap();
        let mut ctx = ctx_in(dir.path());

        let command = LogsCommand::Remote(LogsRemoteArgs {
            node: None,
            follow: false,
            tail: 0,
        });

        let err = handle_logs_command(&mut ctx, &command).await.unwrap_err();
        assert!(err.to_string().contains("No remote compose file found"));
    }
}