govctl 0.19.0

Project governance CLI for RFC, ADR, and Work Item management
//! Path-based nested field addressing per [[RFC-0002:C-EDIT-FIELD-CONTRACT]].
//!
//! Parses field paths like `alternatives[0].pros[1]` into structured segments
//! for nested access into ADR alternatives, work item acceptance criteria, etc.

use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticResult};
use winnow::Parser;
use winnow::ascii::digit1;
use winnow::combinator::{delimited, eof, opt, separated, terminated};
use winnow::error::{ContextError, ErrMode};
use winnow::token::{any, take_while};

type ParseErr = ErrMode<ContextError>;

#[derive(Debug, Clone, PartialEq, Eq)]
struct RawPathSegment {
    name: String,
    index: Option<String>,
}

/// A single segment in a field path (e.g., `alternatives[0]`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathSegment {
    pub name: String,
    pub index: Option<i32>,
}

/// A parsed field path with one or more segments.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FieldPath {
    pub segments: Vec<PathSegment>,
}

impl FieldPath {
    /// True if this is a single segment with no index (flat field like "title").
    pub fn is_simple(&self) -> bool {
        self.segments.len() == 1 && self.segments[0].index.is_none()
    }

    /// Get the flat field name if this is a simple path.
    pub fn as_simple(&self) -> Option<&str> {
        if self.is_simple() {
            Some(&self.segments[0].name)
        } else {
            None
        }
    }

    /// True if the last segment has an explicit index.
    pub fn has_terminal_index(&self) -> bool {
        self.segments.last().is_some_and(|s| s.index.is_some())
    }
}

impl std::fmt::Display for FieldPath {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        for (i, seg) in self.segments.iter().enumerate() {
            if i > 0 {
                f.write_str(".")?;
            }
            f.write_str(&seg.name)?;
            if let Some(idx) = seg.index {
                write!(f, "[{idx}]")?;
            }
        }
        Ok(())
    }
}

/// Parse a field path string into a `FieldPath`.
///
/// Grammar: `segment ('.' segment | '[' index ']')*`
/// where `segment` is `[a-z_][a-z0-9_]*` and `index` is `[0-9]+`.
#[cfg(test)]
pub fn parse_field_path(input: &str) -> DiagnosticResult<FieldPath> {
    parse_raw_field_path(input)
}

/// Parse a field path string into raw segments, without alias normalization.
pub fn parse_raw_field_path(input: &str) -> DiagnosticResult<FieldPath> {
    if input.is_empty() {
        return Err(Diagnostic::new(
            DiagnosticCode::E0814InvalidPath,
            "Field path cannot be empty",
            "path",
        ));
    }

    let raw_segments = terminated(path_segments_parser, eof)
        .parse(input)
        .map_err(|_| {
            Diagnostic::new(
                DiagnosticCode::E0814InvalidPath,
                format!("Invalid field path: {input}"),
                "path",
            )
        })?;

    let mut segments = Vec::with_capacity(raw_segments.len());
    for raw in raw_segments {
        let index = match raw.index {
            Some(text) => Some(text.parse::<i32>().map_err(|_| {
                Diagnostic::new(
                    DiagnosticCode::E0814InvalidPath,
                    format!("Invalid field path: invalid index '{text}'"),
                    "path",
                )
            })?),
            None => None,
        };
        segments.push(PathSegment {
            name: raw.name,
            index,
        });
    }

    Ok(FieldPath { segments })
}

fn path_segments_parser(input: &mut &str) -> Result<Vec<RawPathSegment>, ParseErr> {
    separated(1.., parse_segment_raw, '.').parse_next(input)
}

fn parse_segment_raw(input: &mut &str) -> Result<RawPathSegment, ParseErr> {
    let name = parse_name_raw(input)?;
    let index = opt(parse_index_text).parse_next(input)?;
    Ok(RawPathSegment { name, index })
}

fn parse_name_raw(rest: &mut &str) -> Result<String, ParseErr> {
    let first = any.verify(|c: &char| is_name_start(*c)).parse_next(rest)?;
    let suffix: &str = take_while(0.., is_name_char).parse_next(rest)?;
    Ok(format!("{first}{suffix}"))
}

fn parse_index_text(rest: &mut &str) -> Result<String, ParseErr> {
    let digits: &str = delimited('[', digit1, ']').parse_next(rest)?;
    Ok(digits.to_string())
}

fn is_name_start(c: char) -> bool {
    c.is_ascii_lowercase() || c == '_'
}

fn is_name_char(c: char) -> bool {
    c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'
}

/// Resolve a zero-based index against an array length.
pub fn resolve_index(idx: i32, len: usize) -> DiagnosticResult<usize> {
    let len_i = len as i32;
    if idx < 0 || idx >= len_i {
        return Err(Diagnostic::new(
            DiagnosticCode::E0816PathIndexOutOfBounds,
            format!("Index {idx} out of range (array has {len} items)"),
            "path",
        ));
    }
    Ok(idx as usize)
}

#[cfg(test)]
mod tests;