Skip to main content

lean_ctx/core/
read_mode.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Type-safe `ctx_read` modes (#528 / #509 Phase 2).
3//!
4//! Historically the read mode travelled through the whole pipeline as a bare
5//! `&str` (`"full"`, `"map"`, `"lines:5-10"`, `"density:0.40"`, …) and the
6//! knowledge of *which* modes exist — and how each one is classified (cacheable?
7//! lossy summary? counts as compressed?) — was duplicated across the registered
8//! handler, the read core and `render.rs`. That stringly-typed design let invalid
9//! states slip through (silently falling back to `full`) and let the duplicated
10//! classifications drift.
11//!
12//! [`ReadMode`] is the single source of truth for the mode vocabulary:
13//!
14//! * [`ReadMode::from_str`] parses (and *validates*) the canonical strings.
15//! * [`Display`](std::fmt::Display) round-trips **byte-identically** to those
16//!   same strings, so the type can be threaded through the typed decision points
17//!   without touching the string-mode MCP boundary or `render.rs` (back-compat).
18//! * the classification methods (`ReadMode::is_compressed_cacheable`,
19//!   `ReadMode::allows_raw_cap`, `ReadMode::is_lossy_summary`,
20//!   `ReadMode::counts_as_compressed`) replace the scattered `matches!(mode,
21//!   …)` predicates, and the test module locks each one to the legacy predicate
22//!   it replaces so behaviour can never silently change.
23
24use std::fmt;
25use std::str::FromStr;
26
27/// Sentinel `end` meaning "to end of file" — preserved from the historical
28/// `lines:N-999999` form so [`Display`](std::fmt::Display) stays byte-stable.
29pub(crate) const LINE_RANGE_EOF: u32 = 999_999;
30
31/// A 1-based, inclusive line window (`lines:start-end`).
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct LineRange {
34    pub start: u32,
35    pub end: u32,
36}
37
38impl LineRange {
39    /// Window `start..=end`. `start` is clamped to ≥ 1 to mirror the handler's
40    /// historical `start.max(1)` behaviour (#253).
41    #[must_use]
42    pub fn new(start: u32, end: u32) -> Self {
43        Self {
44            start: start.max(1),
45            end,
46        }
47    }
48
49    /// Window from `start` to end of file (the `lines:N-999999` form).
50    #[must_use]
51    pub(crate) fn to_eof(start: u32) -> Self {
52        Self::new(start, LINE_RANGE_EOF)
53    }
54}
55
56impl fmt::Display for LineRange {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        write!(f, "{}-{}", self.start, self.end)
59    }
60}
61
62/// The mode a `ctx_read` call resolves to.
63///
64/// `Density` carries an `f64`, so the enum is `PartialEq` but not `Eq`.
65#[derive(Debug, Clone, PartialEq)]
66pub enum ReadMode {
67    /// Verbatim, edit-ready (framed) — `"full"`.
68    Full,
69    /// Headerless, trailing-whitespace-stripped verbatim — `"full-compact"`.
70    /// Used by the Read redirect to produce temp files faithful to the
71    /// original line structure while saving framing overhead.
72    FullCompact,
73    /// Verbatim + per-line `N:hh|` hash anchors, edit-ready for `ctx_patch`
74    /// (epic #1008) — `"anchored"`, or windowed as `"anchored:N-M"` (#811) so a
75    /// bounded anchored read never has to materialize/anchor the whole file.
76    Anchored(Option<LineRange>),
77    /// Exact bytes, no framing — `"raw"`.
78    Raw,
79    /// API surface — `"signatures"`.
80    Signatures,
81    /// Structural outline — `"map"`.
82    Map,
83    /// Aggressive lossy summary — `"aggressive"`.
84    Aggressive,
85    /// Entropy-pruned summary — `"entropy"`.
86    Entropy,
87    /// Semantic chunking (7±2 chunks via Miller's Law) — `"cognitive"`.
88    Cognitive,
89    /// MDL structural description — `"mdl"`.
90    Mdl,
91    /// Task-focused summary — `"task"`.
92    Task,
93    /// One-line pointer/quote — `"reference"`.
94    Reference,
95    /// Learned/auto mode selection — `"auto"`.
96    Auto,
97    /// Git delta vs the cached copy — `"diff"`.
98    Diff,
99    /// Line window — `"lines:start-end"`.
100    Lines(LineRange),
101    /// Comma multi-select — `"lines:5,10-20"` (#971).
102    ///
103    /// The parts are validated but kept verbatim rather than re-derived into
104    /// `Vec<LineRange>`. The window semantics live in
105    /// `render::extract_line_range`, which reads the raw mode string, so a
106    /// structured payload here would have no consumer — and it could not
107    /// round-trip: a bare `5` selects *line 5*, which no `LineRange` spells
108    /// without becoming `5-5`. Validation is what this type owes its callers;
109    /// interpretation belongs to the renderer.
110    LinesMulti(String),
111    /// Tail window — `"lines:-N"`, the last `N` lines of the file (#1759).
112    ///
113    /// This is what the Bash-hook rewrite turns `tail -N file` into. It used
114    /// to be rejected as malformed, so every rewritten `tail` failed with a
115    /// parse error instead of returning the last N lines. Like `LinesMulti`
116    /// the window is resolved by `render::extract_line_range` — it needs the
117    /// file's line count — so only the count is carried here.
118    LinesTail(u32),
119    /// Target-density compression — `"density:0.NN"`.
120    Density(f64),
121}
122
123/// One spelling per [`ReadMode`] variant family — what the docs count as
124/// "read modes" (#1896). `mode_families_cover_every_variant` keeps it exact.
125#[cfg(test)]
126pub(crate) const MODE_FAMILIES: &[&str] = &[
127    "full",
128    "full-compact",
129    "anchored",
130    "raw",
131    "signatures",
132    "map",
133    "aggressive",
134    "entropy",
135    "cognitive",
136    "mdl",
137    "task",
138    "reference",
139    "auto",
140    "diff",
141    "lines:5-10",
142    "density:0.40",
143];
144
145/// Error returned when a string is not a recognised [`ReadMode`].
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum ParseModeError {
148    /// The string is not any known mode keyword or prefix.
149    Unknown(String),
150    /// A known prefix (`lines:` / `density:`) with an unparseable payload.
151    Malformed(String),
152}
153
154impl fmt::Display for ParseModeError {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        match self {
157            ParseModeError::Unknown(s) => write!(f, "unknown read mode '{s}'"),
158            ParseModeError::Malformed(s) => write!(f, "malformed read mode '{s}'"),
159        }
160    }
161}
162
163impl ParseModeError {
164    /// Actionable, caller-facing message for a rejected mode string (#1209).
165    ///
166    /// `Display` states *what* is wrong; this also says *what to type instead* —
167    /// critical for the `lines:N:M` colon-typo trap, where a second colon reads
168    /// natural (muscle memory from `file:line:col`) but is malformed. Without
169    /// this the range failed silently: a small file returned an empty window
170    /// ("No lines matched"), a large one bounced to a full-file dump.
171    #[must_use]
172    pub fn user_message(&self) -> String {
173        match self {
174            ParseModeError::Malformed(s) if s.starts_with("lines:") => format!(
175                "invalid read mode \"{s}\": expected lines:START-END (dash), e.g. lines:72-92, \
176                 lines:-N for the last N lines, or a comma multi-select like lines:5,10-20"
177            ),
178            ParseModeError::Malformed(s) if s.starts_with("anchored:") => format!(
179                "invalid read mode \"{s}\": expected anchored:START-END (dash), e.g. anchored:72-92"
180            ),
181            ParseModeError::Malformed(s) if s.starts_with("density:") => {
182                format!("invalid read mode \"{s}\": expected density:0.NN, e.g. density:0.40")
183            }
184            ParseModeError::Malformed(s) | ParseModeError::Unknown(s) => format!(
185                "invalid read mode \"{s}\": expected one of full, signatures, map, cognitive, mdl, \
186                 auto, raw, anchored, reference, diff, lines:N-M, lines:-N, anchored:N-M, \
187                 density:0.NN"
188            ),
189        }
190    }
191}
192
193impl std::error::Error for ParseModeError {}
194
195/// Validate a comma multi-select payload (`"5,10-20"`) and return it verbatim
196/// (#971).
197///
198/// Each part must be a bare line `N` or a span `N-M`, so garbage still fails
199/// exactly as it does for a single range. Before this existed, a comma payload
200/// hit [`parse_line_range`]'s `split_once('-')` and left `"622,1214-1218"` for
201/// `parse::<u32>()`, making the whole mode unparseable — which silently cost it
202/// [`ReadMode::is_precise_pinned_read`] and let bounce-prevention rewrite a
203/// window request to `full`.
204fn parse_line_multi(payload: &str) -> Result<String, ParseModeError> {
205    let malformed = || ParseModeError::Malformed(format!("lines:{payload}"));
206    for part in payload.split(',') {
207        let part = part.trim();
208        if let Some(count) = part.strip_prefix('-') {
209            // #1759: a `-N` part is a tail window (the last N lines). It must
210            // be recognised before `split_once('-')`, which would otherwise
211            // read `-3` as the span `""-"3"` and reject it.
212            parse_tail_count(count).ok_or_else(malformed)?;
213        } else if let Some((start, end)) = part.split_once('-') {
214            start.trim().parse::<u32>().map_err(|_| malformed())?;
215            end.trim().parse::<u32>().map_err(|_| malformed())?;
216        } else {
217            part.parse::<u32>().map_err(|_| malformed())?;
218        }
219    }
220    Ok(payload.to_string())
221}
222
223/// Parse the `N` of a tail window (`lines:-N`, #1759).
224///
225/// Zero is rejected: "the last 0 lines" is never what a caller meant, and a
226/// rewritten `tail -0` would otherwise silently return an empty window.
227fn parse_tail_count(payload: &str) -> Option<u32> {
228    payload.trim().parse::<u32>().ok().filter(|n| *n >= 1)
229}
230
231/// Parse the payload of a line-range mode (`"5-10"`, `"5-999999"`, or a bare
232/// `"5"` meaning "from line N to EOF"). `whole` is the full original mode string
233/// (`"lines:…"` or `"anchored:…"`) so a rejection names the mode the caller
234/// actually typed — #1209: an `anchored:` typo must not be reported as `lines:`.
235fn parse_line_range(payload: &str, whole: &str) -> Result<LineRange, ParseModeError> {
236    let malformed = || ParseModeError::Malformed(whole.to_string());
237    if let Some((a, b)) = payload.split_once('-') {
238        let start = a.trim().parse::<u32>().map_err(|_| malformed())?;
239        let end = b.trim().parse::<u32>().map_err(|_| malformed())?;
240        Ok(LineRange::new(start, end))
241    } else {
242        // A bare `lines:N` means "from line N to EOF".
243        let start = payload.trim().parse::<u32>().map_err(|_| malformed())?;
244        Ok(LineRange::to_eof(start))
245    }
246}
247
248impl FromStr for ReadMode {
249    type Err = ParseModeError;
250
251    fn from_str(s: &str) -> Result<Self, Self::Err> {
252        Ok(match s {
253            "full" => ReadMode::Full,
254            "full-compact" => ReadMode::FullCompact,
255            "anchored" => ReadMode::Anchored(None),
256            "raw" => ReadMode::Raw,
257            "signatures" => ReadMode::Signatures,
258            "map" => ReadMode::Map,
259            "aggressive" => ReadMode::Aggressive,
260            "entropy" => ReadMode::Entropy,
261            "cognitive" => ReadMode::Cognitive,
262            "mdl" => ReadMode::Mdl,
263            "task" => ReadMode::Task,
264            "reference" => ReadMode::Reference,
265            "auto" => ReadMode::Auto,
266            "diff" => ReadMode::Diff,
267            other => {
268                if let Some(payload) = other.strip_prefix("lines:") {
269                    // #971: a comma payload is a multi-select, not a span.
270                    if payload.contains(',') {
271                        ReadMode::LinesMulti(parse_line_multi(payload)?)
272                    } else if let Some(count) = payload.trim().strip_prefix('-') {
273                        // #1759: `lines:-N` is the last N lines — the mode the
274                        // Bash-hook rewrite emits for `tail -N file`.
275                        ReadMode::LinesTail(
276                            parse_tail_count(count)
277                                .ok_or_else(|| ParseModeError::Malformed(other.to_string()))?,
278                        )
279                    } else {
280                        ReadMode::Lines(parse_line_range(payload, other)?)
281                    }
282                } else if let Some(count) = other.trim().strip_prefix('-')
283                    && count.chars().all(|c| c.is_ascii_digit())
284                    && !count.is_empty()
285                {
286                    // #1813: the schema documents a bare `-N` tail form, but only
287                    // the `lines:-N` spelling was implemented. A bare `-3` fell
288                    // through to `Unknown` and was answered with the *head* of
289                    // the file — the caller read the wrong end, and on a long
290                    // file truncation hid that the tail never arrived. Honour
291                    // the documented spelling instead of removing it: `tail -N`
292                    // is what a caller reaches for, and `-N` is how they write it.
293                    ReadMode::LinesTail(
294                        parse_tail_count(count)
295                            .ok_or_else(|| ParseModeError::Malformed(other.to_string()))?,
296                    )
297                } else if let Some(payload) = other.strip_prefix("anchored:") {
298                    ReadMode::Anchored(Some(parse_line_range(payload, other)?))
299                } else if let Some(payload) = other.strip_prefix("density:") {
300                    let target = payload
301                        .trim()
302                        .parse::<f64>()
303                        .map_err(|_| ParseModeError::Malformed(other.to_string()))?;
304                    ReadMode::Density(target)
305                } else {
306                    return Err(ParseModeError::Unknown(other.to_string()));
307                }
308            }
309        })
310    }
311}
312
313/// Rewrite the documented bare tail spelling `-N` to its canonical `lines:-N`
314/// form (#1813).
315///
316/// The schema has always advertised `-N=tail`, but only `lines:-N` was ever
317/// implemented. A bare `-3` therefore failed to parse as a mode and was
318/// answered with the *head* of the file, with no header and no warning — the
319/// caller read the wrong end, and on a long file the token cap hid that the
320/// tail had never been delivered. Silently returning the opposite end is the
321/// worst of the available answers, so the documented spelling is honoured
322/// rather than removed: `tail -N` is what a caller reaches for, and `-N` is how
323/// they write it.
324///
325/// This lives beside [`ReadMode`] because spelling is what that type owns, and
326/// because both entry points — the MCP handler and `lean-ctx read` — must agree.
327/// Canonicalizing to one internal form also avoids teaching a second spelling
328/// to the render path and cache key, which branch on `starts_with("lines:")` in
329/// several places each.
330///
331/// Only a pure `-<digits>` payload is rewritten. Anything else (`-N-M`, `-x`, a
332/// bare `-`) is left alone so it still reaches the normal unknown-mode warning
333/// instead of being silently reinterpreted — which is the failure this exists
334/// to remove.
335#[must_use]
336///
337/// A mode that is still invalid after that gets one more chance: an
338/// unambiguous misspelling of a valid mode is repaired by
339/// `repair_mode_spelling` (`lines:72:92`, `Full`).
340pub fn canonicalize_tail_mode(mode: &str) -> Option<String> {
341    let trimmed = mode.trim();
342    canonical_bare_window(trimmed).or_else(|| {
343        trimmed
344            .parse::<ReadMode>()
345            .is_err()
346            .then(|| repair_mode_spelling(trimmed))
347            .flatten()
348    })
349}
350
351/// The `lines:` form of a bare window spelling (`-N`, `5,10-20`).
352fn canonical_bare_window(trimmed: &str) -> Option<String> {
353    // GH #2000: the schema documents the bare comma multi-select (`5,10-20`)
354    // next to `-N`, and it had the same failure: no `lines:` prefix, so it
355    // parsed as an unknown mode and the whole file came back unmarked. Only a
356    // payload `parse_line_multi` accepts is rewritten, so garbage still reaches
357    // the unknown-mode path.
358    if trimmed.contains(',') {
359        return parse_line_multi(trimmed)
360            .ok()
361            .map(|payload| format!("lines:{payload}"));
362    }
363    let count = trimmed.strip_prefix('-')?;
364    (!count.is_empty() && count.chars().all(|c| c.is_ascii_digit()))
365        .then(|| format!("lines:-{count}"))
366}
367
368/// Unambiguous misspellings of a valid mode: a capitalised keyword (`Full`),
369/// a range written with another separator (`lines:72:92`, `lines:72..92`,
370/// `lines:72–92`) or a prefix joined by a space or `=` (`lines 72-92`). Only a
371/// repair that then parses is returned, so anything genuinely unclear still
372/// reaches the actionable invalid-mode error (#1209).
373fn repair_mode_spelling(mode: &str) -> Option<String> {
374    let lower = mode.to_ascii_lowercase();
375    if lower != mode && lower.parse::<ReadMode>().is_ok() {
376        return Some(lower);
377    }
378    for prefix in ["lines", "anchored"] {
379        let Some(rest) = lower.strip_prefix(prefix) else {
380            continue;
381        };
382        let rest = rest.trim_start_matches([':', '=', ' ']);
383        let mut payload = rest.trim().to_string();
384        for separator in ["..", "\u{2013}", "\u{2014}", " to ", ":"] {
385            payload = payload.replace(separator, "-");
386        }
387        payload.retain(|c| !c.is_whitespace());
388        let candidate = format!("{prefix}:{payload}");
389        if candidate != mode && candidate.parse::<ReadMode>().is_ok() {
390            return Some(candidate);
391        }
392    }
393    None
394}
395
396impl fmt::Display for ReadMode {
397    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
398        let keyword = match self {
399            ReadMode::Full => "full",
400            ReadMode::FullCompact => "full-compact",
401            ReadMode::Raw => "raw",
402            ReadMode::Signatures => "signatures",
403            ReadMode::Map => "map",
404            ReadMode::Aggressive => "aggressive",
405            ReadMode::Entropy => "entropy",
406            ReadMode::Cognitive => "cognitive",
407            ReadMode::Mdl => "mdl",
408            ReadMode::Task => "task",
409            ReadMode::Reference => "reference",
410            ReadMode::Auto => "auto",
411            ReadMode::Diff => "diff",
412            ReadMode::Anchored(None) => "anchored",
413            ReadMode::Anchored(Some(range)) => return write!(f, "anchored:{range}"),
414            ReadMode::Lines(range) => return write!(f, "lines:{range}"),
415            ReadMode::LinesMulti(payload) => return write!(f, "lines:{payload}"),
416            ReadMode::LinesTail(count) => return write!(f, "lines:-{count}"),
417            // Matches the handler's historical `format!("density:{:.2}", …)`.
418            ReadMode::Density(target) => return write!(f, "density:{target:.2}"),
419        };
420        f.write_str(keyword)
421    }
422}
423
424impl ReadMode {
425    /// `map`/`signatures` — the lossy summaries whose rendered body is stored in
426    /// the per-file `compressed_outputs` cache. Replaces `is_cacheable_mode`.
427    #[must_use]
428    pub fn is_compressed_cacheable(&self) -> bool {
429        matches!(self, ReadMode::Map | ReadMode::Signatures | ReadMode::Mdl)
430    }
431
432    /// Whole-file views the `#361` anti-inflation raw cap applies to. Selection
433    /// and delta views (`lines:`, `reference`, `diff`, `raw`) have view-specific
434    /// semantics and are never capped. Replaces `mode_allows_raw_cap`.
435    #[must_use]
436    pub fn allows_raw_cap(&self) -> bool {
437        !matches!(
438            self,
439            ReadMode::Lines(_)
440                | ReadMode::LinesMulti(_)
441                | ReadMode::LinesTail(_)
442                | ReadMode::Reference
443                | ReadMode::Diff
444                | ReadMode::Raw
445                // Anchored carries per-line anchors the agent edits against;
446                // collapsing to bare bytes on a small file would strip them and
447                // defeat the mode, so it opts out of the #361 raw cap.
448                | ReadMode::Anchored(_)
449        )
450    }
451
452    /// Lossy summaries eligible for cross-file block dedup (#…): the body is a
453    /// summary, so shared blocks can be elided. Replaces the inline
454    /// `dedup_allowed` match.
455    #[must_use]
456    pub fn is_lossy_summary(&self) -> bool {
457        matches!(
458            self,
459            ReadMode::Map
460                | ReadMode::Signatures
461                | ReadMode::Aggressive
462                | ReadMode::Entropy
463                | ReadMode::Cognitive
464                | ReadMode::Mdl
465                | ReadMode::Task
466        )
467    }
468
469    /// Whether a read in this mode counts as "compressed" for bounce/quality
470    /// tracking (#538). Only verbatim `full` and the `diff` delta are *not*
471    /// compressed. Replaces the inline `!matches!(mode, "full"|"diff"|"lines")`
472    /// predicate — a resolved line window is the string `"lines:N-M"`, never the
473    /// bare `"lines"`, so that arm was dead and `Lines` stays compressed here.
474    #[must_use]
475    pub fn counts_as_compressed(&self) -> bool {
476        // `anchored` is lossless (verbatim + anchors), so like `full` it must not
477        // count as a "compressed" read for bounce/quality tracking.
478        // `full-compact` only strips trailing whitespace — functionally verbatim.
479        !matches!(
480            self,
481            ReadMode::Full | ReadMode::FullCompact | ReadMode::Diff | ReadMode::Anchored(_)
482        )
483    }
484
485    /// Precise, pinned reads (#843): the caller asked for an exact byte-window
486    /// or delta and must get exactly that back. Context-gate overrides
487    /// (bounce-prevention, pressure-downgrade, graph/knowledge heuristics)
488    /// must never silently reinterpret one of these to e.g. `full` — that
489    /// discards the window/anchors/delta the caller explicitly asked for.
490    /// Kept as an enum match (not a string-prefix check) so a future mode
491    /// variant that's also precise/pinned has to be added here explicitly
492    /// instead of silently falling through an allowlist.
493    #[must_use]
494    pub fn is_precise_pinned_read(&self) -> bool {
495        matches!(
496            self,
497            ReadMode::Diff
498                | ReadMode::Lines(_)
499                | ReadMode::LinesMulti(_)
500                | ReadMode::LinesTail(_)
501                | ReadMode::Anchored(_)
502        )
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    #[test]
511    fn unambiguous_mode_misspellings_are_repaired() {
512        for (input, expected) in [
513            ("lines:72:92", "lines:72-92"),
514            ("lines:72..92", "lines:72-92"),
515            ("lines:72\u{2013}92", "lines:72-92"),
516            ("lines 72-92", "lines:72-92"),
517            ("lines=10-20", "lines:10-20"),
518            ("anchored:5:9", "anchored:5-9"),
519            ("Full", "full"),
520            ("MAP", "map"),
521        ] {
522            assert_eq!(
523                canonicalize_tail_mode(input).as_deref(),
524                Some(expected),
525                "{input}"
526            );
527        }
528        for untouched in ["full", "lines:5-10", "lines:-3", "nonsense", "lines:abc"] {
529            assert_eq!(canonicalize_tail_mode(untouched), None, "{untouched}");
530        }
531    }
532
533    /// Every canonical mode string the handler/`render.rs` produce or accept.
534    const CANONICAL: &[&str] = &[
535        "full",
536        "full-compact",
537        "anchored",
538        "raw",
539        "signatures",
540        "map",
541        "aggressive",
542        "entropy",
543        "cognitive",
544        "mdl",
545        "task",
546        "reference",
547        "auto",
548        "diff",
549        "lines:5-10",
550        "lines:5-999999",
551        "lines:-3",
552        "density:0.40",
553    ];
554
555    // --- Legacy predicates being replaced (kept verbatim so the equivalence
556    // tests below pin behaviour to the exact prior semantics). ---
557
558    fn legacy_is_cacheable(mode: &str) -> bool {
559        ["map", "signatures", "mdl"].contains(&mode)
560    }
561
562    fn legacy_allows_raw_cap(mode: &str) -> bool {
563        !(mode.starts_with("lines:") || matches!(mode, "reference" | "diff" | "raw" | "anchored"))
564    }
565
566    fn legacy_is_lossy_summary(mode: &str) -> bool {
567        matches!(
568            mode,
569            "map" | "signatures" | "aggressive" | "entropy" | "cognitive" | "mdl" | "task"
570        )
571    }
572
573    fn legacy_counts_as_compressed(mode: &str) -> bool {
574        !matches!(
575            mode,
576            "full" | "full-compact" | "diff" | "lines" | "anchored"
577        )
578    }
579
580    #[test]
581    fn round_trips_every_canonical_mode() {
582        for mode in CANONICAL {
583            let parsed: ReadMode = mode.parse().expect("canonical mode parses");
584            assert_eq!(
585                parsed.to_string(),
586                *mode,
587                "Display must round-trip '{mode}' byte-identically"
588            );
589        }
590    }
591
592    #[test]
593    fn classification_matches_legacy_predicates() {
594        for mode in CANONICAL {
595            let parsed: ReadMode = mode.parse().expect("canonical mode parses");
596            assert_eq!(
597                parsed.is_compressed_cacheable(),
598                legacy_is_cacheable(mode),
599                "is_compressed_cacheable diverged for '{mode}'"
600            );
601            assert_eq!(
602                parsed.allows_raw_cap(),
603                legacy_allows_raw_cap(mode),
604                "allows_raw_cap diverged for '{mode}'"
605            );
606            assert_eq!(
607                parsed.is_lossy_summary(),
608                legacy_is_lossy_summary(mode),
609                "is_lossy_summary diverged for '{mode}'"
610            );
611            assert_eq!(
612                parsed.counts_as_compressed(),
613                legacy_counts_as_compressed(mode),
614                "counts_as_compressed diverged for '{mode}'"
615            );
616        }
617    }
618
619    #[test]
620    fn unknown_mode_is_rejected_by_from_str() {
621        assert_eq!(
622            "wat".parse::<ReadMode>(),
623            Err(ParseModeError::Unknown("wat".to_string()))
624        );
625        assert_eq!(
626            "".parse::<ReadMode>(),
627            Err(ParseModeError::Unknown(String::new()))
628        );
629    }
630
631    #[test]
632    fn malformed_parameterized_modes_are_rejected() {
633        assert_eq!(
634            "lines:abc".parse::<ReadMode>(),
635            Err(ParseModeError::Malformed("lines:abc".to_string()))
636        );
637        assert_eq!(
638            "lines:5-x".parse::<ReadMode>(),
639            Err(ParseModeError::Malformed("lines:5-x".to_string()))
640        );
641        assert_eq!(
642            "density:nope".parse::<ReadMode>(),
643            Err(ParseModeError::Malformed("density:nope".to_string()))
644        );
645    }
646
647    #[test]
648    fn user_message_names_the_dash_form_for_colon_typo() {
649        // #1209: the `lines:44:48` colon typo must yield an actionable message
650        // naming the dash form, not a silent empty window or full-file bounce.
651        let err = "lines:44:48".parse::<ReadMode>().unwrap_err();
652        let msg = err.user_message();
653        assert!(msg.contains("lines:44:48"), "echoes the offending input");
654        assert!(msg.contains("lines:START-END"), "names the expected form");
655        assert!(
656            msg.contains("lines:72-92") || msg.contains("dash"),
657            "shows a dash example"
658        );
659
660        // The same trap on the anchored line-range mode.
661        let anchored = "anchored:44:48".parse::<ReadMode>().unwrap_err();
662        assert!(anchored.user_message().contains("anchored:START-END"));
663
664        // A wholly unknown keyword still lists the valid vocabulary.
665        let unknown = "wat".parse::<ReadMode>().unwrap_err();
666        assert!(unknown.user_message().contains("lines:N-M"));
667    }
668
669    #[test]
670    fn line_range_parses_bounded_unbounded_and_bare() {
671        assert_eq!(
672            "lines:5-10".parse::<ReadMode>().unwrap(),
673            ReadMode::Lines(LineRange::new(5, 10))
674        );
675        assert_eq!(
676            "lines:5-999999".parse::<ReadMode>().unwrap(),
677            ReadMode::Lines(LineRange::to_eof(5))
678        );
679        // A bare `lines:5` means "from line 5 to EOF".
680        assert_eq!(
681            "lines:5".parse::<ReadMode>().unwrap(),
682            ReadMode::Lines(LineRange::to_eof(5))
683        );
684    }
685
686    // --- #971: comma multi-select ---
687
688    #[test]
689    fn comma_multi_select_parses() {
690        // The exact payload from #971, plus the form the tool description
691        // documents (`lines:5,10-20`) — both used to be Malformed.
692        assert_eq!(
693            "lines:620-622,1214-1218".parse::<ReadMode>().unwrap(),
694            ReadMode::LinesMulti("620-622,1214-1218".to_string())
695        );
696        assert_eq!(
697            "lines:5,10-20".parse::<ReadMode>().unwrap(),
698            ReadMode::LinesMulti("5,10-20".to_string())
699        );
700    }
701
702    #[test]
703    fn comma_multi_select_round_trips_byte_identically() {
704        // The module contract: Display reproduces the canonical string exactly.
705        for mode in ["lines:5,10-20", "lines:620-622,1214-1218", "lines:1,2,3"] {
706            assert_eq!(mode.parse::<ReadMode>().unwrap().to_string(), mode);
707        }
708    }
709
710    #[test]
711    fn comma_multi_select_is_pinned_and_never_capped() {
712        // The #971 regression in one place: an unparseable mode was treated as
713        // not-pinned, which let bounce-prevention rewrite the window to `full`,
714        // and opted it into the #361 raw cap that `lines:` must never get. Both
715        // must hold for the comma form exactly as for `lines:N-M`.
716        let multi: ReadMode = "lines:620-622,1214-1218".parse().unwrap();
717        assert!(
718            multi.is_precise_pinned_read(),
719            "a comma multi-select is still a precise pinned read (#843)"
720        );
721        assert!(
722            !multi.allows_raw_cap(),
723            "a comma multi-select is a selection view and is never raw-capped (#361)"
724        );
725        // Classified identically to the single-range form.
726        let single: ReadMode = "lines:620-622".parse().unwrap();
727        assert_eq!(
728            multi.is_precise_pinned_read(),
729            single.is_precise_pinned_read()
730        );
731        assert_eq!(multi.allows_raw_cap(), single.allows_raw_cap());
732        assert_eq!(multi.counts_as_compressed(), single.counts_as_compressed());
733        assert_eq!(multi.is_lossy_summary(), single.is_lossy_summary());
734        assert_eq!(
735            multi.is_compressed_cacheable(),
736            single.is_compressed_cacheable()
737        );
738    }
739
740    #[test]
741    fn malformed_multi_select_still_rejected() {
742        // Validation must not weaken: garbage in a part fails as before.
743        for mode in ["lines:5,abc", "lines:5-x,10", "lines:5,,10", "lines:5,10-"] {
744            assert!(
745                mode.parse::<ReadMode>().is_err(),
746                "'{mode}' must not parse as a valid multi-select"
747            );
748        }
749    }
750
751    // --- #1759: tail window ---
752
753    #[test]
754    fn tail_window_parses_and_round_trips() {
755        // The exact mode the Bash-hook rewrite emits for `tail -3 file`. It was
756        // Malformed before, so the rewrite failed on every invocation.
757        assert_eq!(
758            "lines:-3".parse::<ReadMode>().unwrap(),
759            ReadMode::LinesTail(3)
760        );
761        assert_eq!(
762            "lines:-3".parse::<ReadMode>().unwrap().to_string(),
763            "lines:-3"
764        );
765        assert_eq!(
766            "lines:-250".parse::<ReadMode>().unwrap(),
767            ReadMode::LinesTail(250)
768        );
769    }
770
771    #[test]
772    fn tail_window_is_pinned_and_never_capped() {
773        // Same contract as `lines:N-M`: the caller asked for an exact window and
774        // must get exactly that back (#843), and a selection view is never
775        // raw-capped (#361).
776        let tail: ReadMode = "lines:-3".parse().unwrap();
777        let single: ReadMode = "lines:1-3".parse().unwrap();
778        assert!(tail.is_precise_pinned_read());
779        assert!(!tail.allows_raw_cap());
780        assert_eq!(tail.counts_as_compressed(), single.counts_as_compressed());
781        assert_eq!(tail.is_lossy_summary(), single.is_lossy_summary());
782        assert_eq!(
783            tail.is_compressed_cacheable(),
784            single.is_compressed_cacheable()
785        );
786    }
787
788    #[test]
789    fn tail_window_rejects_zero_and_garbage() {
790        for mode in ["lines:-0", "lines:-", "lines:-x", "lines:--3", "lines:-3-5"] {
791            let err = mode
792                .parse::<ReadMode>()
793                .expect_err("a malformed tail window must not parse");
794            assert_eq!(err, ParseModeError::Malformed(mode.to_string()), "{mode}");
795            assert!(
796                err.user_message().contains("lines:-N"),
797                "the message must name the tail form for '{mode}'"
798            );
799        }
800    }
801
802    #[test]
803    fn tail_window_is_accepted_inside_a_multi_select() {
804        // A `-N` part goes through the same validation as `lines:-N`; it is
805        // kept verbatim and interpreted by the renderer like every other part.
806        assert_eq!(
807            "lines:1-3,-2".parse::<ReadMode>().unwrap(),
808            ReadMode::LinesMulti("1-3,-2".to_string())
809        );
810        assert!("lines:1-3,-0".parse::<ReadMode>().is_err());
811    }
812
813    #[test]
814    fn line_range_clamps_start_to_one() {
815        assert_eq!(LineRange::new(0, 10).start, 1);
816    }
817
818    #[test]
819    fn anchored_window_parses_and_displays() {
820        assert_eq!(
821            "anchored:5-10".parse::<ReadMode>().unwrap(),
822            ReadMode::Anchored(Some(LineRange::new(5, 10)))
823        );
824        assert_eq!(
825            "anchored:5-10".parse::<ReadMode>().unwrap().to_string(),
826            "anchored:5-10"
827        );
828        assert_eq!(
829            "anchored".parse::<ReadMode>().unwrap(),
830            ReadMode::Anchored(None)
831        );
832    }
833
834    #[test]
835    fn anchored_window_opts_out_of_raw_cap_and_compressed_count() {
836        let windowed: ReadMode = "anchored:5-10".parse().unwrap();
837        assert!(!windowed.allows_raw_cap());
838        assert!(!windowed.counts_as_compressed());
839    }
840
841    #[test]
842    fn density_display_normalizes_to_two_decimals() {
843        // Parsing is lenient; Display normalizes to the handler's `{:.2}` form so
844        // identical reads stay byte-stable (#498 determinism).
845        let parsed: ReadMode = "density:0.5".parse().unwrap();
846        assert_eq!(parsed, ReadMode::Density(0.5));
847        assert_eq!(parsed.to_string(), "density:0.50");
848    }
849
850    #[test]
851    fn is_precise_pinned_read_covers_diff_lines_and_anchored() {
852        for mode in ["diff", "lines:5-10", "anchored", "anchored:5-10"] {
853            let parsed: ReadMode = mode.parse().expect("canonical mode parses");
854            assert!(
855                parsed.is_precise_pinned_read(),
856                "'{mode}' must be a precise pinned read"
857            );
858        }
859        for mode in ["full", "map", "signatures", "auto", "task"] {
860            let parsed: ReadMode = mode.parse().expect("canonical mode parses");
861            assert!(
862                !parsed.is_precise_pinned_read(),
863                "'{mode}' must not be a precise pinned read"
864            );
865        }
866    }
867}