Skip to main content

ridl_core/
interface_lock.rs

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