ccd-cli 1.0.0-alpha.2

Bootstrap and validate Continuous Context Development repositories
use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;

use anyhow::{Context, Result};
use serde::Serialize;

use crate::output::CommandReport;
use crate::paths::{git as git_paths, state as state_paths};
use crate::repo::marker as repo_marker;
use crate::repo::registry as repo_registry;

#[derive(Serialize)]
pub struct GcReport {
    command: &'static str,
    ok: bool,
    path: String,
    workspace_state_root: String,
    clone_state_root: String,
    marker_present: bool,
    removed_workspace_states: Vec<String>,
    removed_clone_states: Vec<String>,
    stale_repo_overlays: Vec<StaleRepoOverlay>,
}

#[derive(Serialize)]
pub struct StaleRepoOverlay {
    profile: String,
    project_id: String,
    locality_id: String,
    path: String,
    registry_path: String,
}

impl CommandReport for GcReport {
    fn exit_code(&self) -> ExitCode {
        ExitCode::SUCCESS
    }

    fn render_text(&self) {
        if self.removed_workspace_states.is_empty() {
            println!("No workspace-local garbage removed.");
        } else {
            println!(
                "Removed {} workspace-local state path(s).",
                self.removed_workspace_states.len()
            );
            for path in &self.removed_workspace_states {
                println!("- {path}");
            }
        }

        if self.stale_repo_overlays.is_empty() {
            println!("No stale repo overlays detected.");
            return;
        }

        println!();
        println!("Stale repo overlays (reported only, not deleted):");
        for overlay in &self.stale_repo_overlays {
            println!(
                "- profile={} project_id={} overlay={} missing={}",
                overlay.profile, overlay.project_id, overlay.path, overlay.registry_path
            );
        }
    }
}

pub fn run(repo_root: &Path) -> Result<GcReport> {
    let git_ccd_root = git_paths::ccd_dir(repo_root)?;
    let ccd_root = state_paths::default_ccd_root()?;
    let marker_present = repo_marker::load(repo_root)?.is_some();
    let clone_state_root = git_ccd_root.join("profiles");

    let removed_clone_states = prune_clone_state(&git_ccd_root, &ccd_root, marker_present)?;
    let stale_repo_overlays = find_stale_repo_overlays(&ccd_root)?;

    Ok(GcReport {
        command: "gc",
        ok: true,
        path: repo_root.display().to_string(),
        workspace_state_root: clone_state_root.display().to_string(),
        clone_state_root: clone_state_root.display().to_string(),
        marker_present,
        removed_workspace_states: removed_clone_states.clone(),
        removed_clone_states,
        stale_repo_overlays,
    })
}

fn prune_clone_state(
    git_ccd_root: &Path,
    ccd_root: &Path,
    marker_present: bool,
) -> Result<Vec<String>> {
    let clone_profiles_root = git_ccd_root.join("profiles");
    let mut removed = Vec::new();

    for profile_dir in sorted_child_dirs(&clone_profiles_root)? {
        let profile_name = profile_dir
            .file_name()
            .map(|value| value.to_string_lossy().into_owned())
            .unwrap_or_default();
        let durable_profile_root = ccd_root.join("profiles").join(&profile_name);
        let should_remove = !marker_present || !durable_profile_root.is_dir();
        if !should_remove {
            continue;
        }

        fs::remove_dir_all(&profile_dir)
            .with_context(|| format!("failed to remove {}", profile_dir.display()))?;
        removed.push(profile_dir.display().to_string());
    }

    remove_if_empty(&clone_profiles_root)?;
    remove_if_empty(git_ccd_root)?;

    Ok(removed)
}

fn find_stale_repo_overlays(ccd_root: &Path) -> Result<Vec<StaleRepoOverlay>> {
    let profiles_root = ccd_root.join("profiles");
    let mut stale = Vec::new();

    for profile_root in sorted_child_dirs(&profiles_root)? {
        let profile = profile_root
            .file_name()
            .map(|value| value.to_string_lossy().into_owned())
            .unwrap_or_default();
        let repo_overlays_root = profile_root.join("repos");

        for overlay_root in sorted_child_dirs(&repo_overlays_root)? {
            let locality_id = overlay_root
                .file_name()
                .map(|value| value.to_string_lossy().into_owned())
                .unwrap_or_default();
            let registry_path = ccd_root
                .join("repos")
                .join(&locality_id)
                .join(repo_registry::REPO_METADATA_FILE);
            if registry_path.is_file() {
                continue;
            }

            stale.push(StaleRepoOverlay {
                profile: profile.clone(),
                project_id: locality_id.clone(),
                locality_id,
                path: overlay_root.display().to_string(),
                registry_path: registry_path.display().to_string(),
            });
        }
    }

    Ok(stale)
}

fn sorted_child_dirs(root: &Path) -> Result<Vec<PathBuf>> {
    let Ok(read_dir) = fs::read_dir(root) else {
        return Ok(Vec::new());
    };

    let mut entries = read_dir
        .filter_map(|entry| entry.ok())
        .filter_map(|entry| {
            let Ok(file_type) = entry.file_type() else {
                return None;
            };
            if file_type.is_dir() {
                Some(entry.path())
            } else {
                None
            }
        })
        .collect::<Vec<_>>();
    entries.sort();
    Ok(entries)
}

fn remove_if_empty(path: &Path) -> Result<()> {
    if !path.is_dir() {
        return Ok(());
    }

    let mut entries = fs::read_dir(path)
        .with_context(|| format!("failed to read directory {}", path.display()))?;
    if entries.next().is_some() {
        return Ok(());
    }

    fs::remove_dir(path).with_context(|| format!("failed to remove {}", path.display()))?;
    Ok(())
}