use std::collections::BTreeMap;
use std::fmt::Write as _;
use sealmap_model::{Fingerprint, SourcePath, SymbolId};
use serde::{Deserialize, Serialize};
pub const LOCK_VERSION: u32 = 1;
pub const ALGORITHM: &str = "sm1";
pub const LOCK_FILE: &str = "seals.lock";
pub const GENERATOR: &str = concat!("sealmap ", env!("CARGO_PKG_VERSION"));
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Lock {
pub version: u32,
pub algorithm: String,
pub generator: String,
pub topics: BTreeMap<String, TopicSeal>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct TopicSeal {
pub id: String,
pub file: SourcePath,
pub topic_hash: Fingerprint,
pub reviewer: String,
pub model: String,
pub date: String,
pub symbols: BTreeMap<SymbolId, SymbolSeal>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct SymbolSeal {
pub sig: Fingerprint,
pub body: Fingerprint,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum LockError {
#[error("not a valid seals.lock: {0}")]
Syntax(String),
#[error("lock version {found} is not supported (expected {LOCK_VERSION})")]
Version {
found: i64,
},
#[error("lock algorithm `{0}` is not supported (expected `{ALGORITHM}`)")]
Algorithm(String),
#[error("topic `{topic}`: {message}")]
Field {
topic: String,
message: String,
},
#[error("topic `{0}` is sealed twice")]
DuplicateTopic(String),
#[error("file `{0}` is sealed by two topics")]
DuplicateFile(String),
#[error("topic `{topic}` seals `{symbol}` twice")]
DuplicateSymbol {
topic: String,
symbol: String,
},
#[error("the lock is not in canonical form (rewrite it with `sealmap seal sign`)")]
NonCanonical,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawLock {
version: i64,
algorithm: String,
generator: String,
#[serde(default)]
topic: Vec<RawTopic>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawTopic {
id: String,
file: String,
topic_hash: String,
reviewer: String,
model: String,
date: String,
symbols: Vec<RawSymbol>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawSymbol {
id: String,
sig: String,
body: String,
}
impl Default for Lock {
fn default() -> Self {
Self::new()
}
}
impl Lock {
pub fn new() -> Self {
Self {
version: LOCK_VERSION,
algorithm: ALGORITHM.into(),
generator: GENERATOR.into(),
topics: BTreeMap::new(),
}
}
pub fn insert(&mut self, topic: TopicSeal) {
self.topics.insert(topic.id.clone(), topic);
}
pub fn by_file(&self, file: &SourcePath) -> Option<&TopicSeal> {
self.topics.values().find(|t| &t.file == file)
}
pub fn parse(text: &str) -> Result<Self, LockError> {
let text = normalise(text);
let raw: RawLock = toml::from_str(&text).map_err(|e| LockError::Syntax(one_line(&e.to_string())))?;
if raw.version != i64::from(LOCK_VERSION) {
return Err(LockError::Version { found: raw.version });
}
if raw.algorithm != ALGORITHM {
return Err(LockError::Algorithm(raw.algorithm));
}
let mut topics = BTreeMap::new();
let mut files = BTreeMap::new();
for t in raw.topic {
let topic = TopicSeal::from_raw(t)?;
if files.insert(topic.file.clone(), topic.id.clone()).is_some() {
return Err(LockError::DuplicateFile(topic.file.to_string()));
}
if topics.contains_key(&topic.id) {
return Err(LockError::DuplicateTopic(topic.id));
}
topics.insert(topic.id.clone(), topic);
}
Ok(Self { version: LOCK_VERSION, algorithm: raw.algorithm, generator: raw.generator, topics })
}
pub fn parse_canonical(text: &str) -> Result<Self, LockError> {
let lock = Self::parse(text)?;
if lock.to_toml() != text {
return Err(LockError::NonCanonical);
}
Ok(lock)
}
pub fn to_toml(&self) -> String {
let mut s = String::new();
let _ = writeln!(s, "version = {}", self.version);
let _ = writeln!(s, "algorithm = {}", quote(&self.algorithm));
let _ = writeln!(s, "generator = {}", quote(&self.generator));
for t in self.topics.values() {
s.push_str("\n[[topic]]\n");
let _ = writeln!(s, "id = {}", quote(&t.id));
let _ = writeln!(s, "file = {}", quote(t.file.as_str()));
let _ = writeln!(s, "topic_hash = {}", quote(&t.topic_hash.to_string()));
let _ = writeln!(s, "reviewer = {}", quote(&t.reviewer));
let _ = writeln!(s, "model = {}", quote(&t.model));
let _ = writeln!(s, "date = {}", quote(&t.date));
if t.symbols.is_empty() {
s.push_str("symbols = []\n");
} else {
s.push_str("symbols = [\n");
for (id, seal) in &t.symbols {
let _ = writeln!(
s,
" {{ id = {}, sig = {}, body = {} }},",
quote(id.as_str()),
quote(&seal.sig.to_string()),
quote(&seal.body.to_string())
);
}
s.push_str("]\n");
}
}
s
}
}
impl TopicSeal {
fn from_raw(t: RawTopic) -> Result<Self, LockError> {
let field = |message: String| LockError::Field { topic: t.id.clone(), message };
if !is_topic_id(&t.id) {
return Err(field("`id` is empty or contains a control character".into()));
}
let file = SourcePath::new(&t.file).map_err(|e| field(format!("`file`: {e}")))?;
if file.as_str() != t.file {
return Err(field(format!("`file` `{}` is not a normalised relative path", t.file)));
}
let topic_hash =
Fingerprint::parse(&t.topic_hash).ok_or_else(|| field(format!("`topic_hash` `{}`", t.topic_hash)))?;
if !is_date(&t.date) {
return Err(field(format!("`date` `{}` is not YYYY-MM-DD", t.date)));
}
let mut symbols = BTreeMap::new();
for s in &t.symbols {
let id = SymbolId::parse(&s.id).map_err(|e| field(format!("symbol `{}`: {e}", s.id)))?;
let sig =
Fingerprint::parse(&s.sig).ok_or_else(|| field(format!("symbol `{}`: `sig` `{}`", s.id, s.sig)))?;
let body =
Fingerprint::parse(&s.body).ok_or_else(|| field(format!("symbol `{}`: `body` `{}`", s.id, s.body)))?;
if symbols.insert(id, SymbolSeal { sig, body }).is_some() {
return Err(LockError::DuplicateSymbol { topic: t.id.clone(), symbol: s.id.clone() });
}
}
Ok(Self { id: t.id, file, topic_hash, reviewer: t.reviewer, model: t.model, date: t.date, symbols })
}
}
pub(crate) fn is_topic_id(s: &str) -> bool {
!s.is_empty() && s.trim() == s && !s.chars().any(char::is_control)
}
pub(crate) fn is_date(s: &str) -> bool {
let b = s.as_bytes();
if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
return false;
}
let digits =
|r: std::ops::Range<usize>| b[r.clone()].iter().all(u8::is_ascii_digit).then(|| s[r].parse::<u32>().ok());
match (digits(0..4), digits(5..7), digits(8..10)) {
(Some(Some(_)), Some(Some(m)), Some(Some(d))) => (1..=12).contains(&m) && (1..=31).contains(&d),
_ => false,
}
}
fn quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\u{8}' => out.push_str("\\b"),
'\t' => out.push_str("\\t"),
'\n' => out.push_str("\\n"),
'\u{c}' => out.push_str("\\f"),
'\r' => out.push_str("\\r"),
c if c.is_control() => {
let _ = write!(out, "\\u{:04X}", c as u32);
}
c => out.push(c),
}
}
out.push('"');
out
}
fn normalise(text: &str) -> std::borrow::Cow<'_, str> {
if text.contains('\r') { text.replace("\r\n", "\n").replace('\r', "\n").into() } else { text.into() }
}
fn one_line(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quote_round_trips_through_toml() {
for s in ["plain", "a \"b\" c", "back\\slash", "tab\tnew\nline", "\u{1}\u{7f}", "sym:cargo x . `a``b`#", "ünï"]
{
let doc = format!("v = {}\n", quote(s));
let parsed: BTreeMap<String, String> = toml::from_str(&doc).unwrap();
assert_eq!(parsed["v"], s, "{doc}");
}
}
#[test]
fn dates() {
assert!(is_date("2026-10-05"));
for bad in ["2026-1-05", "2026-13-01", "2026-00-01", "2026-10-32", "20261005xx", "2026/10/05", "2026-10-05"]
{
assert!(!is_date(bad), "{bad}");
}
}
}