Skip to main content

supercode_reduce/engine/
stub.rs

1//! The C2 reduction stub: the one shared format/parse module every
2//! placeholder-emitting reduction (A7-A10, and any future kind) goes
3//! through.
4//!
5//! Normative grammar (D2, merging A4 + C2):
6//!
7//! ```text
8//! [sc-reduced <kind> <id>: <summary>]
9//! ```
10//!
11//! - Plain ASCII so the line survives conversion through any harness format
12//!   without escaping.
13//! - `<kind>` is one of the strings below (mirrors [`super::ReductionKind`]).
14//! - `<id>` matches `r\d{4}-[0-9a-f]{4}` (see [`super::make_id`]).
15//! - `<summary>` is one line and contains no `]`.
16
17use super::{ReductionKind, REDUCTION_SENTINEL};
18
19/// The stub `<kind>` token: the wire vocabulary for [`ReductionKind`], kept
20/// as its own type because the grammar's kind strings are a stable contract
21/// independent of the enum's Rust variant names or field shapes.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Kind {
24    /// `tool-output` (A7).
25    ToolOutput,
26    /// `file-read` (A8).
27    FileRead,
28    /// `image` (A9).
29    Image,
30    /// `turns-cleared` (A10).
31    TurnsCleared,
32    /// `tool-input` (TR-10).
33    ToolInput,
34    /// `output-normalized` (T30/TR-4).
35    OutputNormalized,
36    /// `file-read-diffed` (TR-3 / T26).
37    FileReadDiffed,
38    /// `duplicate` (TR-2).
39    Duplicate,
40    /// `superseded` (TR-6 / T16).
41    Superseded,
42}
43
44impl Kind {
45    /// The grammar's `<kind>` token for this variant (e.g. `"tool-output"`).
46    /// `pub` so display code (`sessions show-reductions`, C4) can render the
47    /// kind column straight from a parsed stub rather than re-deriving or
48    /// hand-formatting an equivalent string.
49    pub const fn as_str(self) -> &'static str {
50        match self {
51            Kind::ToolOutput => "tool-output",
52            Kind::FileRead => "file-read",
53            Kind::Image => "image",
54            Kind::TurnsCleared => "turns-cleared",
55            Kind::ToolInput => "tool-input",
56            Kind::OutputNormalized => "output-normalized",
57            Kind::FileReadDiffed => "file-read-diffed",
58            Kind::Duplicate => "duplicate",
59            Kind::Superseded => "superseded",
60        }
61    }
62
63    fn from_str(s: &str) -> Option<Self> {
64        Some(match s {
65            "tool-output" => Kind::ToolOutput,
66            "file-read" => Kind::FileRead,
67            "image" => Kind::Image,
68            "turns-cleared" => Kind::TurnsCleared,
69            "tool-input" => Kind::ToolInput,
70            "output-normalized" => Kind::OutputNormalized,
71            "file-read-diffed" => Kind::FileReadDiffed,
72            "duplicate" => Kind::Duplicate,
73            "superseded" => Kind::Superseded,
74            _ => return None,
75        })
76    }
77}
78
79impl From<&ReductionKind> for Kind {
80    fn from(k: &ReductionKind) -> Self {
81        match k {
82            ReductionKind::ToolOutputTruncated { .. } => Kind::ToolOutput,
83            ReductionKind::FileReadElided { .. } => Kind::FileRead,
84            ReductionKind::ImageRedacted { .. } => Kind::Image,
85            ReductionKind::TurnsCleared { .. } => Kind::TurnsCleared,
86            ReductionKind::ToolInputElided { .. } => Kind::ToolInput,
87            ReductionKind::OutputNormalized { .. } => Kind::OutputNormalized,
88            ReductionKind::FileReadDiffed { .. } => Kind::FileReadDiffed,
89            ReductionKind::DuplicateOutput { .. } => Kind::Duplicate,
90            ReductionKind::Superseded { .. } => Kind::Superseded,
91        }
92    }
93}
94
95/// Is `id` a syntactically valid reduction id (`r\d{4}-[0-9a-f]{4}`)?
96fn is_valid_id(id: &str) -> bool {
97    let Some(rest) = id.strip_prefix('r') else {
98        return false;
99    };
100    let bytes = rest.as_bytes();
101    if bytes.len() != 9 || bytes[4] != b'-' {
102        return false;
103    }
104    bytes[..4].iter().all(u8::is_ascii_digit)
105        && bytes[5..]
106            .iter()
107            .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
108}
109
110/// Format a reduction stub line: `[sc-reduced <kind> <id>: <summary>]`.
111///
112/// `summary` must not contain `]` or a newline; this is a formatting
113/// contract, not something recoverable at parse time, so it is asserted with
114/// `debug_assert!` (a caller that trips it has a bug, not bad input from a
115/// user).
116pub fn format(kind: Kind, id: &str, summary: &str) -> String {
117    debug_assert!(is_valid_id(id), "invalid reduction id: {id:?}");
118    debug_assert!(!summary.contains(']'), "summary contains `]`: {summary:?}");
119    debug_assert!(
120        !summary.contains('\n'),
121        "summary is not one line: {summary:?}"
122    );
123    format!("{REDUCTION_SENTINEL} {} {id}: {summary}]", kind.as_str())
124}
125
126/// Parse a reduction stub line back into `(kind, id, summary)`.
127///
128/// Returns `None` for anything that doesn't match the grammar exactly:
129/// missing brackets, an unknown kind, a malformed id, or a summary
130/// containing `]` (which would make the closing bracket ambiguous).
131pub fn parse(line: &str) -> Option<(Kind, String, String)> {
132    let body = line
133        .strip_prefix(REDUCTION_SENTINEL)?
134        .strip_prefix(' ')?
135        .strip_suffix(']')?;
136    // No further `]` allowed anywhere in the body (summary rule).
137    if body.contains(']') {
138        return None;
139    }
140    let (kind_str, rest) = body.split_once(' ')?;
141    let kind = Kind::from_str(kind_str)?;
142    let (id, summary) = rest.split_once(": ")?;
143    if !is_valid_id(id) || summary.is_empty() || summary.contains('\n') {
144        return None;
145    }
146    Some((kind, id.to_string(), summary.to_string()))
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn format_matches_grammar_and_round_trips() {
155        // Hoisted out of the loop below (clippy::regex_creation_in_loops) —
156        // pre-existing, unrelated to TR-1; fixed in passing since it blocks
157        // `cargo clippy --all-targets -- -D warnings` for the whole crate.
158        let re = regex::Regex::new(
159            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}: [^\]]+\]$",
160        )
161        .unwrap();
162        for kind in [
163            Kind::ToolOutput,
164            Kind::FileRead,
165            Kind::Image,
166            Kind::TurnsCleared,
167            Kind::ToolInput,
168            Kind::OutputNormalized,
169            Kind::FileReadDiffed,
170            Kind::Duplicate,
171            Kind::Superseded,
172        ] {
173            let id = super::super::make_id(42, "9f3cabcd");
174            let line = format(kind, &id, "a one-line summary");
175            assert!(re.is_match(&line), "{line:?} does not match the grammar");
176            assert_eq!(
177                parse(&line),
178                Some((kind, id, "a one-line summary".to_string()))
179            );
180        }
181    }
182
183    #[test]
184    fn parse_rejects_malformed_input() {
185        assert_eq!(parse("no bracket at all"), None);
186        assert_eq!(parse("[sc-reduced bogus-kind r0001-aaaa: x]"), None);
187        assert_eq!(parse("[sc-reduced tool-output not-an-id: x]"), None);
188        assert_eq!(
189            parse("[sc-reduced tool-output r0001-aaaa: has ] bracket]"),
190            None
191        );
192        assert_eq!(parse("[sc-reduced tool-output r0001-aaaa: ]"), None);
193        assert_eq!(parse("[sc-reduced tool-output r0001-aaZZ: x]"), None);
194    }
195}