1use std::fs;
14use std::io::{BufRead, BufReader, Write as _};
15use std::path::{Path, PathBuf};
16use std::time::Duration;
17
18use anyhow::{Context, Result, anyhow};
19use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
20use clap::{Args, Subcommand};
21
22use crate::cli::CliOutput;
23use crate::config::{AppConfig, LoggingConfig};
24use crate::log_paths;
25use crate::logging::resolve_log_dir_with_override;
26
27#[derive(Args)]
28pub struct LogsArgs {
29 #[command(subcommand)]
30 pub action: LogsAction,
31 #[arg(long, global = true, value_name = "TS")]
34 pub since: Option<String>,
35 #[arg(long, global = true, value_name = "TS")]
37 pub until: Option<String>,
38 #[arg(long, global = true)]
40 pub level: Option<String>,
41 #[arg(long, global = true)]
44 pub namespace: Option<String>,
45 #[arg(long, global = true)]
47 pub actor: Option<String>,
48 #[arg(long, global = true)]
50 pub action_filter: Option<String>,
51 #[arg(long, global = true, default_value = "text")]
54 pub format: String,
55 #[arg(long, global = true, value_name = "PATH")]
60 pub log_dir: Option<PathBuf>,
61}
62
63#[derive(Subcommand)]
64pub enum LogsAction {
65 Tail(TailArgs),
67 Cat,
70 Archive,
73 Purge(PurgeArgs),
77}
78
79#[derive(Args)]
80pub struct TailArgs {
81 #[arg(long, default_value_t = 50)]
83 pub lines: usize,
84 #[arg(long, default_value_t = false)]
86 pub follow: bool,
87 #[arg(long, default_value_t = 1000)]
89 pub follow_interval_ms: u64,
90 #[arg(long, default_value_t = 0, hide = true)]
94 pub max_polls: u64,
95}
96
97#[derive(Args)]
98pub struct PurgeArgs {
99 #[arg(long, value_name = "DATE")]
101 pub before: String,
102 #[arg(long, default_value_t = false)]
105 pub no_warn: bool,
106 #[arg(long, default_value_t = false)]
109 pub dry_run: bool,
110}
111
112pub fn run(args: LogsArgs, app_config: &AppConfig, out: &mut CliOutput<'_>) -> Result<()> {
114 let logging_cfg = app_config.effective_logging();
115 let resolved = resolve_log_dir_with_override(args.log_dir.as_deref(), &logging_cfg)
116 .with_context(|| "resolving operational log directory")?;
117 let dir = resolved.path.clone();
118 let _source: log_paths::PathSource = resolved.source;
119 let filters = args_filters(&args);
120 match args.action {
121 LogsAction::Tail(t) => run_tail(&dir, &filters, &t, out),
122 LogsAction::Cat => run_cat(&dir, &filters, out),
123 LogsAction::Archive => run_archive(&dir, &logging_cfg, out),
124 LogsAction::Purge(p) => run_purge(&dir, &p, app_config, out),
125 }
126}
127
128#[derive(Default, Clone)]
129struct Filters {
130 since: Option<DateTime<Utc>>,
131 until: Option<DateTime<Utc>>,
132 level: Option<String>,
133 namespace: Option<String>,
134 actor: Option<String>,
135 action: Option<String>,
136 format_json: bool,
137}
138
139fn args_filters(a: &LogsArgs) -> Filters {
140 Filters {
141 since: a.since.as_deref().and_then(parse_ts),
142 until: a.until.as_deref().and_then(parse_ts),
143 level: a.level.clone(),
144 namespace: a.namespace.clone(),
145 actor: a.actor.clone(),
146 action: a.action_filter.clone(),
147 format_json: a.format == "json",
148 }
149}
150
151fn parse_ts(s: &str) -> Option<DateTime<Utc>> {
152 if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
153 return Some(dt.with_timezone(&Utc));
154 }
155 if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
156 let dt = NaiveDateTime::new(d, NaiveTime::from_hms_opt(0, 0, 0).unwrap());
157 return Some(Utc.from_utc_datetime(&dt));
158 }
159 None
160}
161
162fn line_matches(line: &str, filters: &Filters) -> bool {
163 if let Some(level) = &filters.level
164 && !line
165 .to_ascii_uppercase()
166 .contains(&level.to_ascii_uppercase())
167 {
168 return false;
169 }
170 if let Some(ns) = &filters.namespace
171 && !line.to_ascii_lowercase().contains(&ns.to_ascii_lowercase())
172 {
173 return false;
174 }
175 if let Some(actor) = &filters.actor
176 && !line
177 .to_ascii_lowercase()
178 .contains(&actor.to_ascii_lowercase())
179 {
180 return false;
181 }
182 if let Some(action) = &filters.action
183 && !line
184 .to_ascii_lowercase()
185 .contains(&action.to_ascii_lowercase())
186 {
187 return false;
188 }
189 if filters.since.is_some() || filters.until.is_some() {
190 let ts = extract_timestamp(line);
193 if let Some(ts) = ts {
194 if let Some(since) = filters.since
195 && ts < since
196 {
197 return false;
198 }
199 if let Some(until) = filters.until
200 && ts > until
201 {
202 return false;
203 }
204 }
205 }
206 true
207}
208
209fn extract_timestamp(line: &str) -> Option<DateTime<Utc>> {
210 if let Some(stop) = line.find(' ') {
212 let head = &line[..stop];
213 if let Ok(dt) = DateTime::parse_from_rfc3339(head) {
214 return Some(dt.with_timezone(&Utc));
215 }
216 }
217 if let Some(idx) = line.find("\"timestamp\":\"") {
219 let rest = &line[idx + 13..];
220 if let Some(end) = rest.find('"') {
221 if let Ok(dt) = DateTime::parse_from_rfc3339(&rest[..end]) {
222 return Some(dt.with_timezone(&Utc));
223 }
224 }
225 }
226 None
227}
228
229fn enumerate_log_files(dir: &Path) -> Result<Vec<PathBuf>> {
233 if !dir.exists() {
234 return Ok(Vec::new());
235 }
236 let mut files: Vec<PathBuf> = Vec::new();
237 for entry in fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))? {
238 let entry = entry?;
239 let p = entry.path();
240 if p.is_file()
241 && p.file_name()
242 .and_then(|n| n.to_str())
243 .is_some_and(|n| n.contains("ai-memory") && !n.ends_with(".zst"))
244 {
245 files.push(p);
246 }
247 }
248 files.sort();
249 Ok(files)
250}
251
252fn run_cat(dir: &Path, filters: &Filters, out: &mut CliOutput<'_>) -> Result<()> {
253 for f in enumerate_log_files(dir)? {
254 emit_file(&f, filters, out)?;
255 }
256 Ok(())
257}
258
259fn emit_file(path: &Path, filters: &Filters, out: &mut CliOutput<'_>) -> Result<()> {
260 let f = fs::File::open(path).with_context(|| format!("opening {}", path.display()))?;
261 for line in BufReader::new(f).lines() {
262 let line = line?;
263 if !line_matches(&line, filters) {
264 continue;
265 }
266 emit_line(&line, filters, out)?;
267 }
268 Ok(())
269}
270
271fn emit_line(line: &str, filters: &Filters, out: &mut CliOutput<'_>) -> Result<()> {
272 if filters.format_json {
273 if line.trim_start().starts_with('{') {
276 writeln!(out.stdout, "{line}")?;
277 } else {
278 let v = serde_json::json!({ "line": line });
279 writeln!(out.stdout, "{}", serde_json::to_string(&v)?)?;
280 }
281 } else {
282 writeln!(out.stdout, "{line}")?;
283 }
284 Ok(())
285}
286
287fn run_tail(dir: &Path, filters: &Filters, args: &TailArgs, out: &mut CliOutput<'_>) -> Result<()> {
288 let files = enumerate_log_files(dir)?;
289 let Some(latest) = files.last().cloned() else {
290 return Ok(());
291 };
292 let initial = read_tail_n(&latest, args.lines, filters)?;
294 for line in &initial {
295 emit_line(line, filters, out)?;
296 }
297 if !args.follow {
298 return Ok(());
299 }
300 let mut last_size = fs::metadata(&latest).map(|m| m.len()).unwrap_or(0);
301 let mut polls: u64 = 0;
302 loop {
303 std::thread::sleep(Duration::from_millis(args.follow_interval_ms));
304 polls += 1;
305 let cur_size = fs::metadata(&latest).map(|m| m.len()).unwrap_or(last_size);
306 if cur_size > last_size {
307 let new_lines = read_lines_after_offset(&latest, last_size)?;
308 for line in new_lines {
309 if line_matches(&line, filters) {
310 emit_line(&line, filters, out)?;
311 }
312 }
313 last_size = cur_size;
314 }
315 if args.max_polls > 0 && polls >= args.max_polls {
316 return Ok(());
317 }
318 }
319}
320
321fn read_tail_n(path: &Path, n: usize, filters: &Filters) -> Result<Vec<String>> {
322 let f = fs::File::open(path).with_context(|| format!("opening {}", path.display()))?;
323 let buf = BufReader::new(f);
324 let mut keep: Vec<String> = Vec::with_capacity(n);
325 for line in buf.lines() {
326 let line = line?;
327 if !line_matches(&line, filters) {
328 continue;
329 }
330 keep.push(line);
331 if keep.len() > n {
332 keep.remove(0);
333 }
334 }
335 Ok(keep)
336}
337
338fn read_lines_after_offset(path: &Path, offset: u64) -> Result<Vec<String>> {
339 use std::io::Seek as _;
340 use std::io::SeekFrom;
341 let mut f = fs::File::open(path).with_context(|| format!("opening {}", path.display()))?;
342 f.seek(SeekFrom::Start(offset))?;
343 let buf = BufReader::new(f);
344 let mut out = Vec::new();
345 for line in buf.lines() {
346 out.push(line?);
347 }
348 Ok(out)
349}
350
351fn run_archive(dir: &Path, cfg: &LoggingConfig, out: &mut CliOutput<'_>) -> Result<()> {
352 let retention_days = i64::from(cfg.retention_days.unwrap_or(90));
353 let cutoff = Utc::now() - chrono::Duration::days(retention_days);
354 let mut compressed: u64 = 0;
355 let mut total_in: u64 = 0;
356 let mut total_out: u64 = 0;
357
358 for f in enumerate_log_files(dir)? {
359 let mtime = fs::metadata(&f)
360 .and_then(|m| m.modified())
361 .ok()
362 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
363 .map(|d| Utc.timestamp_opt(d.as_secs() as i64, 0).unwrap());
364 let Some(mtime) = mtime else {
365 continue;
366 };
367 if mtime >= cutoff {
368 continue;
369 }
370 let in_bytes = fs::read(&f).with_context(|| format!("reading {}", f.display()))?;
371 let in_size = in_bytes.len() as u64;
372 let out_path = f.with_extension(format!(
373 "{}.zst",
374 f.extension().and_then(|e| e.to_str()).unwrap_or("log")
375 ));
376 let compressed_bytes = zstd_compress(&in_bytes)?;
377 let out_size = compressed_bytes.len() as u64;
378 fs::write(&out_path, &compressed_bytes)
379 .with_context(|| format!("writing {}", out_path.display()))?;
380 fs::remove_file(&f).with_context(|| format!("removing {}", f.display()))?;
381 compressed += 1;
382 total_in += in_size;
383 total_out += out_size;
384 }
385 writeln!(
386 out.stdout,
387 "archived {compressed} log file(s): {total_in} bytes -> {total_out} bytes"
388 )?;
389 Ok(())
390}
391
392fn zstd_compress(input: &[u8]) -> Result<Vec<u8>> {
393 let mut out = Vec::with_capacity(input.len() / 4 + 64);
394 {
395 let mut encoder = zstd::stream::write::Encoder::new(&mut out, 3)?;
396 encoder.write_all(input)?;
397 encoder.finish()?;
398 }
399 Ok(out)
400}
401
402fn run_purge(
403 dir: &Path,
404 args: &PurgeArgs,
405 app_config: &AppConfig,
406 out: &mut CliOutput<'_>,
407) -> Result<()> {
408 let cutoff = parse_ts(&args.before)
409 .ok_or_else(|| anyhow!("invalid --before date: {} (expected RFC3339)", args.before))?;
410 if !args.no_warn {
411 warn_about_audit_gap(args, app_config, out)?;
412 }
413 if !dir.exists() {
414 return Ok(());
415 }
416 let mut deleted: u64 = 0;
417 for entry in fs::read_dir(dir)? {
418 let entry = entry?;
419 let p = entry.path();
420 if !p.is_file() {
421 continue;
422 }
423 if !p.to_string_lossy().ends_with(".zst") {
424 continue;
425 }
426 let mtime = fs::metadata(&p)
427 .and_then(|m| m.modified())
428 .ok()
429 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
430 .map(|d| Utc.timestamp_opt(d.as_secs() as i64, 0).unwrap());
431 if let Some(mt) = mtime
432 && mt < cutoff
433 {
434 if args.dry_run {
435 writeln!(out.stdout, "would delete: {}", p.display())?;
436 } else {
437 fs::remove_file(&p).with_context(|| format!("removing {}", p.display()))?;
438 writeln!(out.stdout, "deleted: {}", p.display())?;
439 }
440 deleted += 1;
441 }
442 }
443 writeln!(out.stdout, "purged {deleted} archive(s)")?;
444 Ok(())
445}
446
447fn warn_about_audit_gap(
448 args: &PurgeArgs,
449 app_config: &AppConfig,
450 out: &mut CliOutput<'_>,
451) -> Result<()> {
452 let audit = app_config.effective_audit();
453 if !audit.enabled.unwrap_or(false) {
454 return Ok(());
455 }
456 let retention = audit.effective_retention_days();
457 let cutoff = parse_ts(&args.before).unwrap_or_else(Utc::now);
458 let oldest_required = Utc::now() - chrono::Duration::days(i64::from(retention));
459 if cutoff > oldest_required {
460 writeln!(
461 out.stderr,
462 "warning: --before {ts} would delete archives newer than the configured \
463 audit retention horizon ({retention} days, oldest required = {oldest}). \
464 Continuing creates an audit gap that `ai-memory audit verify` will surface. \
465 Pass --no-warn to suppress this message in automated rotation pipelines.",
466 ts = args.before,
467 retention = retention,
468 oldest = oldest_required.to_rfc3339()
469 )?;
470 }
471 Ok(())
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 fn make_log(dir: &Path, name: &str, contents: &str) -> PathBuf {
479 let p = dir.join(name);
480 fs::write(&p, contents).unwrap();
481 p
482 }
483
484 fn output<'a>(stdout: &'a mut Vec<u8>, stderr: &'a mut Vec<u8>) -> CliOutput<'a> {
485 CliOutput::from_std(stdout, stderr)
486 }
487
488 #[test]
489 fn logs_tail_returns_last_n_lines() {
490 let dir = tempfile::tempdir().unwrap();
491 let body = (1..=100)
492 .map(|i| format!("line {i}"))
493 .collect::<Vec<_>>()
494 .join("\n");
495 make_log(dir.path(), "ai-memory.log.2026-04-30", &body);
496 let mut stdout = Vec::new();
497 let mut stderr = Vec::new();
498 {
499 let mut out = output(&mut stdout, &mut stderr);
500 let filters = Filters::default();
501 let args = TailArgs {
502 lines: 5,
503 follow: false,
504 follow_interval_ms: 50,
505 max_polls: 0,
506 };
507 run_tail(dir.path(), &filters, &args, &mut out).unwrap();
508 }
509 let s = std::str::from_utf8(&stdout).unwrap();
510 let lines: Vec<&str> = s.lines().collect();
511 assert_eq!(lines.len(), 5);
512 assert_eq!(lines.last().unwrap(), &"line 100");
513 }
514
515 #[test]
516 fn logs_tail_follows_appended_lines() {
517 let dir = tempfile::tempdir().unwrap();
518 let path = make_log(dir.path(), "ai-memory.log.2026-04-30", "first\n");
519 let path_clone = path.clone();
520 std::thread::spawn(move || {
521 std::thread::sleep(Duration::from_millis(60));
522 let mut f = fs::OpenOptions::new()
523 .append(true)
524 .open(&path_clone)
525 .unwrap();
526 writeln!(f, "second").unwrap();
527 writeln!(f, "third").unwrap();
528 });
529 let mut stdout = Vec::new();
530 let mut stderr = Vec::new();
531 {
532 let mut out = output(&mut stdout, &mut stderr);
533 let filters = Filters::default();
534 let args = TailArgs {
535 lines: 10,
536 follow: true,
537 follow_interval_ms: 30,
538 max_polls: 6,
539 };
540 run_tail(dir.path(), &filters, &args, &mut out).unwrap();
541 }
542 let s = std::str::from_utf8(&stdout).unwrap();
543 assert!(s.contains("first"), "got: {s}");
544 assert!(s.contains("second"), "expected appended line: {s}");
545 }
546
547 #[test]
548 fn logs_archive_compresses_with_zstd() {
549 let dir = tempfile::tempdir().unwrap();
550 let body = "x".repeat(8192);
554 make_log(dir.path(), "ai-memory.log.2025-01-01", &body);
555 let mut stdout = Vec::new();
556 let mut stderr = Vec::new();
557 let cfg = LoggingConfig {
558 retention_days: Some(0),
559 ..Default::default()
560 };
561 {
562 let mut out = output(&mut stdout, &mut stderr);
563 run_archive(dir.path(), &cfg, &mut out).unwrap();
564 }
565 let s = std::str::from_utf8(&stdout).unwrap();
566 assert!(s.contains("archived 1"), "expected archive count: {s}");
567 let entries: Vec<_> = fs::read_dir(dir.path())
569 .unwrap()
570 .filter_map(|e| e.ok())
571 .map(|e| e.file_name().into_string().unwrap_or_default())
572 .collect();
573 assert!(
574 entries.iter().any(|n| n.ends_with(".zst")),
575 "expected a .zst entry, got {entries:?}"
576 );
577 }
578
579 #[test]
580 fn logs_purge_warns_about_audit_gap() {
581 let dir = tempfile::tempdir().unwrap();
582 let mut stdout = Vec::new();
583 let mut stderr = Vec::new();
584 let app_config = AppConfig {
585 audit: Some(crate::config::AuditConfig {
586 enabled: Some(true),
587 retention_days: Some(90),
588 ..Default::default()
589 }),
590 ..Default::default()
591 };
592 {
593 let mut out = output(&mut stdout, &mut stderr);
594 let args = PurgeArgs {
595 before: Utc::now().to_rfc3339(),
596 no_warn: false,
597 dry_run: true,
598 };
599 run_purge(dir.path(), &args, &app_config, &mut out).unwrap();
600 }
601 let serr = std::str::from_utf8(&stderr).unwrap();
602 assert!(
603 serr.contains("audit gap"),
604 "expected audit-gap warning: {serr}"
605 );
606 }
607
608 #[test]
609 fn logs_filter_namespace_substring() {
610 let dir = tempfile::tempdir().unwrap();
611 let body = "alpha line\nbeta line ns=widgets\ngamma line";
612 make_log(dir.path(), "ai-memory.log.2026-04-30", body);
613 let mut stdout = Vec::new();
614 let mut stderr = Vec::new();
615 {
616 let mut out = output(&mut stdout, &mut stderr);
617 let filters = Filters {
618 namespace: Some("widgets".to_string()),
619 ..Default::default()
620 };
621 run_cat(dir.path(), &filters, &mut out).unwrap();
622 }
623 let s = std::str::from_utf8(&stdout).unwrap();
624 assert!(s.contains("beta line"));
625 assert!(!s.contains("alpha line"));
626 assert!(!s.contains("gamma line"));
627 }
628
629 #[test]
630 fn logs_format_json_wraps_text_lines() {
631 let dir = tempfile::tempdir().unwrap();
632 let body = "plain text line";
633 make_log(dir.path(), "ai-memory.log.2026-04-30", body);
634 let mut stdout = Vec::new();
635 let mut stderr = Vec::new();
636 {
637 let mut out = output(&mut stdout, &mut stderr);
638 let filters = Filters {
639 format_json: true,
640 ..Default::default()
641 };
642 run_cat(dir.path(), &filters, &mut out).unwrap();
643 }
644 let s = std::str::from_utf8(&stdout).unwrap();
645 let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
646 assert_eq!(v["line"], "plain text line");
647 }
648
649 #[test]
656 fn parse_ts_accepts_rfc3339_form() {
657 let dt = parse_ts("2026-04-30T12:34:56+00:00").unwrap();
658 assert_eq!(
662 dt.format("%Y-%m-%dT%H:%M:%S+00:00").to_string(),
663 "2026-04-30T12:34:56+00:00"
664 );
665 }
666
667 #[test]
668 fn parse_ts_accepts_yyyy_mm_dd_form() {
669 let dt = parse_ts("2026-04-30").unwrap();
670 assert_eq!(
672 dt.format("%Y-%m-%d %H:%M:%S").to_string(),
673 "2026-04-30 00:00:00"
674 );
675 }
676
677 #[test]
678 fn parse_ts_returns_none_for_garbage() {
679 assert!(parse_ts("not a date").is_none());
680 assert!(parse_ts("").is_none());
681 assert!(parse_ts("2026/04/30").is_none());
682 }
683
684 #[test]
685 fn args_filters_threads_every_field() {
686 let args = LogsArgs {
687 action: LogsAction::Cat,
688 since: Some("2026-04-30".to_string()),
689 until: Some("2026-05-01".to_string()),
690 level: Some("WARN".to_string()),
691 namespace: Some("ns".to_string()),
692 actor: Some("alice".to_string()),
693 action_filter: Some("recall".to_string()),
694 format: "json".to_string(),
695 log_dir: None,
696 };
697 let f = args_filters(&args);
698 assert!(f.since.is_some());
699 assert!(f.until.is_some());
700 assert_eq!(f.level.as_deref(), Some("WARN"));
701 assert_eq!(f.namespace.as_deref(), Some("ns"));
702 assert_eq!(f.actor.as_deref(), Some("alice"));
703 assert_eq!(f.action.as_deref(), Some("recall"));
704 assert!(f.format_json);
705 }
706
707 #[test]
708 fn args_filters_handles_garbage_since_as_none() {
709 let args = LogsArgs {
710 action: LogsAction::Cat,
711 since: Some("garbage".to_string()),
712 until: None,
713 level: None,
714 namespace: None,
715 actor: None,
716 action_filter: None,
717 format: "text".to_string(),
718 log_dir: None,
719 };
720 let f = args_filters(&args);
721 assert!(f.since.is_none(), "garbage should fall back to None");
722 assert!(!f.format_json);
723 }
724
725 #[test]
726 fn line_matches_filters_by_level_substring() {
727 let f = Filters {
728 level: Some("error".to_string()),
729 ..Default::default()
730 };
731 assert!(line_matches("ERROR something happened", &f));
732 assert!(line_matches("level=ERROR", &f));
733 assert!(!line_matches("INFO uneventful", &f));
734 }
735
736 #[test]
737 fn line_matches_filters_by_actor_case_insensitive() {
738 let f = Filters {
739 actor: Some("ALICE".to_string()),
740 ..Default::default()
741 };
742 assert!(line_matches("user=alice@example.com", &f));
743 assert!(!line_matches("user=bob@example.com", &f));
744 }
745
746 #[test]
747 fn line_matches_filters_by_action_substring() {
748 let f = Filters {
749 action: Some("recall".to_string()),
750 ..Default::default()
751 };
752 assert!(line_matches("action=recall ns=widgets", &f));
753 assert!(!line_matches("action=store ns=widgets", &f));
754 }
755
756 #[test]
757 fn line_matches_combined_filters_all_must_pass() {
758 let f = Filters {
759 level: Some("WARN".to_string()),
760 actor: Some("alice".to_string()),
761 namespace: Some("widgets".to_string()),
762 action: Some("store".to_string()),
763 ..Default::default()
764 };
765 assert!(line_matches("WARN action=store actor=alice ns=widgets", &f));
767 assert!(!line_matches(
769 "WARN action=store actor=alice ns=other-ns",
770 &f
771 ));
772 assert!(!line_matches(
773 "INFO action=store actor=alice ns=widgets",
774 &f
775 ));
776 }
777
778 #[test]
779 fn line_matches_drops_lines_outside_since_window() {
780 let f = Filters {
782 since: parse_ts("2026-04-30"),
783 ..Default::default()
784 };
785 let line = "2026-01-01T00:00:00+00:00 INFO old line";
786 assert!(!line_matches(line, &f));
787 let line2 = "2026-05-01T00:00:00+00:00 INFO recent";
789 assert!(line_matches(line2, &f));
790 }
791
792 #[test]
793 fn line_matches_drops_lines_after_until_window() {
794 let f = Filters {
795 until: parse_ts("2026-04-30"),
796 ..Default::default()
797 };
798 let line = "2026-05-01T00:00:00+00:00 INFO too recent";
800 assert!(!line_matches(line, &f));
801 let line2 = "2026-04-29T00:00:00+00:00 INFO ok";
802 assert!(line_matches(line2, &f));
803 }
804
805 #[test]
806 fn line_matches_keeps_lines_with_no_extractable_timestamp_and_window_filter() {
807 let f = Filters {
810 since: parse_ts("2026-04-30"),
811 ..Default::default()
812 };
813 assert!(line_matches("plain message no timestamp here", &f));
814 }
815
816 #[test]
817 fn extract_timestamp_recognises_leading_rfc3339() {
818 let line = "2026-04-30T12:00:00+00:00 INFO hi";
819 let ts = extract_timestamp(line).expect("rfc3339 token");
820 assert_eq!(ts.format("%Y-%m-%d").to_string(), "2026-04-30");
821 }
822
823 #[test]
824 fn extract_timestamp_recognises_json_timestamp_field() {
825 let line = r#"{"timestamp":"2026-04-30T12:00:00+00:00","msg":"hi"}"#;
826 let ts = extract_timestamp(line).expect("json timestamp");
827 assert_eq!(ts.format("%Y-%m-%d").to_string(), "2026-04-30");
828 }
829
830 #[test]
831 fn extract_timestamp_returns_none_when_absent() {
832 assert!(extract_timestamp("no timestamp").is_none());
833 assert!(extract_timestamp(r#"{"foo":"bar"}"#).is_none());
834 assert!(extract_timestamp(r#"{"timestamp":"garbage"}"#).is_none());
836 }
837
838 #[test]
839 fn logs_run_dispatches_to_cat_action() {
840 let dir = tempfile::tempdir().unwrap();
841 make_log(dir.path(), "ai-memory.log.2026-04-30", "hello world\n");
842 let mut stdout = Vec::new();
843 let mut stderr = Vec::new();
844 let app_config = AppConfig {
845 logging: Some(LoggingConfig {
846 path: Some(dir.path().to_string_lossy().into_owned()),
847 ..Default::default()
848 }),
849 ..Default::default()
850 };
851 {
852 let mut out = output(&mut stdout, &mut stderr);
853 let args = LogsArgs {
854 action: LogsAction::Cat,
855 since: None,
856 until: None,
857 level: None,
858 namespace: None,
859 actor: None,
860 action_filter: None,
861 format: "text".to_string(),
862 log_dir: Some(dir.path().to_path_buf()),
863 };
864 run(args, &app_config, &mut out).unwrap();
865 }
866 let s = std::str::from_utf8(&stdout).unwrap();
867 assert!(s.contains("hello world"), "got: {s}");
868 }
869
870 #[test]
871 fn logs_run_dispatches_to_tail_action() {
872 let dir = tempfile::tempdir().unwrap();
873 make_log(
874 dir.path(),
875 "ai-memory.log.2026-04-30",
876 "line a\nline b\nline c\n",
877 );
878 let mut stdout = Vec::new();
879 let mut stderr = Vec::new();
880 let app_config = AppConfig::default();
881 {
882 let mut out = output(&mut stdout, &mut stderr);
883 let args = LogsArgs {
884 action: LogsAction::Tail(TailArgs {
885 lines: 2,
886 follow: false,
887 follow_interval_ms: 50,
888 max_polls: 0,
889 }),
890 since: None,
891 until: None,
892 level: None,
893 namespace: None,
894 actor: None,
895 action_filter: None,
896 format: "text".to_string(),
897 log_dir: Some(dir.path().to_path_buf()),
898 };
899 run(args, &app_config, &mut out).unwrap();
900 }
901 let s = std::str::from_utf8(&stdout).unwrap();
902 let lines: Vec<&str> = s.lines().collect();
903 assert_eq!(lines.len(), 2, "tail --lines=2 must cap output: {s}");
904 }
905
906 #[test]
907 fn logs_run_dispatches_to_archive_action_no_op_on_empty_dir() {
908 let dir = tempfile::tempdir().unwrap();
909 let mut stdout = Vec::new();
910 let mut stderr = Vec::new();
911 let app_config = AppConfig::default();
912 {
913 let mut out = output(&mut stdout, &mut stderr);
914 let args = LogsArgs {
915 action: LogsAction::Archive,
916 since: None,
917 until: None,
918 level: None,
919 namespace: None,
920 actor: None,
921 action_filter: None,
922 format: "text".to_string(),
923 log_dir: Some(dir.path().to_path_buf()),
924 };
925 run(args, &app_config, &mut out).unwrap();
926 }
927 let s = std::str::from_utf8(&stdout).unwrap();
928 assert!(s.contains("archived 0"), "expected zero count: {s}");
929 }
930
931 #[test]
932 fn logs_run_dispatches_to_purge_action() {
933 let dir = tempfile::tempdir().unwrap();
934 let mut stdout = Vec::new();
935 let mut stderr = Vec::new();
936 let app_config = AppConfig::default();
937 {
938 let mut out = output(&mut stdout, &mut stderr);
939 let args = LogsArgs {
940 action: LogsAction::Purge(PurgeArgs {
941 before: "2099-01-01".to_string(),
942 no_warn: true,
943 dry_run: true,
944 }),
945 since: None,
946 until: None,
947 level: None,
948 namespace: None,
949 actor: None,
950 action_filter: None,
951 format: "text".to_string(),
952 log_dir: Some(dir.path().to_path_buf()),
953 };
954 run(args, &app_config, &mut out).unwrap();
955 }
956 let s = std::str::from_utf8(&stdout).unwrap();
957 assert!(s.contains("purged 0"), "got: {s}");
958 }
959
960 fn backdate_mtime_to_epoch(path: &Path) {
965 let f = fs::OpenOptions::new().write(true).open(path).unwrap();
966 let one_second_past_epoch = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1);
970 f.set_modified(one_second_past_epoch).unwrap();
971 }
972
973 #[test]
974 fn logs_purge_dry_run_prints_would_delete_without_removing() {
975 let dir = tempfile::tempdir().unwrap();
976 let archive = dir.path().join("ai-memory.log.2010-01-01.zst");
977 fs::write(&archive, b"compressed").unwrap();
978 backdate_mtime_to_epoch(&archive);
979 let mut stdout = Vec::new();
980 let mut stderr = Vec::new();
981 let app_config = AppConfig::default();
982 {
983 let mut out = output(&mut stdout, &mut stderr);
984 let args = PurgeArgs {
985 before: Utc::now().to_rfc3339(),
987 no_warn: true,
988 dry_run: true,
989 };
990 run_purge(dir.path(), &args, &app_config, &mut out).unwrap();
991 }
992 let s = std::str::from_utf8(&stdout).unwrap();
993 assert!(s.contains("would delete:"), "got: {s}");
994 assert!(s.contains("purged 1"), "must report count: {s}");
995 assert!(archive.exists(), "dry run must not remove the file");
996 }
997
998 #[test]
999 fn logs_purge_actually_deletes_when_not_dry_run() {
1000 let dir = tempfile::tempdir().unwrap();
1001 let archive = dir.path().join("ai-memory.log.2010-01-01.zst");
1002 fs::write(&archive, b"compressed").unwrap();
1003 backdate_mtime_to_epoch(&archive);
1004 let mut stdout = Vec::new();
1005 let mut stderr = Vec::new();
1006 let app_config = AppConfig::default();
1007 {
1008 let mut out = output(&mut stdout, &mut stderr);
1009 let args = PurgeArgs {
1010 before: Utc::now().to_rfc3339(),
1011 no_warn: true,
1012 dry_run: false,
1013 };
1014 run_purge(dir.path(), &args, &app_config, &mut out).unwrap();
1015 }
1016 let s = std::str::from_utf8(&stdout).unwrap();
1017 assert!(s.contains("deleted:"), "got: {s}");
1018 assert!(!archive.exists(), "non-dry-run must remove the file");
1019 }
1020
1021 #[test]
1022 fn logs_purge_skips_non_zst_files() {
1023 let dir = tempfile::tempdir().unwrap();
1024 let plain = dir.path().join("ai-memory.log.2010-01-01");
1026 fs::write(&plain, b"raw").unwrap();
1027 let mut stdout = Vec::new();
1028 let mut stderr = Vec::new();
1029 let app_config = AppConfig::default();
1030 {
1031 let mut out = output(&mut stdout, &mut stderr);
1032 let args = PurgeArgs {
1033 before: Utc::now().to_rfc3339(),
1034 no_warn: true,
1035 dry_run: false,
1036 };
1037 run_purge(dir.path(), &args, &app_config, &mut out).unwrap();
1038 }
1039 assert!(plain.exists(), "non-.zst files must be left alone");
1040 }
1041
1042 #[test]
1043 fn logs_purge_returns_ok_when_dir_missing() {
1044 let tmp = tempfile::tempdir().unwrap();
1045 let bogus = tmp.path().join("does-not-exist");
1046 let mut stdout = Vec::new();
1047 let mut stderr = Vec::new();
1048 let app_config = AppConfig::default();
1049 let mut out = output(&mut stdout, &mut stderr);
1050 let args = PurgeArgs {
1051 before: Utc::now().to_rfc3339(),
1052 no_warn: true,
1053 dry_run: true,
1054 };
1055 run_purge(&bogus, &args, &app_config, &mut out).unwrap();
1057 }
1058
1059 #[test]
1060 fn logs_purge_rejects_garbage_before_date() {
1061 let dir = tempfile::tempdir().unwrap();
1062 let mut stdout = Vec::new();
1063 let mut stderr = Vec::new();
1064 let app_config = AppConfig::default();
1065 let mut out = output(&mut stdout, &mut stderr);
1066 let args = PurgeArgs {
1067 before: "definitely-not-a-date".to_string(),
1068 no_warn: true,
1069 dry_run: true,
1070 };
1071 let err = run_purge(dir.path(), &args, &app_config, &mut out).unwrap_err();
1072 assert!(
1073 format!("{err}").contains("invalid --before date"),
1074 "got: {err}"
1075 );
1076 }
1077
1078 #[test]
1079 fn logs_archive_skips_recent_files() {
1080 let dir = tempfile::tempdir().unwrap();
1081 make_log(dir.path(), "ai-memory.log.2026-04-30", "today");
1083 let cfg = LoggingConfig {
1084 retention_days: Some(90),
1085 ..Default::default()
1086 };
1087 let mut stdout = Vec::new();
1088 let mut stderr = Vec::new();
1089 {
1090 let mut out = output(&mut stdout, &mut stderr);
1091 run_archive(dir.path(), &cfg, &mut out).unwrap();
1092 }
1093 let s = std::str::from_utf8(&stdout).unwrap();
1094 assert!(s.contains("archived 0"), "got: {s}");
1095 }
1096
1097 #[test]
1098 fn logs_run_resolves_log_dir_from_config_when_no_override() {
1099 let dir = tempfile::tempdir().unwrap();
1103 make_log(dir.path(), "ai-memory.log.2026-04-30", "from-config\n");
1104 let mut stdout = Vec::new();
1105 let mut stderr = Vec::new();
1106 let app_config = AppConfig {
1107 logging: Some(LoggingConfig {
1108 path: Some(dir.path().to_string_lossy().into_owned()),
1109 ..Default::default()
1110 }),
1111 ..Default::default()
1112 };
1113 {
1114 let mut out = output(&mut stdout, &mut stderr);
1115 let args = LogsArgs {
1116 action: LogsAction::Cat,
1117 since: None,
1118 until: None,
1119 level: None,
1120 namespace: None,
1121 actor: None,
1122 action_filter: None,
1123 format: "text".to_string(),
1124 log_dir: None,
1125 };
1126 run(args, &app_config, &mut out).unwrap();
1127 }
1128 let s = std::str::from_utf8(&stdout).unwrap();
1129 assert!(s.contains("from-config"), "expected config layer used: {s}");
1130 }
1131}