Skip to main content

agora_agentkit/govlog/
data_read.rs

1//! Reading part of a data set's file (0.103): a [`LineRange`], and the
2//! [`DataCitation`] that names a file at a commit, with or without one.
3//!
4//! A range is a selective read, not a page: there is no cursor and no
5//! "next" (agora `memory/feedback_no_paging.md`). A reader who means to
6//! read a whole file reads it whole; a range is for a part, or for a file
7//! too large for one read ([`MAX_WHOLE_READ_BYTES`](super::MAX_WHOLE_READ_BYTES)).
8
9use serde::{Deserialize, Serialize};
10
11use super::{DataFilePath, GitCommitId, GovernanceLogId};
12use crate::ids::GovernanceLogPrefix;
13
14/// Lines `start` to `end` of a file, 1-based and inclusive; `end` absent
15/// means to the end of the file.
16///
17/// Written `"110-300"`, or `"110-"` to the end. Read leniently, as models
18/// and people write ranges: `"110..300"`, `"110–300"`, `"L110-L300"`,
19/// `"lines 110-300"`, `"-300"` (from the first line), `"110"` (that line
20/// alone), or a bare number. Validated: lines are numbered from 1, and a
21/// range cannot end before it starts.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub struct LineRange {
24    start: u64,
25    end: Option<u64>,
26}
27
28/// A string that is not a [`LineRange`]: the message says why and what the
29/// form is
30#[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/// Where a [`LineRange`] falls in a text: the byte span to serve, and the
38/// range as served (its end clamped to the file's last line)
39#[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/// A [`LineRange`] that starts past a file's last line
47#[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    /// How a range is written, for error messages
59    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    /// How a refused range opens, as the seed runner shows a malformed
64    /// `lines` argument, so that every range error reads alike (0.105.0)
65    pub const INVALID: &'static str = "Invalid arguments at `lines`: ";
66
67    /// Lines `start..=end` (`end` absent: to the end), or why not
68    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    /// The first line, from 1
79    pub fn start(&self) -> u64 {
80        self.start
81    }
82
83    /// The last line, or `None` for the end of the file
84    pub fn end(&self) -> Option<u64> {
85        self.end
86    }
87
88    /// The span of `text` this range covers. A line is what ends at a
89    /// `\n`, or the text's end; an end past the last line is the last line
90    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        // Byte offset of each line's start, as needed
100        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
122/// How many lines `text` has: one per `\n`, and one more for text after
123/// the last `\n`. An empty text has none
124pub 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        // `L110` as a code host writes it
156        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
193/// A string, or a bare number for one line
194impl<'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// A plain string with no `pattern`, inline: the agora CLAUDE.md rules for
217// tool schemas (no `$ref`; no `pattern`, which a strict decoder would
218// enforce by inventing values). Parsing checks it.
219#[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/// The first 7 to 40 hex digits of a git commit id, lowercase: how a
244/// [`DataCitation`] names the commit it was read at
245#[derive(Debug, Clone, PartialEq, Eq, Hash)]
246pub struct GitCommitPrefix(String);
247
248impl GitCommitPrefix {
249    /// The fewest hex digits accepted, as `git` abbreviates by default
250    pub const MIN_LEN: usize = 7;
251    /// A whole SHA-1 commit id
252    pub const MAX_LEN: usize = 40;
253
254    pub fn as_str(&self) -> &str {
255        &self.0
256    }
257
258    /// `true` when `commit` starts with this prefix
259    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/// A data set's file, cited at the commit it was read at, and optionally
289/// lines of it: `DATA-2026-0001:<commit>:<path>:110-300`, or without the
290/// range for the whole file (0.103). The commit may be left out, the path
291/// then written from the root with a leading `/`:
292/// `DATA-2026-0001:/<path>` or `DATA-2026-0001:/<path>:110-300`, for the
293/// commit the set pins as it stands (0.110.0).
294///
295/// `get_content` takes one as its `id` and reads that file (and those
296/// lines). The commit is a prefix of at least seven hex digits of the
297/// commit the data set pins. A server reads the set as revised (its latest
298/// version) when the commit is the latest version's, and as originally
299/// signed when it is the original's, so a citation made before any revision
300/// keeps reading what it cited. One made at an intermediate revision's
301/// commit stops resolving once a later revision pins another commit. One
302/// without a commit reads the latest version, as the set's own id does: it
303/// is unambiguous today (a set pins exactly one commit) but follows any
304/// revision, so the canonical citation, which a record should keep, names
305/// the commit; the file read says which it was.
306///
307/// After the set's id and its `:`, a citation is one of two forms
308/// (Steward, 2026-10-20): `<commit>:<path>`, the commit 7 to 40 hex
309/// digits, or `/<path>`, a path from the repository root with no commit.
310/// Anything else is refused, naming both, so no path is ever taken for a
311/// commit or a commit for a path. The parse lives here alone: the scheme
312/// may change (a URL form may come with the `get_content` consolidation).
313///
314/// The path runs to the end, or to the last `:` when what follows that is
315/// a range written `<from>-<to>` or `<from>-`, so a path may hold a `:`.
316#[derive(Debug, Clone, PartialEq, Eq, Hash)]
317pub struct DataCitation {
318    /// A `DATA-…` id
319    pub set: GovernanceLogId,
320    /// The commit cited, or `None` for the one the set pins as it stands
321    /// (0.110.0)
322    pub commit: Option<GitCommitPrefix>,
323    pub path: DataFilePath,
324    pub lines: Option<LineRange>,
325}
326
327/// A string shaped like a [`DataCitation`] (a `DATA-…` id and a `:`) that
328/// is not one
329#[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    /// What follows the set's id is neither `<commit>:` nor `/`
334    #[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    /// How a citation is written, for error messages
351    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    /// `true` when `s` starts with a `DATA-…` id and a `:`, so it is meant
359    /// as a citation and its error is the one to show
360    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    /// `true` when this citation reads at `commit`: it names a prefix of
368    /// it, or no commit at all (0.110.0)
369    pub fn reads_at(&self, commit: &GitCommitId) -> bool {
370        self.commit.as_ref().is_none_or(|c| c.matches(commit))
371    }
372
373    /// This citation naming `commit`, its canonical form once the commit a
374    /// commit-less one read at is known (0.110.0)
375    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        // `/<path>`: no commit, for the one the set pins; else
399        // `<commit>:<path>` (Steward, 2026-10-20). Nothing else.
400        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                    // Say what is wrong with the commit first
408                    rest.parse::<GitCommitPrefix>()?;
409                    return Err(DataCitationError::NoPath);
410                }
411            },
412        };
413        // The range in a citation is strictly `<from>-<to>` or `<from>-`,
414        // as `Display` writes it, so a path ending in `:12` is never read
415        // as a range of another file (the review of 0.103)
416        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        // After a commit, a path is read leniently (`./x`, `/x`); after the
433        // `/` of the commit-less form it is taken as written, so `//x` is
434        // an empty segment and refused
435        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        // A trailing newline ends the last line; it does not start one
559        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        // A path may hold a `:`; what follows the last one is a range only
589        // when it reads as one
590        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        // Only the canonical `<from>-<to>` is a range: `log:12` is a file
595        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        // Neither `<commit>:` nor `/`: refused, naming both forms
616        // (Steward, 2026-10-20)
617        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        // The path checks hold in both forms: `..`, `.`, empty segments,
653        // `\` and control characters
654        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        // No hex segment is ever a commit after the `/`
702        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        // ... and before it, `<hex>:` is the commit
715        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        // Paths with colons, with and without a range
720        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        // Round trips, with and without the commit
734        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        // The pinned commit fills it in; any commit reads it
749        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}