1use std::fmt;
25use std::str::FromStr;
26
27pub(crate) const LINE_RANGE_EOF: u32 = 999_999;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct LineRange {
34 pub start: u32,
35 pub end: u32,
36}
37
38impl LineRange {
39 #[must_use]
42 pub fn new(start: u32, end: u32) -> Self {
43 Self {
44 start: start.max(1),
45 end,
46 }
47 }
48
49 #[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#[derive(Debug, Clone, PartialEq)]
66pub enum ReadMode {
67 Full,
69 FullCompact,
73 Anchored(Option<LineRange>),
77 Raw,
79 Signatures,
81 Map,
83 Aggressive,
85 Entropy,
87 Cognitive,
89 Mdl,
91 Task,
93 Reference,
95 Auto,
97 Diff,
99 Lines(LineRange),
101 LinesMulti(String),
111 LinesTail(u32),
119 Density(f64),
121}
122
123#[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#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum ParseModeError {
148 Unknown(String),
150 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 #[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
195fn 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 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
223fn parse_tail_count(payload: &str) -> Option<u32> {
228 payload.trim().parse::<u32>().ok().filter(|n| *n >= 1)
229}
230
231fn 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 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 if payload.contains(',') {
271 ReadMode::LinesMulti(parse_line_multi(payload)?)
272 } else if let Some(count) = payload.trim().strip_prefix('-') {
273 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 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#[must_use]
336pub 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
351fn canonical_bare_window(trimmed: &str) -> Option<String> {
353 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
368fn 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 ReadMode::Density(target) => return write!(f, "density:{target:.2}"),
419 };
420 f.write_str(keyword)
421 }
422}
423
424impl ReadMode {
425 #[must_use]
428 pub fn is_compressed_cacheable(&self) -> bool {
429 matches!(self, ReadMode::Map | ReadMode::Signatures | ReadMode::Mdl)
430 }
431
432 #[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 | ReadMode::Anchored(_)
449 )
450 }
451
452 #[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 #[must_use]
475 pub fn counts_as_compressed(&self) -> bool {
476 !matches!(
480 self,
481 ReadMode::Full | ReadMode::FullCompact | ReadMode::Diff | ReadMode::Anchored(_)
482 )
483 }
484
485 #[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 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 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 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 let anchored = "anchored:44:48".parse::<ReadMode>().unwrap_err();
662 assert!(anchored.user_message().contains("anchored:START-END"));
663
664 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 assert_eq!(
681 "lines:5".parse::<ReadMode>().unwrap(),
682 ReadMode::Lines(LineRange::to_eof(5))
683 );
684 }
685
686 #[test]
689 fn comma_multi_select_parses() {
690 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 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 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 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 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 #[test]
754 fn tail_window_parses_and_round_trips() {
755 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 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 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 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}