1use std::collections::BTreeMap;
31use std::path::PathBuf;
32use std::time::Duration;
33
34use anyhow::{Context, Result};
35use brain::BrainConfig;
36use chrono::{DateTime, Utc};
37
38pub fn capabilities(_config: &BrainConfig) -> Vec<intent::ToolDescriptor> {
41 use crate::capabilities::{backend, native, read_only, usage};
42 vec![native(
43 "logs",
44 "analyze",
45 backend("logs"),
46 read_only(),
47 usage(
48 "The user asks what's been going wrong, to scan recent logs, or to surface recurring errors/warnings.",
49 "For reading a specific file's contents — use fs.read.",
50 &["The daemon has written logs (or the OS log is readable)."],
51 "free / local log read (offline; optional LLM narration)",
52 "\"Any recurring errors in the logs lately?\"",
53 ),
54 )]
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum LogSource {
62 Brain,
64 System,
66}
67
68pub async fn analyze(
73 config: &BrainConfig,
74 source: LogSource,
75 since: &str,
76 lines: usize,
77) -> Result<String> {
78 let window = parse_since(since)?;
80 let lines = lines.max(1);
81
82 let (raw, source_label, cutoff) = match source {
85 LogSource::Brain => {
86 let (raw, label) = read_brain_log(config, lines)?;
87 (raw, label, Some(Utc::now() - window))
88 }
89 LogSource::System => {
90 let (raw, label) = read_system_log(since, lines).await?;
91 (raw, label, None)
92 }
93 };
94
95 let window_label = match source {
96 LogSource::Brain => format!("last {since}, \u{2264}{lines} lines"),
97 LogSource::System => format!("last {since}"),
98 };
99
100 let digest = analyze_lines(&raw, cutoff, &source_label, &window_label);
101 Ok(digest.render())
102}
103
104const MAX_LOG_FILES: usize = 8;
110
111fn read_brain_log(config: &BrainConfig, lines: usize) -> Result<(Vec<String>, String)> {
116 let dir = config.data_dir().join("logs");
117 let files = resolve_log_files(&dir);
118 if files.is_empty() {
119 anyhow::bail!(
120 "no daemon logs in {} — the daemon may never have run (start it with `brain start`)",
121 dir.display()
122 );
123 }
124
125 let mut all: Vec<String> = Vec::new();
126 for path in &files {
127 let content = std::fs::read_to_string(path)
128 .with_context(|| format!("reading daemon log at {}", path.display()))?;
129 all.extend(content.lines().map(str::to_string));
130 }
131
132 let label = if files.len() == 1 {
133 files[0].display().to_string()
134 } else {
135 format!("{} ({} rotated files)", dir.display(), files.len())
136 };
137 Ok((tail_n(all, lines), label))
138}
139
140fn resolve_log_files(dir: &PathBuf) -> Vec<PathBuf> {
145 let Ok(entries) = std::fs::read_dir(dir) else {
146 return Vec::new();
147 };
148 let mut files: Vec<PathBuf> = entries
149 .flatten()
150 .map(|e| e.path())
151 .filter(|p| p.is_file())
152 .filter(|p| {
153 p.file_name()
154 .and_then(|n| n.to_str())
155 .is_some_and(|n| n == "brain.log" || n.starts_with("brain.log."))
156 })
157 .collect();
158 files.sort();
159 if files.len() > MAX_LOG_FILES {
161 files.drain(0..files.len() - MAX_LOG_FILES);
162 }
163 files
164}
165
166async fn read_system_log(since: &str, lines: usize) -> Result<(Vec<String>, String)> {
174 let (binary, args, label) = system_log_command(since)?;
175 let stdout = run_system_log(&binary, &args).await?;
176 let all: Vec<String> = stdout.lines().map(str::to_string).collect();
177 Ok((tail_n(all, lines), label))
178}
179
180#[cfg(target_os = "macos")]
183async fn run_system_log(binary: &str, args: &[String]) -> Result<String> {
184 let output = tokio::time::timeout(
185 Duration::from_secs(30),
186 tokio::process::Command::new(binary).args(args).output(),
187 )
188 .await
189 .map_err(|_| anyhow::anyhow!("`{binary}` timed out after 30s"))?
190 .with_context(|| format!("running `{binary}`"))?;
191
192 if !output.status.success() {
193 let detail = String::from_utf8_lossy(&output.stderr);
194 let detail = detail.trim();
195 anyhow::bail!(
196 "`{binary}` failed{}",
197 if detail.is_empty() {
198 String::new()
199 } else {
200 format!(": {detail}")
201 }
202 );
203 }
204 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
205}
206
207#[cfg(not(target_os = "macos"))]
208async fn run_system_log(binary: &str, args: &[String]) -> Result<String> {
209 use sandbox::SandboxExecutor;
210
211 let timeout = Duration::from_secs(30);
212 let command =
213 sandbox::SandboxCommand::new(binary.to_string(), args.to_vec()).with_timeout(timeout);
214 let executor = sandbox::IsolatedSandbox::new(vec![binary.to_string()], timeout);
215
216 let outcome = executor
217 .run(command)
218 .await
219 .with_context(|| format!("running `{binary}` via the exec sandbox"))?;
220
221 if outcome.exit_code != 0 {
222 let detail = outcome.stderr.trim();
223 anyhow::bail!(
224 "`{binary}` exited {}{}",
225 outcome.exit_code,
226 if detail.is_empty() {
227 String::new()
228 } else {
229 format!(": {detail}")
230 }
231 );
232 }
233 Ok(outcome.stdout)
234}
235
236#[cfg(target_os = "macos")]
237fn system_log_command(since: &str) -> Result<(String, Vec<String>, String)> {
238 let _ = parse_since(since)?;
240 Ok((
241 "log".to_string(),
242 vec![
243 "show".to_string(),
244 "--style".to_string(),
245 "syslog".to_string(),
246 "--last".to_string(),
247 since.to_string(),
248 ],
249 format!("system log (`log show --last {since}`)"),
250 ))
251}
252
253#[cfg(target_os = "linux")]
254fn system_log_command(since: &str) -> Result<(String, Vec<String>, String)> {
255 let ago = linux_since(since)?;
256 Ok((
257 "journalctl".to_string(),
258 vec!["--no-pager".to_string(), "--since".to_string(), ago.clone()],
259 format!("system journal (`journalctl --since \"{ago}\"`)"),
260 ))
261}
262
263#[cfg(not(any(target_os = "macos", target_os = "linux")))]
264fn system_log_command(_since: &str) -> Result<(String, Vec<String>, String)> {
265 anyhow::bail!("the system log source is only supported on macOS and Linux")
266}
267
268#[cfg(any(target_os = "linux", test))]
271fn linux_since(since: &str) -> Result<String> {
272 let (amount, unit) = parse_since_parts(since)?;
273 let noun = match unit {
274 'm' => "minute",
275 'h' => "hour",
276 _ => "day",
277 };
278 let plural = if amount == 1 { "" } else { "s" };
279 Ok(format!("{amount} {noun}{plural} ago"))
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
287enum Level {
288 Error,
289 Warn,
290 Info,
291 Debug,
292 Trace,
293 Other,
294}
295
296impl Level {
297 fn label(self) -> &'static str {
298 match self {
299 Level::Error => "ERROR",
300 Level::Warn => "WARN",
301 Level::Info => "INFO",
302 Level::Debug => "DEBUG",
303 Level::Trace => "TRACE",
304 Level::Other => "other",
305 }
306 }
307
308 fn from_keyword(word: &str) -> Option<Level> {
310 match word.to_ascii_uppercase().as_str() {
311 "ERROR" | "ERR" | "FAULT" | "CRITICAL" | "FATAL" => Some(Level::Error),
312 "WARN" | "WARNING" => Some(Level::Warn),
313 "INFO" | "NOTICE" => Some(Level::Info),
314 "DEBUG" => Some(Level::Debug),
315 "TRACE" => Some(Level::Trace),
316 _ => None,
317 }
318 }
319}
320
321struct ParsedLine {
324 ts: Option<DateTime<Utc>>,
325 level: Level,
326 signature: String,
327}
328
329fn parse_line(line: &str) -> ParsedLine {
332 let trimmed = line.trim_start();
333 if trimmed.starts_with('{') {
334 if let Some(parsed) = parse_json_line(trimmed) {
335 return parsed;
336 }
337 }
338 parse_text_line(line)
339}
340
341fn parse_json_line(line: &str) -> Option<ParsedLine> {
344 let value: serde_json::Value = serde_json::from_str(line).ok()?;
345 let obj = value.as_object()?;
346
347 let ts = obj
348 .get("timestamp")
349 .and_then(|v| v.as_str())
350 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
351 .map(|t| t.with_timezone(&Utc));
352
353 let level = obj
354 .get("level")
355 .and_then(|v| v.as_str())
356 .and_then(Level::from_keyword)
357 .unwrap_or(Level::Other);
358
359 let message = obj
360 .get("fields")
361 .and_then(|f| f.get("message"))
362 .or_else(|| obj.get("message"))
363 .and_then(|v| v.as_str())
364 .unwrap_or(line);
365
366 let target = obj.get("target").and_then(|v| v.as_str()).unwrap_or("");
368 let basis = if target.is_empty() {
369 message.to_string()
370 } else {
371 format!("{target}: {message}")
372 };
373
374 Some(ParsedLine {
375 ts,
376 level,
377 signature: normalize_signature(&basis),
378 })
379}
380
381fn parse_text_line(line: &str) -> ParsedLine {
388 let mut rest = line.trim_start();
389
390 let mut ts = None;
391 if let Some((first, tail)) = split_first_token(rest) {
392 if let Ok(t) = DateTime::parse_from_rfc3339(first) {
393 ts = Some(t.with_timezone(&Utc));
394 rest = tail;
395 }
396 }
397
398 let level = match split_first_token(rest) {
399 Some((first, tail)) if Level::from_keyword(first).is_some() => {
400 rest = tail;
401 Level::from_keyword(first).unwrap()
402 }
403 _ => classify_text_level(line),
405 };
406
407 ParsedLine {
408 ts,
409 level,
410 signature: normalize_signature(rest),
411 }
412}
413
414fn split_first_token(s: &str) -> Option<(&str, &str)> {
417 let s = s.trim_start();
418 if s.is_empty() {
419 return None;
420 }
421 match s.find(char::is_whitespace) {
422 Some(i) => Some((&s[..i], s[i..].trim_start())),
423 None => Some((s, "")),
424 }
425}
426
427fn classify_text_level(line: &str) -> Level {
430 for token in line.split(|c: char| !c.is_ascii_alphabetic()) {
431 if let Some(level) = Level::from_keyword(token) {
432 return level;
433 }
434 }
435 Level::Other
436}
437
438const MAX_SIGNATURE_LEN: usize = 160;
442
443fn normalize_signature(line: &str) -> String {
446 let mut out = String::with_capacity(line.len());
447 for (i, token) in line.split_whitespace().enumerate() {
448 if i > 0 {
449 out.push(' ');
450 }
451 out.push_str(&normalize_token(token));
452 }
453 if out.chars().count() > MAX_SIGNATURE_LEN {
454 out = out.chars().take(MAX_SIGNATURE_LEN).collect();
455 out.push('\u{2026}');
456 }
457 out
458}
459
460fn normalize_token(token: &str) -> String {
461 if looks_like_uuid(token) {
462 return "<uuid>".to_string();
463 }
464 if let Some(rest) = token.strip_prefix("0x") {
465 if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_hexdigit()) {
466 return "<hex>".to_string();
467 }
468 }
469 if token.starts_with('/') && token.len() > 1 {
470 return "<path>".to_string();
471 }
472 if is_timestamp(token) {
473 return "<ts>".to_string();
474 }
475 collapse_digit_runs(token)
476}
477
478fn looks_like_uuid(token: &str) -> bool {
479 let t = token.trim_end_matches([',', ';', ')', ']', '"', '\'']);
481 let parts: Vec<&str> = t.split('-').collect();
482 if parts.len() != 5 {
483 return false;
484 }
485 let lens = [8, 4, 4, 4, 12];
486 parts
487 .iter()
488 .zip(lens)
489 .all(|(p, n)| p.len() == n && p.chars().all(|c| c.is_ascii_hexdigit()))
490}
491
492fn is_timestamp(token: &str) -> bool {
495 let has_digit = token.chars().any(|c| c.is_ascii_digit());
496 let has_sep = token.contains(':') || (token.contains('-') && token.contains('T'));
497 has_digit && has_sep
498}
499
500fn collapse_digit_runs(token: &str) -> String {
503 let mut out = String::with_capacity(token.len());
504 let mut in_run = false;
505 for c in token.chars() {
506 if c.is_ascii_digit() {
507 if !in_run {
508 out.push_str("<n>");
509 in_run = true;
510 }
511 } else {
512 out.push(c);
513 in_run = false;
514 }
515 }
516 out
517}
518
519fn tail_n(mut v: Vec<String>, n: usize) -> Vec<String> {
521 if v.len() > n {
522 v.drain(0..v.len() - n);
523 }
524 v
525}
526
527struct PatternHit {
531 level: Level,
532 signature: String,
533 count: usize,
534}
535
536struct Digest {
538 source_label: String,
539 window_label: String,
540 total_lines: usize,
541 level_counts: BTreeMap<Level, usize>,
542 patterns: Vec<PatternHit>,
544}
545
546const TOP_PATTERNS: usize = 10;
548
549fn analyze_lines(
553 lines: &[String],
554 cutoff: Option<DateTime<Utc>>,
555 source_label: &str,
556 window_label: &str,
557) -> Digest {
558 let mut level_counts: BTreeMap<Level, usize> = BTreeMap::new();
559 let mut groups: BTreeMap<String, (Level, usize)> = BTreeMap::new();
561
562 let mut total = 0usize;
563 for line in lines {
564 if line.trim().is_empty() {
565 continue;
566 }
567 let parsed = parse_line(line);
568 if let (Some(cut), Some(ts)) = (cutoff, parsed.ts) {
569 if ts < cut {
570 continue;
571 }
572 }
573 total += 1;
574 *level_counts.entry(parsed.level).or_insert(0) += 1;
575 let entry = groups.entry(parsed.signature).or_insert((parsed.level, 0));
576 entry.1 += 1;
577 }
578
579 let mut patterns: Vec<PatternHit> = groups
580 .into_iter()
581 .filter(|(_, (_, count))| *count >= 2)
582 .map(|(signature, (level, count))| PatternHit {
583 level,
584 signature,
585 count,
586 })
587 .collect();
588 patterns.sort_by(|a, b| {
590 b.count
591 .cmp(&a.count)
592 .then(a.level.cmp(&b.level))
593 .then(a.signature.cmp(&b.signature))
594 });
595 patterns.truncate(TOP_PATTERNS);
596
597 Digest {
598 source_label: source_label.to_string(),
599 window_label: window_label.to_string(),
600 total_lines: total,
601 level_counts,
602 patterns,
603 }
604}
605
606impl Digest {
607 fn render(&self) -> String {
608 let mut s = String::new();
609 s.push_str(&format!(
610 "Analyzed {} line{} from {} ({})\n",
611 self.total_lines,
612 if self.total_lines == 1 { "" } else { "s" },
613 self.source_label,
614 self.window_label,
615 ));
616
617 let parts: Vec<String> = [
619 Level::Error,
620 Level::Warn,
621 Level::Info,
622 Level::Debug,
623 Level::Trace,
624 Level::Other,
625 ]
626 .into_iter()
627 .filter_map(|lvl| {
628 self.level_counts
629 .get(&lvl)
630 .map(|c| format!("{}: {c}", lvl.label()))
631 })
632 .collect();
633 if parts.is_empty() {
634 s.push_str(" (no log lines in this window)");
635 return s;
636 }
637 s.push_str(&format!(" {}\n", parts.join(" ")));
638
639 if self.patterns.is_empty() {
640 s.push_str("\nNo recurring patterns — every line was distinct after normalization.");
641 } else {
642 s.push_str("\nTop recurring patterns:\n");
643 for p in &self.patterns {
644 s.push_str(&format!(
645 " \u{00d7}{:<4} [{}] {}\n",
646 p.count,
647 p.level.label(),
648 p.signature
649 ));
650 }
651 s.pop();
653 }
654 s
655 }
656}
657
658pub async fn narrate(config: &BrainConfig, digest: &str) -> Result<String> {
668 let provider = cortex::llm::select_provider(&config.llm)
669 .await
670 .map_err(|e| anyhow::anyhow!("no LLM provider reachable: {e}"))?;
671
672 let system = "You are summarizing a log-analysis digest for an operator. \
673 Describe ONLY what the digest below contains — the level counts and the \
674 recurring patterns with their occurrence counts. Do NOT invent log lines, \
675 error messages, root causes, or fixes that are not present in the digest. \
676 If the digest shows no errors or warnings, say the logs look healthy. \
677 Keep it to 2–4 short sentences, plain and factual.";
678
679 let user = format!("Log-analysis digest:\n\n{digest}");
680
681 let messages = [
682 cortex::llm::Message::system(system),
683 cortex::llm::Message::user(user),
684 ];
685 let response = provider
686 .generate(&messages)
687 .await
688 .map_err(|e| anyhow::anyhow!("provider call failed: {e}"))?;
689
690 let text = response.content.trim().to_string();
691 if text.is_empty() {
692 anyhow::bail!("provider returned an empty summary");
693 }
694 Ok(text)
695}
696
697fn parse_since_parts(since: &str) -> Result<(u64, char)> {
701 let since = since.trim();
702 let unit = since
703 .chars()
704 .last()
705 .filter(|c| matches!(c, 'm' | 'h' | 'd'))
706 .ok_or_else(|| {
707 anyhow::anyhow!("invalid since '{since}': expected <n>{{m|h|d}}, e.g. 30m, 1h, 2d")
708 })?;
709 let amount: u64 = since[..since.len() - 1].parse().map_err(|_| {
710 anyhow::anyhow!(
711 "invalid since '{since}': '{}' is not a number",
712 &since[..since.len() - 1]
713 )
714 })?;
715 if amount == 0 {
716 anyhow::bail!("invalid since '{since}': the amount must be greater than zero");
717 }
718 Ok((amount, unit))
719}
720
721fn parse_since(since: &str) -> Result<chrono::Duration> {
723 let (amount, unit) = parse_since_parts(since)?;
724 let amount = amount as i64;
725 Ok(match unit {
726 'm' => chrono::Duration::minutes(amount),
727 'h' => chrono::Duration::hours(amount),
728 _ => chrono::Duration::days(amount),
729 })
730}
731
732pub struct LogAnalysis {
739 config: BrainConfig,
740}
741
742impl LogAnalysis {
743 pub fn new(config: BrainConfig) -> Self {
744 Self { config }
745 }
746}
747
748#[async_trait::async_trait]
749impl cortex::actions::LogAnalysisBackend for LogAnalysis {
750 async fn analyze(
751 &self,
752 system: bool,
753 since: &str,
754 lines: usize,
755 ) -> Result<String, cortex::actions::ActionError> {
756 let source = if system {
757 LogSource::System
758 } else {
759 LogSource::Brain
760 };
761 analyze(&self.config, source, since, lines)
762 .await
763 .map_err(|e| cortex::actions::ActionError::ExecutionFailed(e.to_string()))
764 }
765}
766
767#[cfg(test)]
768mod tests {
769 use super::*;
770
771 #[test]
772 fn normalize_collapses_volatile_tokens() {
773 assert_eq!(normalize_token("0xdeadbeef"), "<hex>");
774 assert_eq!(normalize_token("/Users/x/.brain/db.sqlite"), "<path>");
775 assert_eq!(
776 normalize_token("550e8400-e29b-41d4-a716-446655440000"),
777 "<uuid>"
778 );
779 assert_eq!(normalize_token("2026-06-09T10:11:12.345Z"), "<ts>");
780 assert_eq!(normalize_token("10:11:12"), "<ts>");
781 assert_eq!(normalize_token("tool-42"), "tool-<n>");
782 assert_eq!(normalize_token("count=1500"), "count=<n>");
783 assert_eq!(normalize_token("provider"), "provider");
784 }
785
786 #[test]
787 fn uuid_detection_tolerates_trailing_punctuation() {
788 assert!(looks_like_uuid("550e8400-e29b-41d4-a716-446655440000"));
789 assert!(looks_like_uuid("550e8400-e29b-41d4-a716-446655440000,"));
790 assert!(!looks_like_uuid("not-a-uuid"));
791 assert!(!looks_like_uuid("550e8400-e29b-41d4-a716")); }
793
794 #[test]
795 fn parses_real_pretty_line() {
796 let line = "2026-06-09T08:39:16.314126Z INFO brainos_storage::sqlite: \
798 SQLite database opened at /Users/x/.brain/db/brain.db (pool size 8)";
799 let p = parse_line(line);
800 assert_eq!(p.level, Level::Info);
801 assert!(p.ts.is_some(), "leading RFC3339 token should parse");
802 assert!(p.signature.starts_with("brainos_storage::sqlite:"));
804 assert!(!p.signature.contains("<ts>"));
805 assert!(!p.signature.contains("INFO"));
806 assert!(p.signature.contains("<path>"));
807 assert!(p.signature.contains("pool size <n>"));
808 }
809
810 #[test]
811 fn parses_json_line() {
812 let line = r#"{"timestamp":"2026-06-09T08:39:16.314126Z","level":"WARN","fields":{"message":"breaker open for tool abc-1"},"target":"brainos_resilience"}"#;
813 let p = parse_line(line);
814 assert_eq!(p.level, Level::Warn);
815 assert!(p.ts.is_some());
816 assert!(p.signature.contains("brainos_resilience"));
818 assert!(p.signature.contains("breaker open for tool"));
819 assert!(p.signature.contains("<n>"), "the id should be normalized");
820 assert!(!p.signature.contains("timestamp"));
821 }
822
823 #[test]
824 fn json_lines_with_volatile_ids_group_together() {
825 let lines: Vec<String> = (1..=3)
826 .map(|i| {
827 format!(
828 r#"{{"timestamp":"2026-06-09T10:00:0{i}Z","level":"ERROR","fields":{{"message":"breaker open for tool t-{i}"}},"target":"res"}}"#
829 )
830 })
831 .collect();
832 let digest = analyze_lines(&lines, None, "test", "all");
833 assert_eq!(digest.level_counts.get(&Level::Error), Some(&3));
834 assert_eq!(digest.patterns.len(), 1);
835 assert_eq!(digest.patterns[0].count, 3);
836 }
837
838 #[test]
839 fn analyze_groups_structurally_identical_lines() {
840 let lines: Vec<String> = vec![
841 "2026-06-09T10:00:00Z ERROR circuit breaker open for tool abc-1".into(),
842 "2026-06-09T10:00:01Z ERROR circuit breaker open for tool abc-2".into(),
843 "2026-06-09T10:00:02Z ERROR circuit breaker open for tool abc-3".into(),
844 "2026-06-09T10:00:03Z INFO signal received from user".into(),
845 "2026-06-09T10:00:04Z INFO consolidation complete".into(),
846 ];
847 let digest = analyze_lines(&lines, None, "test", "all");
848
849 assert_eq!(digest.total_lines, 5);
850 assert_eq!(digest.level_counts.get(&Level::Error), Some(&3));
851 assert_eq!(digest.level_counts.get(&Level::Info), Some(&2));
852
853 assert_eq!(digest.patterns.len(), 1);
854 let top = &digest.patterns[0];
855 assert_eq!(top.count, 3);
856 assert_eq!(top.level, Level::Error);
857 assert!(top.signature.contains("circuit breaker open for tool"));
858 assert!(
859 top.signature.contains("<n>"),
860 "tool id should be normalized"
861 );
862 }
863
864 #[test]
865 fn analyze_applies_the_since_cutoff() {
866 let cutoff = Utc::now() - chrono::Duration::hours(1);
867 let fresh = format!("{} ERROR fresh boom", Utc::now().to_rfc3339());
868 let lines: Vec<String> = vec![
869 "2000-01-01T00:00:00Z ERROR ancient boom".into(),
870 fresh,
871 "no timestamp but kept".into(),
872 ];
873 let digest = analyze_lines(&lines, Some(cutoff), "test", "1h");
874 assert_eq!(digest.total_lines, 2);
876 }
877
878 #[test]
879 fn analyze_reports_no_recurrence_when_all_distinct() {
880 let lines: Vec<String> = vec![
881 "alpha event one".into(),
882 "beta event two".into(),
883 "gamma event three".into(),
884 ];
885 let digest = analyze_lines(&lines, None, "test", "all");
886 assert!(digest.patterns.is_empty());
887 assert!(digest.render().contains("No recurring patterns"));
888 }
889
890 #[test]
891 fn resolve_log_files_picks_rotated_files_newest_last() {
892 let dir = tempfile::tempdir().unwrap();
893 let p = dir.path();
894 for name in [
895 "brain.log.2026-06-07",
896 "brain.log.2026-06-09",
897 "brain.log.2026-06-08",
898 "brain.stderr.log", "unrelated.txt", ] {
901 std::fs::write(p.join(name), "x").unwrap();
902 }
903 let files = resolve_log_files(&p.to_path_buf());
904 let names: Vec<String> = files
905 .iter()
906 .map(|f| f.file_name().unwrap().to_str().unwrap().to_string())
907 .collect();
908 assert_eq!(
909 names,
910 vec![
911 "brain.log.2026-06-07",
912 "brain.log.2026-06-08",
913 "brain.log.2026-06-09",
914 ],
915 "rotated files only, sorted chronologically (newest last)"
916 );
917 }
918
919 #[test]
920 fn resolve_log_files_handles_unrotated_single_file() {
921 let dir = tempfile::tempdir().unwrap();
922 std::fs::write(dir.path().join("brain.log"), "x").unwrap();
923 let files = resolve_log_files(&dir.path().to_path_buf());
924 assert_eq!(files.len(), 1);
925 assert!(files[0].ends_with("brain.log"));
926 }
927
928 #[test]
929 fn tail_n_keeps_the_most_recent() {
930 let v: Vec<String> = (0..5).map(|i| i.to_string()).collect();
931 assert_eq!(tail_n(v.clone(), 2), vec!["3".to_string(), "4".to_string()]);
932 assert_eq!(tail_n(v.clone(), 10), v);
933 }
934
935 #[test]
936 fn parse_since_accepts_units_and_rejects_garbage() {
937 assert_eq!(parse_since("30m").unwrap(), chrono::Duration::minutes(30));
938 assert_eq!(parse_since("2h").unwrap(), chrono::Duration::hours(2));
939 assert_eq!(parse_since("1d").unwrap(), chrono::Duration::days(1));
940 assert!(parse_since("5").is_err()); assert!(parse_since("h").is_err()); assert!(parse_since("0h").is_err()); assert!(parse_since("3w").is_err()); }
945
946 #[test]
947 fn linux_since_phrases_are_systemd_shaped() {
948 assert_eq!(linux_since("1h").unwrap(), "1 hour ago");
949 assert_eq!(linux_since("30m").unwrap(), "30 minutes ago");
950 assert_eq!(linux_since("2d").unwrap(), "2 days ago");
951 }
952}