ccd-cli 1.0.0-beta.2

Bootstrap and validate Continuous Context Development repositories
use std::collections::BTreeSet;
use std::path::Path;

use crate::memory::{entries as memory_entries, promote as memory_promote};
use crate::state::session as session_state;

use super::{
    active_session_start_count, CandidateAction, CandidateActionApply, CandidateUpdate,
    CandidateUpdates, MemoryState,
};

pub(super) fn build_candidate_updates(
    repo_root: &Path,
    profile: &str,
    _handoff: &super::HandoffState,
    effective_memory: &MemoryState,
    tracked_session: Option<&session_state::SessionStateFile>,
    branch_memory_available: bool,
) -> CandidateUpdates {
    CandidateUpdates {
        handoff: Vec::new(),
        memory: build_memory_candidates(
            repo_root,
            profile,
            effective_memory,
            tracked_session,
            branch_memory_available,
        ),
    }
}

#[derive(Clone, Copy)]
enum MemoryCandidateScope {
    Clone,
    Branch,
    Pod,
}

impl MemoryCandidateScope {
    fn label(self) -> &'static str {
        match self {
            Self::Clone => "workspace",
            Self::Branch => "work_stream",
            Self::Pod => "pod",
        }
    }

    fn source_scope(self) -> &'static str {
        match self {
            Self::Clone => "workspace-memory",
            Self::Branch => "work-stream-memory",
            Self::Pod => "pod-memory",
        }
    }

    fn suggested_destination(self, branch_memory_available: bool) -> &'static str {
        match self {
            Self::Clone if branch_memory_available => "work-stream-memory",
            Self::Clone | Self::Branch | Self::Pod => "project-memory",
        }
    }

    fn promotion_review_summary(self, entry: &memory_entries::StructuredMemoryEntry) -> String {
        match self {
            Self::Clone => format!(
                "{} memory entry `{}` was touched this session; review whether it should stay workspace-local or be promoted to a higher memory scope.",
                self.label(),
                entry.id
            ),
            Self::Branch | Self::Pod => format!(
                "{} memory entry `{}` was touched this session; review whether it should move into project memory.",
                self.label(),
                entry.id
            ),
        }
    }
}

fn build_memory_candidates(
    repo_root: &Path,
    profile: &str,
    effective_memory: &MemoryState,
    tracked_session: Option<&session_state::SessionStateFile>,
    branch_memory_available: bool,
) -> Vec<CandidateUpdate> {
    let Some(current_session_start_count) = active_session_start_count(tracked_session) else {
        return Vec::new();
    };

    let branch_entry_ids = effective_memory
        .structured
        .branch_entries
        .iter()
        .map(|entry| entry.id.clone())
        .collect::<BTreeSet<_>>();
    let repo_entry_ids = effective_memory
        .structured
        .repo_entries
        .iter()
        .map(|entry| entry.id.clone())
        .collect::<BTreeSet<_>>();
    let context = MemoryCandidateContext {
        repo_root,
        profile,
        branch_entry_ids: &branch_entry_ids,
        repo_entry_ids: &repo_entry_ids,
        current_session_start_count,
        branch_memory_available,
    };

    let mut memory = Vec::new();
    extend_memory_candidates(
        &mut memory,
        &effective_memory.structured.clone_entries,
        MemoryCandidateScope::Clone,
        &context,
    );
    extend_memory_candidates(
        &mut memory,
        &effective_memory.structured.branch_entries,
        MemoryCandidateScope::Branch,
        &context,
    );
    extend_memory_candidates(
        &mut memory,
        &effective_memory.structured.pod_entries,
        MemoryCandidateScope::Pod,
        &context,
    );
    memory
}

struct MemoryCandidateContext<'a> {
    repo_root: &'a Path,
    profile: &'a str,
    branch_entry_ids: &'a BTreeSet<String>,
    repo_entry_ids: &'a BTreeSet<String>,
    current_session_start_count: u64,
    branch_memory_available: bool,
}

fn extend_memory_candidates(
    candidates: &mut Vec<CandidateUpdate>,
    entries: &[memory_entries::StructuredMemoryEntry],
    scope: MemoryCandidateScope,
    context: &MemoryCandidateContext<'_>,
) {
    candidates.extend(
        entries
            .iter()
            .filter_map(|entry| memory_candidate_from_entry(entry, scope, context)),
    );
}

fn memory_candidate_from_entry(
    entry: &memory_entries::StructuredMemoryEntry,
    scope: MemoryCandidateScope,
    context: &MemoryCandidateContext<'_>,
) -> Option<CandidateUpdate> {
    if entry.state != "active"
        || entry.last_touched_session != context.current_session_start_count
        || entry.source_ref.is_some()
        || !memory_promote::is_promotable_entry_type(&entry.entry_type)
    {
        return None;
    }

    let suggested_destination = scope.suggested_destination(context.branch_memory_available);
    if candidate_already_exists(
        &entry.id,
        suggested_destination,
        context.branch_entry_ids,
        context.repo_entry_ids,
    ) {
        return None;
    }

    Some(CandidateUpdate {
        kind: "promotion_review",
        summary: scope.promotion_review_summary(entry),
        section: None,
        replacement_lines: None,
        entry_id: Some(entry.id.clone()),
        source_scope: Some(scope.source_scope()),
        suggested_destination: Some(suggested_destination),
        action: Some(memory_candidate_admit_preview_action(
            context.repo_root,
            context.profile,
            entry.id.as_str(),
            scope,
            suggested_destination,
        )),
    })
}

fn candidate_already_exists(
    entry_id: &str,
    suggested_destination: &'static str,
    branch_entry_ids: &BTreeSet<String>,
    repo_entry_ids: &BTreeSet<String>,
) -> bool {
    match suggested_destination {
        "work-stream-memory" => {
            branch_entry_ids.contains(entry_id) || repo_entry_ids.contains(entry_id)
        }
        "project-memory" => repo_entry_ids.contains(entry_id),
        _ => false,
    }
}

fn memory_candidate_admit_preview_action(
    repo_root: &Path,
    profile: &str,
    entry_id: &str,
    scope: MemoryCandidateScope,
    suggested_destination: &'static str,
) -> CandidateAction {
    CandidateAction {
        kind: "memory_candidate_admit_preview",
        argv: vec![
            "ccd".to_owned(),
            "memory".to_owned(),
            "candidate".to_owned(),
            "admit".to_owned(),
            "--path".to_owned(),
            repo_root.display().to_string(),
            "--profile".to_owned(),
            profile.to_owned(),
            "--entry".to_owned(),
            entry_id.to_owned(),
            "--source-scope".to_owned(),
            scope.source_scope().to_owned(),
            "--destination".to_owned(),
            suggested_destination.to_owned(),
        ],
        note: "Preview only. Add `--write` to stage this reviewed candidate as a higher-scope `promotion_candidate` entry.",
        apply: Some(memory_candidate_admit_apply_action(
            repo_root,
            profile,
            entry_id,
            scope,
            suggested_destination,
        )),
    }
}

fn memory_candidate_admit_apply_action(
    repo_root: &Path,
    profile: &str,
    entry_id: &str,
    scope: MemoryCandidateScope,
    suggested_destination: &'static str,
) -> CandidateActionApply {
    CandidateActionApply {
        argv: vec![
            "ccd".to_owned(),
            "memory".to_owned(),
            "candidate".to_owned(),
            "admit".to_owned(),
            "--path".to_owned(),
            repo_root.display().to_string(),
            "--profile".to_owned(),
            profile.to_owned(),
            "--entry".to_owned(),
            entry_id.to_owned(),
            "--source-scope".to_owned(),
            scope.source_scope().to_owned(),
            "--destination".to_owned(),
            suggested_destination.to_owned(),
            "--write".to_owned(),
        ],
        requirements: Vec::new(),
        note: "Mutates memory surfaces. Execute this apply command only after reviewing the staged candidate.",
    }
}