ai-memory 0.6.3

AI-agnostic persistent memory system — MCP server, HTTP API, and CLI for any AI platform
Documentation
// Copyright 2026 AlphaOne LLC
// SPDX-License-Identifier: Apache-2.0

//! `cmd_archive` migration. See `cli::store` for the design pattern.

use crate::cli::CliOutput;
use crate::cli::helpers::id_short;
use crate::{db, validate};
use anyhow::Result;
use clap::{Args, Subcommand};
use std::path::Path;

#[derive(Args)]
pub struct ArchiveArgs {
    #[command(subcommand)]
    pub action: ArchiveAction,
}

#[derive(Subcommand)]
pub enum ArchiveAction {
    /// List archived memories
    List {
        #[arg(long, short)]
        namespace: Option<String>,
        #[arg(long, default_value_t = 50)]
        limit: usize,
        #[arg(long, default_value_t = 0)]
        offset: usize,
    },
    /// Restore an archived memory back to active
    Restore { id: String },
    /// Permanently delete old archive entries
    Purge {
        /// Delete archive entries older than N days (all if omitted)
        #[arg(long)]
        older_than_days: Option<i64>,
    },
    /// Show archive statistics
    Stats,
}

/// `archive` handler.
pub fn run(
    db_path: &Path,
    args: ArchiveArgs,
    json_out: bool,
    out: &mut CliOutput<'_>,
) -> Result<()> {
    let conn = db::open(db_path)?;
    match args.action {
        ArchiveAction::List {
            namespace,
            limit,
            offset,
        } => {
            let items = db::list_archived(&conn, namespace.as_deref(), limit, offset)?;
            if json_out {
                writeln!(
                    out.stdout,
                    "{}",
                    serde_json::json!({"archived": items, "count": items.len()})
                )?;
            } else if items.is_empty() {
                writeln!(out.stdout, "no archived memories")?;
            } else {
                for item in &items {
                    writeln!(
                        out.stdout,
                        "[{}] {} (archived: {})",
                        id_short(item["id"].as_str().unwrap_or("")),
                        item["title"].as_str().unwrap_or(""),
                        item["archived_at"].as_str().unwrap_or("")
                    )?;
                }
                writeln!(out.stdout, "{} archived memories", items.len())?;
            }
        }
        ArchiveAction::Restore { id } => {
            validate::validate_id(&id)?;
            let restored = db::restore_archived(&conn, &id)?;
            if json_out {
                writeln!(
                    out.stdout,
                    "{}",
                    serde_json::json!({"restored": restored, "id": id})
                )?;
            } else if restored {
                writeln!(out.stdout, "restored: {}", id_short(&id))?;
            } else {
                writeln!(out.stderr, "not found in archive: {id}")?;
                std::process::exit(1);
            }
        }
        ArchiveAction::Purge { older_than_days } => {
            let purged = db::purge_archive(&conn, older_than_days)?;
            if json_out {
                writeln!(out.stdout, "{}", serde_json::json!({"purged": purged}))?;
            } else {
                writeln!(out.stdout, "purged {purged} archived memories")?;
            }
        }
        ArchiveAction::Stats => {
            let stats = db::archive_stats(&conn)?;
            if json_out {
                writeln!(out.stdout, "{stats}")?;
            } else {
                writeln!(out.stdout, "archived: {} total", stats["archived_total"])?;
                if let Some(by_ns) = stats["by_namespace"].as_array() {
                    for ns in by_ns {
                        writeln!(
                            out.stdout,
                            "  {}: {}",
                            ns["namespace"].as_str().unwrap_or(""),
                            ns["count"]
                        )?;
                    }
                }
            }
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cli::test_utils::{TestEnv, seed_memory};

    #[test]
    fn test_archive_list_empty() {
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let args = ArchiveArgs {
            action: ArchiveAction::List {
                namespace: None,
                limit: 50,
                offset: 0,
            },
        };
        {
            let mut out = env.output();
            run(&db, args, false, &mut out).unwrap();
        }
        assert!(env.stdout_str().contains("no archived memories"));
    }

    #[test]
    fn test_archive_list_empty_json() {
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let args = ArchiveArgs {
            action: ArchiveAction::List {
                namespace: None,
                limit: 50,
                offset: 0,
            },
        };
        {
            let mut out = env.output();
            run(&db, args, true, &mut out).unwrap();
        }
        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
        assert_eq!(v["count"].as_u64().unwrap(), 0);
        assert!(v["archived"].is_array());
    }

    #[test]
    fn test_archive_list_with_namespace_filter() {
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let args = ArchiveArgs {
            action: ArchiveAction::List {
                namespace: Some("nope".to_string()),
                limit: 50,
                offset: 0,
            },
        };
        {
            let mut out = env.output();
            run(&db, args, false, &mut out).unwrap();
        }
        // No archived memories in any namespace yet.
        assert!(env.stdout_str().contains("no archived memories"));
    }

    #[test]
    fn test_archive_restore_nonexistent_exits_via_stderr() {
        // process::exit would terminate the test; we instead use a valid-looking
        // ID and expect the stderr write, but since exit(1) happens we test the
        // success branch via direct DB seeding.
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        // Seed a memory and archive it via direct DB call.
        let id = seed_memory(&db, "ns", "t", "c");
        let conn = db::open(&db).unwrap();
        let _ = db::archive_memory(&conn, &id, None);
        drop(conn);
        let args = ArchiveArgs {
            action: ArchiveAction::Restore { id: id.clone() },
        };
        {
            let mut out = env.output();
            run(&db, args, false, &mut out).unwrap();
        }
        assert!(env.stdout_str().contains("restored:"));
    }

    #[test]
    fn test_archive_restore_json() {
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let id = seed_memory(&db, "ns", "t", "c");
        let conn = db::open(&db).unwrap();
        let _ = db::archive_memory(&conn, &id, None);
        drop(conn);
        let args = ArchiveArgs {
            action: ArchiveAction::Restore { id: id.clone() },
        };
        {
            let mut out = env.output();
            run(&db, args, true, &mut out).unwrap();
        }
        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
        assert_eq!(v["restored"].as_bool().unwrap(), true);
    }

    #[test]
    fn test_archive_purge_no_filter() {
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let args = ArchiveArgs {
            action: ArchiveAction::Purge {
                older_than_days: None,
            },
        };
        {
            let mut out = env.output();
            run(&db, args, false, &mut out).unwrap();
        }
        assert!(env.stdout_str().contains("purged 0"));
    }

    #[test]
    fn test_archive_purge_older_than_filter() {
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let args = ArchiveArgs {
            action: ArchiveAction::Purge {
                older_than_days: Some(30),
            },
        };
        {
            let mut out = env.output();
            run(&db, args, true, &mut out).unwrap();
        }
        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
        assert_eq!(v["purged"].as_u64().unwrap(), 0);
    }

    #[test]
    fn test_archive_stats() {
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let args = ArchiveArgs {
            action: ArchiveAction::Stats,
        };
        {
            let mut out = env.output();
            run(&db, args, false, &mut out).unwrap();
        }
        assert!(env.stdout_str().contains("archived:"));
    }

    #[test]
    fn test_archive_stats_json() {
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let args = ArchiveArgs {
            action: ArchiveAction::Stats,
        };
        {
            let mut out = env.output();
            run(&db, args, true, &mut out).unwrap();
        }
        // Stats prints raw json blob, parseable.
        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
        assert!(v["archived_total"].is_number());
    }
}