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(|| 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 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(|| 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        // Use a retention of 0 days so the archiver picks up the file
552        // regardless of its mtime — the file's mtime is "now" since we
553        // just created it.
554        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        // The .zst output replaces the source.
569        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    // ------------------------------------------------------------------
651    // PR-9e coverage uplift (issue #487): exercise the top-level `run`
652    // dispatcher, `args_filters` / `parse_ts`, every `line_matches`
653    // filter combination, `extract_timestamp`, and `run_purge` paths.
654    // ------------------------------------------------------------------
655
656    #[test]
657    fn parse_ts_accepts_rfc3339_form() {
658        let dt = parse_ts("2026-04-30T12:34:56+00:00").unwrap();
659        // Round-trip the parsed value through formatting rather than
660        // hardcoding a Unix epoch — keeps the test robust against
661        // chrono semantic changes.
662        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        // Midnight UTC of 2026-04-30.
672        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        // All four fields appear in the line.
767        assert!(line_matches("WARN action=store actor=alice ns=widgets", &f));
768        // Drop one of the four — must fail.
769        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        // Line is at 2026-01-01; since=2026-04-30 → drop.
782        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        // Line at 2026-05-01 is after since=2026-04-30 → keep.
789        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        // Line at 2026-05-01 is after until=2026-04-30 → drop.
800        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        // No leading RFC3339, no JSON timestamp; the filter falls
809        // through (best-effort) and the line is kept.
810        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        // JSON form with malformed timestamp must also fall through.
836        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    /// Backdate a file's mtime to the Unix epoch so the purge logic
962    /// always finds it "older than" any reasonable cutoff. Uses
963    /// `File::set_modified` (stable since Rust 1.75) so we don't take
964    /// a `filetime` dev-dep for a one-off helper.
965    fn backdate_mtime_to_epoch(path: &Path) {
966        let f = fs::OpenOptions::new().write(true).open(path).unwrap();
967        // Set to one second past epoch so mtime is unambiguously
968        // before any "now" cutoff. The kernel may reject a literal
969        // UNIX_EPOCH on some filesystems.
970        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                // Far-future cutoff so the archive is in scope.
987                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        // Plain log file (no .zst suffix) — must NOT be purged.
1026        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        // Must succeed silently — fresh installs have no log dir yet.
1057        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        // Recent file (mtime ~ now) — retention 90 days; must be kept.
1083        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        // When `log_dir` flag is None, `run` falls through to
1101        // `effective_logging` -> `path`. We point that at a tempdir
1102        // populated with a valid log so `Cat` produces output.
1103        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}