use serde::{Deserialize, Serialize};
use super::{DataFilePath, GitCommitId, GovernanceLogId};
use crate::ids::GovernanceLogPrefix;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct LineRange {
start: u64,
end: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("lines={asked:?}: {why} {}", LineRange::FORM)]
pub struct LineRangeError {
pub asked: String,
pub why: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LineSpan {
pub served: LineRange,
pub start_byte: usize,
pub end_byte: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error(
"lines={asked} starts past the end of the file, which has {total_lines} \
line(s)"
)]
pub struct PastEnd {
pub asked: LineRange,
pub total_lines: u64,
}
impl LineRange {
pub const FORM: &'static str = "Write a range of lines as \"<from>-<to>\" \
(1-based, both included, e.g. \"110-300\"), or \"<from>-\" to read to \
the end of the file.";
pub fn new(start: u64, end: Option<u64>) -> Result<Self, &'static str> {
if start == 0 {
return Err("lines are numbered from 1.");
}
if end.is_some_and(|end| end < start) {
return Err("the range ends before it starts.");
}
Ok(Self { start, end })
}
pub fn start(&self) -> u64 {
self.start
}
pub fn end(&self) -> Option<u64> {
self.end
}
pub fn span(&self, text: &str) -> Result<LineSpan, PastEnd> {
let total_lines = count_lines(text);
if self.start > total_lines {
return Err(PastEnd {
asked: *self,
total_lines,
});
}
let end = self.end.map_or(total_lines, |e| e.min(total_lines));
let mut start_byte = 0;
let mut end_byte = text.len();
for (line, (at, _)) in (1..).zip(text.match_indices('\n')) {
if line + 1 == self.start {
start_byte = at + 1;
}
if line == end {
end_byte = at + 1;
break;
}
}
Ok(LineSpan {
served: Self {
start: self.start,
end: Some(end),
},
start_byte,
end_byte,
})
}
}
pub fn count_lines(text: &str) -> u64 {
text.split_inclusive('\n').count() as u64
}
impl std::fmt::Display for LineRange {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.end {
Some(end) => write!(f, "{}-{end}", self.start),
None => write!(f, "{}-", self.start),
}
}
}
impl std::str::FromStr for LineRange {
type Err = LineRangeError;
fn from_str(asked: &str) -> Result<Self, Self::Err> {
let error = |why| LineRangeError {
asked: asked.to_string(),
why,
};
let mut s = asked.trim().trim_start_matches('#');
for word in ["lines", "line"] {
if let Some(head) = s.get(..word.len())
&& head.eq_ignore_ascii_case(word)
{
s = s[word.len()..].trim_start();
s = s.strip_prefix('=').unwrap_or(s).trim_start();
break;
}
}
let number = |p: &str| -> Result<Option<u64>, LineRangeError> {
let p = p.trim();
let p = p.strip_prefix(['L', 'l']).unwrap_or(p);
if p.is_empty() {
return Ok(None);
}
if !p.chars().all(|c| c.is_ascii_digit()) {
return Err(error("that is not a range of line numbers."));
}
p.parse()
.map(Some)
.map_err(|_| error("that line number is too large."))
};
let (from, to, ranged) =
["...", "..", "-", "\u{2013}", "\u{2014}", " to "]
.iter()
.find_map(|sep| s.split_once(sep).map(|(a, b)| (a, b, true)))
.unwrap_or((s, s, false));
let start = number(from)?;
let end = number(to)?;
let (start, end) = match (start, end, ranged) {
(None, _, false) => return Err(error("no line numbers given.")),
(Some(n), _, false) => (n, Some(n)),
(None, None, true) => return Err(error("no line numbers given.")),
(None, end, true) => (1, end),
(Some(start), end, true) => (start, end),
};
Self::new(start, end).map_err(error)
}
}
impl Serialize for LineRange {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.collect_str(self)
}
}
impl<'de> Deserialize<'de> for LineRange {
fn deserialize<D: serde::Deserializer<'de>>(
d: D,
) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
Text(String),
Line(u64),
}
match Raw::deserialize(d)? {
Raw::Text(s) => s.parse().map_err(serde::de::Error::custom),
Raw::Line(n) => Self::new(n, Some(n)).map_err(|why| {
serde::de::Error::custom(LineRangeError {
asked: n.to_string(),
why,
})
}),
}
}
}
#[cfg(feature = "schemars")]
impl schemars::JsonSchema for LineRange {
fn inline_schema() -> bool {
true
}
fn schema_name() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed("LineRange")
}
fn schema_id() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed(concat!(module_path!(), "::LineRange"))
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"type": "string",
"description": "Lines of a file, 1-based and inclusive: \
\"110-300\", or \"110-\" to the end of the file",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct GitCommitPrefix(String);
impl GitCommitPrefix {
pub const MIN_LEN: usize = 7;
pub const MAX_LEN: usize = 40;
pub fn as_str(&self) -> &str {
&self.0
}
pub fn matches(&self, commit: &GitCommitId) -> bool {
commit.to_hex().starts_with(&self.0)
}
}
impl std::str::FromStr for GitCommitPrefix {
type Err = DataCitationError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let s = s.trim();
let ok = (Self::MIN_LEN..=Self::MAX_LEN).contains(&s.len())
&& s.chars().all(|c| c.is_ascii_hexdigit());
ok.then(|| Self(s.to_ascii_lowercase()))
.ok_or_else(|| DataCitationError::Commit(s.to_string()))
}
}
impl std::fmt::Display for GitCommitPrefix {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl From<GitCommitId> for GitCommitPrefix {
fn from(commit: GitCommitId) -> Self {
Self(commit.to_hex())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct DataCitation {
pub set: GovernanceLogId,
pub commit: GitCommitPrefix,
pub path: DataFilePath,
pub lines: Option<LineRange>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum DataCitationError {
#[error("{0:?} is not a data set's id (DATA-YYYY-NNNN). {form}", form = DataCitation::FORM)]
NotDataSet(String),
#[error(
"{0:?} is not a commit: give at least {min} of the commit's hex digits. {form}",
min = GitCommitPrefix::MIN_LEN,
form = DataCitation::FORM
)]
Commit(String),
#[error("no file path after the commit. {form}", form = DataCitation::FORM)]
NoPath,
#[error("{0}")]
Path(#[from] super::DataFilePathError),
}
impl DataCitation {
pub const FORM: &'static str = "A data set's file is cited as \
DATA-YYYY-NNNN:<commit>:<path>, or \
DATA-YYYY-NNNN:<commit>:<path>:<from>-<to> for some of its lines; \
to read the data set itself, give its id alone.";
pub fn is_citation_shaped(s: &str) -> bool {
s.trim_start().split_once(':').is_some_and(|(head, _)| {
head.parse::<GovernanceLogId>()
.is_ok_and(|id| id.prefix() == GovernanceLogPrefix::Data)
})
}
}
impl std::str::FromStr for DataCitation {
type Err = DataCitationError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let s = s.trim();
let (head, rest) = s
.split_once(':')
.ok_or_else(|| DataCitationError::NotDataSet(s.to_string()))?;
let set: GovernanceLogId = head
.parse()
.ok()
.filter(|id: &GovernanceLogId| {
id.prefix() == GovernanceLogPrefix::Data
})
.ok_or_else(|| DataCitationError::NotDataSet(head.to_string()))?;
let (commit, rest) = match rest.split_once(':') {
Some((commit, rest)) => (commit, rest),
None => {
rest.parse::<GitCommitPrefix>()?;
return Err(DataCitationError::NoPath);
}
};
let commit: GitCommitPrefix = commit.parse()?;
let canonical = |r: &str| {
r.split_once('-').is_some_and(|(a, b)| {
!a.is_empty()
&& a.bytes().all(|c| c.is_ascii_digit())
&& b.bytes().all(|c| c.is_ascii_digit())
})
};
let (path, lines) = match rest.rsplit_once(':') {
Some((path, range)) if !path.is_empty() && canonical(range) => {
match range.parse::<LineRange>() {
Ok(lines) => (path, Some(lines)),
Err(_) => (rest, None),
}
}
_ => (rest, None),
};
let path = path.trim();
let path = path.strip_prefix("./").unwrap_or(path);
let path = path.trim_start_matches('/');
if path.is_empty() {
return Err(DataCitationError::NoPath);
}
Ok(Self {
set,
commit,
path: path.parse()?,
lines,
})
}
}
impl std::fmt::Display for DataCitation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}:{}", self.set, self.commit, self.path)?;
if let Some(lines) = self.lines {
write!(f, ":{lines}")?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn range(s: &str) -> LineRange {
s.parse().unwrap_or_else(|e| panic!("{s:?}: {e}"))
}
#[test]
fn ranges_parse_leniently_and_validate() {
for (written, start, end) in [
("110-300", 110, Some(300)),
(" 110 - 300 ", 110, Some(300)),
("110..300", 110, Some(300)),
("110...300", 110, Some(300)),
("110\u{2013}300", 110, Some(300)),
("L110-L300", 110, Some(300)),
("#L110-L300", 110, Some(300)),
("lines 110-300", 110, Some(300)),
("Lines=110-300", 110, Some(300)),
("110 to 300", 110, Some(300)),
("110-", 110, None),
("110..", 110, None),
("-300", 1, Some(300)),
("110", 110, Some(110)),
("7-7", 7, Some(7)),
] {
let r = range(written);
assert_eq!((r.start(), r.end()), (start, end), "{written:?}");
}
assert_eq!(range("110-300").to_string(), "110-300");
assert_eq!(range("110-").to_string(), "110-");
for (bad, why) in [
("0-10", "numbered from 1"),
("300-110", "ends before it starts"),
("", "no line numbers"),
("-", "no line numbers"),
("abc", "not a range"),
("1-2-3", "not a range"),
("1.5-3", "not a range"),
("99999999999999999999999", "too large"),
] {
let err = bad.parse::<LineRange>().unwrap_err();
let text = err.to_string();
assert!(text.contains(why), "{bad:?}: {text}");
assert!(text.contains("\"110-300\""), "says the form: {text}");
}
}
#[test]
fn ranges_round_trip_as_strings_and_take_a_bare_number() {
let r: LineRange = serde_json::from_str("\"110-300\"").unwrap();
assert_eq!(serde_json::to_value(r).unwrap(), "110-300");
let one: LineRange = serde_json::from_str("42").unwrap();
assert_eq!(one.to_string(), "42-42");
assert!(serde_json::from_str::<LineRange>("0").is_err());
assert!(serde_json::from_str::<LineRange>("\"9-1\"").is_err());
}
#[cfg(feature = "schemars")]
#[test]
fn a_range_is_a_plain_string_in_a_schema() {
let schema = schemars::schema_for!(LineRange);
let text = serde_json::to_string(&schema).unwrap();
assert!(!text.contains("$ref") && !text.contains("$defs"), "{text}");
assert!(!text.contains("pattern"), "{text}");
assert_eq!(schema.get("type").unwrap(), "string");
}
#[test]
fn spans_cover_whole_lines_and_clamp_the_end() {
let text = "one\ntwo\nthree\nfour";
assert_eq!(count_lines(text), 4);
assert_eq!(count_lines("a\n"), 1);
assert_eq!(count_lines(""), 0);
let span = |r: &str| {
let s = range(r).span(text).unwrap();
(&text[s.start_byte..s.end_byte], s.served.to_string())
};
assert_eq!(span("1-1"), ("one\n", "1-1".into()));
assert_eq!(span("2-3"), ("two\nthree\n", "2-3".into()));
assert_eq!(span("3-"), ("three\nfour", "3-4".into()));
assert_eq!(span("4-99"), ("four", "4-4".into()));
assert_eq!(span("1-"), (text, "1-4".into()));
let past = range("5-9").span(text).unwrap_err();
assert_eq!(past.total_lines, 4);
assert!(past.to_string().contains("has 4 line(s)"), "{past}");
assert!(range("1-").span("").is_err());
assert_eq!(range("2-").span("a\nb\n").unwrap().end_byte, 4);
assert!(range("3-").span("a\nb\n").is_err());
}
#[test]
fn citations_parse_and_print() {
let c: DataCitation =
"DATA-2026-0001:89fe7c6:votes/ballots.csv:110-300"
.parse()
.unwrap();
assert_eq!(c.set.as_str(), "DATA-2026-0001");
assert_eq!(c.commit.as_str(), "89fe7c6");
assert_eq!(c.path.as_str(), "votes/ballots.csv");
assert_eq!(c.lines, Some(range("110-300")));
assert_eq!(
c.to_string(),
"DATA-2026-0001:89fe7c6:votes/ballots.csv:110-300"
);
let whole: DataCitation =
" data-2026-1:89FE7C6:votes/ballots.csv ".parse().unwrap();
assert_eq!(whole.set.as_str(), "DATA-2026-0001");
assert_eq!(whole.commit.as_str(), "89fe7c6");
assert_eq!(whole.lines, None);
assert_eq!(
whole.to_string(),
"DATA-2026-0001:89fe7c6:votes/ballots.csv"
);
let colon: DataCitation =
"DATA-2026-0001:89fe7c6:a:b.txt".parse().unwrap();
assert_eq!(colon.path.as_str(), "a:b.txt");
assert_eq!(colon.lines, None);
let file: DataCitation =
"DATA-2026-0001:89fe7c6:log:12".parse().unwrap();
assert_eq!((file.path.as_str(), file.lines), ("log:12", None));
assert_eq!(file.to_string(), "DATA-2026-0001:89fe7c6:log:12");
let both: DataCitation =
"DATA-2026-0001:89fe7c6:a:b.txt:5-".parse().unwrap();
assert_eq!(both.path.as_str(), "a:b.txt");
assert_eq!(both.lines, Some(range("5-")));
let lead: DataCitation =
"DATA-2026-0001:89fe7c6:./votes/x.md".parse().unwrap();
assert_eq!(lead.path.as_str(), "votes/x.md");
}
#[test]
fn citations_refuse_what_they_cannot_name() {
let err = |s: &str| s.parse::<DataCitation>().unwrap_err();
assert!(matches!(
err("GOV-2026-0006:89fe7c6:x"),
DataCitationError::NotDataSet(_)
));
assert!(matches!(
err("DATA-2026-0001:89fe7c:x"),
DataCitationError::Commit(_)
));
assert!(matches!(
err("DATA-2026-0001:not-hex!:x"),
DataCitationError::Commit(_)
));
assert!(matches!(
err(&format!("DATA-2026-0001:{}:x", "a".repeat(41))),
DataCitationError::Commit(_)
));
assert!(matches!(
err("DATA-2026-0001:89fe7c6"),
DataCitationError::NoPath
));
assert!(matches!(
err("DATA-2026-0001:89fe7c6:"),
DataCitationError::NoPath
));
assert!(matches!(
err("DATA-2026-0001: the votes"),
DataCitationError::Commit(_)
));
assert!(matches!(
err("DATA-2026-0001:89fe7c6:../secrets"),
DataCitationError::Path(_)
));
let text = err("DATA-2026-0001:89fe7c6").to_string();
assert!(text.contains("DATA-YYYY-NNNN:<commit>:<path>"), "{text}");
assert!(text.contains("give its id alone"), "{text}");
assert!(DataCitation::is_citation_shaped("DATA-2026-0001:x"));
assert!(DataCitation::is_citation_shaped(" data-2026-1:"));
assert!(!DataCitation::is_citation_shaped("GOV-2026-0006: yes"));
assert!(!DataCitation::is_citation_shaped("DATA-2026-0001"));
}
#[test]
fn a_prefix_matches_its_commit() {
let commit: GitCommitId =
"89fe7c6aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".parse().unwrap();
let prefix: GitCommitPrefix = "89FE7C6".parse().unwrap();
assert!(prefix.matches(&commit));
assert!(
!"89fe7c7"
.parse::<GitCommitPrefix>()
.unwrap()
.matches(&commit)
);
assert!(GitCommitPrefix::from(commit).matches(&commit));
}
}