Skip to main content

ridl_core/
interface_lock.rs

1//! The unit's `interfaces.lock` (lock design §2): the line table that gives
2//! every interface of a unit its number.
3//!
4//! The file lives in the unit's manifest directory, beside its `ridl.toml`,
5//! one per unit, or, for a bare source file compiled without a manifest, in
6//! the file's directory. Only `ridl lock` writes it. Its form is a line table:
7//!
8//! ```text
9//! # interfaces.lock — written by ridl lock; do not edit by hand.
10//! next 6
11//! CruiseControl 1
12//! LaneAssist 2 retired
13//! LaneKeeping 3
14//! DoorControl 4
15//! service:veh.hvac.cabin 5
16//! ```
17//!
18//! - The first content line is `next N`: N is greater than every number in
19//!   the file, live or retired, and is never lowered. `ridl lock` allocates
20//!   from N upward. The line is found by position — the first line the reader
21//!   does not skip — so every later line is an entry, one keyed `next`
22//!   included: `next` is not a keyword, and an interface may be named so
23//!   (plan decision PD-19).
24//! - Every later line is one entry, `Key number`, with the word `retired`
25//!   after the number for a retired entry. Fields are separated by one space.
26//!   An entry's number never changes and no entry is ever removed (lock
27//!   design §4): a rename rewrites the key in place, a retire adds the word.
28//! - The key is a declared interface's catalog name, or `service:` followed by
29//!   the dotted name of the service whose inline shape the entry numbers (lock
30//!   design §3). A catalog name is the interface's source package path
31//!   relative to the unit, then its name, joined by `.` (`cluster.Speed`); an
32//!   interface of the unit's root source package keeps its short name. The
33//!   `service:` prefix is needed because `interface cabin` and
34//!   `service cabin` check clean together in one package.
35//!
36//! The reader ([`parse`]) skips an empty line and every line whose first
37//! non-blank byte is `#`, and trims trailing whitespace (plan decision PD-7);
38//! everything else must parse. A git conflict marker line does not parse. The
39//! writer ([`InterfaceLock::render`]) always emits the header, `next N`, and
40//! the entries in number order, one `\n` after each line, so a rendered lock
41//! parses back to the same table.
42//!
43//! This module is pure text in, table out, and compiles without the `fs`
44//! feature so the `wasm32-unknown-unknown` build carries the reader; only
45//! [`read`] and [`write`] touch the filesystem. The compiler reports a
46//! malformed file as RIDL-410 on the offending line (lock design §8): the
47//! loader wraps the [`LockError`] this module returns, which is why the error
48//! carries a byte range and no diagnostic code.
49
50use core::fmt;
51use core::str::FromStr;
52use std::collections::{BTreeMap, BTreeSet};
53#[cfg(feature = "fs")]
54use std::path::Path;
55
56use rowan::{TextRange, TextSize};
57
58/// The file's name inside the unit's manifest directory, or inside the
59/// directory of a bare source file compiled without a manifest.
60pub const FILE_NAME: &str = "interfaces.lock";
61
62/// The first line of every written file. The reader ignores it like any other
63/// `#` line; the writer and the merge driver both emit it.
64pub const HEADER: &str = "# interfaces.lock — written by ridl lock; do not edit by hand.";
65
66/// One entry's key: which interface body the entry numbers.
67///
68/// Its text form (`Display`, `FromStr`) is the token the file holds and the
69/// spelling `ridl lock`'s flags take: `LaneAssist` for a declared interface,
70/// `service:veh.hvac.cabin` for the inline shape of a service.
71#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
72pub enum LockKey {
73    /// A declared `interface`, by its name.
74    Interface(String),
75    /// A service's inline shape, by the service's dotted name (without the
76    /// `service:` prefix).
77    Service(String),
78}
79
80impl fmt::Display for LockKey {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        match self {
83            LockKey::Interface(name) => f.write_str(name),
84            LockKey::Service(name) => write!(f, "service:{name}"),
85        }
86    }
87}
88
89/// Why a token is not a [`LockKey`].
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct InvalidLockKey(pub String);
92
93impl fmt::Display for InvalidLockKey {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        f.write_str(&self.0)
96    }
97}
98
99impl std::error::Error for InvalidLockKey {}
100
101impl FromStr for LockKey {
102    type Err = InvalidLockKey;
103
104    /// Accepts one or more identifiers (`[A-Za-z][A-Za-z0-9_]*`, the lexer's
105    /// rule) joined by `.` for an interface, or `service:` followed by
106    /// identifiers joined by `.` for an inline shape. No word is reserved:
107    /// `next` is not a keyword of the
108    /// language, so an interface may be named `next`, and [`parse`] finds the
109    /// `next N` line by position rather than by its first word (plan decision
110    /// PD-19).
111    fn from_str(text: &str) -> Result<Self, Self::Err> {
112        if let Some(name) = text.strip_prefix("service:") {
113            if !name.is_empty() && name.split('.').all(is_ident) {
114                Ok(LockKey::Service(name.to_string()))
115            } else {
116                Err(InvalidLockKey(format!(
117                    "`{text}` is not a lock key: a service key is `service:` followed by a dotted name"
118                )))
119            }
120        } else if text.split('.').all(is_ident) {
121            Ok(LockKey::Interface(text.to_string()))
122        } else {
123            Err(InvalidLockKey(format!(
124                "`{text}` is not a lock key: an interface key is a dotted name of identifiers"
125            )))
126        }
127    }
128}
129
130/// The lexer's identifier: a letter, then letters, digits or `_`.
131fn is_ident(text: &str) -> bool {
132    let mut bytes = text.bytes();
133    bytes
134        .next()
135        .is_some_and(|first| first.is_ascii_alphabetic())
136        && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
137}
138
139/// One line of the table.
140#[derive(Debug, Clone, PartialEq, Eq, Hash)]
141pub struct LockEntry {
142    pub key: LockKey,
143    /// 1-based; 0 is never allocated (lock design §1).
144    pub number: u32,
145    pub retired: bool,
146    /// The line's bytes in the file text the entry was parsed from, trailing
147    /// whitespace and the line terminator excluded — where RIDL-409 points
148    /// (plan decision PD-3). An entry [`allocate`](InterfaceLock::allocate)
149    /// created has no line yet and carries the empty range at 0.
150    pub range: TextRange,
151}
152
153/// The parsed table.
154#[derive(Debug, Clone, PartialEq, Eq, Hash)]
155pub struct InterfaceLock {
156    /// The next number to allocate; greater than every entry's number.
157    pub next: u32,
158    /// Every entry, live and retired, in file order. [`render`](Self::render)
159    /// sorts them by number.
160    pub entries: Vec<LockEntry>,
161}
162
163impl Default for InterfaceLock {
164    /// `next 1` with no entries — what the merge driver reads an empty BASE
165    /// as (lock design §6), and what the first plain `ridl lock` starts from.
166    fn default() -> Self {
167        Self {
168            next: 1,
169            entries: Vec::new(),
170        }
171    }
172}
173
174/// Why a text is not a lock file: the first malformed shape found, reading
175/// top to bottom, and the byte range of the offending line — or the empty
176/// range at 0 when the text is empty or has no `next` line (plan decision
177/// PD-3).
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct LockError {
180    pub message: String,
181    pub range: TextRange,
182}
183
184/// Why an in-memory edit cannot be applied.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub enum LockEditError {
187    /// No live entry holds the key (it is absent, or every entry with it is
188    /// retired).
189    NoLiveEntry(LockKey),
190    /// A live entry already holds the key.
191    KeyAlreadyLive(LockKey),
192}
193
194impl fmt::Display for LockEditError {
195    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
196        match self {
197            LockEditError::NoLiveEntry(key) => {
198                write!(f, "no live entry `{key}` in {FILE_NAME}")
199            }
200            LockEditError::KeyAlreadyLive(key) => {
201                write!(f, "`{key}` is already a live entry in {FILE_NAME}")
202            }
203        }
204    }
205}
206
207impl std::error::Error for LockEditError {}
208
209/// Parses the text of an `interfaces.lock`.
210///
211/// The malformed shapes, each reported on the first line that shows it (lock
212/// design §2): no `next` line; a `next` line that does not parse; `next` less
213/// than or equal to an entry's number; one number on two entries; one live
214/// key on two entries; a line that does not parse — a git conflict marker
215/// included. Entries may arrive in any order.
216///
217/// The `next` line is the first line the reader does not skip, and every
218/// later line is an entry (plan decision PD-19). A second `next N` line is
219/// therefore the entry of an interface named `next`, and a hand-edited file
220/// with two `next` lines is still caught: the second one's number must be
221/// below the first one's, or the file is malformed.
222pub fn parse(text: &str) -> Result<InterfaceLock, LockError> {
223    let mut next: Option<u32> = None;
224    let mut entries: Vec<LockEntry> = Vec::new();
225    let mut offset = 0usize;
226
227    for raw_line in text.split_inclusive('\n') {
228        let start = offset;
229        offset += raw_line.len();
230        let line = raw_line.trim_end();
231        if line.is_empty() || line.trim_start().starts_with('#') {
232            continue;
233        }
234        let range = byte_range(start, start + line.len());
235
236        let Some(bound) = next else {
237            next = Some(parse_next_line(line, range)?);
238            continue;
239        };
240        let entry = parse_entry_line(line, range)?;
241        if entry.number >= bound {
242            return Err(LockError {
243                message: format!(
244                    "`next` is {bound}, which is not greater than the number {} of `{}`",
245                    entry.number, entry.key
246                ),
247                range,
248            });
249        }
250        if let Some(other) = entries.iter().find(|other| other.number == entry.number) {
251            return Err(LockError {
252                message: format!(
253                    "number {} is on two entries: `{}` and `{}`",
254                    entry.number, other.key, entry.key
255                ),
256                range,
257            });
258        }
259        if !entry.retired
260            && let Some(other) = entries
261                .iter()
262                .find(|other| !other.retired && other.key == entry.key)
263        {
264            return Err(LockError {
265                message: format!(
266                    "live key `{}` is on two entries: {} and {}",
267                    entry.key, other.number, entry.number
268                ),
269                range,
270            });
271        }
272        entries.push(entry);
273    }
274
275    match next {
276        Some(next) => Ok(InterfaceLock { next, entries }),
277        None => Err(LockError {
278            message: "no `next` line: the first line after the header must be `next N`".to_string(),
279            range: TextRange::default(),
280        }),
281    }
282}
283
284/// The first content line: `next N`. A line that does not open with the word
285/// `next` means the file has no `next` line, reported at 0..0; one that opens
286/// with it and does not parse is reported on the line.
287fn parse_next_line(line: &str, range: TextRange) -> Result<u32, LockError> {
288    let mut fields = line.split(' ');
289    if fields.next() != Some("next") {
290        return Err(LockError {
291            message: "no `next` line: the first line after the header must be `next N`".to_string(),
292            range: TextRange::default(),
293        });
294    }
295    match (fields.next().and_then(parse_number), fields.next()) {
296        (Some(next), None) => Ok(next),
297        _ => Err(LockError {
298            message: "the `next` line does not parse: expected `next N` with N at least 1"
299                .to_string(),
300            range,
301        }),
302    }
303}
304
305/// An entry line: `Key number` or `Key number retired`, fields separated by
306/// one space.
307fn parse_entry_line(line: &str, range: TextRange) -> Result<LockEntry, LockError> {
308    let does_not_parse = |reason: &str| LockError {
309        message: format!("line does not parse: {reason}"),
310        range,
311    };
312    let mut fields = line.split(' ');
313    let key = fields
314        .next()
315        .unwrap_or_default()
316        .parse::<LockKey>()
317        .map_err(|InvalidLockKey(reason)| does_not_parse(&reason))?;
318    let number = fields
319        .next()
320        .and_then(parse_number)
321        .ok_or_else(|| does_not_parse("expected `Key N` with N at least 1"))?;
322    let retired = match fields.next() {
323        None => false,
324        Some("retired") => true,
325        Some(_) => {
326            return Err(does_not_parse(
327                "expected `retired` or the end of the line after the number",
328            ));
329        }
330    };
331    if fields.next().is_some() {
332        return Err(does_not_parse("unexpected text after `retired`"));
333    }
334    Ok(LockEntry {
335        key,
336        number,
337        retired,
338        range,
339    })
340}
341
342/// A number as the writer spells it: decimal digits, no leading zero, at
343/// least 1, within `u32`.
344fn parse_number(text: &str) -> Option<u32> {
345    if text.is_empty() || text.starts_with('0') || !text.bytes().all(|b| b.is_ascii_digit()) {
346        return None;
347    }
348    text.parse().ok()
349}
350
351fn byte_range(start: usize, end: usize) -> TextRange {
352    TextRange::new(TextSize::from(start as u32), TextSize::from(end as u32))
353}
354
355impl InterfaceLock {
356    /// The file text: the header, `next N`, then every entry in number order,
357    /// one `\n` after each line.
358    pub fn render(&self) -> String {
359        let mut out = format!("{HEADER}\nnext {}\n", self.next);
360        let mut entries: Vec<&LockEntry> = self.entries.iter().collect();
361        entries.sort_by_key(|entry| entry.number);
362        for entry in entries {
363            push_line(&mut out, entry);
364        }
365        out
366    }
367
368    /// The live entry holding `key`, if any. A retired entry never matches.
369    pub fn live(&self, key: &LockKey) -> Option<&LockEntry> {
370        self.entries
371            .iter()
372            .find(|entry| !entry.retired && entry.key == *key)
373    }
374
375    /// The highest number in the table, retired entries included; 0 when
376    /// there is none.
377    pub fn max_number(&self) -> u32 {
378        self.entries
379            .iter()
380            .map(|entry| entry.number)
381            .max()
382            .unwrap_or(0)
383    }
384
385    /// Allocates `next` to a new live entry for `key` and raises `next` by
386    /// one. A retired entry with the same key does not stand in the way: the
387    /// old name is free for a later, unrelated interface (lock design §4).
388    pub fn allocate(&mut self, key: LockKey) -> Result<u32, LockEditError> {
389        if self.live(&key).is_some() {
390            return Err(LockEditError::KeyAlreadyLive(key));
391        }
392        let number = self.next;
393        self.next = number
394            .checked_add(1)
395            .expect("interface numbers stay far below u32::MAX");
396        self.entries.push(LockEntry {
397            key,
398            number,
399            retired: false,
400            range: TextRange::default(),
401        });
402        Ok(number)
403    }
404
405    /// Rewrites the live entry `old` to hold the key `new`, in place; the
406    /// number does not change and nothing is allocated. Returns the number.
407    pub fn rename(&mut self, old: &LockKey, new: LockKey) -> Result<u32, LockEditError> {
408        let index = self
409            .entries
410            .iter()
411            .position(|entry| !entry.retired && entry.key == *old)
412            .ok_or_else(|| LockEditError::NoLiveEntry(old.clone()))?;
413        if self.live(&new).is_some() {
414            return Err(LockEditError::KeyAlreadyLive(new));
415        }
416        let entry = &mut self.entries[index];
417        entry.key = new;
418        Ok(entry.number)
419    }
420
421    /// Marks the live entry `key` retired; the line and its number stay and
422    /// `next` does not move. Returns the number.
423    pub fn retire(&mut self, key: &LockKey) -> Result<u32, LockEditError> {
424        let entry = self
425            .entries
426            .iter_mut()
427            .find(|entry| !entry.retired && entry.key == *key)
428            .ok_or_else(|| LockEditError::NoLiveEntry(key.clone()))?;
429        entry.retired = true;
430        Ok(entry.number)
431    }
432}
433
434/// One entry as a line of the file: `Key number`, then ` retired` for a
435/// retired entry, then `\n`.
436fn push_line(out: &mut String, entry: &LockEntry) {
437    out.push_str(&entry.key.to_string());
438    out.push(' ');
439    out.push_str(&entry.number.to_string());
440    if entry.retired {
441        out.push_str(" retired");
442    }
443    out.push('\n');
444}
445
446/// What `ridl lock merge` writes to OURS (lock design §6).
447#[derive(Debug, Clone, PartialEq, Eq)]
448pub enum MergeOutcome {
449    /// Every entry agreed: the merged table, which the driver renders. Its
450    /// entries carry the empty range at 0, as allocated entries do.
451    Clean(InterfaceLock),
452    /// At least one entry disagreed: the whole file text, with git conflict
453    /// markers around only the disagreeing entries. The text does not parse
454    /// (RIDL-410) until an author resolves it.
455    Conflict { text: String },
456}
457
458/// Which side a merged entry came from.
459#[derive(Clone, Copy, PartialEq, Eq)]
460enum Side {
461    Ours,
462    Theirs,
463    Both,
464}
465
466/// One block between conflict markers: what OURS holds and what THEIRS holds,
467/// placed in the file at the number `at`.
468struct Conflict {
469    at: u32,
470    ours: Vec<LockEntry>,
471    theirs: Vec<LockEntry>,
472}
473
474/// A three-way merge over entries, not lines (lock design §6): the git merge
475/// driver's whole computation, with no file access.
476///
477/// Entries are matched by number, and two entries agree when their key and
478/// their retired flag agree — the line's position and range do not count. At
479/// each number the rule is git's own three-way rule, with one extension:
480///
481/// - OURS and THEIRS agree: kept once. (Absent from both: dropped.)
482/// - one side is as BASE: the other side's version is taken — an addition, a
483///   rename, a retire, or, for a line deleted by hand, its absence.
484/// - both sides differ from BASE and from each other, and BASE holds the
485///   number: a conflict on that entry.
486/// - both sides differ, and BASE holds no such number — both sides allocated
487///   the number: OURS keeps it and THEIRS's entry is renumbered to the next
488///   free number, which is safe because a branch never allocates (§6,
489///   "Renumbering THEIRS is safe").
490///
491/// The merged entries are then checked as a table would be: a live key on two
492/// numbers is a conflict between the entries the two sides brought, placed at
493/// the lower number (§6, last row). `next` is the maximum of the three sides'
494/// `next`, plus one per renumbered entry — renumbering allocates from that
495/// maximum upward, so `next` is never lowered.
496///
497/// `marker_size` is the length of each marker line (`<<<<<<< ours`,
498/// `=======`, `>>>>>>> theirs`); git passes its `conflict-marker-size`
499/// attribute, 7 by default, and the caller passes at least 1.
500pub fn merge(
501    base: &InterfaceLock,
502    ours: &InterfaceLock,
503    theirs: &InterfaceLock,
504    marker_size: usize,
505) -> MergeOutcome {
506    let base_at = by_number(base);
507    let ours_at = by_number(ours);
508    let theirs_at = by_number(theirs);
509    let numbers: BTreeSet<u32> = base_at
510        .keys()
511        .chain(ours_at.keys())
512        .chain(theirs_at.keys())
513        .copied()
514        .collect();
515
516    let mut next = base.next.max(ours.next).max(theirs.next);
517    let mut resolved: Vec<(Side, LockEntry)> = Vec::new();
518    let mut conflicts: Vec<Conflict> = Vec::new();
519    for number in numbers {
520        let b = base_at.get(&number).copied();
521        let o = ours_at.get(&number).copied();
522        let t = theirs_at.get(&number).copied();
523        if agree(o, t) {
524            if let Some(entry) = o {
525                resolved.push((Side::Both, fresh(entry)));
526            }
527        } else if agree(o, b) {
528            if let Some(entry) = t {
529                resolved.push((Side::Theirs, fresh(entry)));
530            }
531        } else if agree(t, b) {
532            if let Some(entry) = o {
533                resolved.push((Side::Ours, fresh(entry)));
534            }
535        } else if let (None, Some(ours_entry), Some(theirs_entry)) = (b, o, t) {
536            resolved.push((Side::Ours, fresh(ours_entry)));
537            let mut renumbered = fresh(theirs_entry);
538            renumbered.number = next;
539            next = next
540                .checked_add(1)
541                .expect("interface numbers stay far below u32::MAX");
542            resolved.push((Side::Theirs, renumbered));
543        } else {
544            conflicts.push(Conflict {
545                at: number,
546                ours: o.map(fresh).into_iter().collect(),
547                theirs: t.map(fresh).into_iter().collect(),
548            });
549        }
550    }
551
552    // A live key on two numbers: the entries the two sides brought are one
553    // conflict, and leave the merged table.
554    let mut by_key: BTreeMap<&LockKey, Vec<usize>> = BTreeMap::new();
555    for (index, (_, entry)) in resolved.iter().enumerate() {
556        if !entry.retired {
557            by_key.entry(&entry.key).or_default().push(index);
558        }
559    }
560    let duplicated: Vec<Vec<usize>> = by_key
561        .into_values()
562        .filter(|indices| indices.len() > 1)
563        .collect();
564    let mut in_conflict = vec![false; resolved.len()];
565    for indices in duplicated {
566        let mut conflict = Conflict {
567            at: u32::MAX,
568            ours: Vec::new(),
569            theirs: Vec::new(),
570        };
571        for index in indices {
572            in_conflict[index] = true;
573            let (side, entry) = &resolved[index];
574            conflict.at = conflict.at.min(entry.number);
575            if *side != Side::Theirs {
576                conflict.ours.push(entry.clone());
577            }
578            if *side != Side::Ours {
579                conflict.theirs.push(entry.clone());
580            }
581        }
582        conflicts.push(conflict);
583    }
584
585    let mut entries: Vec<LockEntry> = resolved
586        .into_iter()
587        .zip(in_conflict)
588        .filter(|(_, taken)| !taken)
589        .map(|((_, entry), _)| entry)
590        .collect();
591    entries.sort_by_key(|entry| entry.number);
592    if conflicts.is_empty() {
593        return MergeOutcome::Clean(InterfaceLock { next, entries });
594    }
595
596    // The file in number order, each conflict block at its number.
597    let mut text = format!("{HEADER}\nnext {next}\n");
598    conflicts.sort_by_key(|conflict| conflict.at);
599    let mut conflicts = conflicts.into_iter().peekable();
600    for entry in &entries {
601        while conflicts
602            .peek()
603            .is_some_and(|conflict| conflict.at < entry.number)
604        {
605            push_conflict(&mut text, &conflicts.next().expect("peeked"), marker_size);
606        }
607        push_line(&mut text, entry);
608    }
609    for conflict in conflicts {
610        push_conflict(&mut text, &conflict, marker_size);
611    }
612    MergeOutcome::Conflict { text }
613}
614
615fn by_number(lock: &InterfaceLock) -> BTreeMap<u32, &LockEntry> {
616    lock.entries
617        .iter()
618        .map(|entry| (entry.number, entry))
619        .collect()
620}
621
622/// Whether two sides hold the same thing at a number: both absent, or both
623/// present with the same key and retired flag.
624fn agree(a: Option<&LockEntry>, b: Option<&LockEntry>) -> bool {
625    match (a, b) {
626        (None, None) => true,
627        (Some(a), Some(b)) => a.key == b.key && a.retired == b.retired,
628        _ => false,
629    }
630}
631
632/// A copy of `entry` with no line yet.
633fn fresh(entry: &LockEntry) -> LockEntry {
634    LockEntry {
635        range: TextRange::default(),
636        ..entry.clone()
637    }
638}
639
640fn push_conflict(out: &mut String, conflict: &Conflict, marker_size: usize) {
641    out.push_str(&"<".repeat(marker_size));
642    out.push_str(" ours\n");
643    for entry in &conflict.ours {
644        push_line(out, entry);
645    }
646    out.push_str(&"=".repeat(marker_size));
647    out.push('\n');
648    for entry in &conflict.theirs {
649        push_line(out, entry);
650    }
651    out.push_str(&">".repeat(marker_size));
652    out.push_str(" theirs\n");
653}
654
655/// The raw text of `dir/interfaces.lock`, or `None` when the file does not
656/// exist. Parsing is the caller's, so a malformed file can be reported with
657/// its text (RIDL-410 needs the line). Any other I/O failure — a file that is
658/// not valid UTF-8 included — is the error.
659#[cfg(feature = "fs")]
660pub fn read(dir: &Path) -> std::io::Result<Option<String>> {
661    match std::fs::read_to_string(dir.join(FILE_NAME)) {
662        Ok(text) => Ok(Some(text)),
663        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
664        Err(err) => Err(err),
665    }
666}
667
668/// Writes `lock` to `dir/interfaces.lock`, replacing any existing file.
669#[cfg(feature = "fs")]
670pub fn write(dir: &Path, lock: &InterfaceLock) -> std::io::Result<()> {
671    std::fs::write(dir.join(FILE_NAME), lock.render())
672}
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677
678    /// The lock design §2 example, byte for byte.
679    const EXAMPLE: &str = "\
680# interfaces.lock — written by ridl lock; do not edit by hand.
681next 6
682CruiseControl 1
683LaneAssist 2 retired
684LaneKeeping 3
685DoorControl 4
686service:veh.hvac.cabin 5
687";
688
689    #[test]
690    fn an_interface_key_is_a_dotted_relative_name() {
691        assert_eq!(
692            key("cluster.SpeedDisplay"),
693            LockKey::Interface("cluster.SpeedDisplay".into())
694        );
695        assert_eq!(key("Session"), LockKey::Interface("Session".into()));
696        for bad in ["cluster..Speed", ".Speed", "cluster.", "a-b.Speed"] {
697            assert!(bad.parse::<LockKey>().is_err(), "{bad}");
698        }
699    }
700
701    #[test]
702    fn a_dotted_key_round_trips_through_parse_and_render() {
703        let lock = parse("next 3\ncluster.Speed 1\nSession 2\n").unwrap();
704        let text = lock.render();
705        assert!(
706            text.ends_with("next 3\ncluster.Speed 1\nSession 2\n"),
707            "{text}"
708        );
709        assert_eq!(parse(&text).unwrap().render(), text);
710    }
711
712    fn key(text: &str) -> LockKey {
713        text.parse().expect("a valid lock key")
714    }
715
716    fn range(start: usize, end: usize) -> TextRange {
717        TextRange::new(TextSize::from(start as u32), TextSize::from(end as u32))
718    }
719
720    /// The byte range of `line` inside `text`, which must hold it once.
721    fn line_range(text: &str, line: &str) -> TextRange {
722        let start = text.find(line).expect("the line is in the text");
723        range(start, start + line.len())
724    }
725
726    fn malformed(text: &str) -> LockError {
727        parse(text).expect_err("the text is malformed")
728    }
729
730    // --- §12 bullet 1: the round trip ----------------------------------
731
732    #[test]
733    fn a_rendered_lock_parses_back_to_the_same_table() {
734        let lock = parse(EXAMPLE).expect("the design's example parses");
735
736        assert_eq!(lock.next, 6);
737        assert_eq!(lock.entries.len(), 5);
738        assert_eq!(
739            lock.entries[0].key,
740            LockKey::Interface("CruiseControl".to_string())
741        );
742        assert_eq!(lock.entries[0].number, 1);
743        assert!(!lock.entries[0].retired);
744        assert!(lock.entries[1].retired, "`LaneAssist 2 retired` is retired");
745        assert_eq!(
746            lock.entries[4].key,
747            LockKey::Service("veh.hvac.cabin".to_string()),
748            "the `service:` prefix keys the inline shape"
749        );
750
751        assert_eq!(
752            lock.render(),
753            EXAMPLE,
754            "the writer reproduces the file byte for byte"
755        );
756        assert_eq!(
757            parse(&lock.render()).expect("the rendered text parses"),
758            lock
759        );
760    }
761
762    /// PD-3: RIDL-409 points at the entry's line, so an entry carries its
763    /// line's byte range, trailing whitespace and the line terminator
764    /// excluded.
765    #[test]
766    fn an_entry_range_covers_its_line() {
767        let lock = parse(EXAMPLE).expect("the design's example parses");
768        assert_eq!(
769            lock.entries[1].range,
770            line_range(EXAMPLE, "LaneAssist 2 retired")
771        );
772        assert_eq!(
773            lock.entries[4].range,
774            line_range(EXAMPLE, "service:veh.hvac.cabin 5")
775        );
776    }
777
778    /// PD-7: an empty line and a line whose first non-blank byte is `#` are
779    /// skipped, and trailing whitespace — a `\r` included — is trimmed.
780    #[test]
781    fn header_comment_and_blank_lines_are_skipped() {
782        let text = "# interfaces.lock — written by ridl lock; do not edit by hand.\n\
783                    \n\
784                    \x20  # an indented comment\n\
785                    next 3  \n\
786                    A 1\r\n\
787                    \t\n\
788                    B 2\t";
789        let lock = parse(text).expect("comments, blank lines and trailing whitespace are skipped");
790        assert_eq!(lock.next, 3);
791        assert_eq!(
792            lock.entries
793                .iter()
794                .map(|entry| (entry.key.to_string(), entry.number))
795                .collect::<Vec<_>>(),
796            [("A".to_string(), 1), ("B".to_string(), 2)]
797        );
798        assert_eq!(lock.entries[0].range, line_range(text, "A 1"));
799        assert_eq!(lock.entries[1].range, line_range(text, "B 2"));
800    }
801
802    // --- §2 "Malformed", one test per shape ----------------------------
803
804    /// PD-3: no `next` line is reported at `0..0`, an empty file included.
805    /// A `next` line that is present but does not parse is reported on its
806    /// own line.
807    #[test]
808    fn a_missing_next_line_is_malformed() {
809        for text in ["", "# a header only\n\n", "CruiseControl 1\nnext 2\n"] {
810            let error = malformed(text);
811            assert!(
812                error.message.contains("no `next` line"),
813                "{text:?} must report the missing `next` line, got: {}",
814                error.message
815            );
816            assert_eq!(error.range, range(0, 0), "{text:?} is reported at 0..0");
817        }
818        for text in ["next\n", "next 0\n", "next x\n", "next 3 4\n", "next 03\n"] {
819            let error = malformed(text);
820            assert!(
821                error.message.contains("`next` line does not parse"),
822                "{text:?} must report the bad `next` line, got: {}",
823                error.message
824            );
825            assert_eq!(error.range, line_range(text, text.trim_end()));
826        }
827    }
828
829    #[test]
830    fn a_next_not_above_every_entry_is_malformed() {
831        let text = "next 3\nA 1\nB 3\nC 2\n";
832        let error = malformed(text);
833        assert_eq!(
834            error.message,
835            "`next` is 3, which is not greater than the number 3 of `B`"
836        );
837        assert_eq!(
838            error.range,
839            line_range(text, "B 3"),
840            "the first offending line"
841        );
842
843        let error = malformed("next 2\nA 5 retired\n");
844        assert_eq!(
845            error.message, "`next` is 2, which is not greater than the number 5 of `A`",
846            "a retired entry is bounded too"
847        );
848    }
849
850    #[test]
851    fn one_number_on_two_entries_is_malformed() {
852        let text = "next 3\nA 1\nB 2\nC 1\n";
853        let error = malformed(text);
854        assert_eq!(error.message, "number 1 is on two entries: `A` and `C`");
855        assert_eq!(
856            error.range,
857            line_range(text, "C 1"),
858            "the second entry's line"
859        );
860
861        let error = malformed("next 3\nA 1 retired\nB 1\n");
862        assert_eq!(
863            error.message, "number 1 is on two entries: `A` and `B`",
864            "a retired entry holds its number too"
865        );
866    }
867
868    #[test]
869    fn one_live_key_on_two_entries_is_malformed() {
870        let text = "next 4\nA 1\nB 2\nA 3\n";
871        let error = malformed(text);
872        assert_eq!(error.message, "live key `A` is on two entries: 1 and 3");
873        assert_eq!(
874            error.range,
875            line_range(text, "A 3"),
876            "the second entry's line"
877        );
878
879        let error = malformed("next 3\nservice:x 1\nservice:x 2\n");
880        assert_eq!(
881            error.message,
882            "live key `service:x` is on two entries: 1 and 2"
883        );
884    }
885
886    /// §4: a retired entry keeps its line and its number, and the name is
887    /// free for a later, unrelated interface.
888    #[test]
889    fn a_retired_key_may_be_live_again_on_another_number() {
890        let lock = parse("next 3\nA 1 retired\nA 2\n").expect("a retired name may be live again");
891        assert_eq!(lock.live(&key("A")).map(|entry| entry.number), Some(2));
892
893        let lock = parse("next 3\nA 1 retired\nA 2 retired\n")
894            .expect("a name retired twice is two retired entries");
895        assert_eq!(lock.live(&key("A")), None);
896    }
897
898    #[test]
899    fn a_line_that_does_not_parse_is_malformed() {
900        for line in [
901            "A",
902            "A x",
903            "A 0",
904            "A 01",
905            "A 1 dead",
906            "A 1 retired extra",
907            " A 1",
908            "A  1",
909            "A-B 1",
910            "9A 1",
911            "service: 1",
912            "service:a..b 1",
913            "service:a.b. 1",
914        ] {
915            let text = format!("next 3\n{line}\n");
916            let error = malformed(&text);
917            assert!(
918                error.message.contains("does not parse"),
919                "{line:?} must be a line that does not parse, got: {}",
920                error.message
921            );
922            assert_eq!(
923                error.range,
924                line_range(&text, line),
925                "{line:?} is reported on its line"
926            );
927        }
928    }
929
930    /// PD-7: a git conflict marker line is a line that does not parse, and
931    /// the first marker is the first offending line.
932    #[test]
933    fn git_conflict_markers_are_malformed() {
934        let text = "\
935# interfaces.lock — written by ridl lock; do not edit by hand.
936next 4
937A 1
938<<<<<<< HEAD
939B 2
940=======
941Bee 2
942>>>>>>> feature
943C 3
944";
945        let error = malformed(text);
946        assert!(
947            error.message.contains("does not parse"),
948            "got: {}",
949            error.message
950        );
951        assert_eq!(error.range, line_range(text, "<<<<<<< HEAD"));
952
953        for marker in [
954            "<<<<<<< HEAD",
955            "=======",
956            "||||||| merged common ancestors",
957            ">>>>>>> feature",
958        ] {
959            let text = format!("next 2\n{marker}\nA 1\n");
960            let error = malformed(&text);
961            assert!(
962                error.message.contains("does not parse"),
963                "{marker:?}: {}",
964                error.message
965            );
966            assert_eq!(error.range, line_range(&text, marker));
967        }
968    }
969
970    // --- §2 the key ----------------------------------------------------
971
972    /// §2: an interface `cabin` and a service `cabin` are two keys.
973    #[test]
974    fn cabin_and_service_cabin_are_two_keys() {
975        let lock = parse("next 3\ncabin 1\nservice:cabin 2\n").expect("two distinct keys");
976        assert_eq!(key("cabin"), LockKey::Interface("cabin".to_string()));
977        assert_eq!(key("service:cabin"), LockKey::Service("cabin".to_string()));
978        assert_ne!(key("cabin"), key("service:cabin"));
979        assert_eq!(lock.live(&key("cabin")).map(|entry| entry.number), Some(1));
980        assert_eq!(
981            lock.live(&key("service:cabin")).map(|entry| entry.number),
982            Some(2)
983        );
984    }
985
986    #[test]
987    fn lock_key_display_and_from_str_agree() {
988        for text in [
989            "CruiseControl",
990            "service:veh.hvac.cabin",
991            "cabin",
992            "service:cabin",
993            "A_B9",
994            "next",
995            "service:next",
996        ] {
997            assert_eq!(key(text).to_string(), text);
998        }
999        for text in [
1000            "",
1001            "service:",
1002            "a b",
1003            "service:a..b",
1004            "9x",
1005            "-",
1006            "service:Ab-c",
1007            "Service:x",
1008        ] {
1009            assert!(
1010                text.parse::<LockKey>().is_err(),
1011                "{text:?} is not a lock key"
1012            );
1013        }
1014    }
1015
1016    /// PD-19: `next` is not a keyword, so an interface may be named `next`.
1017    /// The `next` line is found by position — the first line the reader does
1018    /// not skip — and every later line is an entry, one keyed `next` included.
1019    #[test]
1020    fn an_interface_named_next_round_trips() {
1021        let text = format!("{HEADER}\nnext 4\nA 1\nnext 3\n");
1022        let lock = parse(&text).expect("an entry keyed `next` parses");
1023        assert_eq!(lock.next, 4);
1024        assert_eq!(lock.live(&key("next")).map(|entry| entry.number), Some(3));
1025        assert_eq!(lock.entries[1].range, line_range(&text, "next 3"));
1026        assert_eq!(lock.render(), text, "the entry is written back as `next 3`");
1027
1028        let mut lock = InterfaceLock::default();
1029        assert_eq!(lock.allocate(key("next")), Ok(1));
1030        assert_eq!(lock.render(), format!("{HEADER}\nnext 2\nnext 1\n"));
1031        let reread = parse(&lock.render()).expect("the rendered lock parses");
1032        assert_eq!(reread.next, 2);
1033        assert_eq!(reread.live(&key("next")).map(|entry| entry.number), Some(1));
1034
1035        // A hand-edited file with a second `next` line is still malformed
1036        // (design §2): the second line is an entry, and an entry's number
1037        // must be below `next`.
1038        let error = malformed("next 3\nA 1\nnext 5\n");
1039        assert_eq!(
1040            error.message,
1041            "`next` is 3, which is not greater than the number 5 of `next`"
1042        );
1043        assert_eq!(error.range, line_range("next 3\nA 1\nnext 5\n", "next 5"));
1044        assert!(
1045            malformed("next 3\nA 1\nnext 3\n")
1046                .message
1047                .starts_with("`next` is 3, which is not greater"),
1048            "an equal number is not below `next` either"
1049        );
1050    }
1051
1052    // --- the writer ----------------------------------------------------
1053
1054    /// PD-7: the writer always emits the header, `next N`, the entries in
1055    /// number order, one `\n` after each line.
1056    #[test]
1057    fn render_writes_entries_in_number_order_with_one_newline_each() {
1058        let lock = parse("next 4\nC 3\nA 1\nB 2 retired").expect("entries may arrive in any order");
1059        assert_eq!(
1060            lock.render(),
1061            format!("{HEADER}\nnext 4\nA 1\nB 2 retired\nC 3\n")
1062        );
1063    }
1064
1065    /// §6: the merge driver reads an empty BASE as `next 1` with no entries;
1066    /// that is the default lock.
1067    #[test]
1068    fn an_empty_lock_is_next_one_with_no_entries() {
1069        let lock = InterfaceLock::default();
1070        assert_eq!(lock.next, 1);
1071        assert_eq!(lock.entries, Vec::new());
1072        assert_eq!(lock.max_number(), 0);
1073        assert_eq!(lock.render(), format!("{HEADER}\nnext 1\n"));
1074        assert_eq!(parse(&lock.render()).expect("the empty lock parses"), lock);
1075    }
1076
1077    #[test]
1078    fn max_number_counts_retired_entries() {
1079        let lock = parse("next 9\nA 1\nB 8 retired\n").expect("parses");
1080        assert_eq!(lock.max_number(), 8);
1081    }
1082
1083    // --- §4 the edits: an entry's number never changes -----------------
1084
1085    #[test]
1086    fn allocate_takes_next_and_raises_it() {
1087        let mut lock = parse(EXAMPLE).expect("parses");
1088        assert_eq!(lock.allocate(key("Parking")), Ok(6));
1089        assert_eq!(lock.next, 7);
1090        let entry = lock.live(&key("Parking")).expect("the new entry is live");
1091        assert_eq!(entry.number, 6);
1092        assert!(!entry.retired);
1093        assert_eq!(
1094            entry.range,
1095            TextRange::default(),
1096            "an allocated entry has no line yet"
1097        );
1098
1099        assert_eq!(
1100            lock.allocate(key("CruiseControl")),
1101            Err(LockEditError::KeyAlreadyLive(key("CruiseControl")))
1102        );
1103        assert_eq!(
1104            lock.allocate(key("LaneAssist")),
1105            Ok(7),
1106            "a retired name is free for a new interface (§4)"
1107        );
1108        assert_eq!(lock.next, 8);
1109        assert!(lock.render().ends_with("Parking 6\nLaneAssist 7\n"));
1110    }
1111
1112    #[test]
1113    fn rename_keeps_the_number() {
1114        let mut lock = parse(EXAMPLE).expect("parses");
1115        assert_eq!(
1116            lock.rename(&key("LaneKeeping"), key("LaneCentering")),
1117            Ok(3)
1118        );
1119        assert_eq!(
1120            lock.live(&key("LaneCentering")).map(|entry| entry.number),
1121            Some(3)
1122        );
1123        assert_eq!(lock.live(&key("LaneKeeping")), None);
1124        assert_eq!(lock.next, 6, "a rename never allocates");
1125        assert!(lock.render().contains("\nLaneCentering 3\n"));
1126
1127        assert_eq!(
1128            lock.rename(&key("Absent"), key("X")),
1129            Err(LockEditError::NoLiveEntry(key("Absent")))
1130        );
1131        assert_eq!(
1132            lock.rename(&key("LaneAssist"), key("X")),
1133            Err(LockEditError::NoLiveEntry(key("LaneAssist"))),
1134            "a retired entry is not live"
1135        );
1136        assert_eq!(
1137            lock.rename(&key("CruiseControl"), key("DoorControl")),
1138            Err(LockEditError::KeyAlreadyLive(key("DoorControl")))
1139        );
1140        assert_eq!(
1141            lock.rename(&key("CruiseControl"), key("CruiseControl")),
1142            Err(LockEditError::KeyAlreadyLive(key("CruiseControl")))
1143        );
1144        assert_eq!(
1145            lock.rename(&key("service:veh.hvac.cabin"), key("service:veh.hvac.rear")),
1146            Ok(5),
1147            "a service rename is an entry rename (§3)"
1148        );
1149    }
1150
1151    #[test]
1152    fn retire_keeps_the_number_and_next() {
1153        let mut lock = parse(EXAMPLE).expect("parses");
1154        assert_eq!(lock.retire(&key("DoorControl")), Ok(4));
1155        assert_eq!(lock.live(&key("DoorControl")), None);
1156        assert_eq!(lock.next, 6, "a retire never lowers or raises `next`");
1157        assert_eq!(lock.max_number(), 5);
1158        assert!(lock.render().contains("\nDoorControl 4 retired\n"));
1159
1160        assert_eq!(
1161            lock.retire(&key("LaneAssist")),
1162            Err(LockEditError::NoLiveEntry(key("LaneAssist"))),
1163            "already retired"
1164        );
1165        assert_eq!(
1166            lock.retire(&key("Absent")),
1167            Err(LockEditError::NoLiveEntry(key("Absent")))
1168        );
1169    }
1170
1171    #[test]
1172    fn edit_errors_name_the_key() {
1173        assert_eq!(
1174            LockEditError::NoLiveEntry(key("A")).to_string(),
1175            "no live entry `A` in interfaces.lock"
1176        );
1177        assert_eq!(
1178            LockEditError::KeyAlreadyLive(key("service:a.b")).to_string(),
1179            "`service:a.b` is already a live entry in interfaces.lock"
1180        );
1181    }
1182
1183    // --- the merge driver's own rules ------------------------------------
1184    //
1185    // The §6 table rows are pinned against the built binary in
1186    // `crates/ridl/tests/lock_merge.rs`. These pin the two rules for a line
1187    // deleted by hand, which the design's table does not list.
1188
1189    fn table(next: u32, entries: &str) -> InterfaceLock {
1190        parse(&format!("{HEADER}\nnext {next}\n{entries}")).expect("a valid table")
1191    }
1192
1193    #[test]
1194    fn merge_drops_a_line_deleted_on_one_side_and_unchanged_on_the_other() {
1195        let base = table(3, "A 1\nB 2\n");
1196        let ours = table(3, "A 1\n");
1197        let theirs = table(4, "A 1\nB 2\nC 3\n");
1198        let expected = table(4, "A 1\nC 3\n").render();
1199        match merge(&base, &ours, &theirs, 7) {
1200            MergeOutcome::Clean(lock) => assert_eq!(lock.render(), expected),
1201            MergeOutcome::Conflict { text } => panic!("unexpected conflict:\n{text}"),
1202        }
1203        match merge(&base, &theirs, &ours, 7) {
1204            MergeOutcome::Clean(lock) => assert_eq!(lock.render(), expected),
1205            MergeOutcome::Conflict { text } => panic!("unexpected conflict:\n{text}"),
1206        }
1207    }
1208
1209    #[test]
1210    fn merge_conflicts_when_a_line_deleted_on_one_side_changed_on_the_other() {
1211        let base = table(3, "A 1\nB 2\n");
1212        let ours = table(3, "A 1\n");
1213        let theirs = table(3, "A 1\nB 2 retired\n");
1214        let MergeOutcome::Conflict { text } = merge(&base, &ours, &theirs, 7) else {
1215            panic!("a deletion against a change is a conflict");
1216        };
1217        assert_eq!(
1218            text,
1219            format!("{HEADER}\nnext 3\nA 1\n<<<<<<< ours\n=======\nB 2 retired\n>>>>>>> theirs\n")
1220        );
1221        assert!(parse(&text).is_err(), "a conflict text does not parse");
1222    }
1223
1224    // --- the `fs` half -------------------------------------------------
1225
1226    #[cfg(feature = "fs")]
1227    mod fs {
1228        use super::*;
1229        use std::path::PathBuf;
1230        use std::sync::atomic::{AtomicUsize, Ordering};
1231
1232        /// A unique directory under the system temp dir, removed on drop.
1233        struct TempDir(PathBuf);
1234
1235        impl TempDir {
1236            fn new(label: &str) -> Self {
1237                static COUNTER: AtomicUsize = AtomicUsize::new(0);
1238                let mut path = std::env::temp_dir();
1239                path.push(format!(
1240                    "ridl-core-interface-lock-{label}-{}-{}",
1241                    std::process::id(),
1242                    COUNTER.fetch_add(1, Ordering::SeqCst),
1243                ));
1244                std::fs::create_dir_all(&path).expect("create the temp dir");
1245                Self(path)
1246            }
1247        }
1248
1249        impl Drop for TempDir {
1250            fn drop(&mut self) {
1251                let _ = std::fs::remove_dir_all(&self.0);
1252            }
1253        }
1254
1255        #[test]
1256        fn read_returns_none_when_the_file_is_absent() {
1257            let dir = TempDir::new("absent");
1258            assert_eq!(read(&dir.0).expect("an absent file is not an error"), None);
1259        }
1260
1261        #[test]
1262        fn write_then_read_round_trips_the_text() {
1263            let dir = TempDir::new("round-trip");
1264            let lock = parse(EXAMPLE).expect("parses");
1265            write(&dir.0, &lock).expect("the file is written");
1266            assert!(dir.0.join(FILE_NAME).is_file());
1267            assert_eq!(
1268                read(&dir.0).expect("the file is read"),
1269                Some(EXAMPLE.to_string())
1270            );
1271        }
1272    }
1273}