Skip to main content

bn/commands/
logs.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4
5/// Return the log directory path, creating it if needed.
6pub fn log_dir() -> Result<PathBuf> {
7    let dir = dirs::data_local_dir()
8        .unwrap_or_else(|| PathBuf::from("/tmp"))
9        .join("beans")
10        .join("logs");
11    std::fs::create_dir_all(&dir).context("Failed to create beans log directory")?;
12    Ok(dir)
13}
14
15/// Find the most recent log file for a bean.
16///
17/// Log files follow the pattern `{bean_id}-{timestamp}.log` in the log directory.
18/// The bean_id in filenames has dots replaced with underscores for filesystem safety.
19pub fn find_latest_log(bean_id: &str) -> Result<Option<PathBuf>> {
20    let dir = log_dir()?;
21    let logs = find_all_logs_in(bean_id, &dir)?;
22    Ok(logs.into_iter().last())
23}
24
25/// Find all log files for a bean, sorted oldest to newest.
26pub fn find_all_logs(bean_id: &str) -> Result<Vec<PathBuf>> {
27    let dir = log_dir()?;
28    find_all_logs_in(bean_id, &dir)
29}
30
31/// Find all logs for a bean in a specific directory.
32fn find_all_logs_in(bean_id: &str, dir: &Path) -> Result<Vec<PathBuf>> {
33    if !dir.exists() {
34        return Ok(Vec::new());
35    }
36
37    // Bean IDs may contain dots (e.g. "5.1"), which get encoded as underscores
38    // in filenames. Match both the raw id and underscore-encoded form.
39    let safe_id = bean_id.replace('.', "_");
40
41    let mut logs: Vec<PathBuf> = std::fs::read_dir(dir)?
42        .filter_map(|entry| entry.ok())
43        .map(|entry| entry.path())
44        .filter(|path| {
45            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
46            // Match patterns: {bean_id}-*.log or {safe_id}-*.log
47            (name.starts_with(&format!("{}-", bean_id))
48                || name.starts_with(&format!("{}-", safe_id)))
49                && name.ends_with(".log")
50        })
51        .collect();
52
53    // Sort by filename (timestamp is embedded, so lexicographic = chronological)
54    logs.sort();
55    Ok(logs)
56}
57
58/// View agent output from log files.
59///
60/// Default: print the latest log file.
61/// `--follow`: exec into `tail -f` for live following.
62/// `--all`: print all logs with headers.
63pub fn cmd_logs(beans_dir: &Path, id: &str, follow: bool, all: bool) -> Result<()> {
64    let _ = beans_dir; // Used for validation context only
65
66    if all {
67        return show_all_logs(id);
68    }
69
70    // Also check agents.json for log_path hint
71    let log_path = find_log_path(id)?;
72
73    match log_path {
74        Some(path) => {
75            if follow {
76                follow_log(&path)
77            } else {
78                print_log(&path)
79            }
80        }
81        None => {
82            anyhow::bail!(
83                "No logs for bean {}. Has it been dispatched with bn run?",
84                id
85            );
86        }
87    }
88}
89
90/// Try to find a log path — first from agents.json, then from filesystem search.
91fn find_log_path(bean_id: &str) -> Result<Option<PathBuf>> {
92    // Check agents.json for a log_path hint
93    if let Ok(agents) = super::agents::load_agents() {
94        if let Some(entry) = agents.get(bean_id) {
95            if let Some(ref log_path) = entry.log_path {
96                let path = PathBuf::from(log_path);
97                if path.exists() {
98                    return Ok(Some(path));
99                }
100            }
101        }
102    }
103
104    // Fall back to filesystem search
105    find_latest_log(bean_id)
106}
107
108/// Print a log file to stdout.
109fn print_log(path: &Path) -> Result<()> {
110    let contents = std::fs::read_to_string(path)
111        .with_context(|| format!("Failed to read {}", path.display()))?;
112    print!("{}", contents);
113    Ok(())
114}
115
116/// Follow a log file with tail -f. Replaces the current process.
117fn follow_log(path: &Path) -> Result<()> {
118    let status = std::process::Command::new("tail")
119        .args(["-f", &path.display().to_string()])
120        .status()
121        .context("Failed to exec tail -f")?;
122
123    if !status.success() {
124        anyhow::bail!("tail exited with code {}", status.code().unwrap_or(-1));
125    }
126    Ok(())
127}
128
129/// Show all log files for a bean with headers.
130fn show_all_logs(bean_id: &str) -> Result<()> {
131    let logs = find_all_logs(bean_id)?;
132
133    if logs.is_empty() {
134        anyhow::bail!(
135            "No logs for bean {}. Has it been dispatched with bn run?",
136            bean_id
137        );
138    }
139
140    for (i, path) in logs.iter().enumerate() {
141        let filename = path
142            .file_name()
143            .and_then(|n| n.to_str())
144            .unwrap_or("unknown");
145
146        if i > 0 {
147            println!();
148        }
149        println!("═══ {} ═══", filename);
150        println!();
151
152        match std::fs::read_to_string(path) {
153            Ok(contents) => print!("{}", contents),
154            Err(e) => eprintln!("  (error reading {}: {})", path.display(), e),
155        }
156    }
157
158    println!();
159    println!("{} log file(s) for bean {}", logs.len(), bean_id);
160
161    Ok(())
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn log_dir_creates_directory() {
170        let dir = log_dir().unwrap();
171        assert!(dir.exists());
172    }
173
174    #[test]
175    fn find_latest_log_returns_none_for_unknown() {
176        // For a bean ID that's very unlikely to have logs
177        let result = find_latest_log("nonexistent_bean_99999").unwrap();
178        assert!(result.is_none());
179    }
180
181    #[test]
182    fn find_all_logs_in_empty_dir() {
183        let dir = tempfile::tempdir().unwrap();
184        let logs = find_all_logs_in("5.1", dir.path()).unwrap();
185        assert!(logs.is_empty());
186    }
187
188    #[test]
189    fn find_all_logs_in_matches_bean_id() {
190        let dir = tempfile::tempdir().unwrap();
191
192        // Create some log files
193        std::fs::write(dir.path().join("5_1-20260223-100000.log"), "log 1").unwrap();
194        std::fs::write(dir.path().join("5_1-20260223-110000.log"), "log 2").unwrap();
195        std::fs::write(dir.path().join("5_2-20260223-100000.log"), "other bean").unwrap();
196        std::fs::write(dir.path().join("unrelated.txt"), "not a log").unwrap();
197
198        let logs = find_all_logs_in("5.1", dir.path()).unwrap();
199        assert_eq!(logs.len(), 2);
200
201        // Should be sorted chronologically
202        assert!(logs[0]
203            .file_name()
204            .unwrap()
205            .to_str()
206            .unwrap()
207            .contains("100000"));
208        assert!(logs[1]
209            .file_name()
210            .unwrap()
211            .to_str()
212            .unwrap()
213            .contains("110000"));
214    }
215
216    #[test]
217    fn find_all_logs_in_matches_raw_id() {
218        let dir = tempfile::tempdir().unwrap();
219
220        // Some systems might use the raw ID with dots
221        std::fs::write(dir.path().join("5.1-20260223-100000.log"), "log 1").unwrap();
222
223        let logs = find_all_logs_in("5.1", dir.path()).unwrap();
224        assert_eq!(logs.len(), 1);
225    }
226
227    #[test]
228    fn find_latest_log_returns_most_recent() {
229        let dir = tempfile::tempdir().unwrap();
230        std::fs::write(dir.path().join("8-20260223-080000.log"), "early").unwrap();
231        std::fs::write(dir.path().join("8-20260223-120000.log"), "later").unwrap();
232
233        let logs = find_all_logs_in("8", dir.path()).unwrap();
234        assert_eq!(logs.len(), 2);
235        let latest = logs.last().unwrap();
236        assert!(latest
237            .file_name()
238            .unwrap()
239            .to_str()
240            .unwrap()
241            .contains("120000"));
242    }
243
244    #[test]
245    fn find_all_logs_nonexistent_dir() {
246        let path = Path::new("/tmp/definitely_not_a_real_beans_dir_xyz");
247        let logs = find_all_logs_in("1", path).unwrap();
248        assert!(logs.is_empty());
249    }
250}