//! CLI command for viewing and managing gateway logs. //! //! Provides access to gateway logs through three mechanisms: //! - Reading the gateway log file (`~/.ironclaw/gateway.log`) //! - Streaming live logs via the gateway's SSE endpoint (`/api/logs/events`) //! - Getting/setting the runtime log level via `/api/logs/level` use std::io::{Seek, SeekFrom}; use std::path::Path; use clap::Args; /// View and manage gateway logs. #[derive(Args, Debug, Clone)] #[command( about = "View and manage gateway logs", long_about = "Tail gateway logs, stream live output, or adjust log level.\nExamples:\n ironclaw logs # Show last 200 lines\n ironclaw logs --follow # Stream live logs via SSE\n ironclaw logs --limit 50 --json # Last 50 lines as JSON\n ironclaw logs --level # Show current log level\n ironclaw logs --level debug # Set log level to debug" )] pub struct LogsCommand { /// Stream live logs from the running gateway via SSE. /// Replays recent history then streams new entries in real time. #[arg(short, long)] pub follow: bool, /// Maximum number of lines to show (default: 200) #[arg(short, long, default_value = "200")] pub limit: usize, /// Output log entries as JSON (one object per line) #[arg(long)] pub json: bool, /// Display timestamps in local timezone #[arg(long)] pub local_time: bool, /// Plain text output (no ANSI styling) #[arg(long)] pub plain: bool, /// Gateway URL (default: http://{GATEWAY_HOST}:{GATEWAY_PORT}) #[arg(long)] pub url: Option, /// Gateway auth token (reads GATEWAY_AUTH_TOKEN env if not set) #[arg(long)] pub token: Option, /// Connection timeout in milliseconds (default: 5000) #[arg(long, default_value = "5000")] pub timeout: u64, /// Get or set runtime log level. Without a value, shows current level. /// With a value (trace|debug|info|warn|error), sets the level. #[arg(long, num_args = 0..=1, default_missing_value = "")] pub level: Option, } /// Resolved gateway connection parameters. struct GatewayParams { base_url: String, token: String, } /// Run the logs CLI command. pub async fn run_logs_command(cmd: LogsCommand, config_path: Option<&Path>) -> anyhow::Result<()> { // --level takes priority: it's a control-plane operation, not log viewing. if let Some(level_arg) = &cmd.level { let params = resolve_gateway_params(&cmd, config_path).await?; if level_arg.is_empty() { return cmd_get_level(&cmd, ¶ms).await; } else { return cmd_set_level(&cmd, level_arg, ¶ms).await; } } if cmd.follow { let params = resolve_gateway_params(&cmd, config_path).await?; cmd_follow(&cmd, ¶ms).await } else { cmd_show(&cmd) } } // ── Show log file ──────────────────────────────────────────────────────── /// Read the last N lines from `~/.ironclaw/gateway.log`. /// /// Uses a reverse-scan strategy: seeks to the end of the file and reads /// backwards in chunks to find the last `limit` newlines, so memory usage /// is proportional to the output size, not the file size. fn cmd_show(cmd: &LogsCommand) -> anyhow::Result<()> { let log_path = crate::bootstrap::ironclaw_base_dir().join("gateway.log"); if !log_path.exists() { anyhow::bail!( "No gateway log file found at {}.\n\ The log file is created when the gateway runs in background mode \ (e.g. `ironclaw gateway start`).", log_path.display() ); } let lines = tail_file(&log_path, cmd.limit)?; if lines.is_empty() { println!("(log file is empty)"); return Ok(()); } if cmd.json { for line in &lines { let obj = serde_json::json!({ "line": line }); println!("{}", obj); } } else { for line in &lines { println!("{}", line); } } Ok(()) } /// Read the last `n` lines from a file by scanning backwards from EOF. /// /// Reads in 8 KiB chunks from the end, counting newlines until enough /// are found or the beginning of the file is reached. fn tail_file(path: &Path, n: usize) -> anyhow::Result> { let mut file = std::fs::File::open(path) .map_err(|e| anyhow::anyhow!("Failed to open {}: {}", path.display(), e))?; let file_len = file .seek(SeekFrom::End(0)) .map_err(|e| anyhow::anyhow!("Failed to seek {}: {}", path.display(), e))?; if file_len == 0 { return Ok(Vec::new()); } // Read backwards in chunks to find enough newlines. const CHUNK_SIZE: u64 = 8192; let mut tail_bytes = Vec::new(); let mut newline_count = 0; let mut remaining = file_len; while remaining > 0 && newline_count <= n { let read_size = std::cmp::min(CHUNK_SIZE, remaining); remaining -= read_size; file.seek(SeekFrom::Start(remaining)) .map_err(|e| anyhow::anyhow!("Seek failed: {e}"))?; let mut chunk = vec![0u8; read_size as usize]; std::io::Read::read_exact(&mut file, &mut chunk) .map_err(|e| anyhow::anyhow!("Read failed: {e}"))?; // Count newlines in this chunk (backwards). for &byte in chunk.iter().rev() { if byte == b'\n' { newline_count += 1; } } // Prepend chunk to collected bytes. chunk.append(&mut tail_bytes); tail_bytes = chunk; } // Convert to string and take last N lines. let text = String::from_utf8_lossy(&tail_bytes); let all_lines: Vec<&str> = text.lines().collect(); let start = all_lines.len().saturating_sub(n); Ok(all_lines[start..].iter().map(|s| s.to_string()).collect()) } // ── Follow (live SSE stream) ───────────────────────────────────────────── /// Connect to the gateway's `/api/logs/events` SSE endpoint and stream logs. async fn cmd_follow(cmd: &LogsCommand, params: &GatewayParams) -> anyhow::Result<()> { let timeout_dur = std::time::Duration::from_millis(cmd.timeout); let client = reqwest::Client::builder() .connect_timeout(timeout_dur) .build() .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))?; let url = format!("{}/api/logs/events", params.base_url); let resp = client .get(&url) .header("Authorization", format!("Bearer {}", params.token)) .header("Accept", "text/event-stream") // No per-request timeout: SSE streams are long-lived. .timeout(std::time::Duration::from_secs(u64::MAX / 2)) .send() .await .map_err(|e| { anyhow::anyhow!( "Failed to connect to gateway at {url}: {e}\n\ Is the gateway running? Try `ironclaw gateway status`." ) })?; if !resp.status().is_success() { anyhow::bail!( "Gateway returned HTTP {}: {}", resp.status(), resp.text().await.unwrap_or_default() ); } eprintln!("Connected to {} — streaming logs (Ctrl-C to stop)", url); // Parse SSE stream line by line. let mut bytes_stream = resp.bytes_stream(); let mut buffer = String::new(); let mut lines_shown: usize = 0; use futures::StreamExt; while let Some(chunk) = bytes_stream.next().await { let chunk = chunk.map_err(|e| anyhow::anyhow!("Stream error: {e}"))?; buffer.push_str(&String::from_utf8_lossy(&chunk)); // Process complete lines from the buffer. while let Some(newline_pos) = buffer.find('\n') { let line = buffer[..newline_pos].to_string(); buffer = buffer[newline_pos + 1..].to_string(); // SSE format: "data: {...}" lines carry the payload. if let Some(data) = line.strip_prefix("data: ") && let Ok(entry) = serde_json::from_str::(data) { print_log_entry(&entry, cmd); lines_shown += 1; } // Skip "event:", "id:", "retry:", and empty keepalive lines. } } if lines_shown == 0 { eprintln!("(no log entries received)"); } Ok(()) } // ── Log level get/set ──────────────────────────────────────────────────── /// GET /api/logs/level — show the current log level. async fn cmd_get_level(cmd: &LogsCommand, params: &GatewayParams) -> anyhow::Result<()> { let timeout_dur = std::time::Duration::from_millis(cmd.timeout); let client = reqwest::Client::builder() .timeout(timeout_dur) .build() .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))?; let url = format!("{}/api/logs/level", params.base_url); let resp = client .get(&url) .header("Authorization", format!("Bearer {}", params.token)) .send() .await .map_err(|e| { anyhow::anyhow!( "Failed to connect to gateway at {url}: {e}\n\ Is the gateway running? Try `ironclaw gateway status`." ) })?; if !resp.status().is_success() { anyhow::bail!( "Gateway returned HTTP {}: {}", resp.status(), resp.text().await.unwrap_or_default() ); } let body: serde_json::Value = resp .json() .await .map_err(|e| anyhow::anyhow!("Invalid response: {e}"))?; if cmd.json { println!( "{}", serde_json::to_string_pretty(&body).unwrap_or_default() ); } else { let level = body .get("level") .and_then(|v| v.as_str()) .unwrap_or("unknown"); println!("Current log level: {}", level); } Ok(()) } /// PUT /api/logs/level — change the runtime log level. async fn cmd_set_level( cmd: &LogsCommand, level: &str, params: &GatewayParams, ) -> anyhow::Result<()> { const VALID: &[&str] = &["trace", "debug", "info", "warn", "error"]; let level_lower = level.to_lowercase(); if !VALID.contains(&level_lower.as_str()) { anyhow::bail!( "Invalid log level '{}'. Must be one of: {}", level, VALID.join(", ") ); } let timeout_dur = std::time::Duration::from_millis(cmd.timeout); let client = reqwest::Client::builder() .timeout(timeout_dur) .build() .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))?; let url = format!("{}/api/logs/level", params.base_url); let resp = client .put(&url) .header("Authorization", format!("Bearer {}", params.token)) .json(&serde_json::json!({ "level": level_lower })) .send() .await .map_err(|e| { anyhow::anyhow!( "Failed to connect to gateway at {url}: {e}\n\ Is the gateway running? Try `ironclaw gateway status`." ) })?; if !resp.status().is_success() { anyhow::bail!( "Gateway returned HTTP {}: {}", resp.status(), resp.text().await.unwrap_or_default() ); } let body: serde_json::Value = resp .json() .await .map_err(|e| anyhow::anyhow!("Invalid response: {e}"))?; if cmd.json { println!( "{}", serde_json::to_string_pretty(&body).unwrap_or_default() ); } else { let new_level = body .get("level") .and_then(|v| v.as_str()) .unwrap_or(&level_lower); println!("Log level set to: {}", new_level); } Ok(()) } // ── Helpers ────────────────────────────────────────────────────────────── /// Resolve gateway connection params from CLI flags, config file, or env. /// /// Priority: --url/--token flags > config TOML > env vars > defaults. async fn resolve_gateway_params( cmd: &LogsCommand, config_path: Option<&Path>, ) -> anyhow::Result { // Load gateway config. Errors propagate when --config is explicit. let gw_config = load_gateway_config(config_path).await?; // URL: --url flag > config TOML > env vars > defaults. let base_url = if let Some(url) = &cmd.url { url.trim_end_matches('/').to_string() } else if let Some(cfg) = &gw_config { format!("http://{}:{}", cfg.host, cfg.port) } else { let host = std::env::var("GATEWAY_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); let port: u16 = std::env::var("GATEWAY_PORT") .ok() .and_then(|p| p.parse().ok()) .unwrap_or(3000); format!("http://{}:{}", host, port) }; // Token: --token flag > config TOML > env var. let token = if let Some(token) = &cmd.token { token.clone() } else if let Some(t) = gw_config.as_ref().and_then(|c| c.auth_token.clone()) { t } else { std::env::var("GATEWAY_AUTH_TOKEN").map_err(|_| { anyhow::anyhow!( "No auth token provided. Use --token or set GATEWAY_AUTH_TOKEN.\n\ The token is printed when the gateway starts." ) })? }; Ok(GatewayParams { base_url, token }) } /// Try to load gateway config from the TOML config file. /// /// If `config_path` was explicitly provided (via `--config`), errors are /// propagated — the user asked for a specific file and deserves a clear /// failure when it is missing, unreadable, or malformed. When no path /// was given we fall back to env-only resolution and silently return /// `None` on failure so that `ironclaw logs` works without any config. async fn load_gateway_config( config_path: Option<&Path>, ) -> anyhow::Result> { if config_path.is_some() { // Explicit --config: propagate errors. let config = crate::config::Config::from_env_with_toml(config_path) .await .map_err(|e| anyhow::anyhow!("{e:#}"))?; Ok(config.channels.gateway) } else { // No explicit config: best-effort, swallow errors. let config = crate::config::Config::from_env_with_toml(None).await.ok(); Ok(config.and_then(|c| c.channels.gateway)) } } /// Print a single log entry to stdout. fn print_log_entry(entry: &serde_json::Value, cmd: &LogsCommand) { if cmd.json { println!("{}", serde_json::to_string(entry).unwrap_or_default()); return; } let level = entry.get("level").and_then(|v| v.as_str()).unwrap_or("?"); let target = entry.get("target").and_then(|v| v.as_str()).unwrap_or(""); let message = entry.get("message").and_then(|v| v.as_str()).unwrap_or(""); let timestamp = entry .get("timestamp") .and_then(|v| v.as_str()) .unwrap_or(""); let display_ts = if cmd.local_time { convert_to_local_time(timestamp) } else { timestamp.to_string() }; if cmd.plain { println!("{} {} [{}] {}", display_ts, level, target, message); } else { let level_colored = colorize_level(level); println!("{} {} [{}] {}", display_ts, level_colored, target, message); } } /// Convert an RFC 3339 timestamp to local time display. fn convert_to_local_time(ts: &str) -> String { chrono::DateTime::parse_from_rfc3339(ts) .map(|dt| { dt.with_timezone(&chrono::Local) .format("%Y-%m-%dT%H:%M:%S%.3f") .to_string() }) .unwrap_or_else(|_| ts.to_string()) } /// Apply ANSI color to log level for terminal display. fn colorize_level(level: &str) -> String { match level { "ERROR" => format!("\x1b[31m{}\x1b[0m", level), // red "WARN" => format!("\x1b[33m{}\x1b[0m", level), // yellow "INFO" => format!("\x1b[32m{}\x1b[0m", level), // green "DEBUG" => format!("\x1b[36m{}\x1b[0m", level), // cyan "TRACE" => format!("\x1b[90m{}\x1b[0m", level), // gray _ => level.to_string(), } } #[cfg(test)] mod tests { use super::*; #[test] fn test_colorize_level() { assert!(colorize_level("ERROR").contains("\x1b[31m")); assert!(colorize_level("WARN").contains("\x1b[33m")); assert!(colorize_level("INFO").contains("\x1b[32m")); assert!(colorize_level("DEBUG").contains("\x1b[36m")); assert!(colorize_level("TRACE").contains("\x1b[90m")); assert_eq!(colorize_level("UNKNOWN"), "UNKNOWN"); } #[test] fn test_convert_to_local_time_valid() { let ts = "2024-01-15T10:30:00.000Z"; let result = convert_to_local_time(ts); assert!(result.contains("2024-01-15")); } #[test] fn test_convert_to_local_time_invalid() { let ts = "not-a-timestamp"; assert_eq!(convert_to_local_time(ts), "not-a-timestamp"); } #[test] fn test_print_log_entry_json() { let entry = serde_json::json!({ "level": "INFO", "target": "ironclaw::agent", "message": "test message", "timestamp": "2024-01-15T10:30:00.000Z" }); let cmd = LogsCommand { follow: false, limit: 200, json: true, local_time: false, plain: false, url: None, token: None, timeout: 5000, level: None, }; // Should not panic print_log_entry(&entry, &cmd); } #[test] fn test_tail_file_small() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test.log"); std::fs::write(&path, "line1\nline2\nline3\nline4\nline5\n").unwrap(); let result = tail_file(&path, 3).unwrap(); assert_eq!(result, vec!["line3", "line4", "line5"]); } #[test] fn test_tail_file_fewer_lines_than_limit() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test.log"); std::fs::write(&path, "a\nb\n").unwrap(); let result = tail_file(&path, 200).unwrap(); assert_eq!(result, vec!["a", "b"]); } #[test] fn test_tail_file_empty() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test.log"); std::fs::write(&path, "").unwrap(); let result = tail_file(&path, 10).unwrap(); assert!(result.is_empty()); } #[test] fn test_tail_file_large() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("big.log"); // Write 10000 lines to test chunked reading. let content: String = (0..10000).map(|i| format!("line {}\n", i)).collect(); std::fs::write(&path, &content).unwrap(); let result = tail_file(&path, 5).unwrap(); assert_eq!(result.len(), 5); assert_eq!(result[0], "line 9995"); assert_eq!(result[4], "line 9999"); } #[test] fn test_tail_file_no_trailing_newline() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test.log"); std::fs::write(&path, "line1\nline2\nline3").unwrap(); let result = tail_file(&path, 2).unwrap(); assert_eq!(result, vec!["line2", "line3"]); } }