llman 0.0.46

A tool for managing LLM application rules(prompts) ...
Documentation
use crate::sdd::shared::constants::LLMANSPEC_DIR_NAME;
use crate::sdd::shared::ids::validate_sdd_id;
use anyhow::{Context, Result, anyhow};
use chrono::NaiveDate;
use regex::Regex;
use std::fs;
use std::path::{Path, PathBuf};

pub const FREEZE_ARCHIVE_NAME: &str = "freezed_changes.7z.archived";

#[derive(Debug, Clone)]
pub struct FreezeArgs {
    pub before: Option<String>,
    pub keep_recent: usize,
    pub dry_run: bool,
    pub no_interactive: bool,
}

#[derive(Debug, Clone)]
pub struct ThawArgs {
    pub change: Vec<String>,
    pub dest: Option<PathBuf>,
}

#[derive(Debug, Clone)]
struct ArchivedChangeDir {
    name: String,
    path: PathBuf,
    date: NaiveDate,
}

pub fn run_freeze(args: FreezeArgs) -> Result<()> {
    run_freeze_with_root(Path::new("."), args)
}

pub fn run_thaw(args: ThawArgs) -> Result<()> {
    run_thaw_with_root(Path::new("."), args)
}

fn run_freeze_with_root(root: &Path, args: FreezeArgs) -> Result<()> {
    let archive_dir = archive_root(root);
    if !archive_dir.exists() {
        return Err(anyhow!(
            "archive directory not found: {}",
            archive_dir.display()
        ));
    }
    let before_date = parse_before_date(args.before.as_deref())?;
    let candidates = select_freeze_candidates(&archive_dir, before_date, args.keep_recent)?;
    let freeze_file = archive_dir.join(FREEZE_ARCHIVE_NAME);

    if args.dry_run {
        println!("Dry run: freeze target {}", freeze_file.display());
        if candidates.is_empty() {
            println!("No archived changes selected for freezing.");
        } else {
            println!("Would freeze {} archived changes:", candidates.len());
            for candidate in &candidates {
                println!("  - {}", candidate.name);
            }
        }
        return Ok(());
    }

    if candidates.is_empty() {
        println!("No archived changes selected for freezing.");
        return Ok(());
    }

    let tempdir = tempfile::tempdir().context("create tempdir for freeze staging")?;
    let staging_root = tempdir.path().join("staging");
    fs::create_dir_all(&staging_root).context("create staging root")?;

    if freeze_file.exists() {
        sevenz_rust2::decompress_file(&freeze_file, &staging_root)
            .map_err(|e| anyhow!("read existing freeze archive failed: {e}"))?;
    }

    for candidate in &candidates {
        let target = staging_root.join(&candidate.name);
        if target.exists() {
            fs::remove_dir_all(&target)
                .with_context(|| format!("remove existing staged dir {}", target.display()))?;
        }
        copy_dir_recursive(&candidate.path, &target)?;
    }

    let temp_archive = archive_dir.join(format!(".{}.tmp", FREEZE_ARCHIVE_NAME));
    if temp_archive.exists() {
        if temp_archive.is_dir() {
            fs::remove_dir_all(&temp_archive)?;
        } else {
            fs::remove_file(&temp_archive)?;
        }
    }

    sevenz_rust2::compress_to_path(&staging_root, &temp_archive)
        .map_err(|e| anyhow!("write freeze archive failed: {e}"))?;
    replace_file_atomically(&temp_archive, &freeze_file)?;

    for candidate in &candidates {
        fs::remove_dir_all(&candidate.path).with_context(|| {
            format!(
                "remove frozen source directory {}",
                candidate.path.display()
            )
        })?;
    }

    println!(
        "Froze {} archived changes into {}",
        candidates.len(),
        freeze_file.display()
    );
    Ok(())
}

fn run_thaw_with_root(root: &Path, args: ThawArgs) -> Result<()> {
    let archive_dir = archive_root(root);
    let freeze_file = archive_dir.join(FREEZE_ARCHIVE_NAME);
    if !freeze_file.exists() {
        return Err(anyhow!(
            "freeze archive not found: {}",
            freeze_file.display()
        ));
    }

    for change in &args.change {
        validate_sdd_id(change, "change")?;
    }

    let dest = args.dest.unwrap_or_else(|| archive_dir.join(".thawed"));
    fs::create_dir_all(&dest)
        .with_context(|| format!("create thaw destination {}", dest.display()))?;

    if args.change.is_empty() {
        sevenz_rust2::decompress_file(&freeze_file, &dest)
            .map_err(|e| anyhow!("thaw archive failed: {e}"))?;
        println!("Thawed archive to {}", dest.display());
        return Ok(());
    }

    let tempdir = tempfile::tempdir().context("create tempdir for selective thaw")?;
    sevenz_rust2::decompress_file(&freeze_file, tempdir.path())
        .map_err(|e| anyhow!("thaw archive failed: {e}"))?;

    for change in &args.change {
        let src = tempdir.path().join(change);
        if !src.exists() {
            return Err(anyhow!(
                "archived change '{}' not found in freeze archive",
                change
            ));
        }
        let target = dest.join(change);
        if target.exists() {
            fs::remove_dir_all(&target)
                .with_context(|| format!("remove existing thaw target {}", target.display()))?;
        }
        copy_dir_recursive(&src, &target)?;
    }

    println!(
        "Thawed {} selected archived changes to {}",
        args.change.len(),
        dest.display()
    );
    Ok(())
}

fn parse_before_date(value: Option<&str>) -> Result<Option<NaiveDate>> {
    match value {
        None => Ok(None),
        Some(raw) => {
            let date = NaiveDate::parse_from_str(raw, "%Y-%m-%d")
                .map_err(|_| anyhow!("invalid --before date '{}', expected YYYY-MM-DD", raw))?;
            Ok(Some(date))
        }
    }
}

fn select_freeze_candidates(
    archive_dir: &Path,
    before: Option<NaiveDate>,
    keep_recent: usize,
) -> Result<Vec<ArchivedChangeDir>> {
    let mut candidates = collect_archived_change_dirs(archive_dir)?;
    if let Some(limit) = before {
        candidates.retain(|entry| entry.date < limit);
    }
    candidates.sort_by(|a, b| a.date.cmp(&b.date).then_with(|| a.name.cmp(&b.name)));
    if keep_recent >= candidates.len() {
        return Ok(Vec::new());
    }
    let keep_until = candidates.len() - keep_recent;
    Ok(candidates.into_iter().take(keep_until).collect())
}

fn collect_archived_change_dirs(archive_dir: &Path) -> Result<Vec<ArchivedChangeDir>> {
    let mut entries = Vec::new();
    let date_dir_re = Regex::new(r"^\d{4}-\d{2}-\d{2}-.+$").expect("compile regex");
    for entry in fs::read_dir(archive_dir)? {
        let entry = entry?;
        if !entry.file_type()?.is_dir() {
            continue;
        }
        let name = entry.file_name().to_string_lossy().to_string();
        if !date_dir_re.is_match(&name) {
            continue;
        }
        let date = parse_dir_date(&name)
            .ok_or_else(|| anyhow!("invalid archive directory date prefix '{}'", name))?;
        entries.push(ArchivedChangeDir {
            name,
            path: entry.path(),
            date,
        });
    }
    Ok(entries)
}

fn parse_dir_date(name: &str) -> Option<NaiveDate> {
    let prefix = name.get(..10)?;
    NaiveDate::parse_from_str(prefix, "%Y-%m-%d").ok()
}

fn replace_file_atomically(temp_file: &Path, final_file: &Path) -> Result<()> {
    let backup_file = final_file.with_extension("archived.bak");
    if backup_file.exists() {
        fs::remove_file(&backup_file).with_context(|| {
            format!(
                "remove stale backup file before replace {}",
                backup_file.display()
            )
        })?;
    }

    let had_existing = final_file.exists();
    if had_existing {
        fs::rename(final_file, &backup_file).with_context(|| {
            format!(
                "move existing freeze archive to backup {}",
                backup_file.display()
            )
        })?;
    }

    if let Err(err) = fs::rename(temp_file, final_file) {
        if had_existing {
            let _ = fs::rename(&backup_file, final_file);
        }
        return Err(anyhow!(
            "replace freeze archive failed (target: {}): {}",
            final_file.display(),
            err
        ));
    }

    if had_existing && backup_file.exists() {
        fs::remove_file(&backup_file).with_context(|| {
            format!(
                "remove freeze archive backup after successful replace {}",
                backup_file.display()
            )
        })?;
    }

    Ok(())
}

fn copy_dir_recursive(from: &Path, to: &Path) -> Result<()> {
    fs::create_dir_all(to).with_context(|| format!("create directory {}", to.display()))?;
    for entry in fs::read_dir(from)? {
        let entry = entry?;
        let src = entry.path();
        let dst = to.join(entry.file_name());
        let file_type = entry.file_type()?;
        if file_type.is_dir() {
            copy_dir_recursive(&src, &dst)?;
        } else if file_type.is_file() {
            if let Some(parent) = dst.parent() {
                fs::create_dir_all(parent)?;
            }
            fs::copy(&src, &dst)
                .with_context(|| format!("copy file {} -> {}", src.display(), dst.display()))?;
        }
    }
    Ok(())
}

fn archive_root(root: &Path) -> PathBuf {
    root.join(LLMANSPEC_DIR_NAME)
        .join("changes")
        .join("archive")
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    #[test]
    fn collect_archived_changes_ignores_non_date_dirs() {
        let dir = tempdir().expect("tempdir");
        let archive = dir.path().join("archive");
        fs::create_dir_all(archive.join("2026-01-01-a")).expect("mkdir");
        fs::create_dir_all(archive.join(".thawed")).expect("mkdir");
        fs::create_dir_all(archive.join("notes")).expect("mkdir");
        fs::write(archive.join("freezed_changes.7z.archived"), b"x").expect("write");

        let entries = collect_archived_change_dirs(&archive).expect("collect");
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].name, "2026-01-01-a");
    }

    #[test]
    fn select_candidates_respects_before_and_keep_recent() {
        let dir = tempdir().expect("tempdir");
        let archive = dir.path().join("archive");
        fs::create_dir_all(archive.join("2026-01-01-a")).expect("mkdir");
        fs::create_dir_all(archive.join("2026-01-02-b")).expect("mkdir");
        fs::create_dir_all(archive.join("2026-01-03-c")).expect("mkdir");

        let before = NaiveDate::from_ymd_opt(2026, 1, 4).expect("valid date");
        let selected = select_freeze_candidates(&archive, Some(before), 1).expect("select");
        assert_eq!(selected.len(), 2);
        assert_eq!(selected[0].name, "2026-01-01-a");
        assert_eq!(selected[1].name, "2026-01-02-b");
    }
}