use std::fs;
use std::io::{BufRead, BufReader, Write as _};
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
use clap::{Args, Subcommand};
use crate::cli::CliOutput;
use crate::config::{AppConfig, LoggingConfig};
use crate::log_paths;
use crate::logging::resolve_log_dir_with_override;
#[derive(Args)]
pub struct LogsArgs {
#[command(subcommand)]
pub action: LogsAction,
#[arg(long, global = true, value_name = "TS")]
pub since: Option<String>,
#[arg(long, global = true, value_name = "TS")]
pub until: Option<String>,
#[arg(long, global = true)]
pub level: Option<String>,
#[arg(long, global = true)]
pub namespace: Option<String>,
#[arg(long, global = true)]
pub actor: Option<String>,
#[arg(long, global = true)]
pub action_filter: Option<String>,
#[arg(long, global = true, default_value = "text")]
pub format: String,
#[arg(long, global = true, value_name = "PATH")]
pub log_dir: Option<PathBuf>,
}
#[derive(Subcommand)]
pub enum LogsAction {
Tail(TailArgs),
Cat,
Archive,
Purge(PurgeArgs),
}
#[derive(Args)]
pub struct TailArgs {
#[arg(long, default_value_t = 50)]
pub lines: usize,
#[arg(long, default_value_t = false)]
pub follow: bool,
#[arg(long, default_value_t = 1000)]
pub follow_interval_ms: u64,
#[arg(long, default_value_t = 0, hide = true)]
pub max_polls: u64,
}
#[derive(Args)]
pub struct PurgeArgs {
#[arg(long, value_name = "DATE")]
pub before: String,
#[arg(long, default_value_t = false)]
pub no_warn: bool,
#[arg(long, default_value_t = false)]
pub dry_run: bool,
}
pub fn run(args: LogsArgs, app_config: &AppConfig, out: &mut CliOutput<'_>) -> Result<()> {
let logging_cfg = app_config.effective_logging();
let resolved = resolve_log_dir_with_override(args.log_dir.as_deref(), &logging_cfg)
.with_context(|| "resolving operational log directory")?;
let dir = resolved.path.clone();
let _source: log_paths::PathSource = resolved.source;
let filters = args_filters(&args);
match args.action {
LogsAction::Tail(t) => run_tail(&dir, &filters, &t, out),
LogsAction::Cat => run_cat(&dir, &filters, out),
LogsAction::Archive => run_archive(&dir, &logging_cfg, out),
LogsAction::Purge(p) => run_purge(&dir, &p, app_config, out),
}
}
#[derive(Default, Clone)]
struct Filters {
since: Option<DateTime<Utc>>,
until: Option<DateTime<Utc>>,
level: Option<String>,
namespace: Option<String>,
actor: Option<String>,
action: Option<String>,
format_json: bool,
}
fn args_filters(a: &LogsArgs) -> Filters {
Filters {
since: a.since.as_deref().and_then(parse_ts),
until: a.until.as_deref().and_then(parse_ts),
level: a.level.clone(),
namespace: a.namespace.clone(),
actor: a.actor.clone(),
action: a.action_filter.clone(),
format_json: a.format == "json",
}
}
fn parse_ts(s: &str) -> Option<DateTime<Utc>> {
if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
return Some(dt.with_timezone(&Utc));
}
if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
let dt = NaiveDateTime::new(d, NaiveTime::from_hms_opt(0, 0, 0).unwrap());
return Some(Utc.from_utc_datetime(&dt));
}
None
}
fn line_matches(line: &str, filters: &Filters) -> bool {
if let Some(level) = &filters.level
&& !line
.to_ascii_uppercase()
.contains(&level.to_ascii_uppercase())
{
return false;
}
if let Some(ns) = &filters.namespace
&& !line.to_ascii_lowercase().contains(&ns.to_ascii_lowercase())
{
return false;
}
if let Some(actor) = &filters.actor
&& !line
.to_ascii_lowercase()
.contains(&actor.to_ascii_lowercase())
{
return false;
}
if let Some(action) = &filters.action
&& !line
.to_ascii_lowercase()
.contains(&action.to_ascii_lowercase())
{
return false;
}
if filters.since.is_some() || filters.until.is_some() {
let ts = extract_timestamp(line);
if let Some(ts) = ts {
if let Some(since) = filters.since
&& ts < since
{
return false;
}
if let Some(until) = filters.until
&& ts > until
{
return false;
}
}
}
true
}
fn extract_timestamp(line: &str) -> Option<DateTime<Utc>> {
if let Some(stop) = line.find(' ') {
let head = &line[..stop];
if let Ok(dt) = DateTime::parse_from_rfc3339(head) {
return Some(dt.with_timezone(&Utc));
}
}
if let Some(idx) = line.find("\"timestamp\":\"") {
let rest = &line[idx + 13..];
if let Some(end) = rest.find('"') {
if let Ok(dt) = DateTime::parse_from_rfc3339(&rest[..end]) {
return Some(dt.with_timezone(&Utc));
}
}
}
None
}
fn enumerate_log_files(dir: &Path) -> Result<Vec<PathBuf>> {
if !dir.exists() {
return Ok(Vec::new());
}
let mut files: Vec<PathBuf> = Vec::new();
for entry in fs::read_dir(dir).with_context(|| crate::errors::msg::reading(dir.display()))? {
let entry = entry?;
let p = entry.path();
if p.is_file()
&& p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.contains("ai-memory") && !n.ends_with(".zst"))
{
files.push(p);
}
}
files.sort();
Ok(files)
}
fn run_cat(dir: &Path, filters: &Filters, out: &mut CliOutput<'_>) -> Result<()> {
for f in enumerate_log_files(dir)? {
emit_file(&f, filters, out)?;
}
Ok(())
}
fn emit_file(path: &Path, filters: &Filters, out: &mut CliOutput<'_>) -> Result<()> {
let f = fs::File::open(path).with_context(|| crate::errors::msg::opening(path.display()))?;
for line in BufReader::new(f).lines() {
let line = line?;
if !line_matches(&line, filters) {
continue;
}
emit_line(&line, filters, out)?;
}
Ok(())
}
fn emit_line(line: &str, filters: &Filters, out: &mut CliOutput<'_>) -> Result<()> {
if filters.format_json {
if line.trim_start().starts_with('{') {
writeln!(out.stdout, "{line}")?;
} else {
let v = serde_json::json!({ "line": line });
writeln!(out.stdout, "{}", serde_json::to_string(&v)?)?;
}
} else {
writeln!(out.stdout, "{line}")?;
}
Ok(())
}
fn run_tail(dir: &Path, filters: &Filters, args: &TailArgs, out: &mut CliOutput<'_>) -> Result<()> {
let files = enumerate_log_files(dir)?;
let Some(latest) = files.last().cloned() else {
return Ok(());
};
let initial = read_tail_n(&latest, args.lines, filters)?;
for line in &initial {
emit_line(line, filters, out)?;
}
if !args.follow {
return Ok(());
}
let mut last_size = fs::metadata(&latest).map(|m| m.len()).unwrap_or(0);
let mut polls: u64 = 0;
loop {
std::thread::sleep(Duration::from_millis(args.follow_interval_ms));
polls += 1;
let cur_size = fs::metadata(&latest).map(|m| m.len()).unwrap_or(last_size);
if cur_size > last_size {
let new_lines = read_lines_after_offset(&latest, last_size)?;
for line in new_lines {
if line_matches(&line, filters) {
emit_line(&line, filters, out)?;
}
}
last_size = cur_size;
}
if args.max_polls > 0 && polls >= args.max_polls {
return Ok(());
}
}
}
fn read_tail_n(path: &Path, n: usize, filters: &Filters) -> Result<Vec<String>> {
let f = fs::File::open(path).with_context(|| crate::errors::msg::opening(path.display()))?;
let buf = BufReader::new(f);
let mut keep: Vec<String> = Vec::with_capacity(n);
for line in buf.lines() {
let line = line?;
if !line_matches(&line, filters) {
continue;
}
keep.push(line);
if keep.len() > n {
keep.remove(0);
}
}
Ok(keep)
}
fn read_lines_after_offset(path: &Path, offset: u64) -> Result<Vec<String>> {
use std::io::Seek as _;
use std::io::SeekFrom;
let mut f =
fs::File::open(path).with_context(|| crate::errors::msg::opening(path.display()))?;
f.seek(SeekFrom::Start(offset))?;
let buf = BufReader::new(f);
let mut out = Vec::new();
for line in buf.lines() {
out.push(line?);
}
Ok(out)
}
fn run_archive(dir: &Path, cfg: &LoggingConfig, out: &mut CliOutput<'_>) -> Result<()> {
let retention_days = i64::from(cfg.retention_days.unwrap_or(90));
let cutoff = Utc::now() - chrono::Duration::days(retention_days);
let mut compressed: u64 = 0;
let mut total_in: u64 = 0;
let mut total_out: u64 = 0;
for f in enumerate_log_files(dir)? {
let mtime = fs::metadata(&f)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| Utc.timestamp_opt(d.as_secs() as i64, 0).unwrap());
let Some(mtime) = mtime else {
continue;
};
if mtime >= cutoff {
continue;
}
let in_bytes = fs::read(&f).with_context(|| crate::errors::msg::reading(f.display()))?;
let in_size = in_bytes.len() as u64;
let out_path = f.with_extension(format!(
"{}.zst",
f.extension().and_then(|e| e.to_str()).unwrap_or("log")
));
let compressed_bytes = zstd_compress(&in_bytes)?;
let out_size = compressed_bytes.len() as u64;
fs::write(&out_path, &compressed_bytes)
.with_context(|| crate::errors::msg::writing(out_path.display()))?;
fs::remove_file(&f).with_context(|| format!("removing {}", f.display()))?;
compressed += 1;
total_in += in_size;
total_out += out_size;
}
writeln!(
out.stdout,
"archived {compressed} log file(s): {total_in} bytes -> {total_out} bytes"
)?;
Ok(())
}
fn zstd_compress(input: &[u8]) -> Result<Vec<u8>> {
let mut out = Vec::with_capacity(input.len() / 4 + 64);
{
let mut encoder = zstd::stream::write::Encoder::new(&mut out, 3)?;
encoder.write_all(input)?;
encoder.finish()?;
}
Ok(out)
}
fn run_purge(
dir: &Path,
args: &PurgeArgs,
app_config: &AppConfig,
out: &mut CliOutput<'_>,
) -> Result<()> {
let cutoff = parse_ts(&args.before)
.ok_or_else(|| anyhow!("invalid --before date: {} (expected RFC3339)", args.before))?;
if !args.no_warn {
warn_about_audit_gap(args, app_config, out)?;
}
if !dir.exists() {
return Ok(());
}
let mut deleted: u64 = 0;
for entry in fs::read_dir(dir)? {
let entry = entry?;
let p = entry.path();
if !p.is_file() {
continue;
}
if !p.to_string_lossy().ends_with(".zst") {
continue;
}
let mtime = fs::metadata(&p)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| Utc.timestamp_opt(d.as_secs() as i64, 0).unwrap());
if let Some(mt) = mtime
&& mt < cutoff
{
if args.dry_run {
writeln!(out.stdout, "would delete: {}", p.display())?;
} else {
fs::remove_file(&p).with_context(|| format!("removing {}", p.display()))?;
writeln!(out.stdout, "deleted: {}", p.display())?;
}
deleted += 1;
}
}
writeln!(out.stdout, "purged {deleted} archive(s)")?;
Ok(())
}
fn warn_about_audit_gap(
args: &PurgeArgs,
app_config: &AppConfig,
out: &mut CliOutput<'_>,
) -> Result<()> {
let audit = app_config.effective_audit();
if !audit.enabled.unwrap_or(false) {
return Ok(());
}
let retention = audit.effective_retention_days();
let cutoff = parse_ts(&args.before).unwrap_or_else(Utc::now);
let oldest_required = Utc::now() - chrono::Duration::days(i64::from(retention));
if cutoff > oldest_required {
writeln!(
out.stderr,
"warning: --before {ts} would delete archives newer than the configured \
audit retention horizon ({retention} days, oldest required = {oldest}). \
Continuing creates an audit gap that `ai-memory audit verify` will surface. \
Pass --no-warn to suppress this message in automated rotation pipelines.",
ts = args.before,
retention = retention,
oldest = oldest_required.to_rfc3339()
)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn make_log(dir: &Path, name: &str, contents: &str) -> PathBuf {
let p = dir.join(name);
fs::write(&p, contents).unwrap();
p
}
fn output<'a>(stdout: &'a mut Vec<u8>, stderr: &'a mut Vec<u8>) -> CliOutput<'a> {
CliOutput::from_std(stdout, stderr)
}
#[test]
fn logs_tail_returns_last_n_lines() {
let dir = tempfile::tempdir().unwrap();
let body = (1..=100)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
make_log(dir.path(), "ai-memory.log.2026-04-30", &body);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
{
let mut out = output(&mut stdout, &mut stderr);
let filters = Filters::default();
let args = TailArgs {
lines: 5,
follow: false,
follow_interval_ms: 50,
max_polls: 0,
};
run_tail(dir.path(), &filters, &args, &mut out).unwrap();
}
let s = std::str::from_utf8(&stdout).unwrap();
let lines: Vec<&str> = s.lines().collect();
assert_eq!(lines.len(), 5);
assert_eq!(lines.last().unwrap(), &"line 100");
}
#[test]
fn logs_tail_follows_appended_lines() {
let dir = tempfile::tempdir().unwrap();
let path = make_log(dir.path(), "ai-memory.log.2026-04-30", "first\n");
let path_clone = path.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(60));
let mut f = fs::OpenOptions::new()
.append(true)
.open(&path_clone)
.unwrap();
writeln!(f, "second").unwrap();
writeln!(f, "third").unwrap();
});
let mut stdout = Vec::new();
let mut stderr = Vec::new();
{
let mut out = output(&mut stdout, &mut stderr);
let filters = Filters::default();
let args = TailArgs {
lines: 10,
follow: true,
follow_interval_ms: 30,
max_polls: 6,
};
run_tail(dir.path(), &filters, &args, &mut out).unwrap();
}
let s = std::str::from_utf8(&stdout).unwrap();
assert!(s.contains("first"), "got: {s}");
assert!(s.contains("second"), "expected appended line: {s}");
}
#[test]
fn logs_archive_compresses_with_zstd() {
let dir = tempfile::tempdir().unwrap();
let body = "x".repeat(8192);
make_log(dir.path(), "ai-memory.log.2025-01-01", &body);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let cfg = LoggingConfig {
retention_days: Some(0),
..Default::default()
};
{
let mut out = output(&mut stdout, &mut stderr);
run_archive(dir.path(), &cfg, &mut out).unwrap();
}
let s = std::str::from_utf8(&stdout).unwrap();
assert!(s.contains("archived 1"), "expected archive count: {s}");
let entries: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().into_string().unwrap_or_default())
.collect();
assert!(
entries.iter().any(|n| n.ends_with(".zst")),
"expected a .zst entry, got {entries:?}"
);
}
#[test]
fn logs_purge_warns_about_audit_gap() {
let dir = tempfile::tempdir().unwrap();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let app_config = AppConfig {
audit: Some(crate::config::AuditConfig {
enabled: Some(true),
retention_days: Some(90),
..Default::default()
}),
..Default::default()
};
{
let mut out = output(&mut stdout, &mut stderr);
let args = PurgeArgs {
before: Utc::now().to_rfc3339(),
no_warn: false,
dry_run: true,
};
run_purge(dir.path(), &args, &app_config, &mut out).unwrap();
}
let serr = std::str::from_utf8(&stderr).unwrap();
assert!(
serr.contains("audit gap"),
"expected audit-gap warning: {serr}"
);
}
#[test]
fn logs_filter_namespace_substring() {
let dir = tempfile::tempdir().unwrap();
let body = "alpha line\nbeta line ns=widgets\ngamma line";
make_log(dir.path(), "ai-memory.log.2026-04-30", body);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
{
let mut out = output(&mut stdout, &mut stderr);
let filters = Filters {
namespace: Some("widgets".to_string()),
..Default::default()
};
run_cat(dir.path(), &filters, &mut out).unwrap();
}
let s = std::str::from_utf8(&stdout).unwrap();
assert!(s.contains("beta line"));
assert!(!s.contains("alpha line"));
assert!(!s.contains("gamma line"));
}
#[test]
fn logs_format_json_wraps_text_lines() {
let dir = tempfile::tempdir().unwrap();
let body = "plain text line";
make_log(dir.path(), "ai-memory.log.2026-04-30", body);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
{
let mut out = output(&mut stdout, &mut stderr);
let filters = Filters {
format_json: true,
..Default::default()
};
run_cat(dir.path(), &filters, &mut out).unwrap();
}
let s = std::str::from_utf8(&stdout).unwrap();
let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
assert_eq!(v["line"], "plain text line");
}
#[test]
fn parse_ts_accepts_rfc3339_form() {
let dt = parse_ts("2026-04-30T12:34:56+00:00").unwrap();
assert_eq!(
dt.format("%Y-%m-%dT%H:%M:%S+00:00").to_string(),
"2026-04-30T12:34:56+00:00"
);
}
#[test]
fn parse_ts_accepts_yyyy_mm_dd_form() {
let dt = parse_ts("2026-04-30").unwrap();
assert_eq!(
dt.format("%Y-%m-%d %H:%M:%S").to_string(),
"2026-04-30 00:00:00"
);
}
#[test]
fn parse_ts_returns_none_for_garbage() {
assert!(parse_ts("not a date").is_none());
assert!(parse_ts("").is_none());
assert!(parse_ts("2026/04/30").is_none());
}
#[test]
fn args_filters_threads_every_field() {
let args = LogsArgs {
action: LogsAction::Cat,
since: Some("2026-04-30".to_string()),
until: Some("2026-05-01".to_string()),
level: Some("WARN".to_string()),
namespace: Some("ns".to_string()),
actor: Some("alice".to_string()),
action_filter: Some("recall".to_string()),
format: "json".to_string(),
log_dir: None,
};
let f = args_filters(&args);
assert!(f.since.is_some());
assert!(f.until.is_some());
assert_eq!(f.level.as_deref(), Some("WARN"));
assert_eq!(f.namespace.as_deref(), Some("ns"));
assert_eq!(f.actor.as_deref(), Some("alice"));
assert_eq!(f.action.as_deref(), Some("recall"));
assert!(f.format_json);
}
#[test]
fn args_filters_handles_garbage_since_as_none() {
let args = LogsArgs {
action: LogsAction::Cat,
since: Some("garbage".to_string()),
until: None,
level: None,
namespace: None,
actor: None,
action_filter: None,
format: "text".to_string(),
log_dir: None,
};
let f = args_filters(&args);
assert!(f.since.is_none(), "garbage should fall back to None");
assert!(!f.format_json);
}
#[test]
fn line_matches_filters_by_level_substring() {
let f = Filters {
level: Some("error".to_string()),
..Default::default()
};
assert!(line_matches("ERROR something happened", &f));
assert!(line_matches("level=ERROR", &f));
assert!(!line_matches("INFO uneventful", &f));
}
#[test]
fn line_matches_filters_by_actor_case_insensitive() {
let f = Filters {
actor: Some("ALICE".to_string()),
..Default::default()
};
assert!(line_matches("user=alice@example.com", &f));
assert!(!line_matches("user=bob@example.com", &f));
}
#[test]
fn line_matches_filters_by_action_substring() {
let f = Filters {
action: Some("recall".to_string()),
..Default::default()
};
assert!(line_matches("action=recall ns=widgets", &f));
assert!(!line_matches("action=store ns=widgets", &f));
}
#[test]
fn line_matches_combined_filters_all_must_pass() {
let f = Filters {
level: Some("WARN".to_string()),
actor: Some("alice".to_string()),
namespace: Some("widgets".to_string()),
action: Some("store".to_string()),
..Default::default()
};
assert!(line_matches("WARN action=store actor=alice ns=widgets", &f));
assert!(!line_matches(
"WARN action=store actor=alice ns=other-ns",
&f
));
assert!(!line_matches(
"INFO action=store actor=alice ns=widgets",
&f
));
}
#[test]
fn line_matches_drops_lines_outside_since_window() {
let f = Filters {
since: parse_ts("2026-04-30"),
..Default::default()
};
let line = "2026-01-01T00:00:00+00:00 INFO old line";
assert!(!line_matches(line, &f));
let line2 = "2026-05-01T00:00:00+00:00 INFO recent";
assert!(line_matches(line2, &f));
}
#[test]
fn line_matches_drops_lines_after_until_window() {
let f = Filters {
until: parse_ts("2026-04-30"),
..Default::default()
};
let line = "2026-05-01T00:00:00+00:00 INFO too recent";
assert!(!line_matches(line, &f));
let line2 = "2026-04-29T00:00:00+00:00 INFO ok";
assert!(line_matches(line2, &f));
}
#[test]
fn line_matches_keeps_lines_with_no_extractable_timestamp_and_window_filter() {
let f = Filters {
since: parse_ts("2026-04-30"),
..Default::default()
};
assert!(line_matches("plain message no timestamp here", &f));
}
#[test]
fn extract_timestamp_recognises_leading_rfc3339() {
let line = "2026-04-30T12:00:00+00:00 INFO hi";
let ts = extract_timestamp(line).expect("rfc3339 token");
assert_eq!(ts.format("%Y-%m-%d").to_string(), "2026-04-30");
}
#[test]
fn extract_timestamp_recognises_json_timestamp_field() {
let line = r#"{"timestamp":"2026-04-30T12:00:00+00:00","msg":"hi"}"#;
let ts = extract_timestamp(line).expect("json timestamp");
assert_eq!(ts.format("%Y-%m-%d").to_string(), "2026-04-30");
}
#[test]
fn extract_timestamp_returns_none_when_absent() {
assert!(extract_timestamp("no timestamp").is_none());
assert!(extract_timestamp(r#"{"foo":"bar"}"#).is_none());
assert!(extract_timestamp(r#"{"timestamp":"garbage"}"#).is_none());
}
#[test]
fn logs_run_dispatches_to_cat_action() {
let dir = tempfile::tempdir().unwrap();
make_log(dir.path(), "ai-memory.log.2026-04-30", "hello world\n");
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let app_config = AppConfig {
logging: Some(LoggingConfig {
path: Some(dir.path().to_string_lossy().into_owned()),
..Default::default()
}),
..Default::default()
};
{
let mut out = output(&mut stdout, &mut stderr);
let args = LogsArgs {
action: LogsAction::Cat,
since: None,
until: None,
level: None,
namespace: None,
actor: None,
action_filter: None,
format: "text".to_string(),
log_dir: Some(dir.path().to_path_buf()),
};
run(args, &app_config, &mut out).unwrap();
}
let s = std::str::from_utf8(&stdout).unwrap();
assert!(s.contains("hello world"), "got: {s}");
}
#[test]
fn logs_run_dispatches_to_tail_action() {
let dir = tempfile::tempdir().unwrap();
make_log(
dir.path(),
"ai-memory.log.2026-04-30",
"line a\nline b\nline c\n",
);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let app_config = AppConfig::default();
{
let mut out = output(&mut stdout, &mut stderr);
let args = LogsArgs {
action: LogsAction::Tail(TailArgs {
lines: 2,
follow: false,
follow_interval_ms: 50,
max_polls: 0,
}),
since: None,
until: None,
level: None,
namespace: None,
actor: None,
action_filter: None,
format: "text".to_string(),
log_dir: Some(dir.path().to_path_buf()),
};
run(args, &app_config, &mut out).unwrap();
}
let s = std::str::from_utf8(&stdout).unwrap();
let lines: Vec<&str> = s.lines().collect();
assert_eq!(lines.len(), 2, "tail --lines=2 must cap output: {s}");
}
#[test]
fn logs_run_dispatches_to_archive_action_no_op_on_empty_dir() {
let dir = tempfile::tempdir().unwrap();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let app_config = AppConfig::default();
{
let mut out = output(&mut stdout, &mut stderr);
let args = LogsArgs {
action: LogsAction::Archive,
since: None,
until: None,
level: None,
namespace: None,
actor: None,
action_filter: None,
format: "text".to_string(),
log_dir: Some(dir.path().to_path_buf()),
};
run(args, &app_config, &mut out).unwrap();
}
let s = std::str::from_utf8(&stdout).unwrap();
assert!(s.contains("archived 0"), "expected zero count: {s}");
}
#[test]
fn logs_run_dispatches_to_purge_action() {
let dir = tempfile::tempdir().unwrap();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let app_config = AppConfig::default();
{
let mut out = output(&mut stdout, &mut stderr);
let args = LogsArgs {
action: LogsAction::Purge(PurgeArgs {
before: "2099-01-01".to_string(),
no_warn: true,
dry_run: true,
}),
since: None,
until: None,
level: None,
namespace: None,
actor: None,
action_filter: None,
format: "text".to_string(),
log_dir: Some(dir.path().to_path_buf()),
};
run(args, &app_config, &mut out).unwrap();
}
let s = std::str::from_utf8(&stdout).unwrap();
assert!(s.contains("purged 0"), "got: {s}");
}
fn backdate_mtime_to_epoch(path: &Path) {
let f = fs::OpenOptions::new().write(true).open(path).unwrap();
let one_second_past_epoch = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1);
f.set_modified(one_second_past_epoch).unwrap();
}
#[test]
fn logs_purge_dry_run_prints_would_delete_without_removing() {
let dir = tempfile::tempdir().unwrap();
let archive = dir.path().join("ai-memory.log.2010-01-01.zst");
fs::write(&archive, b"compressed").unwrap();
backdate_mtime_to_epoch(&archive);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let app_config = AppConfig::default();
{
let mut out = output(&mut stdout, &mut stderr);
let args = PurgeArgs {
before: Utc::now().to_rfc3339(),
no_warn: true,
dry_run: true,
};
run_purge(dir.path(), &args, &app_config, &mut out).unwrap();
}
let s = std::str::from_utf8(&stdout).unwrap();
assert!(s.contains("would delete:"), "got: {s}");
assert!(s.contains("purged 1"), "must report count: {s}");
assert!(archive.exists(), "dry run must not remove the file");
}
#[test]
fn logs_purge_actually_deletes_when_not_dry_run() {
let dir = tempfile::tempdir().unwrap();
let archive = dir.path().join("ai-memory.log.2010-01-01.zst");
fs::write(&archive, b"compressed").unwrap();
backdate_mtime_to_epoch(&archive);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let app_config = AppConfig::default();
{
let mut out = output(&mut stdout, &mut stderr);
let args = PurgeArgs {
before: Utc::now().to_rfc3339(),
no_warn: true,
dry_run: false,
};
run_purge(dir.path(), &args, &app_config, &mut out).unwrap();
}
let s = std::str::from_utf8(&stdout).unwrap();
assert!(s.contains("deleted:"), "got: {s}");
assert!(!archive.exists(), "non-dry-run must remove the file");
}
#[test]
fn logs_purge_skips_non_zst_files() {
let dir = tempfile::tempdir().unwrap();
let plain = dir.path().join("ai-memory.log.2010-01-01");
fs::write(&plain, b"raw").unwrap();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let app_config = AppConfig::default();
{
let mut out = output(&mut stdout, &mut stderr);
let args = PurgeArgs {
before: Utc::now().to_rfc3339(),
no_warn: true,
dry_run: false,
};
run_purge(dir.path(), &args, &app_config, &mut out).unwrap();
}
assert!(plain.exists(), "non-.zst files must be left alone");
}
#[test]
fn logs_purge_returns_ok_when_dir_missing() {
let tmp = tempfile::tempdir().unwrap();
let bogus = tmp.path().join("does-not-exist");
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let app_config = AppConfig::default();
let mut out = output(&mut stdout, &mut stderr);
let args = PurgeArgs {
before: Utc::now().to_rfc3339(),
no_warn: true,
dry_run: true,
};
run_purge(&bogus, &args, &app_config, &mut out).unwrap();
}
#[test]
fn logs_purge_rejects_garbage_before_date() {
let dir = tempfile::tempdir().unwrap();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let app_config = AppConfig::default();
let mut out = output(&mut stdout, &mut stderr);
let args = PurgeArgs {
before: "definitely-not-a-date".to_string(),
no_warn: true,
dry_run: true,
};
let err = run_purge(dir.path(), &args, &app_config, &mut out).unwrap_err();
assert!(
format!("{err}").contains("invalid --before date"),
"got: {err}"
);
}
#[test]
fn logs_archive_skips_recent_files() {
let dir = tempfile::tempdir().unwrap();
make_log(dir.path(), "ai-memory.log.2026-04-30", "today");
let cfg = LoggingConfig {
retention_days: Some(90),
..Default::default()
};
let mut stdout = Vec::new();
let mut stderr = Vec::new();
{
let mut out = output(&mut stdout, &mut stderr);
run_archive(dir.path(), &cfg, &mut out).unwrap();
}
let s = std::str::from_utf8(&stdout).unwrap();
assert!(s.contains("archived 0"), "got: {s}");
}
#[test]
fn logs_run_resolves_log_dir_from_config_when_no_override() {
let dir = tempfile::tempdir().unwrap();
make_log(dir.path(), "ai-memory.log.2026-04-30", "from-config\n");
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let app_config = AppConfig {
logging: Some(LoggingConfig {
path: Some(dir.path().to_string_lossy().into_owned()),
..Default::default()
}),
..Default::default()
};
{
let mut out = output(&mut stdout, &mut stderr);
let args = LogsArgs {
action: LogsAction::Cat,
since: None,
until: None,
level: None,
namespace: None,
actor: None,
action_filter: None,
format: "text".to_string(),
log_dir: None,
};
run(args, &app_config, &mut out).unwrap();
}
let s = std::str::from_utf8(&stdout).unwrap();
assert!(s.contains("from-config"), "expected config layer used: {s}");
}
}