use std::fmt;
use std::str::FromStr;
pub(crate) const LINE_RANGE_EOF: u32 = 999_999;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct LineRange {
pub(crate) start: u32,
pub(crate) end: u32,
}
impl LineRange {
#[must_use]
pub(crate) fn new(start: u32, end: u32) -> Self {
Self {
start: start.max(1),
end,
}
}
#[must_use]
pub(crate) fn to_eof(start: u32) -> Self {
Self::new(start, LINE_RANGE_EOF)
}
}
impl fmt::Display for LineRange {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}-{}", self.start, self.end)
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum ReadMode {
Full,
FullCompact,
Anchored(Option<LineRange>),
Raw,
Signatures,
Map,
Aggressive,
Entropy,
Task,
Reference,
Auto,
Diff,
Lines(LineRange),
LinesMulti(String),
Density(f64),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ParseModeError {
Unknown(String),
Malformed(String),
}
impl fmt::Display for ParseModeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ParseModeError::Unknown(s) => write!(f, "unknown read mode '{s}'"),
ParseModeError::Malformed(s) => write!(f, "malformed read mode '{s}'"),
}
}
}
impl ParseModeError {
#[must_use]
pub(crate) fn user_message(&self) -> String {
match self {
ParseModeError::Malformed(s) if s.starts_with("lines:") => format!(
"invalid read mode \"{s}\": expected lines:START-END (dash), e.g. lines:72-92 \
(or a comma multi-select like lines:5,10-20)"
),
ParseModeError::Malformed(s) if s.starts_with("anchored:") => format!(
"invalid read mode \"{s}\": expected anchored:START-END (dash), e.g. anchored:72-92"
),
ParseModeError::Malformed(s) if s.starts_with("density:") => {
format!("invalid read mode \"{s}\": expected density:0.NN, e.g. density:0.40")
}
ParseModeError::Malformed(s) | ParseModeError::Unknown(s) => format!(
"invalid read mode \"{s}\": expected one of full, signatures, map, auto, raw, \
anchored, reference, diff, lines:N-M, anchored:N-M, density:0.NN"
),
}
}
}
impl std::error::Error for ParseModeError {}
fn parse_line_multi(payload: &str) -> Result<String, ParseModeError> {
let malformed = || ParseModeError::Malformed(format!("lines:{payload}"));
for part in payload.split(',') {
let part = part.trim();
if let Some((start, end)) = part.split_once('-') {
start.trim().parse::<u32>().map_err(|_| malformed())?;
end.trim().parse::<u32>().map_err(|_| malformed())?;
} else {
part.parse::<u32>().map_err(|_| malformed())?;
}
}
Ok(payload.to_string())
}
fn parse_line_range(payload: &str, whole: &str) -> Result<LineRange, ParseModeError> {
let malformed = || ParseModeError::Malformed(whole.to_string());
if let Some((a, b)) = payload.split_once('-') {
let start = a.trim().parse::<u32>().map_err(|_| malformed())?;
let end = b.trim().parse::<u32>().map_err(|_| malformed())?;
Ok(LineRange::new(start, end))
} else {
let start = payload.trim().parse::<u32>().map_err(|_| malformed())?;
Ok(LineRange::to_eof(start))
}
}
impl FromStr for ReadMode {
type Err = ParseModeError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s {
"full" => ReadMode::Full,
"full-compact" => ReadMode::FullCompact,
"anchored" => ReadMode::Anchored(None),
"raw" => ReadMode::Raw,
"signatures" => ReadMode::Signatures,
"map" => ReadMode::Map,
"aggressive" => ReadMode::Aggressive,
"entropy" => ReadMode::Entropy,
"task" => ReadMode::Task,
"reference" => ReadMode::Reference,
"auto" => ReadMode::Auto,
"diff" => ReadMode::Diff,
other => {
if let Some(payload) = other.strip_prefix("lines:") {
if payload.contains(',') {
ReadMode::LinesMulti(parse_line_multi(payload)?)
} else {
ReadMode::Lines(parse_line_range(payload, other)?)
}
} else if let Some(payload) = other.strip_prefix("anchored:") {
ReadMode::Anchored(Some(parse_line_range(payload, other)?))
} else if let Some(payload) = other.strip_prefix("density:") {
let target = payload
.trim()
.parse::<f64>()
.map_err(|_| ParseModeError::Malformed(other.to_string()))?;
ReadMode::Density(target)
} else {
return Err(ParseModeError::Unknown(other.to_string()));
}
}
})
}
}
impl fmt::Display for ReadMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let keyword = match self {
ReadMode::Full => "full",
ReadMode::FullCompact => "full-compact",
ReadMode::Raw => "raw",
ReadMode::Signatures => "signatures",
ReadMode::Map => "map",
ReadMode::Aggressive => "aggressive",
ReadMode::Entropy => "entropy",
ReadMode::Task => "task",
ReadMode::Reference => "reference",
ReadMode::Auto => "auto",
ReadMode::Diff => "diff",
ReadMode::Anchored(None) => "anchored",
ReadMode::Anchored(Some(range)) => return write!(f, "anchored:{range}"),
ReadMode::Lines(range) => return write!(f, "lines:{range}"),
ReadMode::LinesMulti(payload) => return write!(f, "lines:{payload}"),
ReadMode::Density(target) => return write!(f, "density:{target:.2}"),
};
f.write_str(keyword)
}
}
impl ReadMode {
#[must_use]
pub(crate) fn is_compressed_cacheable(&self) -> bool {
matches!(self, ReadMode::Map | ReadMode::Signatures)
}
#[must_use]
pub(crate) fn allows_raw_cap(&self) -> bool {
!matches!(
self,
ReadMode::Lines(_)
| ReadMode::LinesMulti(_)
| ReadMode::Reference
| ReadMode::Diff
| ReadMode::Raw
| ReadMode::Anchored(_)
)
}
#[must_use]
pub(crate) fn is_lossy_summary(&self) -> bool {
matches!(
self,
ReadMode::Map
| ReadMode::Signatures
| ReadMode::Aggressive
| ReadMode::Entropy
| ReadMode::Task
)
}
#[must_use]
pub(crate) fn counts_as_compressed(&self) -> bool {
!matches!(
self,
ReadMode::Full | ReadMode::FullCompact | ReadMode::Diff | ReadMode::Anchored(_)
)
}
#[must_use]
pub(crate) fn is_precise_pinned_read(&self) -> bool {
matches!(
self,
ReadMode::Diff | ReadMode::Lines(_) | ReadMode::LinesMulti(_) | ReadMode::Anchored(_)
)
}
}
#[cfg(test)]
mod tests {
use super::*;
const CANONICAL: &[&str] = &[
"full",
"full-compact",
"anchored",
"raw",
"signatures",
"map",
"aggressive",
"entropy",
"task",
"reference",
"auto",
"diff",
"lines:5-10",
"lines:5-999999",
"density:0.40",
];
fn legacy_is_cacheable(mode: &str) -> bool {
["map", "signatures"].contains(&mode)
}
fn legacy_allows_raw_cap(mode: &str) -> bool {
!(mode.starts_with("lines:") || matches!(mode, "reference" | "diff" | "raw" | "anchored"))
}
fn legacy_is_lossy_summary(mode: &str) -> bool {
matches!(
mode,
"map" | "signatures" | "aggressive" | "entropy" | "task"
)
}
fn legacy_counts_as_compressed(mode: &str) -> bool {
!matches!(
mode,
"full" | "full-compact" | "diff" | "lines" | "anchored"
)
}
#[test]
fn round_trips_every_canonical_mode() {
for mode in CANONICAL {
let parsed: ReadMode = mode.parse().expect("canonical mode parses");
assert_eq!(
parsed.to_string(),
*mode,
"Display must round-trip '{mode}' byte-identically"
);
}
}
#[test]
fn classification_matches_legacy_predicates() {
for mode in CANONICAL {
let parsed: ReadMode = mode.parse().expect("canonical mode parses");
assert_eq!(
parsed.is_compressed_cacheable(),
legacy_is_cacheable(mode),
"is_compressed_cacheable diverged for '{mode}'"
);
assert_eq!(
parsed.allows_raw_cap(),
legacy_allows_raw_cap(mode),
"allows_raw_cap diverged for '{mode}'"
);
assert_eq!(
parsed.is_lossy_summary(),
legacy_is_lossy_summary(mode),
"is_lossy_summary diverged for '{mode}'"
);
assert_eq!(
parsed.counts_as_compressed(),
legacy_counts_as_compressed(mode),
"counts_as_compressed diverged for '{mode}'"
);
}
}
#[test]
fn unknown_mode_is_rejected_by_from_str() {
assert_eq!(
"wat".parse::<ReadMode>(),
Err(ParseModeError::Unknown("wat".to_string()))
);
assert_eq!(
"".parse::<ReadMode>(),
Err(ParseModeError::Unknown(String::new()))
);
}
#[test]
fn malformed_parameterized_modes_are_rejected() {
assert_eq!(
"lines:abc".parse::<ReadMode>(),
Err(ParseModeError::Malformed("lines:abc".to_string()))
);
assert_eq!(
"lines:5-x".parse::<ReadMode>(),
Err(ParseModeError::Malformed("lines:5-x".to_string()))
);
assert_eq!(
"density:nope".parse::<ReadMode>(),
Err(ParseModeError::Malformed("density:nope".to_string()))
);
}
#[test]
fn user_message_names_the_dash_form_for_colon_typo() {
let err = "lines:44:48".parse::<ReadMode>().unwrap_err();
let msg = err.user_message();
assert!(msg.contains("lines:44:48"), "echoes the offending input");
assert!(msg.contains("lines:START-END"), "names the expected form");
assert!(
msg.contains("lines:72-92") || msg.contains("dash"),
"shows a dash example"
);
let anchored = "anchored:44:48".parse::<ReadMode>().unwrap_err();
assert!(anchored.user_message().contains("anchored:START-END"));
let unknown = "wat".parse::<ReadMode>().unwrap_err();
assert!(unknown.user_message().contains("lines:N-M"));
}
#[test]
fn line_range_parses_bounded_unbounded_and_bare() {
assert_eq!(
"lines:5-10".parse::<ReadMode>().unwrap(),
ReadMode::Lines(LineRange::new(5, 10))
);
assert_eq!(
"lines:5-999999".parse::<ReadMode>().unwrap(),
ReadMode::Lines(LineRange::to_eof(5))
);
assert_eq!(
"lines:5".parse::<ReadMode>().unwrap(),
ReadMode::Lines(LineRange::to_eof(5))
);
}
#[test]
fn comma_multi_select_parses() {
assert_eq!(
"lines:620-622,1214-1218".parse::<ReadMode>().unwrap(),
ReadMode::LinesMulti("620-622,1214-1218".to_string())
);
assert_eq!(
"lines:5,10-20".parse::<ReadMode>().unwrap(),
ReadMode::LinesMulti("5,10-20".to_string())
);
}
#[test]
fn comma_multi_select_round_trips_byte_identically() {
for mode in ["lines:5,10-20", "lines:620-622,1214-1218", "lines:1,2,3"] {
assert_eq!(mode.parse::<ReadMode>().unwrap().to_string(), mode);
}
}
#[test]
fn comma_multi_select_is_pinned_and_never_capped() {
let multi: ReadMode = "lines:620-622,1214-1218".parse().unwrap();
assert!(
multi.is_precise_pinned_read(),
"a comma multi-select is still a precise pinned read (#843)"
);
assert!(
!multi.allows_raw_cap(),
"a comma multi-select is a selection view and is never raw-capped (#361)"
);
let single: ReadMode = "lines:620-622".parse().unwrap();
assert_eq!(
multi.is_precise_pinned_read(),
single.is_precise_pinned_read()
);
assert_eq!(multi.allows_raw_cap(), single.allows_raw_cap());
assert_eq!(multi.counts_as_compressed(), single.counts_as_compressed());
assert_eq!(multi.is_lossy_summary(), single.is_lossy_summary());
assert_eq!(
multi.is_compressed_cacheable(),
single.is_compressed_cacheable()
);
}
#[test]
fn malformed_multi_select_still_rejected() {
for mode in ["lines:5,abc", "lines:5-x,10", "lines:5,,10", "lines:5,10-"] {
assert!(
mode.parse::<ReadMode>().is_err(),
"'{mode}' must not parse as a valid multi-select"
);
}
}
#[test]
fn line_range_clamps_start_to_one() {
assert_eq!(LineRange::new(0, 10).start, 1);
}
#[test]
fn anchored_window_parses_and_displays() {
assert_eq!(
"anchored:5-10".parse::<ReadMode>().unwrap(),
ReadMode::Anchored(Some(LineRange::new(5, 10)))
);
assert_eq!(
"anchored:5-10".parse::<ReadMode>().unwrap().to_string(),
"anchored:5-10"
);
assert_eq!(
"anchored".parse::<ReadMode>().unwrap(),
ReadMode::Anchored(None)
);
}
#[test]
fn anchored_window_opts_out_of_raw_cap_and_compressed_count() {
let windowed: ReadMode = "anchored:5-10".parse().unwrap();
assert!(!windowed.allows_raw_cap());
assert!(!windowed.counts_as_compressed());
}
#[test]
fn density_display_normalizes_to_two_decimals() {
let parsed: ReadMode = "density:0.5".parse().unwrap();
assert_eq!(parsed, ReadMode::Density(0.5));
assert_eq!(parsed.to_string(), "density:0.50");
}
#[test]
fn is_precise_pinned_read_covers_diff_lines_and_anchored() {
for mode in ["diff", "lines:5-10", "anchored", "anchored:5-10"] {
let parsed: ReadMode = mode.parse().expect("canonical mode parses");
assert!(
parsed.is_precise_pinned_read(),
"'{mode}' must be a precise pinned read"
);
}
for mode in ["full", "map", "signatures", "auto", "task"] {
let parsed: ReadMode = mode.parse().expect("canonical mode parses");
assert!(
!parsed.is_precise_pinned_read(),
"'{mode}' must not be a precise pinned read"
);
}
}
}