void-cli 0.0.3

CLI for void — anonymous encrypted source control
//! Local repo registry for persistent repo identity and lookup.
//!
//! Stores per-repo records in `~/.void/repos/<uuid>.json` for:
//! - Name-based clone/pull (e.g., `void clone myapp`)
//! - Head CID tracking across push/pull
//! - TOFU trust model for incoming shares
//!
//! One file per repo avoids lock contention and enables atomic updates.

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};

/// A registry entry for a known repository.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RepoRecord {
    /// UUID v4 identifying this repository.
    pub id: String,
    /// Human-friendly name (derived from directory on init).
    pub name: String,
    /// Optional description.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// Branch name → HEAD CID mapping.
    #[serde(default)]
    pub head: HashMap<String, String>,
    /// How this repo was acquired: "self" or contact name.
    pub origin: String,
    /// Signing pubkeys (hex) trusted to push updates for this repo.
    #[serde(default)]
    pub trusted_sources: Vec<String>,
    /// Known local checkout paths (lazy-pruned on read).
    #[serde(default)]
    pub local_paths: Vec<PathBuf>,
    /// Deprecated: was used for plaintext key store (removed).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub key_ref: Option<String>,
    /// ISO8601 creation timestamp.
    pub created: String,
    /// ISO8601 last-updated timestamp.
    pub updated: String,
}

/// Get the registry directory (~/.void/repos/).
///
/// Respects the `HOME` environment variable (for test isolation),
/// falling back to `dirs::home_dir()`.
pub fn registry_dir() -> PathBuf {
    std::env::var("HOME")
        .ok()
        .map(PathBuf::from)
        .or_else(dirs::home_dir)
        .unwrap_or_else(|| PathBuf::from("."))
        .join(".void")
        .join("repos")
}

/// Load a single registry record by repo UUID.
///
/// Performs lazy pruning: removes stale or non-absolute local paths
/// and writes the record back if anything changed.
pub fn load_record(id: &str) -> Result<Option<RepoRecord>, String> {
    let path = registry_dir().join(format!("{}.json", id));
    if !path.exists() {
        return Ok(None);
    }
    let content =
        fs::read_to_string(&path).map_err(|e| format!("failed to read registry record: {}", e))?;
    let mut record: RepoRecord = serde_json::from_str(&content)
        .map_err(|e| format!("failed to parse registry record: {}", e))?;

    // Lazy prune: remove relative and stale paths, save back if changed
    if prune_local_paths(&mut record) {
        let _ = save_record(&record);
    }

    Ok(Some(record))
}

/// Save a registry record (creates or overwrites).
pub fn save_record(record: &RepoRecord) -> Result<(), String> {
    let dir = registry_dir();
    fs::create_dir_all(&dir).map_err(|e| format!("failed to create registry dir: {}", e))?;
    let path = dir.join(format!("{}.json", record.id));
    let content = serde_json::to_string_pretty(record)
        .map_err(|e| format!("failed to serialize registry record: {}", e))?;
    fs::write(&path, content).map_err(|e| format!("failed to write registry record: {}", e))?;
    Ok(())
}

/// Delete a registry record. Returns true if the file existed.
pub fn delete_record(id: &str) -> Result<bool, String> {
    let path = registry_dir().join(format!("{}.json", id));
    if !path.exists() {
        return Ok(false);
    }
    fs::remove_file(&path).map_err(|e| format!("failed to delete registry record: {}", e))?;
    Ok(true)
}

/// List all registry records (reads all JSON files in ~/.void/repos/).
///
/// Performs lazy pruning on each record's local paths.
pub fn list_records() -> Result<Vec<RepoRecord>, String> {
    let dir = registry_dir();
    if !dir.exists() {
        return Ok(Vec::new());
    }
    let mut records = Vec::new();
    for entry in fs::read_dir(&dir).map_err(|e| format!("failed to read registry dir: {}", e))? {
        let entry = entry.map_err(|e| format!("failed to read dir entry: {}", e))?;
        let path = entry.path();
        if path.extension().map(|e| e == "json").unwrap_or(false) {
            let content = match fs::read_to_string(&path) {
                Ok(c) => c,
                Err(_) => continue,
            };
            match serde_json::from_str::<RepoRecord>(&content) {
                Ok(mut record) => {
                    // Lazy prune: remove relative and stale paths, save back if changed
                    if prune_local_paths(&mut record) {
                        let _ = save_record(&record);
                    }
                    records.push(record);
                }
                Err(_) => continue,
            }
        }
    }
    // Sort by name for consistent output
    records.sort_by(|a, b| a.name.cmp(&b.name));
    Ok(records)
}

/// Remove invalid local paths: non-absolute paths (e.g. legacy `"."` entries)
/// and paths that no longer contain a `.void` directory.
/// Returns true if any paths were pruned.
pub fn prune_local_paths(record: &mut RepoRecord) -> bool {
    let before = record.local_paths.len();
    record
        .local_paths
        .retain(|p| p.is_absolute() && p.join(".void").exists());
    record.local_paths.len() < before
}

/// Register a new repo or update an existing record.
///
/// If a record with the given `id` already exists, updates its local_paths
/// and trusted_sources. Otherwise creates a new record.
pub fn register_repo(
    id: &str,
    name: &str,
    local_path: &Path,
    origin: &str,
    signing_pubkey: Option<&str>,
) -> Result<RepoRecord, String> {
    let now = chrono::Utc::now().to_rfc3339();

    // Canonicalize to absolute path so we never store relative paths like "."
    let abs_path = local_path
        .canonicalize()
        .unwrap_or_else(|_| local_path.to_path_buf());

    let record = match load_record(id)? {
        Some(mut existing) => {
            // Update existing record
            if abs_path.is_absolute()
                && !abs_path.as_os_str().is_empty()
                && !existing.local_paths.contains(&abs_path)
            {
                existing.local_paths.push(abs_path);
            }
            if let Some(spk) = signing_pubkey {
                if !existing.trusted_sources.contains(&spk.to_string()) {
                    existing.trusted_sources.push(spk.to_string());
                }
            }
            existing.updated = now;
            existing
        }
        None => {
            // Create new record
            let mut local_paths = Vec::new();
            if abs_path.is_absolute() && !abs_path.as_os_str().is_empty() {
                local_paths.push(abs_path);
            }
            let mut trusted_sources = Vec::new();
            if let Some(spk) = signing_pubkey {
                trusted_sources.push(spk.to_string());
            }
            RepoRecord {
                id: id.to_string(),
                name: name.to_string(),
                description: None,
                head: HashMap::new(),
                origin: origin.to_string(),
                trusted_sources,
                local_paths,
                key_ref: None,
                created: now.clone(),
                updated: now,
            }
        }
    };

    save_record(&record)?;
    Ok(record)
}

/// Update the HEAD CID for a branch in a registry record.
pub fn update_head(id: &str, branch: &str, cid: &str) -> Result<(), String> {
    let mut record =
        load_record(id)?.ok_or_else(|| format!("no registry record for repo {}", id))?;
    record.head.insert(branch.to_string(), cid.to_string());
    record.updated = chrono::Utc::now().to_rfc3339();
    save_record(&record)
}

/// Resolve a target string to a RepoRecord. Tries exact UUID, UUID prefix, then name.
///
/// Returns `Err` if no match found or if multiple matches are ambiguous.
/// For interactive disambiguation, use `resolve_target_interactive`.
pub fn resolve_target(target: &str) -> Result<RepoRecord, String> {
    match resolve_target_candidates(target)? {
        candidates if candidates.is_empty() => {
            Err(format!("no repo matching '{}' in registry", target))
        }
        candidates if candidates.len() == 1 => Ok(candidates.into_iter().next().unwrap()),
        candidates => Err(format!(
            "ambiguous: {} repos match '{}' in registry",
            candidates.len(),
            target
        )),
    }
}

/// Resolve a target string with interactive disambiguation.
///
/// If multiple repos match, presents a selection menu to the user.
/// Falls back to non-interactive error if not a TTY.
pub fn resolve_target_interactive(target: &str) -> Result<RepoRecord, String> {
    let candidates = resolve_target_candidates(target)?;
    match candidates.len() {
        0 => Err(format!("no repo matching '{}' in registry", target)),
        1 => Ok(candidates.into_iter().next().unwrap()),
        _ => {
            // Build selection items
            let items: Vec<String> = candidates
                .iter()
                .map(|r| {
                    let short_id = &r.id[..8.min(r.id.len())];
                    let paths = if r.local_paths.is_empty() {
                        String::new()
                    } else {
                        format!(" ({})", r.local_paths[0].display())
                    };
                    format!("{} [{}]{}", r.name, short_id, paths)
                })
                .collect();

            let selection = dialoguer::Select::new()
                .with_prompt(format!(
                    "Multiple repos match '{}'. Which one?",
                    target
                ))
                .items(&items)
                .default(0)
                .interact_opt()
                .map_err(|e| format!("selection failed: {}", e))?;

            match selection {
                Some(idx) => Ok(candidates.into_iter().nth(idx).unwrap()),
                None => Err("selection cancelled".to_string()),
            }
        }
    }
}

/// Find all candidate repos matching a target string.
///
/// Tries exact UUID, UUID prefix, then name (case-insensitive).
fn resolve_target_candidates(target: &str) -> Result<Vec<RepoRecord>, String> {
    // Try exact UUID first
    if let Ok(Some(record)) = load_record(target) {
        return Ok(vec![record]);
    }

    // Try UUID prefix match
    let all = list_records()?;
    let prefix_matches: Vec<RepoRecord> = all
        .iter()
        .filter(|r| r.id.starts_with(target))
        .cloned()
        .collect();
    if !prefix_matches.is_empty() {
        return Ok(prefix_matches);
    }

    // Try name match (case-insensitive)
    let lower = target.to_lowercase();
    let name_matches: Vec<RepoRecord> = all
        .into_iter()
        .filter(|r| r.name.to_lowercase() == lower)
        .collect();
    Ok(name_matches)
}

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

    /// Override registry_dir to use a temp dir for testing.
    fn setup_test_registry() -> (tempfile::TempDir, PathBuf) {
        let dir = tempdir().unwrap();
        let repos_dir = dir.path().join(".void").join("repos");
        fs::create_dir_all(&repos_dir).unwrap();
        (dir, repos_dir)
    }

    fn save_test_record(repos_dir: &Path, record: &RepoRecord) {
        let path = repos_dir.join(format!("{}.json", record.id));
        let content = serde_json::to_string_pretty(record).unwrap();
        fs::write(path, content).unwrap();
    }

    fn make_record(id: &str, name: &str) -> RepoRecord {
        let now = chrono::Utc::now().to_rfc3339();
        RepoRecord {
            id: id.to_string(),
            name: name.to_string(),
            description: None,
            head: HashMap::new(),
            origin: "self".to_string(),
            trusted_sources: Vec::new(),
            local_paths: Vec::new(),
            key_ref: None,
            created: now.clone(),
            updated: now,
        }
    }

    #[test]
    fn test_repo_record_serialization() {
        let mut record = make_record("test-uuid", "my-project");
        record
            .head
            .insert("trunk".to_string(), "bafyabc123".to_string());
        record.trusted_sources.push("deadbeef".to_string());

        let json = serde_json::to_string(&record).unwrap();
        assert!(json.contains("\"id\":\"test-uuid\""));
        assert!(json.contains("\"name\":\"my-project\""));
        assert!(json.contains("\"origin\":\"self\""));

        // Roundtrip
        let restored: RepoRecord = serde_json::from_str(&json).unwrap();
        assert_eq!(restored.id, "test-uuid");
        assert_eq!(restored.name, "my-project");
        assert_eq!(restored.head.get("trunk"), Some(&"bafyabc123".to_string()));
    }

    #[test]
    fn test_prune_local_paths() {
        let mut record = make_record("test", "proj");
        record.local_paths.push(PathBuf::from("/nonexistent/path1"));
        record.local_paths.push(PathBuf::from("/nonexistent/path2"));

        let pruned = prune_local_paths(&mut record);
        assert!(pruned);
        assert!(record.local_paths.is_empty());
    }

    #[test]
    fn test_prune_local_paths_removes_relative() {
        let mut record = make_record("test", "proj");
        record.local_paths.push(PathBuf::from("."));
        record.local_paths.push(PathBuf::from("relative/path"));

        let pruned = prune_local_paths(&mut record);
        assert!(pruned);
        assert!(record.local_paths.is_empty());
    }

    #[test]
    fn test_prune_local_paths_keeps_valid() {
        let dir = tempdir().unwrap();
        fs::create_dir_all(dir.path().join(".void")).unwrap();

        let mut record = make_record("test", "proj");
        record.local_paths.push(dir.path().to_path_buf());
        record.local_paths.push(PathBuf::from("/nonexistent"));
        record.local_paths.push(PathBuf::from(".")); // relative — should be pruned

        let pruned = prune_local_paths(&mut record);
        assert!(pruned);
        assert_eq!(record.local_paths.len(), 1);
        assert_eq!(record.local_paths[0], dir.path());
    }
}