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(|| crate::errors::msg::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(|| crate::errors::msg::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(|| crate::errors::msg::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 =
342 fs::File::open(path).with_context(|| crate::errors::msg::opening(path.display()))?;
343 f.seek(SeekFrom::Start(offset))?;
344 let buf = BufReader::new(f);
345 let mut out = Vec::new();
346 for line in buf.lines() {
347 out.push(line?);
348 }
349 Ok(out)
350}
351
352fn run_archive(dir: &Path, cfg: &LoggingConfig, out: &mut CliOutput<'_>) -> Result<()> {
353 let retention_days = i64::from(cfg.retention_days.unwrap_or(90));
354 let cutoff = Utc::now() - chrono::Duration::days(retention_days);
355 let mut compressed: u64 = 0;
356 let mut total_in: u64 = 0;
357 let mut total_out: u64 = 0;
358
359 for f in enumerate_log_files(dir)? {
360 let mtime = fs::metadata(&f)
361 .and_then(|m| m.modified())
362 .ok()
363 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
364 .map(|d| Utc.timestamp_opt(d.as_secs() as i64, 0).unwrap());
365 let Some(mtime) = mtime else {
366 continue;
367 };
368 if mtime >= cutoff {
369 continue;
370 }
371 let in_bytes = fs::read(&f).with_context(|| crate::errors::msg::reading(f.display()))?;
372 let in_size = in_bytes.len() as u64;
373 let out_path = f.with_extension(format!(
374 "{}.zst",
375 f.extension().and_then(|e| e.to_str()).unwrap_or("log")
376 ));
377 let compressed_bytes = zstd_compress(&in_bytes)?;
378 let out_size = compressed_bytes.len() as u64;
379 fs::write(&out_path, &compressed_bytes)
380 .with_context(|| crate::errors::msg::writing(out_path.display()))?;
381 fs::remove_file(&f).with_context(|| format!("removing {}", f.display()))?;
382 compressed += 1;
383 total_in += in_size;
384 total_out += out_size;
385 }
386 writeln!(
387 out.stdout,
388 "archived {compressed} log file(s): {total_in} bytes -> {total_out} bytes"
389 )?;
390 Ok(())
391}
392
393fn zstd_compress(input: &[u8]) -> Result<Vec<u8>> {
394 let mut out = Vec::with_capacity(input.len() / 4 + 64);
395 {
396 let mut encoder = zstd::stream::write::Encoder::new(&mut out, 3)?;
397 encoder.write_all(input)?;
398 encoder.finish()?;
399 }
400 Ok(out)
401}
402
403fn run_purge(
404 dir: &Path,
405 args: &PurgeArgs,
406 app_config: &AppConfig,
407 out: &mut CliOutput<'_>,
408) -> Result<()> {
409 let cutoff = parse_ts(&args.before)
410 .ok_or_else(|| anyhow!("invalid --before date: {} (expected RFC3339)", args.before))?;
411 if !args.no_warn {
412 warn_about_audit_gap(args, app_config, out)?;
413 }
414 if !dir.exists() {
415 return Ok(());
416 }
417 let mut deleted: u64 = 0;
418 for entry in fs::read_dir(dir)? {
419 let entry = entry?;
420 let p = entry.path();
421 if !p.is_file() {
422 continue;
423 }
424 if !p.to_string_lossy().ends_with(".zst") {
425 continue;
426 }
427 let mtime = fs::metadata(&p)
428 .and_then(|m| m.modified())
429 .ok()
430 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
431 .map(|d| Utc.timestamp_opt(d.as_secs() as i64, 0).unwrap());
432 if let Some(mt) = mtime
433 && mt < cutoff
434 {
435 if args.dry_run {
436 writeln!(out.stdout, "would delete: {}", p.display())?;
437 } else {
438 fs::remove_file(&p).with_context(|| format!("removing {}", p.display()))?;
439 writeln!(out.stdout, "deleted: {}", p.display())?;
440 }
441 deleted += 1;
442 }
443 }
444 writeln!(out.stdout, "purged {deleted} archive(s)")?;
445 Ok(())
446}
447
448fn warn_about_audit_gap(
449 args: &PurgeArgs,
450 app_config: &AppConfig,
451 out: &mut CliOutput<'_>,
452) -> Result<()> {
453 let audit = app_config.effective_audit();
454 if !audit.enabled.unwrap_or(false) {
455 return Ok(());
456 }
457 let retention = audit.effective_retention_days();
458 let cutoff = parse_ts(&args.before).unwrap_or_else(Utc::now);
459 let oldest_required = Utc::now() - chrono::Duration::days(i64::from(retention));
460 if cutoff > oldest_required {
461 writeln!(
462 out.stderr,
463 "warning: --before {ts} would delete archives newer than the configured \
464 audit retention horizon ({retention} days, oldest required = {oldest}). \
465 Continuing creates an audit gap that `ai-memory audit verify` will surface. \
466 Pass --no-warn to suppress this message in automated rotation pipelines.",
467 ts = args.before,
468 retention = retention,
469 oldest = oldest_required.to_rfc3339()
470 )?;
471 }
472 Ok(())
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478
479 fn make_log(dir: &Path, name: &str, contents: &str) -> PathBuf {
480 let p = dir.join(name);
481 fs::write(&p, contents).unwrap();
482 p
483 }
484
485 fn output<'a>(stdout: &'a mut Vec<u8>, stderr: &'a mut Vec<u8>) -> CliOutput<'a> {
486 CliOutput::from_std(stdout, stderr)
487 }
488
489 #[test]
490 fn logs_tail_returns_last_n_lines() {
491 let dir = tempfile::tempdir().unwrap();
492 let body = (1..=100)
493 .map(|i| format!("line {i}"))
494 .collect::<Vec<_>>()
495 .join("\n");
496 make_log(dir.path(), "ai-memory.log.2026-04-30", &body);
497 let mut stdout = Vec::new();
498 let mut stderr = Vec::new();
499 {
500 let mut out = output(&mut stdout, &mut stderr);
501 let filters = Filters::default();
502 let args = TailArgs {
503 lines: 5,
504 follow: false,
505 follow_interval_ms: 50,
506 max_polls: 0,
507 };
508 run_tail(dir.path(), &filters, &args, &mut out).unwrap();
509 }
510 let s = std::str::from_utf8(&stdout).unwrap();
511 let lines: Vec<&str> = s.lines().collect();
512 assert_eq!(lines.len(), 5);
513 assert_eq!(lines.last().unwrap(), &"line 100");
514 }
515
516 #[test]
517 fn logs_tail_follows_appended_lines() {
518 let dir = tempfile::tempdir().unwrap();
519 let path = make_log(dir.path(), "ai-memory.log.2026-04-30", "first\n");
520 let path_clone = path.clone();
521 std::thread::spawn(move || {
522 std::thread::sleep(Duration::from_millis(60));
523 let mut f = fs::OpenOptions::new()
524 .append(true)
525 .open(&path_clone)
526 .unwrap();
527 writeln!(f, "second").unwrap();
528 writeln!(f, "third").unwrap();
529 });
530 let mut stdout = Vec::new();
531 let mut stderr = Vec::new();
532 {
533 let mut out = output(&mut stdout, &mut stderr);
534 let filters = Filters::default();
535 let args = TailArgs {
536 lines: 10,
537 follow: true,
538 follow_interval_ms: 30,
539 max_polls: 6,
540 };
541 run_tail(dir.path(), &filters, &args, &mut out).unwrap();
542 }
543 let s = std::str::from_utf8(&stdout).unwrap();
544 assert!(s.contains("first"), "got: {s}");
545 assert!(s.contains("second"), "expected appended line: {s}");
546 }
547
548 #[test]
549 fn logs_archive_compresses_with_zstd() {
550 let dir = tempfile::tempdir().unwrap();
551 let body = "x".repeat(8192);
555 make_log(dir.path(), "ai-memory.log.2025-01-01", &body);
556 let mut stdout = Vec::new();
557 let mut stderr = Vec::new();
558 let cfg = LoggingConfig {
559 retention_days: Some(0),
560 ..Default::default()
561 };
562 {
563 let mut out = output(&mut stdout, &mut stderr);
564 run_archive(dir.path(), &cfg, &mut out).unwrap();
565 }
566 let s = std::str::from_utf8(&stdout).unwrap();
567 assert!(s.contains("archived 1"), "expected archive count: {s}");
568 let entries: Vec<_> = fs::read_dir(dir.path())
570 .unwrap()
571 .filter_map(|e| e.ok())
572 .map(|e| e.file_name().into_string().unwrap_or_default())
573 .collect();
574 assert!(
575 entries.iter().any(|n| n.ends_with(".zst")),
576 "expected a .zst entry, got {entries:?}"
577 );
578 }
579
580 #[test]
581 fn logs_purge_warns_about_audit_gap() {
582 let dir = tempfile::tempdir().unwrap();
583 let mut stdout = Vec::new();
584 let mut stderr = Vec::new();
585 let app_config = AppConfig {
586 audit: Some(crate::config::AuditConfig {
587 enabled: Some(true),
588 retention_days: Some(90),
589 ..Default::default()
590 }),
591 ..Default::default()
592 };
593 {
594 let mut out = output(&mut stdout, &mut stderr);
595 let args = PurgeArgs {
596 before: Utc::now().to_rfc3339(),
597 no_warn: false,
598 dry_run: true,
599 };
600 run_purge(dir.path(), &args, &app_config, &mut out).unwrap();
601 }
602 let serr = std::str::from_utf8(&stderr).unwrap();
603 assert!(
604 serr.contains("audit gap"),
605 "expected audit-gap warning: {serr}"
606 );
607 }
608
609 #[test]
610 fn logs_filter_namespace_substring() {
611 let dir = tempfile::tempdir().unwrap();
612 let body = "alpha line\nbeta line ns=widgets\ngamma line";
613 make_log(dir.path(), "ai-memory.log.2026-04-30", body);
614 let mut stdout = Vec::new();
615 let mut stderr = Vec::new();
616 {
617 let mut out = output(&mut stdout, &mut stderr);
618 let filters = Filters {
619 namespace: Some("widgets".to_string()),
620 ..Default::default()
621 };
622 run_cat(dir.path(), &filters, &mut out).unwrap();
623 }
624 let s = std::str::from_utf8(&stdout).unwrap();
625 assert!(s.contains("beta line"));
626 assert!(!s.contains("alpha line"));
627 assert!(!s.contains("gamma line"));
628 }
629
630 #[test]
631 fn logs_format_json_wraps_text_lines() {
632 let dir = tempfile::tempdir().unwrap();
633 let body = "plain text line";
634 make_log(dir.path(), "ai-memory.log.2026-04-30", body);
635 let mut stdout = Vec::new();
636 let mut stderr = Vec::new();
637 {
638 let mut out = output(&mut stdout, &mut stderr);
639 let filters = Filters {
640 format_json: true,
641 ..Default::default()
642 };
643 run_cat(dir.path(), &filters, &mut out).unwrap();
644 }
645 let s = std::str::from_utf8(&stdout).unwrap();
646 let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
647 assert_eq!(v["line"], "plain text line");
648 }
649
650 #[test]
657 fn parse_ts_accepts_rfc3339_form() {
658 let dt = parse_ts("2026-04-30T12:34:56+00:00").unwrap();
659 assert_eq!(
663 dt.format("%Y-%m-%dT%H:%M:%S+00:00").to_string(),
664 "2026-04-30T12:34:56+00:00"
665 );
666 }
667
668 #[test]
669 fn parse_ts_accepts_yyyy_mm_dd_form() {
670 let dt = parse_ts("2026-04-30").unwrap();
671 assert_eq!(
673 dt.format("%Y-%m-%d %H:%M:%S").to_string(),
674 "2026-04-30 00:00:00"
675 );
676 }
677
678 #[test]
679 fn parse_ts_returns_none_for_garbage() {
680 assert!(parse_ts("not a date").is_none());
681 assert!(parse_ts("").is_none());
682 assert!(parse_ts("2026/04/30").is_none());
683 }
684
685 #[test]
686 fn args_filters_threads_every_field() {
687 let args = LogsArgs {
688 action: LogsAction::Cat,
689 since: Some("2026-04-30".to_string()),
690 until: Some("2026-05-01".to_string()),
691 level: Some("WARN".to_string()),
692 namespace: Some("ns".to_string()),
693 actor: Some("alice".to_string()),
694 action_filter: Some("recall".to_string()),
695 format: "json".to_string(),
696 log_dir: None,
697 };
698 let f = args_filters(&args);
699 assert!(f.since.is_some());
700 assert!(f.until.is_some());
701 assert_eq!(f.level.as_deref(), Some("WARN"));
702 assert_eq!(f.namespace.as_deref(), Some("ns"));
703 assert_eq!(f.actor.as_deref(), Some("alice"));
704 assert_eq!(f.action.as_deref(), Some("recall"));
705 assert!(f.format_json);
706 }
707
708 #[test]
709 fn args_filters_handles_garbage_since_as_none() {
710 let args = LogsArgs {
711 action: LogsAction::Cat,
712 since: Some("garbage".to_string()),
713 until: None,
714 level: None,
715 namespace: None,
716 actor: None,
717 action_filter: None,
718 format: "text".to_string(),
719 log_dir: None,
720 };
721 let f = args_filters(&args);
722 assert!(f.since.is_none(), "garbage should fall back to None");
723 assert!(!f.format_json);
724 }
725
726 #[test]
727 fn line_matches_filters_by_level_substring() {
728 let f = Filters {
729 level: Some("error".to_string()),
730 ..Default::default()
731 };
732 assert!(line_matches("ERROR something happened", &f));
733 assert!(line_matches("level=ERROR", &f));
734 assert!(!line_matches("INFO uneventful", &f));
735 }
736
737 #[test]
738 fn line_matches_filters_by_actor_case_insensitive() {
739 let f = Filters {
740 actor: Some("ALICE".to_string()),
741 ..Default::default()
742 };
743 assert!(line_matches("user=alice@example.com", &f));
744 assert!(!line_matches("user=bob@example.com", &f));
745 }
746
747 #[test]
748 fn line_matches_filters_by_action_substring() {
749 let f = Filters {
750 action: Some("recall".to_string()),
751 ..Default::default()
752 };
753 assert!(line_matches("action=recall ns=widgets", &f));
754 assert!(!line_matches("action=store ns=widgets", &f));
755 }
756
757 #[test]
758 fn line_matches_combined_filters_all_must_pass() {
759 let f = Filters {
760 level: Some("WARN".to_string()),
761 actor: Some("alice".to_string()),
762 namespace: Some("widgets".to_string()),
763 action: Some("store".to_string()),
764 ..Default::default()
765 };
766 assert!(line_matches("WARN action=store actor=alice ns=widgets", &f));
768 assert!(!line_matches(
770 "WARN action=store actor=alice ns=other-ns",
771 &f
772 ));
773 assert!(!line_matches(
774 "INFO action=store actor=alice ns=widgets",
775 &f
776 ));
777 }
778
779 #[test]
780 fn line_matches_drops_lines_outside_since_window() {
781 let f = Filters {
783 since: parse_ts("2026-04-30"),
784 ..Default::default()
785 };
786 let line = "2026-01-01T00:00:00+00:00 INFO old line";
787 assert!(!line_matches(line, &f));
788 let line2 = "2026-05-01T00:00:00+00:00 INFO recent";
790 assert!(line_matches(line2, &f));
791 }
792
793 #[test]
794 fn line_matches_drops_lines_after_until_window() {
795 let f = Filters {
796 until: parse_ts("2026-04-30"),
797 ..Default::default()
798 };
799 let line = "2026-05-01T00:00:00+00:00 INFO too recent";
801 assert!(!line_matches(line, &f));
802 let line2 = "2026-04-29T00:00:00+00:00 INFO ok";
803 assert!(line_matches(line2, &f));
804 }
805
806 #[test]
807 fn line_matches_keeps_lines_with_no_extractable_timestamp_and_window_filter() {
808 let f = Filters {
811 since: parse_ts("2026-04-30"),
812 ..Default::default()
813 };
814 assert!(line_matches("plain message no timestamp here", &f));
815 }
816
817 #[test]
818 fn extract_timestamp_recognises_leading_rfc3339() {
819 let line = "2026-04-30T12:00:00+00:00 INFO hi";
820 let ts = extract_timestamp(line).expect("rfc3339 token");
821 assert_eq!(ts.format("%Y-%m-%d").to_string(), "2026-04-30");
822 }
823
824 #[test]
825 fn extract_timestamp_recognises_json_timestamp_field() {
826 let line = r#"{"timestamp":"2026-04-30T12:00:00+00:00","msg":"hi"}"#;
827 let ts = extract_timestamp(line).expect("json timestamp");
828 assert_eq!(ts.format("%Y-%m-%d").to_string(), "2026-04-30");
829 }
830
831 #[test]
832 fn extract_timestamp_returns_none_when_absent() {
833 assert!(extract_timestamp("no timestamp").is_none());
834 assert!(extract_timestamp(r#"{"foo":"bar"}"#).is_none());
835 assert!(extract_timestamp(r#"{"timestamp":"garbage"}"#).is_none());
837 }
838
839 #[test]
840 fn logs_run_dispatches_to_cat_action() {
841 let dir = tempfile::tempdir().unwrap();
842 make_log(dir.path(), "ai-memory.log.2026-04-30", "hello world\n");
843 let mut stdout = Vec::new();
844 let mut stderr = Vec::new();
845 let app_config = AppConfig {
846 logging: Some(LoggingConfig {
847 path: Some(dir.path().to_string_lossy().into_owned()),
848 ..Default::default()
849 }),
850 ..Default::default()
851 };
852 {
853 let mut out = output(&mut stdout, &mut stderr);
854 let args = LogsArgs {
855 action: LogsAction::Cat,
856 since: None,
857 until: None,
858 level: None,
859 namespace: None,
860 actor: None,
861 action_filter: None,
862 format: "text".to_string(),
863 log_dir: Some(dir.path().to_path_buf()),
864 };
865 run(args, &app_config, &mut out).unwrap();
866 }
867 let s = std::str::from_utf8(&stdout).unwrap();
868 assert!(s.contains("hello world"), "got: {s}");
869 }
870
871 #[test]
872 fn logs_run_dispatches_to_tail_action() {
873 let dir = tempfile::tempdir().unwrap();
874 make_log(
875 dir.path(),
876 "ai-memory.log.2026-04-30",
877 "line a\nline b\nline c\n",
878 );
879 let mut stdout = Vec::new();
880 let mut stderr = Vec::new();
881 let app_config = AppConfig::default();
882 {
883 let mut out = output(&mut stdout, &mut stderr);
884 let args = LogsArgs {
885 action: LogsAction::Tail(TailArgs {
886 lines: 2,
887 follow: false,
888 follow_interval_ms: 50,
889 max_polls: 0,
890 }),
891 since: None,
892 until: None,
893 level: None,
894 namespace: None,
895 actor: None,
896 action_filter: None,
897 format: "text".to_string(),
898 log_dir: Some(dir.path().to_path_buf()),
899 };
900 run(args, &app_config, &mut out).unwrap();
901 }
902 let s = std::str::from_utf8(&stdout).unwrap();
903 let lines: Vec<&str> = s.lines().collect();
904 assert_eq!(lines.len(), 2, "tail --lines=2 must cap output: {s}");
905 }
906
907 #[test]
908 fn logs_run_dispatches_to_archive_action_no_op_on_empty_dir() {
909 let dir = tempfile::tempdir().unwrap();
910 let mut stdout = Vec::new();
911 let mut stderr = Vec::new();
912 let app_config = AppConfig::default();
913 {
914 let mut out = output(&mut stdout, &mut stderr);
915 let args = LogsArgs {
916 action: LogsAction::Archive,
917 since: None,
918 until: None,
919 level: None,
920 namespace: None,
921 actor: None,
922 action_filter: None,
923 format: "text".to_string(),
924 log_dir: Some(dir.path().to_path_buf()),
925 };
926 run(args, &app_config, &mut out).unwrap();
927 }
928 let s = std::str::from_utf8(&stdout).unwrap();
929 assert!(s.contains("archived 0"), "expected zero count: {s}");
930 }
931
932 #[test]
933 fn logs_run_dispatches_to_purge_action() {
934 let dir = tempfile::tempdir().unwrap();
935 let mut stdout = Vec::new();
936 let mut stderr = Vec::new();
937 let app_config = AppConfig::default();
938 {
939 let mut out = output(&mut stdout, &mut stderr);
940 let args = LogsArgs {
941 action: LogsAction::Purge(PurgeArgs {
942 before: "2099-01-01".to_string(),
943 no_warn: true,
944 dry_run: true,
945 }),
946 since: None,
947 until: None,
948 level: None,
949 namespace: None,
950 actor: None,
951 action_filter: None,
952 format: "text".to_string(),
953 log_dir: Some(dir.path().to_path_buf()),
954 };
955 run(args, &app_config, &mut out).unwrap();
956 }
957 let s = std::str::from_utf8(&stdout).unwrap();
958 assert!(s.contains("purged 0"), "got: {s}");
959 }
960
961 fn backdate_mtime_to_epoch(path: &Path) {
966 let f = fs::OpenOptions::new().write(true).open(path).unwrap();
967 let one_second_past_epoch = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1);
971 f.set_modified(one_second_past_epoch).unwrap();
972 }
973
974 #[test]
975 fn logs_purge_dry_run_prints_would_delete_without_removing() {
976 let dir = tempfile::tempdir().unwrap();
977 let archive = dir.path().join("ai-memory.log.2010-01-01.zst");
978 fs::write(&archive, b"compressed").unwrap();
979 backdate_mtime_to_epoch(&archive);
980 let mut stdout = Vec::new();
981 let mut stderr = Vec::new();
982 let app_config = AppConfig::default();
983 {
984 let mut out = output(&mut stdout, &mut stderr);
985 let args = PurgeArgs {
986 before: Utc::now().to_rfc3339(),
988 no_warn: true,
989 dry_run: true,
990 };
991 run_purge(dir.path(), &args, &app_config, &mut out).unwrap();
992 }
993 let s = std::str::from_utf8(&stdout).unwrap();
994 assert!(s.contains("would delete:"), "got: {s}");
995 assert!(s.contains("purged 1"), "must report count: {s}");
996 assert!(archive.exists(), "dry run must not remove the file");
997 }
998
999 #[test]
1000 fn logs_purge_actually_deletes_when_not_dry_run() {
1001 let dir = tempfile::tempdir().unwrap();
1002 let archive = dir.path().join("ai-memory.log.2010-01-01.zst");
1003 fs::write(&archive, b"compressed").unwrap();
1004 backdate_mtime_to_epoch(&archive);
1005 let mut stdout = Vec::new();
1006 let mut stderr = Vec::new();
1007 let app_config = AppConfig::default();
1008 {
1009 let mut out = output(&mut stdout, &mut stderr);
1010 let args = PurgeArgs {
1011 before: Utc::now().to_rfc3339(),
1012 no_warn: true,
1013 dry_run: false,
1014 };
1015 run_purge(dir.path(), &args, &app_config, &mut out).unwrap();
1016 }
1017 let s = std::str::from_utf8(&stdout).unwrap();
1018 assert!(s.contains("deleted:"), "got: {s}");
1019 assert!(!archive.exists(), "non-dry-run must remove the file");
1020 }
1021
1022 #[test]
1023 fn logs_purge_skips_non_zst_files() {
1024 let dir = tempfile::tempdir().unwrap();
1025 let plain = dir.path().join("ai-memory.log.2010-01-01");
1027 fs::write(&plain, b"raw").unwrap();
1028 let mut stdout = Vec::new();
1029 let mut stderr = Vec::new();
1030 let app_config = AppConfig::default();
1031 {
1032 let mut out = output(&mut stdout, &mut stderr);
1033 let args = PurgeArgs {
1034 before: Utc::now().to_rfc3339(),
1035 no_warn: true,
1036 dry_run: false,
1037 };
1038 run_purge(dir.path(), &args, &app_config, &mut out).unwrap();
1039 }
1040 assert!(plain.exists(), "non-.zst files must be left alone");
1041 }
1042
1043 #[test]
1044 fn logs_purge_returns_ok_when_dir_missing() {
1045 let tmp = tempfile::tempdir().unwrap();
1046 let bogus = tmp.path().join("does-not-exist");
1047 let mut stdout = Vec::new();
1048 let mut stderr = Vec::new();
1049 let app_config = AppConfig::default();
1050 let mut out = output(&mut stdout, &mut stderr);
1051 let args = PurgeArgs {
1052 before: Utc::now().to_rfc3339(),
1053 no_warn: true,
1054 dry_run: true,
1055 };
1056 run_purge(&bogus, &args, &app_config, &mut out).unwrap();
1058 }
1059
1060 #[test]
1061 fn logs_purge_rejects_garbage_before_date() {
1062 let dir = tempfile::tempdir().unwrap();
1063 let mut stdout = Vec::new();
1064 let mut stderr = Vec::new();
1065 let app_config = AppConfig::default();
1066 let mut out = output(&mut stdout, &mut stderr);
1067 let args = PurgeArgs {
1068 before: "definitely-not-a-date".to_string(),
1069 no_warn: true,
1070 dry_run: true,
1071 };
1072 let err = run_purge(dir.path(), &args, &app_config, &mut out).unwrap_err();
1073 assert!(
1074 format!("{err}").contains("invalid --before date"),
1075 "got: {err}"
1076 );
1077 }
1078
1079 #[test]
1080 fn logs_archive_skips_recent_files() {
1081 let dir = tempfile::tempdir().unwrap();
1082 make_log(dir.path(), "ai-memory.log.2026-04-30", "today");
1084 let cfg = LoggingConfig {
1085 retention_days: Some(90),
1086 ..Default::default()
1087 };
1088 let mut stdout = Vec::new();
1089 let mut stderr = Vec::new();
1090 {
1091 let mut out = output(&mut stdout, &mut stderr);
1092 run_archive(dir.path(), &cfg, &mut out).unwrap();
1093 }
1094 let s = std::str::from_utf8(&stdout).unwrap();
1095 assert!(s.contains("archived 0"), "got: {s}");
1096 }
1097
1098 #[test]
1099 fn logs_run_resolves_log_dir_from_config_when_no_override() {
1100 let dir = tempfile::tempdir().unwrap();
1104 make_log(dir.path(), "ai-memory.log.2026-04-30", "from-config\n");
1105 let mut stdout = Vec::new();
1106 let mut stderr = Vec::new();
1107 let app_config = AppConfig {
1108 logging: Some(LoggingConfig {
1109 path: Some(dir.path().to_string_lossy().into_owned()),
1110 ..Default::default()
1111 }),
1112 ..Default::default()
1113 };
1114 {
1115 let mut out = output(&mut stdout, &mut stderr);
1116 let args = LogsArgs {
1117 action: LogsAction::Cat,
1118 since: None,
1119 until: None,
1120 level: None,
1121 namespace: None,
1122 actor: None,
1123 action_filter: None,
1124 format: "text".to_string(),
1125 log_dir: None,
1126 };
1127 run(args, &app_config, &mut out).unwrap();
1128 }
1129 let s = std::str::from_utf8(&stdout).unwrap();
1130 assert!(s.contains("from-config"), "expected config layer used: {s}");
1131 }
1132}