help-probe 0.1.0

CLI tool discovery and automation framework that extracts structured information from command help text
Documentation
use crate::model::ProbeResult;
use anyhow::{Context, Result};
use serde_json;
use std::collections::hash_map::DefaultHasher;
use std::fs;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

/// Cache configuration.
#[derive(Debug, Clone)]
pub struct CacheConfig {
    /// Cache directory path.
    pub cache_dir: PathBuf,
    /// Whether caching is enabled.
    pub enabled: bool,
    /// Maximum cache age in seconds (None = no expiration).
    pub max_age_secs: Option<u64>,
}

impl Default for CacheConfig {
    fn default() -> Self {
        Self {
            cache_dir: default_cache_dir(),
            enabled: true,
            max_age_secs: Some(86400 * 7), // 7 days default
        }
    }
}

/// Get the default cache directory.
pub fn default_cache_dir() -> PathBuf {
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
    PathBuf::from(home).join(".cache").join("help-probe")
}

/// Generate a cache key from command and arguments.
pub fn generate_cache_key(program: &str, args: &[String]) -> String {
    let mut hasher = DefaultHasher::new();
    program.hash(&mut hasher);
    args.hash(&mut hasher);
    format!("{:x}", hasher.finish())
}

/// Get the cache file path for a given key.
fn cache_file_path(cache_dir: &Path, key: &str) -> PathBuf {
    cache_dir.join(format!("{}.json", key))
}

/// Cache entry metadata.
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct CacheEntry {
    /// Timestamp when cached (Unix epoch seconds).
    timestamp: u64,
    /// Command version hash (from help text hash).
    version_hash: String,
    /// The cached probe result.
    result: ProbeResult,
}

/// Read a cached result if available and valid.
pub fn read_cache(
    program: &str,
    args: &[String],
    config: &CacheConfig,
) -> Result<Option<ProbeResult>> {
    if !config.enabled {
        return Ok(None);
    }

    // Ensure cache directory exists
    if !config.cache_dir.exists() {
        fs::create_dir_all(&config.cache_dir)
            .with_context(|| format!("Failed to create cache directory: {:?}", config.cache_dir))?;
    }

    let key = generate_cache_key(program, args);
    let cache_file = cache_file_path(&config.cache_dir, &key);

    if !cache_file.exists() {
        return Ok(None);
    }

    // Read and parse cache file
    let content = fs::read_to_string(&cache_file)
        .with_context(|| format!("Failed to read cache file: {:?}", cache_file))?;

    let entry: CacheEntry = serde_json::from_str(&content)
        .with_context(|| format!("Failed to parse cache file: {:?}", cache_file))?;

    // Check cache age
    if let Some(max_age) = config.max_age_secs {
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs();
        if now.saturating_sub(entry.timestamp) > max_age {
            // Cache expired, remove it
            let _ = fs::remove_file(&cache_file);
            return Ok(None);
        }
    }

    // Verify version hasn't changed by checking if help text hash matches
    // We'll compute a simple hash of the help text to detect changes
    let current_version_hash = compute_version_hash(&entry.result);
    if current_version_hash != entry.version_hash {
        // Command version changed, invalidate cache
        let _ = fs::remove_file(&cache_file);
        return Ok(None);
    }

    Ok(Some(entry.result))
}

/// Write a result to cache.
pub fn write_cache(
    program: &str,
    args: &[String],
    result: &ProbeResult,
    config: &CacheConfig,
) -> Result<()> {
    if !config.enabled {
        return Ok(());
    }

    // Ensure cache directory exists
    if !config.cache_dir.exists() {
        fs::create_dir_all(&config.cache_dir)
            .with_context(|| format!("Failed to create cache directory: {:?}", config.cache_dir))?;
    }

    let key = generate_cache_key(program, args);
    let cache_file = cache_file_path(&config.cache_dir, &key);

    let timestamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs();

    let version_hash = compute_version_hash(result);

    let entry = CacheEntry {
        timestamp,
        version_hash,
        result: result.clone(),
    };

    let content =
        serde_json::to_string_pretty(&entry).context("Failed to serialize cache entry")?;

    // Write to temporary file first, then rename (atomic operation)
    let temp_file = cache_file.with_extension("tmp");
    fs::write(&temp_file, content)
        .with_context(|| format!("Failed to write cache file: {:?}", temp_file))?;
    fs::rename(&temp_file, &cache_file)
        .with_context(|| format!("Failed to rename cache file: {:?}", cache_file))?;

    Ok(())
}

/// Compute a version hash from the probe result.
/// This hash changes when the command's help text changes.
fn compute_version_hash(result: &ProbeResult) -> String {
    let mut hasher = DefaultHasher::new();
    result.raw_stdout.hash(&mut hasher);
    result.raw_stderr.hash(&mut hasher);
    format!("{:x}", hasher.finish())
}

/// Clear the cache for a specific command, or all cache if no command provided.
pub fn clear_cache(
    program: Option<&str>,
    args: Option<&[String]>,
    config: &CacheConfig,
) -> Result<usize> {
    if !config.cache_dir.exists() {
        return Ok(0);
    }

    let mut cleared = 0;

    if let Some(prog) = program {
        // Clear specific command cache
        let key = if let Some(arg_list) = args {
            generate_cache_key(prog, arg_list)
        } else {
            // If no args provided, clear all caches for this program
            // This is a simplified approach - in practice, we'd need to track all keys
            return clear_all_cache(config);
        };
        let cache_file = cache_file_path(&config.cache_dir, &key);
        if cache_file.exists() {
            fs::remove_file(&cache_file)
                .with_context(|| format!("Failed to remove cache file: {:?}", cache_file))?;
            cleared = 1;
        }
    } else {
        // Clear all cache
        return clear_all_cache(config);
    }

    Ok(cleared)
}

/// Clear all cache entries.
fn clear_all_cache(config: &CacheConfig) -> Result<usize> {
    if !config.cache_dir.exists() {
        return Ok(0);
    }

    let mut cleared = 0;
    let entries = fs::read_dir(&config.cache_dir)
        .with_context(|| format!("Failed to read cache directory: {:?}", config.cache_dir))?;

    for entry in entries {
        let entry = entry.context("Failed to read cache directory entry")?;
        let path = entry.path();
        if path.extension().and_then(|s| s.to_str()) == Some("json") {
            fs::remove_file(&path)
                .with_context(|| format!("Failed to remove cache file: {:?}", path))?;
            cleared += 1;
        }
    }

    Ok(cleared)
}