ccd-cli 1.0.0-alpha.9

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

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

use crate::commands::sync::{self, SyncProfile};
use crate::handoff::{self, BranchMode, GitState};
use crate::output::CommandReport;
use crate::paths::git as git_paths;
use crate::paths::state::StateLayout;
use crate::profile;
use crate::repo::marker as repo_marker;
use crate::repo::truth as project_truth;
use crate::state::runtime as runtime_state;
use crate::state::session as session_state;

#[derive(Serialize, Clone)]
pub struct DriftCheck {
    file: String,
    status: &'static str,
    severity: &'static str,
    #[serde(skip_serializing_if = "Option::is_none")]
    last_commit: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    last_commit_date: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    commits_since: Option<usize>,
    message: String,
}

#[derive(Serialize)]
pub struct DriftReport {
    command: &'static str,
    ok: bool,
    repo_root: String,
    profile: String,
    locality_id: String,
    source_order: &'static str,
    stale_count: usize,
    checks: Vec<DriftCheck>,
}

impl CommandReport for DriftReport {
    fn exit_code(&self) -> ExitCode {
        if self.stale_count > 0 {
            ExitCode::from(1)
        } else {
            ExitCode::SUCCESS
        }
    }

    fn render_text(&self) {
        for check in &self.checks {
            let label = match check.status {
                "fresh" => "FRESH",
                "stale" => "STALE",
                "untracked" => "UNTRACKED",
                "missing" => "MISSING",
                _ => "INFO",
            };
            println!("[{label}] {}", check.message);
        }
        println!(
            "Drift summary: {} stale surface(s) out of {} checked.",
            self.stale_count,
            self.checks.len()
        );
    }
}

pub fn run(repo_root: &Path) -> Result<DriftReport> {
    if !git_paths::is_git_work_tree(repo_root) {
        bail!("not a git repository: {}", repo_root.display());
    }

    let profile = profile::resolve(None)?;
    let layout = StateLayout::resolve(repo_root, profile.clone())?;
    if !layout.profile_root().is_dir() {
        bail!(
            "profile `{}` does not exist at {}; bootstrap it with `ccd attach` before using `ccd drift`",
            layout.profile(),
            layout.profile_root().display()
        );
    }

    let marker = repo_marker::load(repo_root)?.ok_or_else(|| {
        anyhow::anyhow!(
            "repo is not linked: {} is missing; bootstrap this clone with `ccd attach` or `ccd link` first",
            repo_root.join(repo_marker::MARKER_FILE).display()
        )
    })?;
    let manifest = project_truth::resolve_manifest(repo_root, &layout, &marker.locality_id)?;

    let mut checks = manifest
        .project_truth_paths
        .iter()
        .map(|path| {
            let label = path
                .strip_prefix(repo_root)
                .unwrap_or(path)
                .display()
                .to_string();
            check_file_drift(repo_root, &label)
        })
        .collect::<Result<Vec<_>>>()?;

    let git = handoff::read_git_state(repo_root, BranchMode::AllowDetachedHead)?;
    let sid = session_state::load_session_id(&layout)?;
    checks.push(check_handoff_drift(&layout, &git, sid.as_deref())?);

    // Skill and policy-mirror drift (Sync drift)
    let agents_path = repo_root.join("AGENTS.md");
    if agents_path.is_file() {
        let agents = fs::read_to_string(&agents_path)?;
        let hash = sync::sha256(&agents);

        let config = layout.load_profile_config()?;
        let mut sync_profile = SyncProfile::default();
        sync_profile.merge_config(&config.sync);

        let generated = sync::render_all(repo_root, &agents, &hash, &sync_profile)?;
        let sync_check = sync::check_generated(&generated);

        for target in &generated.files {
            // Skip targets that don't exist on disk (never generated) or are
            // already tracked by a more specific check (e.g. manifest sources).
            // Missing-file detection is left to `ccd sync --check`.
            if !target.path.is_file() || checks.iter().any(|c| c.file == target.label) {
                continue;
            }

            if let Some(issue) = sync_check.issues.iter().find(|i| i.label == target.label) {
                checks.push(DriftCheck {
                    file: issue.label.clone(),
                    status: "stale",
                    severity: "error",
                    last_commit: None,
                    last_commit_date: None,
                    commits_since: None,
                    message: sync::issue_message(issue, sync::SyncIssueSurface::Drift, repo_root),
                });
            } else {
                checks.push(DriftCheck {
                    file: target.label.clone(),
                    status: "fresh",
                    severity: "info",
                    last_commit: None,
                    last_commit_date: None,
                    commits_since: None,
                    message: format!("{} is in sync", target.label),
                });
            }
        }
    }

    let stale_count = checks
        .iter()
        .filter(|check| check.status == "stale" || check.status == "missing")
        .count();

    Ok(DriftReport {
        command: "drift",
        ok: stale_count == 0,
        repo_root: repo_root.display().to_string(),
        profile: profile.to_string(),
        locality_id: marker.locality_id,
        source_order: manifest.source_order,
        stale_count,
        checks,
    })
}

fn check_file_drift(repo_root: &Path, file: &str) -> Result<DriftCheck> {
    let path = repo_root.join(file);

    if !path.exists() {
        return Ok(DriftCheck {
            file: file.to_string(),
            status: "missing",
            severity: "error",
            last_commit: None,
            last_commit_date: None,
            commits_since: None,
            message: format!("{file} does not exist"),
        });
    }

    let commit_info = git_last_commit_for_file(repo_root, file)?;

    match commit_info {
        None => Ok(DriftCheck {
            file: file.to_string(),
            status: "untracked",
            severity: "info",
            last_commit: None,
            last_commit_date: None,
            commits_since: None,
            message: format!("{file} exists but is not tracked by git"),
        }),
        Some((full_hash, short_hash, date)) => {
            let commits_since = git_commits_since(repo_root, &full_hash)?;

            if commits_since == 0 {
                Ok(DriftCheck {
                    file: file.to_string(),
                    status: "fresh",
                    severity: "info",
                    last_commit: Some(short_hash.clone()),
                    last_commit_date: Some(date),
                    commits_since: Some(0),
                    message: format!("{file} was updated in the latest commit ({short_hash})"),
                })
            } else {
                Ok(DriftCheck {
                    file: file.to_string(),
                    status: "stale",
                    severity: "warning",
                    last_commit: Some(short_hash.clone()),
                    last_commit_date: Some(date),
                    commits_since: Some(commits_since),
                    message: format!(
                        "{file} is {commits_since} commit(s) behind HEAD (last touched in {short_hash})"
                    ),
                })
            }
        }
    }
}

fn check_handoff_drift(
    layout: &StateLayout,
    git: &GitState,
    session_id: Option<&str>,
) -> Result<DriftCheck> {
    let label = layout.state_db_path().display().to_string();
    let surface = runtime_state::load_canonical_handoff_surface(layout)?;

    if surface.is_missing() {
        return Ok(DriftCheck {
            file: label.clone(),
            status: "missing",
            severity: "error",
            last_commit: None,
            last_commit_date: None,
            commits_since: None,
            message: format!("{label} native handoff state does not exist"),
        });
    }

    let _ = (git, session_id);
    let (status, severity, commits_since, message) = (
        "fresh",
        "info",
        Some(0),
        format!(
            "{label} stores canonical handoff continuity only; checkout context is derived live"
        ),
    );

    Ok(DriftCheck {
        file: label,
        status,
        severity,
        last_commit: None,
        last_commit_date: None,
        commits_since,
        message,
    })
}

pub(crate) fn git_last_commit_for_file(
    repo: &Path,
    file: &str,
) -> Result<Option<(String, String, String)>> {
    let output = std::process::Command::new("git")
        .args(["log", "-1", "--format=%H %h %cI", "--", file])
        .current_dir(repo)
        .output()
        .context("failed to run git log")?;

    if !output.status.success() {
        return Ok(None);
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    let line = stdout.trim();
    if line.is_empty() {
        return Ok(None);
    }

    let mut parts = line.splitn(3, ' ');
    let full_hash = parts.next().unwrap_or_default().to_string();
    let short_hash = parts.next().unwrap_or_default().to_string();
    let date = parts.next().unwrap_or_default().to_string();

    if full_hash.is_empty() || short_hash.is_empty() || date.is_empty() {
        return Ok(None);
    }

    Ok(Some((full_hash, short_hash, date)))
}

pub(crate) fn git_commits_since(repo: &Path, commit_hash: &str) -> Result<usize> {
    let output = std::process::Command::new("git")
        .args(["rev-list", "--count", &format!("{commit_hash}..HEAD")])
        .current_dir(repo)
        .output()
        .context("failed to run git rev-list")?;

    ensure!(
        output.status.success(),
        "git rev-list failed for {commit_hash}..HEAD"
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    stdout
        .trim()
        .parse()
        .context("failed to parse commit count")
}