Skip to main content

ridl_core/
manifest.rs

1//! The `ridl.toml` manifest parser (ADR-0002 §4).
2//!
3//! A manifest has one file shape and two mutually exclusive modes: a standalone
4//! [`ManifestKind::Package`] or a [`ManifestKind::Workspace`] (ADR-0002 §4).
5//! Both modes may carry an `[imports]` table that aliases logical package names
6//! to URLs. [`parse_manifest`] reads one manifest's text and returns the parsed
7//! [`Manifest`] together with any [`Diagnostic`]s — content problems are
8//! accumulated diagnostics, never an error return (ADR-0004 §5).
9//!
10//! # Diagnostics (the `MANI-…` namespace, ADR-0007 decision 2)
11//!
12//! `MANI-001` invalid TOML, `MANI-002` both sections, `MANI-003` neither
13//! section, `MANI-005` unknown key (warning), `MANI-006` invalid package name,
14//! `MANI-007` invalid import URL, `MANI-010` a `[lints]` entry that names no
15//! lint or whose value is not a level (warning, ADR-0024 decision 11).
16//! `MANI-004` (nested workspace) is defined in
17//! the catalogue but emitted by the package loader, not here: a
18//! manifest read in isolation cannot know it is a workspace member, so a valid
19//! `[workspace]` manifest parses clean.
20//!
21//! # Spans and the [`FileId`]
22//!
23//! [`parse_manifest`] takes the [`FileId`] the caller interned for the manifest
24//! path (via [`SourceMap::file_id`](crate::diag::SourceMap::file_id)) and stamps
25//! it into every diagnostic [`Span`]. Byte ranges come from the `toml` crate:
26//! [`toml::Spanned`] for the offending value (name, import URL) and the parse
27//! error's own span for `MANI-001`; structural problems with no single
28//! offending value (`MANI-002`, `MANI-003`) point at the relevant section or the
29//! whole document.
30//!
31//! # Parsing strategy
32//!
33//! The text is deserialized three times: once into a typed shape with
34//! [`toml::Spanned`] leaves (for the values and their spans), once into a
35//! key-to-spanned-value map (to enumerate the keys the typed shape silently
36//! drops, so unknown keys can warn), and once into the `[lints]` table with
37//! spanned keys (so each MANI-010 can point at its key). All three read the
38//! same valid TOML; the second cannot fail once the first has, and the third
39//! fails only when `lints` is not a table, which is MANI-010.
40
41use std::collections::BTreeMap;
42use std::ops::Range;
43
44use rowan::{TextRange, TextSize};
45use serde::Deserialize;
46use toml::Spanned;
47
48use crate::diag::{DiagCode, Diagnostic, FileId, Severity, Span};
49use crate::lint::{LintLevel, LintTable, lint_by_name};
50
51/// A parsed `ridl.toml` manifest: its mode-specific [`ManifestKind`], its
52/// `[imports]` table (logical package name to URL), its `[defaults]` as a
53/// [`TimingDefaults`] — the three optional keys `timing`, `command_timing` and
54/// `query_timing` — and its `[lints]` table, all shared by both modes.
55///
56/// `defaults` holds the raw `[defaults]` timing strings (e.g.
57/// `"[100ms..1000ms]"` or `"[..1s]"`), stored **unparsed**: `ridl-core`
58/// cannot depend on `ridl-sem`, so the checker parses and validates them
59/// (MANI-009) — the manifest layer only records the strings (ridl §9.1,
60/// §9.3).
61///
62/// `lints` holds only the valid `[lints]` entries: a registered lint name
63/// mapped to a level. Every other entry is MANI-010 and is dropped
64/// (ADR-0002 §4, ADR-0024 decision 11).
65///
66/// `codegen_header_file` is the raw `[codegen] header-file` value with the byte
67/// range of that value in the manifest text. The loader resolves the path
68/// against the manifest's directory and reads the file.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct Manifest {
71    pub kind: ManifestKind,
72    pub imports: BTreeMap<String, String>,
73    pub defaults: TimingDefaults,
74    pub lints: LintTable,
75    pub codegen_header_file: Option<(String, Range<usize>)>,
76}
77
78/// The raw `[defaults]` timing strings of one manifest, or the merge of
79/// several. Each key is stored unparsed and is `None` when no manifest sets it.
80#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
81pub struct TimingDefaults {
82    /// `[defaults].timing`, the default for signals and events.
83    pub timing: Option<String>,
84    /// `[defaults].command_timing`, the default for commands.
85    pub command_timing: Option<String>,
86    /// `[defaults].query_timing`, the default for queries.
87    pub query_timing: Option<String>,
88}
89
90impl TimingDefaults {
91    /// Per key: `self`'s value, else `fallback`'s.
92    pub fn or(self, fallback: &TimingDefaults) -> TimingDefaults {
93        TimingDefaults {
94            timing: self.timing.or_else(|| fallback.timing.clone()),
95            command_timing: self
96                .command_timing
97                .or_else(|| fallback.command_timing.clone()),
98            query_timing: self.query_timing.or_else(|| fallback.query_timing.clone()),
99        }
100    }
101}
102
103/// The two mutually exclusive manifest modes (ADR-0002 §4).
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum ManifestKind {
106    /// A standalone package distributed as a unit.
107    Package {
108        name: String,
109        /// The byte range of the quoted `name` value, quotes included, or
110        /// of the `[package]` table when `name` is absent.
111        name_span: Range<usize>,
112        version: String,
113    },
114    /// A coordinated set of packages developed together.
115    Workspace { members: Vec<String> },
116}
117
118/// Parses and validates one `ridl.toml`. Content problems (both sections
119/// present, neither present, unknown keys, an invalid package name, an invalid
120/// import URL) are returned as accumulated [`Diagnostic`]s rather than an error;
121/// only a structurally unusable manifest (invalid TOML, ambiguous or missing
122/// mode) yields `None`. `file_id` is the id the caller interned for the manifest
123/// path; every diagnostic span carries it.
124pub fn parse_manifest(file_id: FileId, text: &str) -> (Option<Manifest>, Vec<Diagnostic>) {
125    let mut diags = Vec::new();
126
127    // A syntax (or schema) error means there is no usable manifest: MANI-001.
128    let raw: RawManifest = match toml::from_str(text) {
129        Ok(raw) => raw,
130        Err(err) => {
131            let range = err.span().unwrap_or(0..text.len());
132            // The span already draws the caret at the location, so the message
133            // carries the reason, not the location. A `toml` error renders as a
134            // multi-line block whose first line is the location header
135            // ("TOML parse error at line 2, column 5") and whose last line is
136            // the description ("key with no value, expected `=`"); the last line
137            // is the description-first text the house style wants (T6).
138            let rendered = err.to_string();
139            let message = rendered
140                .lines()
141                .last()
142                .unwrap_or("invalid TOML")
143                .trim()
144                .to_string();
145            diags.push(error(DiagCode::MANI_001, file_id, range, message));
146            return (None, diags);
147        }
148    };
149
150    // Exactly one mode section must be present (ADR-0002 §4).
151    if raw.package.is_some() && raw.workspace.is_some() {
152        let range = raw
153            .workspace
154            .as_ref()
155            .map(Spanned::span)
156            .unwrap_or(0..text.len());
157        diags.push(error(
158            DiagCode::MANI_002,
159            file_id,
160            range,
161            "manifest declares both `[package]` and `[workspace]`; the two modes are mutually exclusive".to_string(),
162        ));
163        return (None, diags);
164    }
165    if raw.package.is_none() && raw.workspace.is_none() {
166        diags.push(error(
167            DiagCode::MANI_003,
168            file_id,
169            0..text.len(),
170            "manifest declares neither `[package]` nor `[workspace]`".to_string(),
171        ));
172        return (None, diags);
173    }
174
175    check_unknown_keys(file_id, text, &mut diags);
176
177    let imports = collect_imports(file_id, raw.imports, &mut diags);
178    // The raw `[defaults]` strings, recorded verbatim; the checker parses
179    // them and reports MANI-009 (ridl §9.1).
180    let defaults = raw
181        .defaults
182        .map(|defaults| {
183            let raw = defaults.into_inner();
184            TimingDefaults {
185                timing: raw.timing,
186                command_timing: raw.command_timing,
187                query_timing: raw.query_timing,
188            }
189        })
190        .unwrap_or_default();
191    let lints = collect_lints(file_id, text, &mut diags);
192    let codegen_header_file = raw
193        .codegen
194        .and_then(|codegen| codegen.into_inner().header_file)
195        .map(|value| (value.get_ref().clone(), value.span()));
196
197    let kind = if let Some(pkg) = raw.package {
198        let section_span = pkg.span();
199        let raw_pkg = pkg.into_inner();
200        let (name, name_span) = match raw_pkg.name {
201            Some(name) => (name.get_ref().clone(), name.span()),
202            None => (String::new(), section_span),
203        };
204        if !is_valid_package_name(&name) {
205            diags.push(error(
206                DiagCode::MANI_006,
207                file_id,
208                name_span.clone(),
209                format!(
210                    "invalid package name `{name}`; expected lowercase dot-separated segments (e.g. `veh.common`)"
211                ),
212            ));
213        }
214        ManifestKind::Package {
215            name,
216            name_span,
217            version: raw_pkg.version,
218        }
219    } else if let Some(ws) = raw.workspace {
220        ManifestKind::Workspace {
221            members: ws.into_inner().members,
222        }
223    } else {
224        // Unreachable: exactly one section is present (checked above).
225        return (None, diags);
226    };
227
228    (
229        Some(Manifest {
230            kind,
231            imports,
232            defaults,
233            lints,
234            codegen_header_file,
235        }),
236        diags,
237    )
238}
239
240/// The raw manifest shape `toml` deserializes into. Every leaf a diagnostic may
241/// point at is a [`toml::Spanned`] so its byte range is available; the mode
242/// sections are spanned so a missing package name or a mode conflict can fall
243/// back to the section's range.
244#[derive(Deserialize)]
245struct RawManifest {
246    package: Option<Spanned<RawPackage>>,
247    workspace: Option<Spanned<RawWorkspace>>,
248    #[serde(default)]
249    imports: BTreeMap<String, Spanned<String>>,
250    defaults: Option<Spanned<RawDefaults>>,
251    codegen: Option<Spanned<RawCodegen>>,
252}
253
254#[derive(Deserialize)]
255struct RawDefaults {
256    timing: Option<String>,
257    command_timing: Option<String>,
258    query_timing: Option<String>,
259}
260
261#[derive(Deserialize)]
262struct RawCodegen {
263    #[serde(rename = "header-file")]
264    header_file: Option<Spanned<String>>,
265}
266
267#[derive(Deserialize)]
268struct RawPackage {
269    name: Option<Spanned<String>>,
270    #[serde(default)]
271    version: String,
272}
273
274#[derive(Deserialize)]
275struct RawWorkspace {
276    #[serde(default)]
277    members: Vec<String>,
278}
279
280/// The `[lints]` table alone, read in a parse of its own so that each key
281/// carries its span. `lints` is not a field of [`RawManifest`]: a typed field
282/// there would make `lints = 1` fail the whole typed parse, which is MANI-001
283/// with no manifest, where ADR-0024 decision 11 wants one MANI-010 and the rest
284/// of the manifest.
285#[derive(Deserialize)]
286struct RawLints {
287    #[serde(default)]
288    lints: Option<BTreeMap<Spanned<String>, Spanned<toml::Value>>>,
289}
290
291/// Collects the `[lints]` table into a [`LintTable`] of registered lint names
292/// and levels (ADR-0002 §4). Every entry whose key is not a lint name, or whose
293/// value is not one of the four level strings, is MANI-010 on the key and is
294/// dropped (ADR-0024 decision 11). A `lints` key that is not a table is one
295/// MANI-010 on the value, and the table is empty.
296fn collect_lints(file_id: FileId, text: &str, diags: &mut Vec<Diagnostic>) -> LintTable {
297    let mut lints = LintTable::new();
298    let raw: RawLints = match toml::from_str(text) {
299        Ok(raw) => raw,
300        Err(_) => {
301            // The typed manifest parse accepted this text, and every key and
302            // value of a table fits `RawLints`, so the only failure left is a
303            // `lints` value that is not a table. Its span comes from the
304            // untyped map (the one `check_unknown_keys` reads).
305            let doc: BTreeMap<String, Spanned<toml::Value>> =
306                toml::from_str(text).unwrap_or_default();
307            let range = doc.get("lints").map(Spanned::span).unwrap_or(0..text.len());
308            diags.push(warning(
309                DiagCode::MANI_010,
310                file_id,
311                range,
312                "`[lints]` must be a table".to_string(),
313            ));
314            return lints;
315        }
316    };
317    for (key, value) in raw.lints.unwrap_or_default() {
318        let name = key.get_ref();
319        let Some(registered) = lint_by_name(name).and_then(|entry| entry.lint) else {
320            diags.push(warning(
321                DiagCode::MANI_010,
322                file_id,
323                key.span(),
324                format!("unknown lint `{name}` in `[lints]`"),
325            ));
326            continue;
327        };
328        let Some(level) = value.get_ref().as_str().and_then(LintLevel::parse) else {
329            diags.push(warning(
330                DiagCode::MANI_010,
331                file_id,
332                key.span(),
333                format!("`[lints].{name}` must be one of \"allow\", \"info\", \"warn\", \"deny\""),
334            ));
335            continue;
336        };
337        lints.insert(registered, level);
338    }
339    lints
340}
341
342/// Collects the `[imports]` table into the public `name -> URL` map, flagging
343/// any value that is not a valid import URL (MANI-007). A flagged import is
344/// still recorded verbatim so downstream resolution can report against it.
345fn collect_imports(
346    file_id: FileId,
347    raw: BTreeMap<String, Spanned<String>>,
348    diags: &mut Vec<Diagnostic>,
349) -> BTreeMap<String, String> {
350    let mut imports = BTreeMap::new();
351    for (key, url) in raw {
352        let value = url.get_ref().clone();
353        if !is_valid_import_url(&value) {
354            diags.push(error(
355                DiagCode::MANI_007,
356                file_id,
357                url.span(),
358                format!("invalid import URL `{value}` for `{key}`; expected an `http://` or `https://` URL with a host"),
359            ));
360        }
361        imports.insert(key, value);
362    }
363    imports
364}
365
366/// Warns (MANI-005) on any key the manifest schema does not define. The typed
367/// [`RawManifest`] silently drops unknown keys, so the text is re-read as a map
368/// of spanned values to enumerate every key. Keys under `[imports]` are the
369/// user's logical package names and are never "unknown".
370fn check_unknown_keys(file_id: FileId, text: &str, diags: &mut Vec<Diagnostic>) {
371    let doc: BTreeMap<String, Spanned<toml::Value>> = match toml::from_str(text) {
372        Ok(doc) => doc,
373        // Unreachable: the typed parse already accepted this text.
374        Err(_) => return,
375    };
376    // The allowed-key lists below must stay in sync with the fields of
377    // `RawPackage`, `RawWorkspace`, `RawDefaults`, and `RawCodegen`: a field added there
378    // without a matching entry here would wrongly warn as an unknown key.
379    // Keys under `[lints]` are lint names; `collect_lints` checks them against
380    // the registry (MANI-010), so they are never "unknown" here.
381    for (key, value) in &doc {
382        match key.as_str() {
383            "package" => check_section_keys(file_id, "package", value, &["name", "version"], diags),
384            "workspace" => check_section_keys(file_id, "workspace", value, &["members"], diags),
385            "defaults" => check_section_keys(
386                file_id,
387                "defaults",
388                value,
389                &["timing", "command_timing", "query_timing"],
390                diags,
391            ),
392            "codegen" => check_section_keys(file_id, "codegen", value, &["header-file"], diags),
393            "imports" => {}
394            "lints" => {}
395            _ => diags.push(warning(
396                DiagCode::MANI_005,
397                file_id,
398                value.span(),
399                format!("unknown manifest key `{key}`"),
400            )),
401        }
402    }
403}
404
405/// Warns (MANI-005) on any key in a mode section that is not in `allowed`. The
406/// section value carries the only span available, so an unknown nested key
407/// points at its section.
408fn check_section_keys(
409    file_id: FileId,
410    section: &str,
411    value: &Spanned<toml::Value>,
412    allowed: &[&str],
413    diags: &mut Vec<Diagnostic>,
414) {
415    let Some(table) = value.get_ref().as_table() else {
416        return;
417    };
418    for key in table.keys() {
419        if !allowed.contains(&key.as_str()) {
420            diags.push(warning(
421                DiagCode::MANI_005,
422                file_id,
423                value.span(),
424                format!("unknown key `{key}` in `[{section}]`"),
425            ));
426        }
427    }
428}
429
430/// A package name is one or more lowercase dot-separated segments, each an ASCII
431/// lowercase letter followed by ASCII lowercase letters or digits (ADR-0002 §1).
432/// The empty string (a missing name) is invalid.
433fn is_valid_package_name(name: &str) -> bool {
434    !name.is_empty() && name.split('.').all(is_valid_name_segment)
435}
436
437fn is_valid_name_segment(segment: &str) -> bool {
438    let mut chars = segment.chars();
439    match chars.next() {
440        Some(first) if first.is_ascii_lowercase() => {}
441        _ => return false,
442    }
443    chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
444}
445
446/// A deliberately minimal import-URL check: the value must use the `http` or
447/// `https` scheme and name a non-empty host (the run up to the first `/`, `?`,
448/// or `#`). Full URL, version-suffix, and registry validation is the fetch
449/// layer's job; this only rejects values that are plainly not URLs.
450fn is_valid_import_url(url: &str) -> bool {
451    let Some(rest) = url
452        .strip_prefix("https://")
453        .or_else(|| url.strip_prefix("http://"))
454    else {
455        return false;
456    };
457    let host_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
458    !rest[..host_end].is_empty()
459}
460
461/// Builds an error [`Diagnostic`] with the given code, file, byte range, and
462/// message. Manifest diagnostics carry no secondary labels or fix-its.
463fn error(code: DiagCode, file: FileId, range: Range<usize>, message: String) -> Diagnostic {
464    diagnostic(code, Severity::Error, file, range, message)
465}
466
467/// Builds a warning [`Diagnostic`] (used only for MANI-005 and MANI-010).
468fn warning(code: DiagCode, file: FileId, range: Range<usize>, message: String) -> Diagnostic {
469    diagnostic(code, Severity::Warning, file, range, message)
470}
471
472fn diagnostic(
473    code: DiagCode,
474    severity: Severity,
475    file: FileId,
476    range: Range<usize>,
477    message: String,
478) -> Diagnostic {
479    Diagnostic {
480        code,
481        severity,
482        message,
483        primary: Span {
484            file,
485            range: to_text_range(range),
486        },
487        labels: Vec::new(),
488        fixits: Vec::new(),
489    }
490}
491
492/// Converts a `toml` byte range into a `rowan::TextRange`, the coordinate space
493/// the diagnostic model works in.
494fn to_text_range(range: Range<usize>) -> TextRange {
495    TextRange::new(
496        TextSize::from(range.start as u32),
497        TextSize::from(range.end as u32),
498    )
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504    use crate::diag::{MANI_CATALOG, SourceMap};
505
506    const STANDALONE: &str = "\
507[package]
508name = \"veh.common\"
509version = \"1.2.0\"
510
511[imports]
512\"some.dep\" = \"https://ridl.example.com/some/dep@v1.0.0\"
513";
514
515    const WORKSPACE: &str = "\
516[workspace]
517members = [\"veh-common\", \"veh-cluster\", \"veh-adas\"]
518
519[imports]
520\"third-party.foo\" = \"https://ridl.example.com/third-party/foo@v1.0.0\"
521";
522
523    fn parse(text: &str) -> (Option<Manifest>, Vec<Diagnostic>) {
524        let mut map = SourceMap::new();
525        let file_id = map.file_id("ridl.toml", text);
526        parse_manifest(file_id, text)
527    }
528
529    fn codes(diags: &[Diagnostic]) -> Vec<&str> {
530        diags.iter().map(|d| d.code.as_str()).collect()
531    }
532
533    #[test]
534    fn adr0002_standalone_example_parses() {
535        let (manifest, diags) = parse(STANDALONE);
536        assert!(diags.is_empty(), "clean standalone manifest, got {diags:?}");
537        let manifest = manifest.expect("standalone manifest parses");
538        assert_eq!(
539            manifest.kind,
540            ManifestKind::Package {
541                name: "veh.common".to_string(),
542                name_span: range_of(STANDALONE, "\"veh.common\""),
543                version: "1.2.0".to_string(),
544            },
545        );
546        assert_eq!(manifest.imports.len(), 1);
547        assert_eq!(
548            manifest.imports.get("some.dep").map(String::as_str),
549            Some("https://ridl.example.com/some/dep@v1.0.0"),
550        );
551    }
552
553    #[test]
554    fn adr0002_workspace_example_parses() {
555        let (manifest, diags) = parse(WORKSPACE);
556        assert!(diags.is_empty(), "clean workspace manifest, got {diags:?}");
557        let manifest = manifest.expect("workspace manifest parses");
558        assert_eq!(
559            manifest.kind,
560            ManifestKind::Workspace {
561                members: vec![
562                    "veh-common".to_string(),
563                    "veh-cluster".to_string(),
564                    "veh-adas".to_string(),
565                ],
566            },
567        );
568        assert_eq!(
569            manifest.imports.get("third-party.foo").map(String::as_str),
570            Some("https://ridl.example.com/third-party/foo@v1.0.0"),
571        );
572    }
573
574    #[test]
575    fn mani_001_invalid_toml() {
576        // `name` with no `=` value is a TOML syntax error.
577        let (manifest, diags) = parse("[package]\nname\n");
578        assert!(manifest.is_none(), "invalid TOML yields no manifest");
579        assert_eq!(codes(&diags), vec!["MANI-001"]);
580        assert_eq!(diags[0].severity, Severity::Error);
581        // The message carries the reason (description-first, T6 house style),
582        // not the "TOML parse error at line …" location header.
583        assert!(
584            diags[0].message.contains("expected `=`"),
585            "MANI-001 message must carry the reason, got {:?}",
586            diags[0].message,
587        );
588        assert!(
589            !diags[0].message.contains("TOML parse error at line"),
590            "MANI-001 message must not be the location header, got {:?}",
591            diags[0].message,
592        );
593    }
594
595    #[test]
596    fn mani_001_carries_type_error_reason() {
597        // A wrong-typed field value is a schema error; its reason must survive.
598        let (manifest, diags) = parse("[package]\nname = 123\nversion = \"1.0.0\"\n");
599        assert!(manifest.is_none());
600        assert_eq!(codes(&diags), vec!["MANI-001"]);
601        assert!(
602            diags[0].message.contains("invalid type"),
603            "MANI-001 message must carry the type-mismatch reason, got {:?}",
604            diags[0].message,
605        );
606        assert!(!diags[0].message.contains("TOML parse error at line"));
607    }
608
609    #[test]
610    fn mani_002_both_sections() {
611        let text = "\
612[package]
613name = \"veh.common\"
614version = \"1.0.0\"
615
616[workspace]
617members = [\"a\"]
618";
619        let (manifest, diags) = parse(text);
620        assert!(manifest.is_none(), "an ambiguous mode yields no manifest");
621        assert_eq!(codes(&diags), vec!["MANI-002"]);
622        assert_eq!(diags[0].severity, Severity::Error);
623    }
624
625    #[test]
626    fn mani_003_neither_section() {
627        // Only `[imports]`, no mode section.
628        let text = "[imports]\n\"x.y\" = \"https://example.com/x\"\n";
629        let (manifest, diags) = parse(text);
630        assert!(manifest.is_none(), "a modeless manifest yields no manifest");
631        assert_eq!(codes(&diags), vec!["MANI-003"]);
632
633        // An empty manifest is the same failure.
634        let (empty, empty_diags) = parse("");
635        assert!(empty.is_none());
636        assert_eq!(codes(&empty_diags), vec!["MANI-003"]);
637    }
638
639    #[test]
640    fn mani_004_workspace_manifest_parses_clean_in_isolation() {
641        // Nested-workspace detection is the loader's job. Parsed
642        // in isolation a `[workspace]` manifest is a valid workspace, never
643        // MANI-004.
644        let (manifest, diags) = parse(WORKSPACE);
645        assert!(diags.is_empty(), "no MANI-004 from the standalone parser");
646        assert!(matches!(
647            manifest.expect("workspace parses").kind,
648            ManifestKind::Workspace { .. },
649        ));
650        // The code exists in the catalogue for the loader to emit.
651        assert!(
652            MANI_CATALOG
653                .iter()
654                .any(|entry| entry.code == DiagCode::MANI_004),
655            "MANI-004 is defined for the loader",
656        );
657    }
658
659    #[test]
660    fn mani_005_unknown_key_warns_but_parses() {
661        // Unknown key inside `[package]`.
662        let text = "\
663[package]
664name = \"veh.common\"
665version = \"1.0.0\"
666description = \"not a known key\"
667";
668        let (manifest, diags) = parse(text);
669        let manifest = manifest.expect("unknown key still parses");
670        assert_eq!(
671            manifest.kind,
672            ManifestKind::Package {
673                name: "veh.common".to_string(),
674                name_span: range_of(text, "\"veh.common\""),
675                version: "1.0.0".to_string(),
676            },
677        );
678        assert_eq!(codes(&diags), vec!["MANI-005"]);
679        assert_eq!(diags[0].severity, Severity::Warning);
680
681        // Unknown top-level section is also warned.
682        let top = "\
683[package]
684name = \"veh.common\"
685version = \"1.0.0\"
686
687[bogus]
688x = 1
689";
690        let (top_manifest, top_diags) = parse(top);
691        assert!(top_manifest.is_some(), "unknown top-level key still parses");
692        assert_eq!(codes(&top_diags), vec!["MANI-005"]);
693        assert_eq!(top_diags[0].severity, Severity::Warning);
694    }
695
696    #[test]
697    fn mani_006_invalid_package_name() {
698        // Uppercase is not a lowercase dot-segment name.
699        let text = "[package]\nname = \"Veh.Common\"\nversion = \"1.0.0\"\n";
700        let (manifest, diags) = parse(text);
701        assert!(manifest.is_some(), "a bad name is a diagnostic, not None");
702        assert_eq!(codes(&diags), vec!["MANI-006"]);
703        assert_eq!(diags[0].severity, Severity::Error);
704
705        // A missing name is an empty name, equally invalid.
706        let no_name = "[package]\nversion = \"1.0.0\"\n";
707        let (_, no_name_diags) = parse(no_name);
708        assert_eq!(codes(&no_name_diags), vec!["MANI-006"]);
709    }
710
711    #[test]
712    fn defaults_timing_is_recorded_unparsed() {
713        // The `[defaults].timing` string rides through verbatim — no MANI-005,
714        // and the checker (not the manifest) validates it (ridl §9.1).
715        let text = "\
716[package]
717name = \"veh.common\"
718version = \"1.0.0\"
719
720[defaults]
721timing = \"[50ms..2s]\"
722";
723        let (manifest, diags) = parse(text);
724        assert!(
725            diags.is_empty(),
726            "a `[defaults]` section is known, got {diags:?}"
727        );
728        let manifest = manifest.expect("the manifest parses");
729        assert_eq!(manifest.defaults.timing.as_deref(), Some("[50ms..2s]"));
730    }
731
732    #[test]
733    fn defaults_command_and_query_timing_parse() {
734        let text = "\
735[package]
736name = \"veh.common\"
737version = \"1.0.0\"
738
739[defaults]
740timing = \"[100ms..1s]\"
741command_timing = \"[..1s]\"
742query_timing = \"[..3s]\"
743";
744        let (manifest, diags) = parse(text);
745        assert!(diags.is_empty(), "known keys draw nothing, got {diags:?}");
746        assert_eq!(
747            manifest.expect("parses").defaults,
748            TimingDefaults {
749                timing: Some("[100ms..1s]".to_string()),
750                command_timing: Some("[..1s]".to_string()),
751                query_timing: Some("[..3s]".to_string()),
752            }
753        );
754    }
755
756    /// `or` resolves each key on its own, in both directions: a key `self`
757    /// sets wins over the fallback's value, and a key `self` leaves unset takes
758    /// the fallback's value.
759    #[test]
760    fn timing_defaults_or_resolves_each_key_on_its_own() {
761        let all = |prefix: &str| TimingDefaults {
762            timing: Some(format!("{prefix}-timing")),
763            command_timing: Some(format!("{prefix}-command")),
764            query_timing: Some(format!("{prefix}-query")),
765        };
766        // Every key set on both sides: `self` wins on every key.
767        assert_eq!(all("own").or(&all("fallback")), all("own"));
768        // No key set on `self`: every key comes from the fallback.
769        assert_eq!(
770            TimingDefaults::default().or(&all("fallback")),
771            all("fallback")
772        );
773        // One key set on `self`: that key is its own, the other two are the
774        // fallback's.
775        let fallback = all("fallback");
776        let own = || Some("own".to_string());
777        let cases = [
778            (
779                TimingDefaults {
780                    timing: own(),
781                    ..TimingDefaults::default()
782                },
783                TimingDefaults {
784                    timing: own(),
785                    ..fallback.clone()
786                },
787            ),
788            (
789                TimingDefaults {
790                    command_timing: own(),
791                    ..TimingDefaults::default()
792                },
793                TimingDefaults {
794                    command_timing: own(),
795                    ..fallback.clone()
796                },
797            ),
798            (
799                TimingDefaults {
800                    query_timing: own(),
801                    ..TimingDefaults::default()
802                },
803                TimingDefaults {
804                    query_timing: own(),
805                    ..fallback.clone()
806                },
807            ),
808        ];
809        for (own, expected) in cases {
810            assert_eq!(own.clone().or(&fallback), expected, "{own:?}");
811        }
812        // A key unset on both sides stays unset.
813        assert_eq!(
814            TimingDefaults::default().or(&TimingDefaults::default()),
815            TimingDefaults::default()
816        );
817    }
818
819    #[test]
820    fn no_defaults_section_leaves_the_defaults_absent() {
821        let (manifest, diags) = parse(STANDALONE);
822        assert!(diags.is_empty());
823        assert_eq!(
824            manifest.expect("parses").defaults,
825            TimingDefaults::default(),
826            "no `[defaults]` means no configured default",
827        );
828    }
829
830    #[test]
831    fn unknown_key_inside_defaults_still_warns() {
832        let text = "\
833[package]
834name = \"veh.common\"
835version = \"1.0.0\"
836
837[defaults]
838timing = \"[100ms..1000ms]\"
839command_timing = \"[..1s]\"
840query_timing = \"[..3s]\"
841bogus = 1
842";
843        let (manifest, diags) = parse(text);
844        assert!(manifest.is_some(), "an unknown nested key still parses");
845        assert_eq!(codes(&diags), vec!["MANI-005"]);
846        assert_eq!(diags[0].severity, Severity::Warning);
847    }
848
849    #[test]
850    fn mani_007_invalid_import_url() {
851        let text = "\
852[package]
853name = \"veh.common\"
854version = \"1.0.0\"
855
856[imports]
857\"some.dep\" = \"not-a-url\"
858";
859        let (manifest, diags) = parse(text);
860        let manifest = manifest.expect("a bad URL is a diagnostic, not None");
861        // The import is still recorded, verbatim.
862        assert_eq!(
863            manifest.imports.get("some.dep").map(String::as_str),
864            Some("not-a-url"),
865        );
866        assert_eq!(codes(&diags), vec!["MANI-007"]);
867        assert_eq!(diags[0].severity, Severity::Error);
868
869        // A plain `http://` host is accepted (no MANI-007).
870        let http = "\
871[package]
872name = \"veh.common\"
873version = \"1.0.0\"
874
875[imports]
876\"some.dep\" = \"http://example.com/some/dep\"
877";
878        let (_, http_diags) = parse(http);
879        assert!(
880            http_diags.is_empty(),
881            "http:// with a host is a valid URL, got {http_diags:?}",
882        );
883    }
884
885    const PACKAGE_HEAD: &str = "[package]\nname = \"veh.common\"\nversion = \"1.0.0\"\n\n";
886    const WORKSPACE_HEAD: &str = "[workspace]\nmembers = [\"a\"]\n\n";
887
888    /// The byte range of the first occurrence of `needle` in `text`.
889    fn range_of(text: &str, needle: &str) -> Range<usize> {
890        let start = text.find(needle).expect("the needle occurs in the text");
891        start..start + needle.len()
892    }
893
894    /// The text of `text` under the primary span of `diag`.
895    fn spanned_text<'a>(text: &'a str, diag: &Diagnostic) -> &'a str {
896        let range = diag.primary.range;
897        &text[usize::from(range.start())..usize::from(range.end())]
898    }
899
900    #[test]
901    fn lints_table_is_read() {
902        for head in [PACKAGE_HEAD, WORKSPACE_HEAD] {
903            let text = format!("{head}[lints]\nmissing-timing = \"deny\"\n");
904            let (manifest, diags) = parse(&text);
905            assert!(diags.is_empty(), "a valid `[lints]` table, got {diags:?}");
906            let manifest = manifest.expect("the manifest parses");
907            assert_eq!(manifest.lints.get("missing-timing"), Some(&LintLevel::Deny));
908            assert_eq!(manifest.lints.len(), 1);
909        }
910    }
911
912    #[test]
913    fn lints_key_is_known() {
914        let text = format!("{PACKAGE_HEAD}[lints]\n");
915        let (manifest, diags) = parse(&text);
916        assert!(diags.is_empty(), "no MANI-005 for `lints`, got {diags:?}");
917        assert!(manifest.expect("the manifest parses").lints.is_empty());
918    }
919
920    #[test]
921    fn no_lints_table_leaves_lints_empty() {
922        let (manifest, diags) = parse(STANDALONE);
923        assert!(diags.is_empty());
924        assert!(manifest.expect("parses").lints.is_empty());
925    }
926
927    #[test]
928    fn unknown_lint_name() {
929        let text = format!("{PACKAGE_HEAD}[lints]\nnope = \"deny\"\n");
930        let (manifest, diags) = parse(&text);
931        assert_eq!(codes(&diags), vec!["MANI-010"]);
932        assert_eq!(diags[0].severity, Severity::Warning);
933        assert_eq!(diags[0].message, "unknown lint `nope` in `[lints]`");
934        assert!(
935            manifest.expect("the manifest parses").lints.is_empty(),
936            "an unknown lint is not recorded",
937        );
938    }
939
940    #[test]
941    fn code_as_key() {
942        let text = format!("{PACKAGE_HEAD}[lints]\n\"RIDL-100\" = \"deny\"\n");
943        let (manifest, diags) = parse(&text);
944        assert_eq!(codes(&diags), vec!["MANI-010"]);
945        assert_eq!(diags[0].message, "unknown lint `RIDL-100` in `[lints]`");
946        assert!(manifest.expect("the manifest parses").lints.is_empty());
947    }
948
949    #[test]
950    fn error_code_as_key() {
951        // An Error code has no lint name, so neither its code nor any name is
952        // accepted as a key.
953        let text = format!("{PACKAGE_HEAD}[lints]\n\"RIDL-101\" = \"allow\"\n");
954        let (manifest, diags) = parse(&text);
955        assert_eq!(codes(&diags), vec!["MANI-010"]);
956        assert!(manifest.expect("the manifest parses").lints.is_empty());
957    }
958
959    #[test]
960    fn bad_level() {
961        for value in ["\"Deny\"", "3"] {
962            let text = format!("{PACKAGE_HEAD}[lints]\nmissing-timing = {value}\n");
963            let (manifest, diags) = parse(&text);
964            assert_eq!(codes(&diags), vec!["MANI-010"], "value {value}");
965            assert_eq!(diags[0].severity, Severity::Warning);
966            assert_eq!(
967                diags[0].message,
968                "`[lints].missing-timing` must be one of \"allow\", \"info\", \"warn\", \"deny\"",
969            );
970            assert_eq!(spanned_text(&text, &diags[0]), "missing-timing");
971            assert!(
972                manifest.expect("the manifest parses").lints.is_empty(),
973                "an entry with a bad level is not recorded",
974            );
975        }
976    }
977
978    #[test]
979    fn valid_entries_survive_a_bad_sibling() {
980        let text = format!("{PACKAGE_HEAD}[lints]\nmissing-timing = \"deny\"\nnope = \"warn\"\n");
981        let (manifest, diags) = parse(&text);
982        assert_eq!(codes(&diags), vec!["MANI-010"]);
983        let manifest = manifest.expect("the manifest parses");
984        assert_eq!(manifest.lints.get("missing-timing"), Some(&LintLevel::Deny));
985        assert_eq!(manifest.lints.len(), 1);
986    }
987
988    #[test]
989    fn lints_not_a_table() {
990        // A top-level key must come before the first section header.
991        let text = format!("lints = 1\n\n{PACKAGE_HEAD}");
992        let (manifest, diags) = parse(&text);
993        assert_eq!(codes(&diags), vec!["MANI-010"], "no MANI-001, no MANI-005");
994        assert_eq!(diags[0].message, "`[lints]` must be a table");
995        assert_eq!(spanned_text(&text, &diags[0]), "1");
996        let manifest = manifest.expect("the rest of the manifest is still read");
997        assert_eq!(
998            manifest.kind,
999            ManifestKind::Package {
1000                name: "veh.common".to_string(),
1001                name_span: range_of(&text, "\"veh.common\""),
1002                version: "1.0.0".to_string(),
1003            },
1004        );
1005        assert!(manifest.lints.is_empty());
1006    }
1007
1008    #[test]
1009    fn mani_010_span_is_the_key() {
1010        let text = format!("{PACKAGE_HEAD}[lints]\nnope = \"deny\"\n");
1011        let (_, diags) = parse(&text);
1012        assert_eq!(codes(&diags), vec!["MANI-010"]);
1013        assert_eq!(spanned_text(&text, &diags[0]), "nope");
1014    }
1015
1016    #[test]
1017    fn codegen_header_file_is_parsed() {
1018        let text = format!("{PACKAGE_HEAD}[codegen]\nheader-file = \"H.txt\"\n");
1019        let (manifest, diags) = parse(&text);
1020        assert!(diags.is_empty(), "no diagnostic, got {diags:?}");
1021        let (path, range) = manifest
1022            .expect("the manifest parses")
1023            .codegen_header_file
1024            .expect("header-file is read");
1025        assert_eq!(path, "H.txt");
1026        assert_eq!(&text[range], "\"H.txt\"");
1027    }
1028
1029    #[test]
1030    fn unknown_codegen_key_is_mani_005() {
1031        let text = format!("{PACKAGE_HEAD}[codegen]\nheader = \"x\"\n");
1032        let (_, diags) = parse(&text);
1033        assert_eq!(codes(&diags), vec!["MANI-005"]);
1034    }
1035}