ccd-cli 1.0.0-beta.3

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

use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

use crate::paths::git as git_paths;
use crate::repo::marker::{self as repo_marker, MarkerSubstrate};

const DIRECTORY_WORKSPACE_BINDING_FILE: &str = "workspace.toml";
const DIRECTORY_WORKSPACE_BINDING_SCHEMA_VERSION: u32 = 1;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SubstrateKind {
    Git,
    Directory,
}

#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct DirectoryWorkspaceBinding {
    #[serde(default = "default_directory_binding_version")]
    version: u32,
    substrate: MarkerSubstrate,
    canonical_root: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ResolvedSubstrate {
    kind: SubstrateKind,
    workspace_state_root: PathBuf,
    workspace_binding_path: Option<PathBuf>,
    canonical_root: Option<PathBuf>,
}

impl ResolvedSubstrate {
    pub(crate) fn resolve(repo_root: &Path, ccd_root: &Path) -> Result<Self> {
        let marker = repo_marker::load(repo_root)?;
        match marker.as_ref().map(|marker| marker.substrate()) {
            Some(MarkerSubstrate::Directory) => Self::resolve_directory(repo_root, ccd_root, true),
            _ => Ok(Self::git(git_paths::ccd_dir(repo_root)?)),
        }
    }

    pub(crate) fn resolve_for_attach(repo_root: &Path, ccd_root: &Path) -> Result<Self> {
        let marker = repo_marker::load(repo_root)?;
        match marker.as_ref().map(|marker| marker.substrate()) {
            Some(MarkerSubstrate::Directory) => Self::resolve_directory(repo_root, ccd_root, false),
            Some(MarkerSubstrate::Git) => Ok(Self::git(git_paths::ccd_dir(repo_root)?)),
            None => match git_paths::ccd_dir(repo_root) {
                Ok(path) => Ok(Self::git(path)),
                Err(_) => Self::resolve_directory(repo_root, ccd_root, false),
            },
        }
    }

    pub(crate) fn git(workspace_state_root: PathBuf) -> Self {
        Self {
            kind: SubstrateKind::Git,
            workspace_state_root,
            workspace_binding_path: None,
            canonical_root: None,
        }
    }

    pub(crate) fn workspace_state_root(&self) -> &Path {
        &self.workspace_state_root
    }

    pub(crate) fn kind(&self) -> SubstrateKind {
        self.kind
    }

    pub(crate) fn is_git(&self) -> bool {
        self.kind == SubstrateKind::Git
    }

    pub(crate) fn workspace_binding_path(&self) -> Option<&Path> {
        self.workspace_binding_path.as_deref()
    }

    pub(crate) fn workspace_binding_exists(&self) -> bool {
        self.workspace_binding_path
            .as_ref()
            .map(|path| path.exists())
            .unwrap_or(false)
    }

    pub(crate) fn ensure_workspace_binding(&self) -> Result<()> {
        if self.kind != SubstrateKind::Directory {
            return Ok(());
        }

        self.validate_directory_state_root()?;

        let Some(binding_path) = self.workspace_binding_path.as_ref() else {
            return Ok(());
        };
        let Some(canonical_root) = self.canonical_root.as_ref() else {
            return Ok(());
        };

        fs::create_dir_all(&self.workspace_state_root).with_context(|| {
            format!(
                "failed to create directory {}",
                self.workspace_state_root.display()
            )
        })?;
        let binding = DirectoryWorkspaceBinding {
            version: DIRECTORY_WORKSPACE_BINDING_SCHEMA_VERSION,
            substrate: MarkerSubstrate::Directory,
            canonical_root: canonical_root.display().to_string(),
        };
        let contents = toml::to_string(&binding).context("failed to serialize workspace TOML")?;
        fs::write(binding_path, contents)
            .with_context(|| format!("failed to write {}", binding_path.display()))?;
        Ok(())
    }

    fn resolve_directory(repo_root: &Path, ccd_root: &Path, require_binding: bool) -> Result<Self> {
        let metadata = fs::symlink_metadata(repo_root)
            .with_context(|| format!("failed to inspect {}", repo_root.display()))?;
        if metadata.file_type().is_symlink() {
            bail!("directory substrate roots cannot be symlinks; use the canonical directory path");
        }

        let canonical_root = repo_root
            .canonicalize()
            .with_context(|| format!("failed to canonicalize {}", repo_root.display()))?;
        let workspace_state_root = ccd_root
            .join("workspaces")
            .join(directory_workspace_id(&canonical_root));
        let substrate = Self {
            kind: SubstrateKind::Directory,
            workspace_binding_path: Some(
                workspace_state_root.join(DIRECTORY_WORKSPACE_BINDING_FILE),
            ),
            workspace_state_root,
            canonical_root: Some(canonical_root),
        };

        substrate.validate_directory_state_root()?;
        if substrate.workspace_binding_exists() {
            substrate.validate_directory_binding()?;
        } else if require_binding {
            let binding_path = substrate
                .workspace_binding_path
                .as_ref()
                .expect("directory binding path");
            bail!(
                "directory workspace binding is missing at {}; run `ccd attach` to bootstrap or repair this directory workspace",
                binding_path.display()
            );
        }

        Ok(substrate)
    }

    fn validate_directory_state_root(&self) -> Result<()> {
        let Some(canonical_root) = self.canonical_root.as_ref() else {
            return Ok(());
        };

        let normalized_workspace_root = normalize_path_for_comparison(&self.workspace_state_root);
        if normalized_workspace_root.starts_with(canonical_root) {
            bail!(
                "directory workspace state root {} is inside project root {}; configure HOME or CCD state outside the project to avoid committing runtime state",
                self.workspace_state_root.display(),
                canonical_root.display()
            );
        }

        let visible_ccd_root = canonical_root.join(".ccd");
        if visible_ccd_root.exists() {
            let metadata = fs::symlink_metadata(&visible_ccd_root)
                .with_context(|| format!("failed to inspect {}", visible_ccd_root.display()))?;
            if metadata.file_type().is_symlink() {
                bail!(
                    "repo-root {} cannot be a symlink for directory substrate projects",
                    visible_ccd_root.display()
                );
            }
            if metadata.is_dir() {
                let unsafe_entries = fs::read_dir(&visible_ccd_root)
                    .with_context(|| {
                        format!("failed to read directory {}", visible_ccd_root.display())
                    })?
                    .filter_map(|entry| entry.ok())
                    .map(|entry| entry.file_name().to_string_lossy().to_string())
                    .filter(|name| name != "commands")
                    .collect::<Vec<_>>();
                if !unsafe_entries.is_empty() {
                    bail!(
                        "repo-root {} contains visible runtime-state entries ({}); remove them before using the directory substrate",
                        visible_ccd_root.display(),
                        unsafe_entries.join(", ")
                    );
                }
            }
        }

        if self.workspace_state_root.exists() {
            let metadata = fs::symlink_metadata(&self.workspace_state_root).with_context(|| {
                format!("failed to inspect {}", self.workspace_state_root.display())
            })?;
            if metadata.file_type().is_symlink() {
                bail!(
                    "directory workspace state root {} cannot be a symlink",
                    self.workspace_state_root.display()
                );
            }
        }

        Ok(())
    }

    fn validate_directory_binding(&self) -> Result<()> {
        let Some(binding_path) = self.workspace_binding_path.as_ref() else {
            return Ok(());
        };
        let Some(canonical_root) = self.canonical_root.as_ref() else {
            return Ok(());
        };

        let metadata = fs::symlink_metadata(binding_path)
            .with_context(|| format!("failed to inspect {}", binding_path.display()))?;
        if metadata.file_type().is_symlink() {
            bail!(
                "directory workspace binding {} cannot be a symlink",
                binding_path.display()
            );
        }

        let contents = fs::read_to_string(binding_path)
            .with_context(|| format!("failed to read {}", binding_path.display()))?;
        let binding: DirectoryWorkspaceBinding =
            toml::from_str(&contents).context("failed to parse workspace TOML")?;
        if binding.substrate != MarkerSubstrate::Directory {
            bail!(
                "directory workspace binding {} must declare substrate = \"directory\"",
                binding_path.display()
            );
        }

        let bound_root = PathBuf::from(&binding.canonical_root);
        if bound_root != *canonical_root {
            bail!(
                "directory workspace binding {} points to {} instead of {}; run `ccd attach` to refresh local workspace state",
                binding_path.display(),
                bound_root.display(),
                canonical_root.display()
            );
        }

        Ok(())
    }
}

fn default_directory_binding_version() -> u32 {
    DIRECTORY_WORKSPACE_BINDING_SCHEMA_VERSION
}

fn directory_workspace_id(canonical_root: &Path) -> String {
    let digest = Sha256::digest(canonical_root.display().to_string().as_bytes());
    format!("dir_{digest:x}")
}

fn normalize_path_for_comparison(path: &Path) -> PathBuf {
    let mut suffix = Vec::new();
    let mut current = path;

    loop {
        match current.canonicalize() {
            Ok(canonical) => {
                let mut normalized = canonical;
                for component in suffix.iter().rev() {
                    normalized.push(component);
                }
                return normalized;
            }
            Err(_) => {
                let Some(file_name) = current.file_name() else {
                    return path.to_path_buf();
                };
                suffix.push(file_name.to_os_string());
                let Some(parent) = current.parent() else {
                    return path.to_path_buf();
                };
                current = parent;
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use std::process::Command;

    use tempfile::tempdir;

    use super::*;
    use crate::repo::marker::RepoMarker;

    #[test]
    fn resolve_detects_git_substrate_and_workspace_state_root() {
        let temp = tempdir().expect("tempdir");
        let home = tempdir().expect("home");
        init_git_repo(temp.path());

        let substrate =
            ResolvedSubstrate::resolve(temp.path(), &home.path().join(".ccd")).expect("resolved");

        assert_eq!(substrate.kind, SubstrateKind::Git);
        assert_eq!(
            substrate.workspace_state_root(),
            temp.path().join(".git/ccd")
        );
    }

    #[test]
    fn resolve_fails_closed_outside_git_repo() {
        let temp = tempdir().expect("tempdir");
        let home = tempdir().expect("home");

        let error = ResolvedSubstrate::resolve(temp.path(), &home.path().join(".ccd"))
            .expect_err("git path should fail");

        assert!(error.to_string().contains("git rev-parse --git-path ccd"));
    }

    #[test]
    fn resolve_for_attach_bootstraps_directory_substrate_outside_git_repo() {
        let temp = tempdir().expect("tempdir");
        let home = tempdir().expect("home");

        let substrate =
            ResolvedSubstrate::resolve_for_attach(temp.path(), &home.path().join(".ccd"))
                .expect("resolved substrate");

        assert_eq!(substrate.kind, SubstrateKind::Directory);
        assert!(substrate
            .workspace_state_root()
            .starts_with(home.path().join(".ccd/workspaces")));
    }

    #[test]
    fn resolve_for_attach_rejects_directory_state_inside_project_root() {
        let temp = tempdir().expect("tempdir");

        let error = ResolvedSubstrate::resolve_for_attach(temp.path(), &temp.path().join(".ccd"))
            .expect_err("unsafe ccd root should fail");

        assert!(error.to_string().contains("inside project root"));
    }

    #[test]
    fn resolve_directory_requires_binding_once_marker_exists() {
        let temp = tempdir().expect("tempdir");
        let home = tempdir().expect("home");
        let marker = RepoMarker::new_directory("ccdrepo_123", None).expect("marker");
        repo_marker::write(temp.path(), &marker).expect("marker written");

        let error = ResolvedSubstrate::resolve(temp.path(), &home.path().join(".ccd"))
            .expect_err("missing binding should fail");

        assert!(error
            .to_string()
            .contains("directory workspace binding is missing"));
    }

    fn init_git_repo(path: &Path) {
        let status = Command::new("git")
            .args(["init", "-b", "main"])
            .current_dir(path)
            .status()
            .expect("failed to execute `git init`");
        assert!(
            status.success(),
            "git init failed with exit status: {status}"
        );
    }
}