a3s 0.10.0

a3s — A3S coding agent CLI; `a3s code` launches the interactive TUI
Documentation
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};

use anyhow::{bail, Context};
use serde_json::json;

use crate::cli::args::{CacheArgs, CacheCommand, OutputMode};
use crate::cli::context::InvocationContext;
use crate::cli::output::{coded_error, render_value, ExitClass};

pub(crate) fn run(args: CacheArgs, context: &InvocationContext) -> anyhow::Result<()> {
    match args.command {
        CacheCommand::Path => path(context),
        CacheCommand::Status => status(context),
        CacheCommand::Prune(args) => prune(args.dry_run, context),
        CacheCommand::Clean(args) => clean(args.dry_run, args.yes, context),
    }
}

fn path(context: &InvocationContext) -> anyhow::Result<()> {
    let output = context.output_mode();
    let path = context.component_paths.cache_root.clone();
    render_value(output, "cache.path", json!({"path": path}), || {
        println!("{}", path.display());
    })
}

fn status(context: &InvocationContext) -> anyhow::Result<()> {
    let output = context.output_mode();
    let path = context.component_paths.cache_root.clone();
    let usage = cache_usage(&path)?;
    render_value(
        output,
        "cache.status",
        json!({"path": path, "files": usage.files, "directories": usage.directories, "bytes": usage.bytes}),
        || {
            println!("path: {}", path.display());
            println!("files: {}", usage.files);
            println!("directories: {}", usage.directories);
            println!("bytes: {}", usage.bytes);
        },
    )
}

fn prune(dry_run: bool, context: &InvocationContext) -> anyhow::Result<()> {
    let output = context.output_mode();
    let root = context.component_paths.cache_root.clone();
    let candidates = prune_candidates(&root)?;
    let bytes = candidates
        .iter()
        .filter_map(|path| std::fs::metadata(path).ok())
        .map(|metadata| metadata.len())
        .sum::<u64>();
    if !dry_run {
        for candidate in &candidates {
            remove_cache_entry(candidate)?;
        }
    }
    render_value(
        output,
        "cache.prune",
        json!({"path": root, "dryRun": dry_run, "removedEntries": candidates.len(), "removedBytes": bytes}),
        || {
            println!(
                "{} {} unreferenced temporary cache entries ({} bytes)",
                if dry_run { "would remove" } else { "removed" },
                candidates.len(),
                bytes
            );
        },
    )
}

fn clean(dry_run: bool, yes: bool, context: &InvocationContext) -> anyhow::Result<()> {
    let output = context.output_mode();
    let root = context.component_paths.cache_root.clone();
    validate_cache_root(&root, context.home.as_deref())?;
    let usage = cache_usage(&root)?;
    if !dry_run && !yes {
        confirm("Remove all recreatable A3S cache content?", context)?;
    }
    if !dry_run && root.is_dir() {
        for entry in std::fs::read_dir(&root)? {
            remove_cache_entry(&entry?.path())?;
        }
    }
    render_value(
        output,
        "cache.clean",
        json!({"path": root, "dryRun": dry_run, "removedFiles": usage.files, "removedBytes": usage.bytes}),
        || {
            println!(
                "{} {} cache files ({} bytes)",
                if dry_run { "would remove" } else { "removed" },
                usage.files,
                usage.bytes
            );
        },
    )
}

#[derive(Default)]
struct CacheUsage {
    files: u64,
    directories: u64,
    bytes: u64,
}

fn cache_usage(root: &Path) -> anyhow::Result<CacheUsage> {
    let mut usage = CacheUsage::default();
    if !root.exists() {
        return Ok(usage);
    }
    walk_cache(root, &mut usage)?;
    Ok(usage)
}

fn walk_cache(path: &Path, usage: &mut CacheUsage) -> anyhow::Result<()> {
    for entry in std::fs::read_dir(path)? {
        let entry = entry?;
        let metadata = entry.metadata()?;
        if metadata.file_type().is_symlink() {
            continue;
        }
        if metadata.is_dir() {
            usage.directories += 1;
            walk_cache(&entry.path(), usage)?;
        } else if metadata.is_file() {
            usage.files += 1;
            usage.bytes = usage.bytes.saturating_add(metadata.len());
        }
    }
    Ok(())
}

fn prune_candidates(root: &Path) -> anyhow::Result<Vec<PathBuf>> {
    let mut candidates = Vec::new();
    if !root.is_dir() {
        return Ok(candidates);
    }
    collect_prune_candidates(root, SystemTime::now(), &mut candidates)?;
    Ok(candidates)
}

fn collect_prune_candidates(
    directory: &Path,
    now: SystemTime,
    candidates: &mut Vec<PathBuf>,
) -> anyhow::Result<()> {
    for entry in std::fs::read_dir(directory)? {
        let entry = entry?;
        let path = entry.path();
        let metadata = entry.metadata()?;
        if metadata.file_type().is_symlink() {
            continue;
        }
        let name = path
            .file_name()
            .and_then(|value| value.to_str())
            .unwrap_or_default();
        let old = metadata
            .modified()
            .ok()
            .and_then(|modified| now.duration_since(modified).ok())
            .is_some_and(|age| age >= Duration::from_secs(24 * 60 * 60));
        if old && (name.ends_with(".tmp") || name.starts_with(".staging-")) {
            candidates.push(path);
        } else if metadata.is_dir() {
            collect_prune_candidates(&path, now, candidates)?;
        }
    }
    Ok(())
}

fn remove_cache_entry(path: &Path) -> anyhow::Result<()> {
    let metadata = std::fs::symlink_metadata(path)?;
    if metadata.is_dir() && !metadata.file_type().is_symlink() {
        std::fs::remove_dir_all(path)
    } else {
        std::fs::remove_file(path)
    }
    .with_context(|| format!("could not remove cache entry {}", path.display()))
}

fn validate_cache_root(root: &Path, home: Option<&Path>) -> anyhow::Result<()> {
    if root.parent().is_none() || root == Path::new("/") {
        bail!("refusing to clean an unsafe cache root {}", root.display());
    }
    if std::fs::symlink_metadata(root).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
        bail!(
            "refusing to clean a symbolic-link cache root {}",
            root.display()
        );
    }
    let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
    if home
        .map(Path::to_path_buf)
        .map(|home| home.canonicalize().unwrap_or(home))
        .is_some_and(|home| home == canonical_root)
    {
        bail!("refusing to clean the home directory as a cache root");
    }
    Ok(())
}

fn confirm(prompt: &str, context: &InvocationContext) -> anyhow::Result<()> {
    if context.output_mode() != OutputMode::Human
        || context.interaction.non_interactive
        || !context.terminal.stdin
        || !context.terminal.stderr
    {
        bail!("cache cleaning requires `--yes` in non-interactive mode");
    }
    eprint!("{prompt} [y/N] ");
    std::io::stderr().flush()?;
    let mut answer = String::new();
    std::io::stdin().read_line(&mut answer)?;
    if matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
        Ok(())
    } else {
        Err(coded_error(
            "operation.cancelled",
            "cache cleaning cancelled",
            ExitClass::Cancelled,
        ))
    }
}