ccd-cli 1.0.0-alpha.8

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

use serde::{Deserialize, Serialize};

#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum IssueReferenceKind {
    GithubIssue,
    CcdId,
    Ambiguous,
}

#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub(crate) struct IssueReference {
    pub kind: IssueReferenceKind,
    pub number: u64,
    pub source: &'static str,
    pub raw_reference: String,
    pub context: String,
}

impl IssueReference {
    #[cfg_attr(not(test), allow(dead_code))]
    pub(crate) fn display(&self) -> String {
        match self.kind {
            IssueReferenceKind::GithubIssue => format!("GH#{}", self.number),
            IssueReferenceKind::CcdId => format!("ccd#{}", self.number),
            IssueReferenceKind::Ambiguous => format!("issue #{}", self.number),
        }
    }
}

pub(crate) fn collect_issue_references(
    branch: &str,
    handoff_title: &str,
    immediate_actions: &[String],
) -> Vec<IssueReference> {
    let mut references = Vec::new();
    references.extend(collect_branch_references(branch));
    references.extend(collect_text_references("handoff_title", handoff_title));
    for action in immediate_actions {
        references.extend(collect_text_references("immediate_action", action));
    }
    dedupe_references(&mut references);
    references
}

fn dedupe_references(references: &mut Vec<IssueReference>) {
    let mut seen = BTreeSet::new();
    references.retain(|reference| {
        seen.insert((
            reference.kind,
            reference.number,
            reference.source,
            reference.raw_reference.clone(),
            reference.context.clone(),
        ))
    });
}

fn collect_branch_references(branch: &str) -> Vec<IssueReference> {
    let mut references = Vec::new();

    for segment in branch.split('/') {
        if let Some(number) = strip_prefixed_number(segment, "issue-") {
            references.push(IssueReference {
                kind: IssueReferenceKind::GithubIssue,
                number,
                source: "branch",
                raw_reference: format!("issue-{number}"),
                context: branch.to_owned(),
            });
            continue;
        }

        if let Some(number) = strip_prefixed_number(segment, "gh-") {
            references.push(IssueReference {
                kind: IssueReferenceKind::GithubIssue,
                number,
                source: "branch",
                raw_reference: format!("gh-{number}"),
                context: branch.to_owned(),
            });
            continue;
        }

        if let Some(number) = strip_prefixed_number(segment, "ccd-") {
            references.push(IssueReference {
                kind: IssueReferenceKind::Ambiguous,
                number,
                source: "branch",
                raw_reference: format!("ccd-{number}"),
                context: branch.to_owned(),
            });
            continue;
        }

        if let Some(number) = leading_branch_number(segment) {
            references.push(IssueReference {
                kind: IssueReferenceKind::GithubIssue,
                number,
                source: "branch",
                raw_reference: segment.to_owned(),
                context: branch.to_owned(),
            });
        }
    }

    references
}

fn strip_prefixed_number(segment: &str, prefix: &str) -> Option<u64> {
    let suffix = segment.strip_prefix(prefix)?;
    let digits = suffix
        .chars()
        .take_while(|ch| ch.is_ascii_digit())
        .collect::<String>();
    if digits.is_empty() {
        return None;
    }

    let remainder = &suffix[digits.len()..];
    if remainder.is_empty()
        || remainder.starts_with('-')
        || remainder.starts_with('_')
        || remainder.starts_with('/')
    {
        return digits.parse().ok();
    }

    None
}

fn leading_branch_number(segment: &str) -> Option<u64> {
    let digits = segment
        .chars()
        .take_while(|ch| ch.is_ascii_digit())
        .collect::<String>();
    if digits.is_empty() {
        return None;
    }

    let remainder = &segment[digits.len()..];
    if remainder.is_empty() || remainder.starts_with('-') || remainder.starts_with('_') {
        return digits.parse().ok();
    }

    None
}

fn collect_text_references(source: &'static str, text: &str) -> Vec<IssueReference> {
    let bytes = text.as_bytes();
    let mut references = Vec::new();
    let mut index = 0usize;

    while index < bytes.len() {
        if bytes[index] != b'#' {
            index += 1;
            continue;
        }

        let digit_start = index + 1;
        let digit_end = consume_digits(bytes, digit_start);
        if digit_end == digit_start {
            index += 1;
            continue;
        }

        let prefix_start = contiguous_ascii_alpha_start(bytes, index);
        let prefix = text[prefix_start..index].to_ascii_lowercase();
        let previous = if prefix_start == 0 {
            None
        } else {
            Some(bytes[prefix_start - 1])
        };

        let kind = if prefix == "ccd" {
            Some(IssueReferenceKind::CcdId)
        } else if prefix == "gh" {
            Some(IssueReferenceKind::GithubIssue)
        } else if previous.is_some_and(|byte| byte.is_ascii_alphanumeric()) {
            None
        } else {
            Some(IssueReferenceKind::GithubIssue)
        };

        let Some(kind) = kind else {
            index = digit_end;
            continue;
        };

        let raw_start = if matches!(
            kind,
            IssueReferenceKind::CcdId | IssueReferenceKind::GithubIssue
        ) && (prefix == "ccd" || prefix == "gh")
        {
            prefix_start
        } else {
            index
        };

        if let Ok(number) = text[digit_start..digit_end].parse() {
            references.push(IssueReference {
                kind,
                number,
                source,
                raw_reference: text[raw_start..digit_end].to_owned(),
                context: text.trim().to_owned(),
            });
        }

        index = digit_end;
    }

    references
}

fn contiguous_ascii_alpha_start(bytes: &[u8], end: usize) -> usize {
    let mut start = end;
    while start > 0 && bytes[start - 1].is_ascii_alphabetic() {
        start -= 1;
    }
    start
}

fn consume_digits(bytes: &[u8], start: usize) -> usize {
    let mut end = start;
    while end < bytes.len() && bytes[end].is_ascii_digit() {
        end += 1;
    }
    end
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_text_and_branch_issue_references() {
        let references = collect_issue_references(
            "codex/ccd-46-retry-backoff",
            "Next Session: Verify #122 follow-up",
            &["Review ccd#46 before merge.".to_owned()],
        );

        assert!(references.iter().any(|reference| {
            reference.source == "branch"
                && reference.kind == IssueReferenceKind::Ambiguous
                && reference.number == 46
        }));
        assert!(references.iter().any(|reference| {
            reference.source == "handoff_title"
                && reference.kind == IssueReferenceKind::GithubIssue
                && reference.number == 122
        }));
        assert!(references.iter().any(|reference| {
            reference.source == "immediate_action"
                && reference.kind == IssueReferenceKind::CcdId
                && reference.number == 46
        }));
    }
}