1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4
5pub 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
15pub 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
25pub 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
31fn find_all_logs_in(bean_id: &str, dir: &Path) -> Result<Vec<PathBuf>> {
33 if !dir.exists() {
34 return Ok(Vec::new());
35 }
36
37 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 (name.starts_with(&format!("{}-", bean_id))
48 || name.starts_with(&format!("{}-", safe_id)))
49 && name.ends_with(".log")
50 })
51 .collect();
52
53 logs.sort();
55 Ok(logs)
56}
57
58pub fn cmd_logs(beans_dir: &Path, id: &str, follow: bool, all: bool) -> Result<()> {
64 let _ = beans_dir; if all {
67 return show_all_logs(id);
68 }
69
70 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
90fn find_log_path(bean_id: &str) -> Result<Option<PathBuf>> {
92 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 find_latest_log(bean_id)
106}
107
108fn 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
116fn 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
129fn 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 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 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 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 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}