1use serde::{Deserialize, Serialize};
10
11use super::{DataFilePath, GitCommitId, GovernanceLogId};
12use crate::ids::GovernanceLogPrefix;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub struct LineRange {
24 start: u64,
25 end: Option<u64>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
31#[error("lines={asked:?}: {why} {}", LineRange::FORM)]
32pub struct LineRangeError {
33 pub asked: String,
34 pub why: &'static str,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct LineSpan {
41 pub served: LineRange,
42 pub start_byte: usize,
43 pub end_byte: usize,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
48#[error(
49 "lines={asked} starts past the end of the file, which has {total_lines} \
50 line(s)"
51)]
52pub struct PastEnd {
53 pub asked: LineRange,
54 pub total_lines: u64,
55}
56
57impl LineRange {
58 pub const FORM: &'static str = "Write a range of lines as \"<from>-<to>\" \
60 (1-based, both included, e.g. \"110-300\"), or \"<from>-\" to read to \
61 the end of the file.";
62
63 pub const INVALID: &'static str = "Invalid arguments at `lines`: ";
66
67 pub fn new(start: u64, end: Option<u64>) -> Result<Self, &'static str> {
69 if start == 0 {
70 return Err("lines are numbered from 1.");
71 }
72 if end.is_some_and(|end| end < start) {
73 return Err("the range ends before it starts.");
74 }
75 Ok(Self { start, end })
76 }
77
78 pub fn start(&self) -> u64 {
80 self.start
81 }
82
83 pub fn end(&self) -> Option<u64> {
85 self.end
86 }
87
88 pub fn span(&self, text: &str) -> Result<LineSpan, PastEnd> {
91 let total_lines = count_lines(text);
92 if self.start > total_lines {
93 return Err(PastEnd {
94 asked: *self,
95 total_lines,
96 });
97 }
98 let end = self.end.map_or(total_lines, |e| e.min(total_lines));
99 let mut start_byte = 0;
101 let mut end_byte = text.len();
102 for (line, (at, _)) in (1..).zip(text.match_indices('\n')) {
103 if line + 1 == self.start {
104 start_byte = at + 1;
105 }
106 if line == end {
107 end_byte = at + 1;
108 break;
109 }
110 }
111 Ok(LineSpan {
112 served: Self {
113 start: self.start,
114 end: Some(end),
115 },
116 start_byte,
117 end_byte,
118 })
119 }
120}
121
122pub fn count_lines(text: &str) -> u64 {
125 text.split_inclusive('\n').count() as u64
126}
127
128impl std::fmt::Display for LineRange {
129 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130 match self.end {
131 Some(end) => write!(f, "{}-{end}", self.start),
132 None => write!(f, "{}-", self.start),
133 }
134 }
135}
136
137impl std::str::FromStr for LineRange {
138 type Err = LineRangeError;
139
140 fn from_str(asked: &str) -> Result<Self, Self::Err> {
141 let error = |why| LineRangeError {
142 asked: asked.to_string(),
143 why,
144 };
145 let mut s = asked.trim().trim_start_matches('#');
146 for word in ["lines", "line"] {
147 if let Some(head) = s.get(..word.len())
148 && head.eq_ignore_ascii_case(word)
149 {
150 s = s[word.len()..].trim_start();
151 s = s.strip_prefix('=').unwrap_or(s).trim_start();
152 break;
153 }
154 }
155 let number = |p: &str| -> Result<Option<u64>, LineRangeError> {
157 let p = p.trim();
158 let p = p.strip_prefix(['L', 'l']).unwrap_or(p);
159 if p.is_empty() {
160 return Ok(None);
161 }
162 if !p.chars().all(|c| c.is_ascii_digit()) {
163 return Err(error("that is not a range of line numbers."));
164 }
165 p.parse()
166 .map(Some)
167 .map_err(|_| error("that line number is too large."))
168 };
169 let (from, to, ranged) =
170 ["...", "..", "-", "\u{2013}", "\u{2014}", " to "]
171 .iter()
172 .find_map(|sep| s.split_once(sep).map(|(a, b)| (a, b, true)))
173 .unwrap_or((s, s, false));
174 let start = number(from)?;
175 let end = number(to)?;
176 let (start, end) = match (start, end, ranged) {
177 (None, _, false) => return Err(error("no line numbers given.")),
178 (Some(n), _, false) => (n, Some(n)),
179 (None, None, true) => return Err(error("no line numbers given.")),
180 (None, end, true) => (1, end),
181 (Some(start), end, true) => (start, end),
182 };
183 Self::new(start, end).map_err(error)
184 }
185}
186
187impl Serialize for LineRange {
188 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
189 s.collect_str(self)
190 }
191}
192
193impl<'de> Deserialize<'de> for LineRange {
195 fn deserialize<D: serde::Deserializer<'de>>(
196 d: D,
197 ) -> Result<Self, D::Error> {
198 #[derive(Deserialize)]
199 #[serde(untagged)]
200 enum Raw {
201 Text(String),
202 Line(u64),
203 }
204 match Raw::deserialize(d)? {
205 Raw::Text(s) => s.parse().map_err(serde::de::Error::custom),
206 Raw::Line(n) => Self::new(n, Some(n)).map_err(|why| {
207 serde::de::Error::custom(LineRangeError {
208 asked: n.to_string(),
209 why,
210 })
211 }),
212 }
213 }
214}
215
216#[cfg(feature = "schemars")]
220impl schemars::JsonSchema for LineRange {
221 fn inline_schema() -> bool {
222 true
223 }
224
225 fn schema_name() -> std::borrow::Cow<'static, str> {
226 std::borrow::Cow::Borrowed("LineRange")
227 }
228
229 fn schema_id() -> std::borrow::Cow<'static, str> {
230 std::borrow::Cow::Borrowed(concat!(module_path!(), "::LineRange"))
231 }
232
233 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
234 schemars::json_schema!({
235 "type": "string",
236 "description": "Lines of a file, 1-based and inclusive: \
237 \"110-300\", \"110-\" to the end of the file, \
238 \"-300\" from its start, or \"110\" for one line",
239 })
240 }
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Hash)]
246pub struct GitCommitPrefix(String);
247
248impl GitCommitPrefix {
249 pub const MIN_LEN: usize = 7;
251 pub const MAX_LEN: usize = 40;
253
254 pub fn as_str(&self) -> &str {
255 &self.0
256 }
257
258 pub fn matches(&self, commit: &GitCommitId) -> bool {
260 commit.to_hex().starts_with(&self.0)
261 }
262}
263
264impl std::str::FromStr for GitCommitPrefix {
265 type Err = DataCitationError;
266
267 fn from_str(s: &str) -> Result<Self, Self::Err> {
268 let s = s.trim();
269 let ok = (Self::MIN_LEN..=Self::MAX_LEN).contains(&s.len())
270 && s.chars().all(|c| c.is_ascii_hexdigit());
271 ok.then(|| Self(s.to_ascii_lowercase()))
272 .ok_or_else(|| DataCitationError::Commit(s.to_string()))
273 }
274}
275
276impl std::fmt::Display for GitCommitPrefix {
277 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278 f.write_str(&self.0)
279 }
280}
281
282impl From<GitCommitId> for GitCommitPrefix {
283 fn from(commit: GitCommitId) -> Self {
284 Self(commit.to_hex())
285 }
286}
287
288#[derive(Debug, Clone, PartialEq, Eq, Hash)]
317pub struct DataCitation {
318 pub set: GovernanceLogId,
320 pub commit: Option<GitCommitPrefix>,
323 pub path: DataFilePath,
324 pub lines: Option<LineRange>,
325}
326
327#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
330pub enum DataCitationError {
331 #[error("{0:?} is not a data set's id (DATA-YYYY-NNNN). {form}", form = DataCitation::FORM)]
332 NotDataSet(String),
333 #[error(
335 "{0:?} is not a commit: if `{0}` was meant as a commit, it must be \
336 {min} to {max} hex digits; a path cited without a commit starts with \
337 `/`. {form}",
338 min = GitCommitPrefix::MIN_LEN,
339 max = GitCommitPrefix::MAX_LEN,
340 form = DataCitation::FORM
341 )]
342 Commit(String),
343 #[error("no file path after the data set's id or commit. {form}", form = DataCitation::FORM)]
344 NoPath,
345 #[error("{0}")]
346 Path(#[from] super::DataFilePathError),
347}
348
349impl DataCitation {
350 pub const FORM: &'static str = "A data set's file is cited as \
352 DATA-YYYY-NNNN:<commit>:<path>, or \
353 DATA-YYYY-NNNN:<commit>:<path>:<from>-<to> for some of its lines; \
354 without a commit, for the one the set pins, the path starts with \
355 `/`: DATA-YYYY-NNNN:/<path> or DATA-YYYY-NNNN:/<path>:<from>-<to>. \
356 To read the data set itself, give its id alone.";
357
358 pub fn is_citation_shaped(s: &str) -> bool {
361 s.trim_start().split_once(':').is_some_and(|(head, _)| {
362 head.parse::<GovernanceLogId>()
363 .is_ok_and(|id| id.prefix() == GovernanceLogPrefix::Data)
364 })
365 }
366
367 pub fn reads_at(&self, commit: &GitCommitId) -> bool {
370 self.commit.as_ref().is_none_or(|c| c.matches(commit))
371 }
372
373 pub fn at(self, commit: impl Into<GitCommitPrefix>) -> Self {
376 Self {
377 commit: Some(commit.into()),
378 ..self
379 }
380 }
381}
382
383impl std::str::FromStr for DataCitation {
384 type Err = DataCitationError;
385
386 fn from_str(s: &str) -> Result<Self, Self::Err> {
387 let s = s.trim();
388 let (head, rest) = s
389 .split_once(':')
390 .ok_or_else(|| DataCitationError::NotDataSet(s.to_string()))?;
391 let set: GovernanceLogId = head
392 .parse()
393 .ok()
394 .filter(|id: &GovernanceLogId| {
395 id.prefix() == GovernanceLogPrefix::Data
396 })
397 .ok_or_else(|| DataCitationError::NotDataSet(head.to_string()))?;
398 let (commit, rest) = match rest.strip_prefix('/') {
401 Some(path) => (None, path),
402 None => match rest.split_once(':') {
403 Some((commit, rest)) => {
404 (Some(commit.parse::<GitCommitPrefix>()?), rest)
405 }
406 None => {
407 rest.parse::<GitCommitPrefix>()?;
409 return Err(DataCitationError::NoPath);
410 }
411 },
412 };
413 let canonical = |r: &str| {
417 r.split_once('-').is_some_and(|(a, b)| {
418 !a.is_empty()
419 && a.bytes().all(|c| c.is_ascii_digit())
420 && b.bytes().all(|c| c.is_ascii_digit())
421 })
422 };
423 let (path, lines) = match rest.rsplit_once(':') {
424 Some((path, range)) if !path.is_empty() && canonical(range) => {
425 match range.parse::<LineRange>() {
426 Ok(lines) => (path, Some(lines)),
427 Err(_) => (rest, None),
428 }
429 }
430 _ => (rest, None),
431 };
432 let path = match commit {
436 Some(_) => {
437 let path = path.trim();
438 let path = path.strip_prefix("./").unwrap_or(path);
439 path.trim_start_matches('/')
440 }
441 None => path,
442 };
443 if path.is_empty() {
444 return Err(DataCitationError::NoPath);
445 }
446 Ok(Self {
447 set,
448 commit,
449 path: path.parse()?,
450 lines,
451 })
452 }
453}
454
455impl std::fmt::Display for DataCitation {
456 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
457 write!(f, "{}:", self.set)?;
458 match &self.commit {
459 Some(commit) => write!(f, "{commit}:")?,
460 None => f.write_str("/")?,
461 }
462 write!(f, "{}", self.path)?;
463 if let Some(lines) = self.lines {
464 write!(f, ":{lines}")?;
465 }
466 Ok(())
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473
474 fn range(s: &str) -> LineRange {
475 s.parse().unwrap_or_else(|e| panic!("{s:?}: {e}"))
476 }
477
478 #[test]
479 fn ranges_parse_leniently_and_validate() {
480 for (written, start, end) in [
481 ("110-300", 110, Some(300)),
482 (" 110 - 300 ", 110, Some(300)),
483 ("110..300", 110, Some(300)),
484 ("110...300", 110, Some(300)),
485 ("110\u{2013}300", 110, Some(300)),
486 ("L110-L300", 110, Some(300)),
487 ("#L110-L300", 110, Some(300)),
488 ("lines 110-300", 110, Some(300)),
489 ("Lines=110-300", 110, Some(300)),
490 ("110 to 300", 110, Some(300)),
491 ("110-", 110, None),
492 ("110..", 110, None),
493 ("-300", 1, Some(300)),
494 ("110", 110, Some(110)),
495 ("7-7", 7, Some(7)),
496 ] {
497 let r = range(written);
498 assert_eq!((r.start(), r.end()), (start, end), "{written:?}");
499 }
500 assert_eq!(range("110-300").to_string(), "110-300");
501 assert_eq!(range("110-").to_string(), "110-");
502 for (bad, why) in [
503 ("0-10", "numbered from 1"),
504 ("300-110", "ends before it starts"),
505 ("", "no line numbers"),
506 ("-", "no line numbers"),
507 ("abc", "not a range"),
508 ("1-2-3", "not a range"),
509 ("1.5-3", "not a range"),
510 ("99999999999999999999999", "too large"),
511 ] {
512 let err = bad.parse::<LineRange>().unwrap_err();
513 let text = err.to_string();
514 assert!(text.contains(why), "{bad:?}: {text}");
515 assert!(text.contains("\"110-300\""), "says the form: {text}");
516 }
517 }
518
519 #[test]
520 fn ranges_round_trip_as_strings_and_take_a_bare_number() {
521 let r: LineRange = serde_json::from_str("\"110-300\"").unwrap();
522 assert_eq!(serde_json::to_value(r).unwrap(), "110-300");
523 let one: LineRange = serde_json::from_str("42").unwrap();
524 assert_eq!(one.to_string(), "42-42");
525 assert!(serde_json::from_str::<LineRange>("0").is_err());
526 assert!(serde_json::from_str::<LineRange>("\"9-1\"").is_err());
527 }
528
529 #[cfg(feature = "schemars")]
530 #[test]
531 fn a_range_is_a_plain_string_in_a_schema() {
532 let schema = schemars::schema_for!(LineRange);
533 let text = serde_json::to_string(&schema).unwrap();
534 assert!(!text.contains("$ref") && !text.contains("$defs"), "{text}");
535 assert!(!text.contains("pattern"), "{text}");
536 assert_eq!(schema.get("type").unwrap(), "string");
537 }
538
539 #[test]
540 fn spans_cover_whole_lines_and_clamp_the_end() {
541 let text = "one\ntwo\nthree\nfour";
542 assert_eq!(count_lines(text), 4);
543 assert_eq!(count_lines("a\n"), 1);
544 assert_eq!(count_lines(""), 0);
545 let span = |r: &str| {
546 let s = range(r).span(text).unwrap();
547 (&text[s.start_byte..s.end_byte], s.served.to_string())
548 };
549 assert_eq!(span("1-1"), ("one\n", "1-1".into()));
550 assert_eq!(span("2-3"), ("two\nthree\n", "2-3".into()));
551 assert_eq!(span("3-"), ("three\nfour", "3-4".into()));
552 assert_eq!(span("4-99"), ("four", "4-4".into()));
553 assert_eq!(span("1-"), (text, "1-4".into()));
554 let past = range("5-9").span(text).unwrap_err();
555 assert_eq!(past.total_lines, 4);
556 assert!(past.to_string().contains("has 4 line(s)"), "{past}");
557 assert!(range("1-").span("").is_err());
558 assert_eq!(range("2-").span("a\nb\n").unwrap().end_byte, 4);
560 assert!(range("3-").span("a\nb\n").is_err());
561 }
562
563 #[test]
564 fn citations_parse_and_print() {
565 let c: DataCitation =
566 "DATA-2026-0001:89fe7c6:votes/ballots.csv:110-300"
567 .parse()
568 .unwrap();
569 assert_eq!(c.set.as_str(), "DATA-2026-0001");
570 assert_eq!(c.commit.as_ref().unwrap().as_str(), "89fe7c6");
571 assert_eq!(c.path.as_str(), "votes/ballots.csv");
572 assert_eq!(c.lines, Some(range("110-300")));
573 assert_eq!(
574 c.to_string(),
575 "DATA-2026-0001:89fe7c6:votes/ballots.csv:110-300"
576 );
577
578 let whole: DataCitation =
579 " data-2026-1:89FE7C6:votes/ballots.csv ".parse().unwrap();
580 assert_eq!(whole.set.as_str(), "DATA-2026-0001");
581 assert_eq!(whole.commit.as_ref().unwrap().as_str(), "89fe7c6");
582 assert_eq!(whole.lines, None);
583 assert_eq!(
584 whole.to_string(),
585 "DATA-2026-0001:89fe7c6:votes/ballots.csv"
586 );
587
588 let colon: DataCitation =
591 "DATA-2026-0001:89fe7c6:a:b.txt".parse().unwrap();
592 assert_eq!(colon.path.as_str(), "a:b.txt");
593 assert_eq!(colon.lines, None);
594 let file: DataCitation =
596 "DATA-2026-0001:89fe7c6:log:12".parse().unwrap();
597 assert_eq!((file.path.as_str(), file.lines), ("log:12", None));
598 assert_eq!(file.to_string(), "DATA-2026-0001:89fe7c6:log:12");
599 let both: DataCitation =
600 "DATA-2026-0001:89fe7c6:a:b.txt:5-".parse().unwrap();
601 assert_eq!(both.path.as_str(), "a:b.txt");
602 assert_eq!(both.lines, Some(range("5-")));
603 let lead: DataCitation =
604 "DATA-2026-0001:89fe7c6:./votes/x.md".parse().unwrap();
605 assert_eq!(lead.path.as_str(), "votes/x.md");
606 }
607
608 #[test]
609 fn citations_refuse_what_they_cannot_name() {
610 let err = |s: &str| s.parse::<DataCitation>().unwrap_err();
611 assert!(matches!(
612 err("GOV-2026-0006:89fe7c6:x"),
613 DataCitationError::NotDataSet(_)
614 ));
615 for (bad, head) in [
618 ("DATA-2026-0001:89fe7c:x", "89fe7c"),
619 ("DATA-2026-0001:89fe7cz:x.md", "89fe7cz"),
620 (
621 &format!("DATA-2026-0001:{}:x", "a".repeat(41)),
622 &"a".repeat(41),
623 ),
624 ("DATA-2026-0001:votes/ballots.csv", "votes/ballots.csv"),
625 ("DATA-2026-0001:abcdef1/file.md", "abcdef1/file.md"),
626 ("DATA-2026-0001:a:b.txt", "a"),
627 ("DATA-2026-0001:", ""),
628 ("DATA-2026-0001::x.md", ""),
629 ("DATA-2026-0001: the votes", "the votes"),
630 ] {
631 let e = err(bad);
632 assert_eq!(e, DataCitationError::Commit(head.to_string()), "{bad}");
633 let text = e.to_string();
634 assert!(
635 text.contains(&format!(
636 "if `{head}` was meant as a commit, it must be 7 to 40 hex digits"
637 )),
638 "{text}"
639 );
640 assert!(text.contains("DATA-YYYY-NNNN:<commit>:<path>"), "{text}");
641 assert!(text.contains("DATA-YYYY-NNNN:/<path>"), "{text}");
642 }
643 assert!(matches!(
644 err("DATA-2026-0001:89fe7c6"),
645 DataCitationError::NoPath
646 ));
647 assert!(matches!(
648 err("DATA-2026-0001:89fe7c6:"),
649 DataCitationError::NoPath
650 ));
651 assert!(matches!(err("DATA-2026-0001:/"), DataCitationError::NoPath));
652 for bad in [
655 "DATA-2026-0001:89fe7c6:../secrets",
656 "DATA-2026-0001:/../secrets",
657 "DATA-2026-0001:/votes/../../secrets",
658 "DATA-2026-0001:/./votes/x.md",
659 "DATA-2026-0001://votes/x.md",
660 "DATA-2026-0001:/votes//x.md",
661 "DATA-2026-0001:/votes\\x.md",
662 "DATA-2026-0001:/votes/\u{7}.md",
663 ] {
664 assert!(matches!(err(bad), DataCitationError::Path(_)), "{bad}");
665 }
666 let text = err("DATA-2026-0001:89fe7c6").to_string();
667 assert!(text.contains("DATA-YYYY-NNNN:<commit>:<path>"), "{text}");
668 assert!(text.contains("give its id alone"), "{text}");
669
670 assert!(DataCitation::is_citation_shaped("DATA-2026-0001:x"));
671 assert!(DataCitation::is_citation_shaped(" data-2026-1:"));
672 assert!(!DataCitation::is_citation_shaped("GOV-2026-0006: yes"));
673 assert!(!DataCitation::is_citation_shaped("DATA-2026-0001"));
674 }
675
676 #[test]
677 fn citations_may_leave_the_commit_out_with_a_leading_slash() {
678 let cite = |s: &str| {
679 s.parse::<DataCitation>()
680 .unwrap_or_else(|e| panic!("{s:?}: {e}"))
681 };
682 let c = cite(
683 "DATA-2026-0001:/council-sitting-gov-2026-0016/transcripts/engineer.md:586-627",
684 );
685 assert_eq!(c.set.as_str(), "DATA-2026-0001");
686 assert_eq!(c.commit, None);
687 assert_eq!(
688 c.path.as_str(),
689 "council-sitting-gov-2026-0016/transcripts/engineer.md"
690 );
691 assert_eq!(c.lines, Some(range("586-627")));
692
693 let whole = cite(" data-2026-1:/votes/ballots.csv ");
694 assert_eq!((whole.commit.as_ref(), whole.lines), (None, None));
695 assert_eq!(whole.to_string(), "DATA-2026-0001:/votes/ballots.csv");
696 assert_eq!(
697 cite("DATA-2026-0001:/README.md:5-").lines,
698 Some(range("5-"))
699 );
700
701 let hexdir = cite("DATA-2026-0001:/abcdef1/file.md:110-300");
703 assert_eq!(
704 (hexdir.commit, hexdir.path.as_str(), hexdir.lines),
705 (None, "abcdef1/file.md", Some(range("110-300")))
706 );
707 let hexfile = cite("DATA-2026-0001:/abcdef1");
708 assert_eq!((hexfile.commit, hexfile.path.as_str()), (None, "abcdef1"));
709 let hexcolon = cite("DATA-2026-0001:/abcdef1:file.md");
710 assert_eq!(
711 (hexcolon.commit, hexcolon.path.as_str()),
712 (None, "abcdef1:file.md")
713 );
714 let at = cite("DATA-2026-0001:abcdef1:file.md");
716 assert_eq!(at.commit.as_ref().unwrap().as_str(), "abcdef1");
717 assert_eq!(at.path.as_str(), "file.md");
718
719 let colon = cite("DATA-2026-0001:/a:b.txt");
721 assert_eq!(
722 (colon.commit.as_ref(), colon.path.as_str()),
723 (None, "a:b.txt")
724 );
725 let log = cite("DATA-2026-0001:/log:12");
726 assert_eq!((log.path.as_str(), log.lines), ("log:12", None));
727 let both = cite("DATA-2026-0001:/a:b.txt:5-9");
728 assert_eq!(
729 (both.path.as_str(), both.lines),
730 ("a:b.txt", Some(range("5-9")))
731 );
732
733 for s in [
735 "DATA-2026-0001:/votes/ballots.csv",
736 "DATA-2026-0001:/votes/ballots.csv:110-300",
737 "DATA-2026-0001:/votes/ballots.csv:110-",
738 "DATA-2026-0001:/abcdef1/file.md:1-2",
739 "DATA-2026-0001:/abcdef1:file.md",
740 "DATA-2026-0001:/a:b.txt:5-9",
741 "DATA-2026-0001:89fe7c6:votes/ballots.csv:110-300",
742 "DATA-2026-0001:89fe7c6:abcdef1:file.md",
743 ] {
744 assert_eq!(cite(s).to_string(), s);
745 assert_eq!(cite(&cite(s).to_string()), cite(s));
746 }
747
748 let commit: GitCommitId =
750 "89fe7c6aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".parse().unwrap();
751 let other: GitCommitId =
752 "0123456aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".parse().unwrap();
753 let c = cite("DATA-2026-0001:/votes/ballots.csv:110-300");
754 assert!(c.reads_at(&commit) && c.reads_at(&other));
755 let pinned = c.clone().at(commit);
756 assert!(pinned.reads_at(&commit) && !pinned.reads_at(&other));
757 assert_eq!(
758 pinned.to_string(),
759 "DATA-2026-0001:89fe7c6aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:votes/ballots.csv:110-300"
760 );
761 assert_eq!((pinned.path, pinned.lines), (c.path, c.lines));
762 }
763
764 #[test]
765 fn a_prefix_matches_its_commit() {
766 let commit: GitCommitId =
767 "89fe7c6aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".parse().unwrap();
768 let prefix: GitCommitPrefix = "89FE7C6".parse().unwrap();
769 assert!(prefix.matches(&commit));
770 assert!(
771 !"89fe7c7"
772 .parse::<GitCommitPrefix>()
773 .unwrap()
774 .matches(&commit)
775 );
776 assert!(GitCommitPrefix::from(commit).matches(&commit));
777 }
778}