govctl 0.18.0

Project governance CLI for RFC, ADR, and Work Item management
use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticResult};
use regex::Regex;

#[derive(Debug, Clone, Default)]
pub struct MatchOptions<'a> {
    pub pattern: Option<&'a str>,
    pub at: Option<i32>,
    pub exact: bool,
    pub regex: bool,
    pub all: bool,
}

#[derive(Debug, Clone, Default)]
pub struct MatchOptionsOwned {
    pub pattern: Option<String>,
    pub at: Option<i32>,
    pub exact: bool,
    pub regex: bool,
    pub all: bool,
}

impl MatchOptionsOwned {
    pub fn as_match_options(&self) -> MatchOptions<'_> {
        MatchOptions {
            pattern: self.pattern.as_deref(),
            at: self.at,
            exact: self.exact,
            regex: self.regex,
            all: self.all,
        }
    }
}

impl<'a> MatchOptions<'a> {
    pub(super) fn at_index(index: i32) -> Self {
        Self {
            pattern: None,
            at: Some(index),
            exact: false,
            regex: false,
            all: false,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MatchUse {
    Remove,
    TickSingle,
}

pub(crate) fn resolve_match_indices(
    id: &str,
    field: &str,
    items: &[&str],
    opts: &MatchOptions,
    use_case: MatchUse,
) -> DiagnosticResult<Vec<usize>> {
    if items.is_empty() {
        return Err(Diagnostic::new(
            DiagnosticCode::E0812FieldEmpty,
            format!("Field {}.{} is empty", id, field),
            id,
        ));
    }

    if opts.all {
        return Ok((0..items.len()).collect());
    }

    if opts.pattern.is_none() && opts.at.is_none() {
        return Err(Diagnostic::new(
            DiagnosticCode::E0801MissingRequiredArg,
            format!(
                "Remove from {}.{} requires an indexed path, exact value, regex, or --all",
                id, field
            ),
            id,
        ));
    }

    let indices: Vec<usize> = if let Some(idx) = opts.at {
        let len = items.len() as i32;
        if idx < 0 || idx >= len {
            return Err(Diagnostic::new(
                DiagnosticCode::E0806InvalidPattern,
                format!(
                    "Index {} out of range (array has {} items)",
                    idx,
                    items.len()
                ),
                "array",
            ));
        }
        vec![idx as usize]
    } else {
        let raw_pattern = opts.pattern.unwrap_or("<index>");
        let pattern = raw_pattern;
        let matches = if opts.regex {
            let re = Regex::new(pattern).map_err(|e| {
                Diagnostic::new(
                    DiagnosticCode::E0806InvalidPattern,
                    format!("Invalid regex: {}", e),
                    id,
                )
            })?;
            items
                .iter()
                .enumerate()
                .filter(|(_, s)| re.is_match(s))
                .map(|(i, _)| i)
                .collect::<Vec<_>>()
        } else if opts.exact {
            items
                .iter()
                .enumerate()
                .filter(|(_, s)| **s == pattern)
                .map(|(i, _)| i)
                .collect::<Vec<_>>()
        } else {
            let pattern_lower = pattern.to_lowercase();
            items
                .iter()
                .enumerate()
                .filter(|(_, s)| s.to_lowercase().contains(&pattern_lower))
                .map(|(i, _)| i)
                .collect::<Vec<_>>()
        };

        if matches.is_empty() {
            return Err(Diagnostic::new(
                DiagnosticCode::E0806InvalidPattern,
                format!("No items match '{}' in {}.{}", raw_pattern, id, field),
                id,
            ));
        }
        matches
    };

    if indices.len() == 1 || (use_case == MatchUse::Remove && opts.all) {
        return Ok(indices);
    }

    let pattern = opts.pattern.unwrap_or("");
    let hint = if use_case == MatchUse::Remove {
        "Use an indexed path to select one item, or --all to remove every item"
    } else {
        "Use an indexed path to select one item"
    };
    let mut msg = format!(
        "{} items match '{}' in {}.{}:\n",
        indices.len(),
        pattern,
        id,
        field
    );
    for &i in &indices {
        msg.push_str(&format!("  [{}] {}\n", i, items[i]));
    }
    msg.push('\n');
    msg.push_str(hint);
    Err(Diagnostic::new(
        DiagnosticCode::E0807AmbiguousMatch,
        msg,
        id,
    ))
}

#[cfg(test)]
mod tests;