Skip to main content

ai_memory/cli/
logs.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `ai-memory logs` — operator-facing CLI for the file logging facility
5//! (PR-5 of issue #487). Tail, archive, purge, filter.
6//!
7//! The subcommand operates on the directory configured by
8//! `[logging] path = ...` in `config.toml`. When logging is disabled
9//! the commands still work against the empty default directory and
10//! exit 0 — the CLI is a no-op rather than a hard error so a fresh
11//! install isn't surprised.
12
13use 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    /// RFC3339 lower bound. Lines older than this are dropped from
32    /// `tail` / `cat` output.
33    #[arg(long, global = true, value_name = "TS")]
34    pub since: Option<String>,
35    /// RFC3339 upper bound.
36    #[arg(long, global = true, value_name = "TS")]
37    pub until: Option<String>,
38    /// Filter to lines whose tracing `level` field equals this.
39    #[arg(long, global = true)]
40    pub level: Option<String>,
41    /// Filter to lines that mention this namespace (case-insensitive
42    /// substring match against the line body).
43    #[arg(long, global = true)]
44    pub namespace: Option<String>,
45    /// Filter to lines that mention this actor / agent_id.
46    #[arg(long, global = true)]
47    pub actor: Option<String>,
48    /// Filter to lines that mention this audit `action`.
49    #[arg(long, global = true)]
50    pub action_filter: Option<String>,
51    /// Output format: `text` (passthrough) or `json` (one filtered
52    /// line per JSON object).
53    #[arg(long, global = true, default_value = "text")]
54    pub format: String,
55    /// Override the operational log directory. Highest-priority layer
56    /// in the resolution ladder (CLI > `AI_MEMORY_LOG_DIR` > `[logging]
57    /// path` in config.toml > platform default). Refuses world-writable
58    /// directories — see `docs/security/audit-trail.md`.
59    #[arg(long, global = true, value_name = "PATH")]
60    pub log_dir: Option<PathBuf>,
61}
62
63#[derive(Subcommand)]
64pub enum LogsAction {
65    /// Print recent log lines and (with `--follow`) stream new ones.
66    Tail(TailArgs),
67    /// Print every log line in chronological order, applying any
68    /// global filters.
69    Cat,
70    /// Compress rotated log files older than the configured
71    /// `retention_days` using zstd.
72    Archive,
73    /// Delete archived log files older than `--before <date>`. Warns
74    /// when the date overlaps the audit retention horizon (deleting
75    /// audit logs creates an audit gap).
76    Purge(PurgeArgs),
77}
78
79#[derive(Args)]
80pub struct TailArgs {
81    /// Number of recent lines to print before tailing. Default 50.
82    #[arg(long, default_value_t = 50)]
83    pub lines: usize,
84    /// Stream new lines as they arrive (poll-based, ~1s cadence).
85    #[arg(long, default_value_t = false)]
86    pub follow: bool,
87    /// Override the poll interval in milliseconds. Default 1000.
88    #[arg(long, default_value_t = 1000)]
89    pub follow_interval_ms: u64,
90    /// Stop tailing after this many polls in `--follow` mode. Tests
91    /// pass a small bound so the loop terminates deterministically.
92    /// 0 = no bound.
93    #[arg(long, default_value_t = 0, hide = true)]
94    pub max_polls: u64,
95}
96
97#[derive(Args)]
98pub struct PurgeArgs {
99    /// Delete archives whose mtime is older than this RFC3339 date.
100    #[arg(long, value_name = "DATE")]
101    pub before: String,
102    /// Suppress the audit-gap warning even when audit logs would be
103    /// deleted. Reserved for automated rotation pipelines.
104    #[arg(long, default_value_t = false)]
105    pub no_warn: bool,
106    /// Dry run — print which files would be deleted without
107    /// removing them.
108    #[arg(long, default_value_t = false)]
109    pub dry_run: bool,
110}
111
112/// `ai-memory logs` entry point.
113pub 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        // Best-effort: scan the line for an RFC3339 prefix or a
191        // `"timestamp":"…"` JSON field.
192        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    // Try a leading RFC3339 token.
211    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    // JSON form.
218    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
229/// Enumerate every `ai-memory.log*` file in `dir`, sorted by name
230/// (which sorts by date because the rolling appender's suffix is
231/// `YYYY-MM-DD[-HH[-MM]]`).
232fn 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 the line is already JSON pass through; otherwise wrap
274        // it so downstream `jq` always sees an object.
275        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    // Read the tail-N matching lines.
293    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        // Use a retention of 0 days so the archiver picks up the file
551        // regardless of its mtime — the file's mtime is "now" since we
552        // just created it.
553        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        // The .zst output replaces the source.
568        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    // ------------------------------------------------------------------
650    // PR-9e coverage uplift (issue #487): exercise the top-level `run`
651    // dispatcher, `args_filters` / `parse_ts`, every `line_matches`
652    // filter combination, `extract_timestamp`, and `run_purge` paths.
653    // ------------------------------------------------------------------
654
655    #[test]
656    fn parse_ts_accepts_rfc3339_form() {
657        let dt = parse_ts("2026-04-30T12:34:56+00:00").unwrap();
658        // Round-trip the parsed value through formatting rather than
659        // hardcoding a Unix epoch — keeps the test robust against
660        // chrono semantic changes.
661        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        // Midnight UTC of 2026-04-30.
671        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        // All four fields appear in the line.
766        assert!(line_matches("WARN action=store actor=alice ns=widgets", &f));
767        // Drop one of the four — must fail.
768        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        // Line is at 2026-01-01; since=2026-04-30 → drop.
781        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        // Line at 2026-05-01 is after since=2026-04-30 → keep.
788        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        // Line at 2026-05-01 is after until=2026-04-30 → drop.
799        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        // No leading RFC3339, no JSON timestamp; the filter falls
808        // through (best-effort) and the line is kept.
809        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        // JSON form with malformed timestamp must also fall through.
835        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    /// Backdate a file's mtime to the Unix epoch so the purge logic
961    /// always finds it "older than" any reasonable cutoff. Uses
962    /// `File::set_modified` (stable since Rust 1.75) so we don't take
963    /// a `filetime` dev-dep for a one-off helper.
964    fn backdate_mtime_to_epoch(path: &Path) {
965        let f = fs::OpenOptions::new().write(true).open(path).unwrap();
966        // Set to one second past epoch so mtime is unambiguously
967        // before any "now" cutoff. The kernel may reject a literal
968        // UNIX_EPOCH on some filesystems.
969        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                // Far-future cutoff so the archive is in scope.
986                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        // Plain log file (no .zst suffix) — must NOT be purged.
1025        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        // Must succeed silently — fresh installs have no log dir yet.
1056        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        // Recent file (mtime ~ now) — retention 90 days; must be kept.
1082        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        // When `log_dir` flag is None, `run` falls through to
1100        // `effective_logging` -> `path`. We point that at a tempdir
1101        // populated with a valid log so `Cat` produces output.
1102        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}