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 crate::ui::style;
use anyhow::Result;
use std::collections::HashMap;
use std::io::Write;
use std::path::PathBuf;
use std::sync::atomic::Ordering;
use std::time::Duration;
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,
}
}
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(())
}
pub async fn handle_logs(ctx: &mut CliContext, args: &LogsArgs) -> Result<()> {
println!();
println!("{}", style::heading("📋 Viewing Logs"));
println!("{}", style::separator(47));
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(())
}
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!();
}
}
fn source_color_indices(log_sources: &HashMap<String, PathBuf>) -> HashMap<String, usize> {
let mut names: Vec<&String> = log_sources.keys().collect();
names.sort();
names
.into_iter()
.enumerate()
.map(|(i, name)| (name.clone(), i))
.collect()
}
fn detect_level(content: &str) -> Option<&'static str> {
content
.split(|c: char| !c.is_ascii_alphanumeric())
.find_map(|word| match word.to_ascii_uppercase().as_str() {
"ERROR" => Some("ERROR"),
"WARN" | "WARNING" => Some("WARN"),
"INFO" => Some("INFO"),
"DEBUG" => Some("DEBUG"),
"TRACE" => Some("TRACE"),
_ => None,
})
}
fn style_log_line(content: &str) -> String {
match detect_level(content) {
Some("ERROR") => style::error(content).to_string(),
Some("WARN") => style::warn(content).to_string(),
Some("INFO") => style::heading(content).to_string(),
Some("DEBUG") | Some("TRACE") => style::muted(content).to_string(),
_ => content.to_string(),
}
}
fn render_log_line(
source_name: &str,
content: &str,
source_colors: &HashMap<String, usize>,
multi_source: bool,
) -> String {
let styled_content = style_log_line(content);
if multi_source {
let color_index = source_colors.get(source_name).copied().unwrap_or(0);
format!(
"{} {}",
style::palette(color_index, format!("[{}]", source_name)),
styled_content
)
} else {
styled_content
}
}
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 source_colors = source_color_indices(log_sources);
let multi_source = log_sources.len() > 1;
let line_count = lines.len();
for log_line in lines {
println!(
"{}",
render_log_line(&log_line.source_name, &log_line.content, &source_colors, multi_source)
);
}
println!();
println!("Showing {} lines from {} source(s)", line_count, log_sources.len());
println!();
Ok(())
}
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)?;
let source_colors = source_color_indices(log_sources);
let multi_source = log_sources.len() > 1;
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 {
print!("{}", render_log_line(name, &line, &source_colors, multi_source));
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"));
}
#[test]
fn detect_level_matches_whole_word_case_insensitively() {
assert_eq!(
detect_level("2024-01-01T00:00:00Z ERROR something broke"),
Some("ERROR")
);
assert_eq!(detect_level("some warn: disk nearly full"), Some("WARN"));
assert_eq!(detect_level("WARNING: low battery"), Some("WARN"));
assert_eq!(detect_level("[INFO] node started"), Some("INFO"));
assert_eq!(detect_level("debug: tick 42"), Some("DEBUG"));
assert_eq!(detect_level("TRACE entering loop"), Some("TRACE"));
assert_eq!(detect_level("plain line with no level token"), None);
}
#[test]
fn detect_level_does_not_match_substrings_within_other_words() {
assert_eq!(detect_level("errorless operation completed"), None);
}
#[test]
fn source_color_indices_are_stable_and_ordered_alphabetically() {
let mut sources = HashMap::new();
sources.insert("zeta".to_string(), PathBuf::from("zeta.log"));
sources.insert("alpha".to_string(), PathBuf::from("alpha.log"));
sources.insert("mid".to_string(), PathBuf::from("mid.log"));
let indices = source_color_indices(&sources);
assert_eq!(indices.get("alpha"), Some(&0));
assert_eq!(indices.get("mid"), Some(&1));
assert_eq!(indices.get("zeta"), Some(&2));
}
#[test]
fn render_log_line_omits_source_prefix_for_single_source() {
let colors = HashMap::new();
let rendered = render_log_line("node-a", "hello world", &colors, false);
assert!(!rendered.contains("node-a"));
assert!(rendered.contains("hello world"));
}
#[test]
fn render_log_line_includes_bracketed_source_prefix_for_multi_source() {
let mut colors = HashMap::new();
colors.insert("node-a".to_string(), 0);
let rendered = render_log_line("node-a", "hello world", &colors, true);
assert!(rendered.contains("[node-a]"));
assert!(rendered.contains("hello world"));
}
}