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