ccd-cli 1.0.0-beta.1

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

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

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

#[derive(Serialize)]
pub struct UnlinkReport {
    command: &'static str,
    ok: bool,
    path: String,
    marker_path: String,
    locality_id: Option<String>,
    unlinked: bool,
    clone_state_root: Option<String>,
    clone_state_present: bool,
    warnings: Vec<String>,
}

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

    fn render_text(&self) {
        if self.unlinked {
            if let Some(locality_id) = &self.locality_id {
                println!("Unlinked workspace from project ID {locality_id}.");
            } else {
                println!("Removed local CCD link marker.");
            }
            println!("Marker removed: {}", self.marker_path);
        } else {
            println!("No repo link found at {}", self.marker_path);
        }

        if let Some(root) = &self.clone_state_root {
            println!("Workspace state root: {root}");
        }

        for warning in &self.warnings {
            println!("Warning: {warning}");
        }
    }
}

pub fn run(repo_root: &Path) -> Result<UnlinkReport> {
    let marker_path = repo_root.join(repo_marker::MARKER_FILE);
    let marker = repo_marker::load(repo_root)?;
    let mut warnings = Vec::new();

    let (locality_id, unlinked) = match marker {
        Some(marker) => {
            if let Err(error) = remove_current_clone_root(repo_root, &marker.locality_id) {
                warnings.push(format!(
                    "failed to prune current clone root from repo registry for locality_id `{}`: {error:#}",
                    marker.locality_id
                ));
            }
            fs::remove_file(&marker_path)
                .with_context(|| format!("failed to remove {}", marker_path.display()))?;
            (Some(marker.locality_id), true)
        }
        None => (None, false),
    };

    let clone_state_root = git_paths::ccd_dir(repo_root)
        .ok()
        .map(|path| path.join("profiles"));
    let clone_state_present = clone_state_root
        .as_ref()
        .map(|path| path.exists())
        .unwrap_or(false);
    if unlinked && clone_state_present {
        warnings.push(format!(
            "workspace-local state remains at {}; run `ccd gc --path {}` to prune it if this workspace will stay detached",
            clone_state_root
                .as_ref()
                .expect("clone state root should exist when present")
                .display(),
            repo_root.display()
        ));
    }

    Ok(UnlinkReport {
        command: "unlink",
        ok: true,
        path: repo_root.display().to_string(),
        marker_path: marker_path.display().to_string(),
        locality_id,
        unlinked,
        clone_state_root: clone_state_root.map(|path| path.display().to_string()),
        clone_state_present,
        warnings,
    })
}

fn remove_current_clone_root(repo_root: &Path, locality_id: &str) -> Result<()> {
    let clone_root = repo_registry::normalize_clone_root(repo_root)?;
    let registry_path = state::default_ccd_root()?
        .join("repos")
        .join(repo_marker::validate_locality_id(locality_id)?)
        .join(repo_registry::REPO_METADATA_FILE);
    let Some(mut entry) = repo_registry::load(&registry_path)? else {
        return Ok(());
    };

    if entry.remove_known_clone_root(&clone_root) {
        repo_registry::write(&registry_path, &entry)?;
    }

    Ok(())
}