//! Handler for the `logs ` command — shows agent log tail for a story. //! //! Reads from the CRDT pipeline state (to resolve story numbers) and from the //! agent log directory on disk. No LLM invocation — handled entirely at the //! bot level. use super::CommandContext; use std::path::{Path, PathBuf}; /// Handle `{bot_name} logs `. /// /// Finds the story by its numeric prefix in the CRDT, then tails the most /// recently modified agent log file for that story. pub(super) fn handle_logs(ctx: &CommandContext) -> Option { let num_str = ctx.args.trim(); if num_str.is_empty() { return Some(format!( "Usage: `{} logs `\n\nShows the last agent log lines for a story.", ctx.bot_name )); } if !num_str.chars().all(|c| c.is_ascii_digit()) { return Some(format!( "Invalid story number: `{num_str}`. Usage: `{} logs `", ctx.bot_name )); } let story_id = match find_story_id_by_number(num_str) { Some(id) => id, None => return Some(format!("Story **{num_str}** not found in the pipeline.")), }; let log_dir = ctx .project_root .join(".huskies") .join("logs") .join(&story_id); match latest_log_file(&log_dir) { Some(log_path) => { let tail = read_log_tail(&log_path, 30); let filename = log_path .file_name() .and_then(|n| n.to_str()) .unwrap_or("agent.log"); if tail.is_empty() { Some(format!("**Agent log** (`{filename}`): *(empty)*")) } else { Some(format!( "**Agent log tail** (`{filename}`):\n```\n{tail}\n```" )) } } None => Some(format!("**Story {num_str}:** *(no agent log found)*")), } } /// Find a story's full ID by its numeric prefix using the CRDT pipeline state. fn find_story_id_by_number(num_str: &str) -> Option { let items = crate::pipeline_state::read_all_typed(); items.into_iter().find_map(|item| { let file_num = item .story_id .0 .split('_') .next() .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) .unwrap_or(""); if file_num == num_str { Some(item.story_id.0) } else { None } }) } /// Find the most recently modified `.log` file in the given directory. fn latest_log_file(log_dir: &Path) -> Option { if !log_dir.is_dir() { return None; } let mut best: Option<(PathBuf, std::time::SystemTime)> = None; for entry in std::fs::read_dir(log_dir).ok()?.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("log") { continue; } let modified = match entry.metadata().and_then(|m| m.modified()) { Ok(t) => t, Err(_) => continue, }; if best.as_ref().is_none_or(|(_, t)| modified > *t) { best = Some((path, modified)); } } best.map(|(p, _)| p) } /// Read the last `n` non-empty lines from a file as a single string. fn read_log_tail(path: &Path, n: usize) -> String { let contents = match std::fs::read_to_string(path) { Ok(c) => c, Err(_) => return String::new(), }; let lines: Vec<&str> = contents.lines().filter(|l| !l.trim().is_empty()).collect(); let start = lines.len().saturating_sub(n); lines[start..].join("\n") } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::agents::AgentPool; use std::collections::HashSet; use std::sync::{Arc, Mutex}; use super::super::{CommandDispatch, try_handle_command}; fn logs_cmd(root: &Path, args: &str) -> Option { let agents = Arc::new(AgentPool::new_test(3000)); let ambient_rooms = Arc::new(Mutex::new(HashSet::new())); let room_id = "!test:example.com".to_string(); let dispatch = CommandDispatch { bot_name: "Timmy", bot_user_id: "@timmy:homeserver.local", project_root: root, agents: &agents, ambient_rooms: &ambient_rooms, room_id: &room_id, }; try_handle_command(&dispatch, &format!("@timmy logs {args}")) } // -- registration ------------------------------------------------------- #[test] fn logs_command_is_registered() { let found = super::super::commands().iter().any(|c| c.name == "logs"); assert!(found, "logs command must be in the registry"); } #[test] fn logs_command_appears_in_help() { let result = super::super::tests::try_cmd_addressed( "Timmy", "@timmy:homeserver.local", "@timmy help", ); let output = result.unwrap(); assert!( output.contains("logs"), "help should list logs command: {output}" ); } // -- input validation --------------------------------------------------- #[test] fn logs_no_args_returns_usage() { let tmp = tempfile::TempDir::new().unwrap(); let output = logs_cmd(tmp.path(), "").unwrap(); assert!( output.contains("Usage"), "no args should show usage: {output}" ); } #[test] fn logs_non_numeric_returns_error() { let tmp = tempfile::TempDir::new().unwrap(); let output = logs_cmd(tmp.path(), "abc").unwrap(); assert!( output.contains("Invalid"), "non-numeric arg should return error: {output}" ); } // -- not found ---------------------------------------------------------- #[test] fn logs_story_not_in_pipeline_returns_friendly_message() { crate::db::ensure_content_store(); let tmp = tempfile::TempDir::new().unwrap(); let output = logs_cmd(tmp.path(), "99998").unwrap(); assert!( output.contains("99998"), "message should include story number: {output}" ); assert!( output.contains("not found") || output.contains("Not found"), "message should say not found: {output}" ); } // -- no log file -------------------------------------------------------- #[test] fn logs_no_log_shows_no_log_message() { use crate::chat::test_helpers::write_story_file; let tmp = tempfile::TempDir::new().unwrap(); write_story_file( tmp.path(), "2_current", "77_story_no_log.md", "---\nname: No Log\n---\n", ); let output = logs_cmd(tmp.path(), "77").unwrap(); assert!( output.contains("no agent log") || output.contains("No agent log"), "should indicate no log exists: {output}" ); } // -- log file present --------------------------------------------------- #[test] fn logs_shows_log_tail_when_log_exists() { use crate::chat::test_helpers::write_story_file; let tmp = tempfile::TempDir::new().unwrap(); write_story_file( tmp.path(), "2_current", "88_story_has_log.md", "---\nname: Has Log\n---\n", ); // Write a log file in the expected location. let log_dir = tmp .path() .join(".huskies") .join("logs") .join("88_story_has_log"); std::fs::create_dir_all(&log_dir).unwrap(); let log_path = log_dir.join("coder-1-session.log"); std::fs::write(&log_path, "line one\nline two\nline three\n").unwrap(); let output = logs_cmd(tmp.path(), "88").unwrap(); assert!( output.contains("line one") || output.contains("line three"), "should show log content: {output}" ); } // -- read_log_tail ------------------------------------------------------- #[test] fn read_log_tail_returns_last_n_lines() { let tmp = tempfile::TempDir::new().unwrap(); let path = tmp.path().join("test.log"); let content = (1..=30) .map(|i| format!("line {i}")) .collect::>() .join("\n"); std::fs::write(&path, &content).unwrap(); let tail = read_log_tail(&path, 5); let lines: Vec<&str> = tail.lines().collect(); assert_eq!(lines.len(), 5); assert_eq!(lines[0], "line 26"); assert_eq!(lines[4], "line 30"); } #[test] fn read_log_tail_fewer_lines_than_n() { let tmp = tempfile::TempDir::new().unwrap(); let path = tmp.path().join("short.log"); std::fs::write(&path, "line A\nline B\n").unwrap(); let tail = read_log_tail(&path, 20); assert!(tail.contains("line A")); assert!(tail.contains("line B")); } // -- latest_log_file ---------------------------------------------------- #[test] fn latest_log_file_returns_none_for_missing_dir() { let tmp = tempfile::TempDir::new().unwrap(); let result = latest_log_file(&tmp.path().join("nonexistent")); assert!(result.is_none()); } #[test] fn latest_log_file_finds_log() { let tmp = tempfile::TempDir::new().unwrap(); let log_path = tmp.path().join("coder-1-sess-abc.log"); std::fs::write(&log_path, "some log content\n").unwrap(); let result = latest_log_file(tmp.path()); assert!(result.is_some()); assert_eq!(result.unwrap(), log_path); } }