supercode-reduce 0.4.9

Optional lossless, reversible session reduction for Supercode
Documentation
//! The C2 reduction stub: the one shared format/parse module every
//! placeholder-emitting reduction (A7-A10, and any future kind) goes
//! through.
//!
//! Normative grammar (D2, merging A4 + C2):
//!
//! ```text
//! [sc-reduced <kind> <id>: <summary>]
//! ```
//!
//! - Plain ASCII so the line survives conversion through any harness format
//!   without escaping.
//! - `<kind>` is one of the strings below (mirrors [`super::ReductionKind`]).
//! - `<id>` matches `r\d{4}-[0-9a-f]{4}` (see [`super::make_id`]).
//! - `<summary>` is one line and contains no `]`.

use super::{ReductionKind, REDUCTION_SENTINEL};

/// The stub `<kind>` token: the wire vocabulary for [`ReductionKind`], kept
/// as its own type because the grammar's kind strings are a stable contract
/// independent of the enum's Rust variant names or field shapes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
    /// `tool-output` (A7).
    ToolOutput,
    /// `file-read` (A8).
    FileRead,
    /// `image` (A9).
    Image,
    /// `turns-cleared` (A10).
    TurnsCleared,
    /// `tool-input` (TR-10).
    ToolInput,
    /// `output-normalized` (T30/TR-4).
    OutputNormalized,
    /// `file-read-diffed` (TR-3 / T26).
    FileReadDiffed,
    /// `duplicate` (TR-2).
    Duplicate,
    /// `superseded` (TR-6 / T16).
    Superseded,
}

impl Kind {
    /// The grammar's `<kind>` token for this variant (e.g. `"tool-output"`).
    /// `pub` so display code (`sessions show-reductions`, C4) can render the
    /// kind column straight from a parsed stub rather than re-deriving or
    /// hand-formatting an equivalent string.
    pub const fn as_str(self) -> &'static str {
        match self {
            Kind::ToolOutput => "tool-output",
            Kind::FileRead => "file-read",
            Kind::Image => "image",
            Kind::TurnsCleared => "turns-cleared",
            Kind::ToolInput => "tool-input",
            Kind::OutputNormalized => "output-normalized",
            Kind::FileReadDiffed => "file-read-diffed",
            Kind::Duplicate => "duplicate",
            Kind::Superseded => "superseded",
        }
    }

    fn from_str(s: &str) -> Option<Self> {
        Some(match s {
            "tool-output" => Kind::ToolOutput,
            "file-read" => Kind::FileRead,
            "image" => Kind::Image,
            "turns-cleared" => Kind::TurnsCleared,
            "tool-input" => Kind::ToolInput,
            "output-normalized" => Kind::OutputNormalized,
            "file-read-diffed" => Kind::FileReadDiffed,
            "duplicate" => Kind::Duplicate,
            "superseded" => Kind::Superseded,
            _ => return None,
        })
    }
}

impl From<&ReductionKind> for Kind {
    fn from(k: &ReductionKind) -> Self {
        match k {
            ReductionKind::ToolOutputTruncated { .. } => Kind::ToolOutput,
            ReductionKind::FileReadElided { .. } => Kind::FileRead,
            ReductionKind::ImageRedacted { .. } => Kind::Image,
            ReductionKind::TurnsCleared { .. } => Kind::TurnsCleared,
            ReductionKind::ToolInputElided { .. } => Kind::ToolInput,
            ReductionKind::OutputNormalized { .. } => Kind::OutputNormalized,
            ReductionKind::FileReadDiffed { .. } => Kind::FileReadDiffed,
            ReductionKind::DuplicateOutput { .. } => Kind::Duplicate,
            ReductionKind::Superseded { .. } => Kind::Superseded,
        }
    }
}

/// Is `id` a syntactically valid reduction id (`r\d{4}-[0-9a-f]{4}`)?
fn is_valid_id(id: &str) -> bool {
    let Some(rest) = id.strip_prefix('r') else {
        return false;
    };
    let bytes = rest.as_bytes();
    if bytes.len() != 9 || bytes[4] != b'-' {
        return false;
    }
    bytes[..4].iter().all(u8::is_ascii_digit)
        && bytes[5..]
            .iter()
            .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}

/// Format a reduction stub line: `[sc-reduced <kind> <id>: <summary>]`.
///
/// `summary` must not contain `]` or a newline; this is a formatting
/// contract, not something recoverable at parse time, so it is asserted with
/// `debug_assert!` (a caller that trips it has a bug, not bad input from a
/// user).
pub fn format(kind: Kind, id: &str, summary: &str) -> String {
    debug_assert!(is_valid_id(id), "invalid reduction id: {id:?}");
    debug_assert!(!summary.contains(']'), "summary contains `]`: {summary:?}");
    debug_assert!(
        !summary.contains('\n'),
        "summary is not one line: {summary:?}"
    );
    format!("{REDUCTION_SENTINEL} {} {id}: {summary}]", kind.as_str())
}

/// Parse a reduction stub line back into `(kind, id, summary)`.
///
/// Returns `None` for anything that doesn't match the grammar exactly:
/// missing brackets, an unknown kind, a malformed id, or a summary
/// containing `]` (which would make the closing bracket ambiguous).
pub fn parse(line: &str) -> Option<(Kind, String, String)> {
    let body = line
        .strip_prefix(REDUCTION_SENTINEL)?
        .strip_prefix(' ')?
        .strip_suffix(']')?;
    // No further `]` allowed anywhere in the body (summary rule).
    if body.contains(']') {
        return None;
    }
    let (kind_str, rest) = body.split_once(' ')?;
    let kind = Kind::from_str(kind_str)?;
    let (id, summary) = rest.split_once(": ")?;
    if !is_valid_id(id) || summary.is_empty() || summary.contains('\n') {
        return None;
    }
    Some((kind, id.to_string(), summary.to_string()))
}

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

    #[test]
    fn format_matches_grammar_and_round_trips() {
        // Hoisted out of the loop below (clippy::regex_creation_in_loops) —
        // pre-existing, unrelated to TR-1; fixed in passing since it blocks
        // `cargo clippy --all-targets -- -D warnings` for the whole crate.
        let re = regex::Regex::new(
            r"^\[sc-reduced (tool-output|file-read|image|turns-cleared|tool-input|output-normalized|file-read-diffed|duplicate|superseded) r\d{4}-[0-9a-f]{4}: [^\]]+\]$",
        )
        .unwrap();
        for kind in [
            Kind::ToolOutput,
            Kind::FileRead,
            Kind::Image,
            Kind::TurnsCleared,
            Kind::ToolInput,
            Kind::OutputNormalized,
            Kind::FileReadDiffed,
            Kind::Duplicate,
            Kind::Superseded,
        ] {
            let id = super::super::make_id(42, "9f3cabcd");
            let line = format(kind, &id, "a one-line summary");
            assert!(re.is_match(&line), "{line:?} does not match the grammar");
            assert_eq!(
                parse(&line),
                Some((kind, id, "a one-line summary".to_string()))
            );
        }
    }

    #[test]
    fn parse_rejects_malformed_input() {
        assert_eq!(parse("no bracket at all"), None);
        assert_eq!(parse("[sc-reduced bogus-kind r0001-aaaa: x]"), None);
        assert_eq!(parse("[sc-reduced tool-output not-an-id: x]"), None);
        assert_eq!(
            parse("[sc-reduced tool-output r0001-aaaa: has ] bracket]"),
            None
        );
        assert_eq!(parse("[sc-reduced tool-output r0001-aaaa: ]"), None);
        assert_eq!(parse("[sc-reduced tool-output r0001-aaZZ: x]"), None);
    }
}