Skip to main content

lumen_modules/
link_kit.rs

1//! The link kit: one target's link line, its inputs, and the modules on it.
2//!
3//! A link kit is what lets a machine with no Rust toolchain produce an
4//! executable with the engine and a chosen set of runtime modules compiled
5//! in. The release workflow builds the static launcher once per target with a
6//! recorder in the linker's place (`tools/link-recorder`), keeps every file
7//! that link read, and writes a [`Manifest`] describing the command that
8//! produced the binary. Replaying that command with a module's object files
9//! left out, or its register symbol forced in, is how one prebuilt kit turns
10//! into any of the executables its modules can spell.
11//!
12//! A second kind of kit records the link of the engine's shared library
13//! instead ([`ArtifactKind::SharedEngine`]): `liblumen_engine` on Linux and
14//! macOS, `lumen.dll` on Windows. Replaying it with only the capabilities an
15//! app uses, and with the export list cut down to what the files shipped
16//! beside it resolve, gives each packaged app an engine of its own.
17//!
18//! Two things about the recorded line are not obvious and are why the
19//! manifest is typed rather than a list of strings:
20//!
21//! - Some arguments name files that must travel with the kit (the rlibs and
22//!   the temporary objects), some name directories that must not (the host's
23//!   `/usr/lib`), and some name neither. A replay has to tell them apart to
24//!   re-root the first kind and leave the third alone.
25//! - A module's contribution to the line is a subset of it: its rlib, and the
26//!   native libraries its own crate graph asked for. Dropping a module means
27//!   dropping exactly that subset, which is what the `module` attribution on
28//!   [`LinkArg::File`] and [`LinkArg::SysLib`] records.
29//!
30//! Everything here is data. Nothing in this module opens a file, and nothing
31//! in the engine reads it: the producer is `lumenc link-kit emit` and the
32//! consumer is a `lumenc` running on someone else's machine.
33//!
34//! The schema is versioned and pre-1.0, so it changes whenever a better shape
35//! turns up; a kit whose [`Manifest::schema`] is not [`SCHEMA_VERSION`] is
36//! refused rather than guessed at.
37
38use std::collections::BTreeMap;
39
40use serde::{Deserialize, Serialize};
41
42use crate::{REGISTER_PREFIX, entry_symbol};
43
44/// The manifest version this build writes and accepts.
45pub const SCHEMA_VERSION: u32 = 4;
46
47/// One target's link kit.
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49pub struct Manifest {
50    /// Schema version, checked against [`SCHEMA_VERSION`] before anything
51    /// else is read.
52    pub schema: u32,
53    /// Release-asset target name, for example `linux-x86_64`.
54    pub target: String,
55    /// The Rust target triple the recorded link was for.
56    pub rust_triple: String,
57    /// `rustc --version` of the toolchain that produced the inputs, for a
58    /// report when a replay fails.
59    pub rustc: String,
60    /// The Lumen version the kit was built from. A kit and the app artifact
61    /// it links are only one build together.
62    pub lumen_version: String,
63    /// The program that replays [`Manifest::args`].
64    pub driver: Driver,
65    /// The recorded link line, one entry per argument.
66    pub args: Vec<LinkArg>,
67    /// Every runtime module the kit can link in.
68    pub modules: Vec<KitModule>,
69    /// Every optional subsystem the kit can link in, and when an app gets
70    /// each one.
71    pub capabilities: Vec<KitCapability>,
72    /// How the app's compiled artifact reaches the executable.
73    pub artifact: Artifact,
74    /// The engine build the recorded inputs are, as `lumen_engine_build_id`
75    /// reports it. A shared-engine kit records it so a replay can check it is
76    /// relinking the engine the rest of the toolchain was built against;
77    /// absent where the recorded binary carries no engine build id.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub build_id: Option<String>,
80}
81
82/// What replays the line.
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84pub struct Driver {
85    /// The kind of program the arguments are written for.
86    pub kind: DriverKind,
87    /// The object format's dialect, named after LLD's flavors: `gnu`,
88    /// `darwin`, or `link`. It is what says how a symbol is forced onto the
89    /// line - `-u` for `gnu` and `darwin`, `/INCLUDE:` for `link`.
90    pub flavor: String,
91    /// Kit-relative path of the driver the kit ships, when it ships one.
92    /// Absent means the driver is the consumer's own (`cc`).
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub path: Option<String>,
95}
96
97/// The kind of program a replay runs.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100pub enum DriverKind {
101    /// The platform's C compiler, which drives the system linker and
102    /// contributes the C runtime startup files. The consumer's own.
103    Cc,
104    /// LLD, run directly. Shipped in the kit, because a Windows machine with
105    /// no toolchain has no linker to borrow.
106    Lld,
107}
108
109/// One argument of the recorded link line.
110///
111/// Every variant renders to exactly one argument. Where a flag and its value
112/// share a token, the flag is the entry's `prefix` and the value is what the
113/// consumer resolves; where they are two tokens, the flag is a
114/// [`LinkArg::Lit`] of its own.
115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116#[serde(tag = "kind", rename_all = "snake_case")]
117pub enum LinkArg {
118    /// Passed through as it was recorded.
119    Lit {
120        /// The argument.
121        value: String,
122    },
123    /// Where the output path goes. The consumer substitutes its own.
124    Out {
125        /// Flag this value is joined to, empty when the flag is a separate
126        /// argument.
127        prefix: String,
128    },
129    /// A file the kit carries, at `path` under the kit's `stage` directory.
130    File {
131        /// Path relative to the kit's `stage` directory.
132        path: String,
133        /// The module this file belongs to, when it belongs to one. A replay
134        /// that leaves that module out leaves this file out.
135        #[serde(default, skip_serializing_if = "Option::is_none")]
136        module: Option<String>,
137    },
138    /// A directory the linker searches.
139    #[serde(rename = "sysdir")]
140    SysDir {
141        /// Flag this value is joined to, empty when the flag is a separate
142        /// argument.
143        prefix: String,
144        /// Kit-relative when `staged`, otherwise a path on the machine that
145        /// recorded the line, passed through for the consumer's own copy of
146        /// the same system directory.
147        path: String,
148        /// Whether the kit carries this directory.
149        staged: bool,
150    },
151    /// The list of symbols a library exports, which rustc writes for every
152    /// library link: a version script for the GNU linkers, an exported-symbols
153    /// list for ld64, a module-definition file for MSVC. The kit carries the
154    /// recorded list; a replay either passes it on or puts its own in its
155    /// place, which is what decides what the relinked library keeps.
156    ExportList {
157        /// The flag the path is joined to, such as `-Wl,--version-script=`.
158        prefix: String,
159        /// The recorded list, relative to the kit's `stage` directory.
160        path: String,
161    },
162    /// A native library the linker resolves by name.
163    #[serde(rename = "syslib")]
164    SysLib {
165        /// Flag this name is joined to: `-l` for the Unix drivers, empty for
166        /// the MSVC one, which names libraries outright.
167        prefix: String,
168        /// The library's name, as the driver spells it.
169        name: String,
170        /// The module whose crate graph asked for this library, when the
171        /// producer could attribute it. A replay that leaves that module out
172        /// leaves this entry out, so the executable does not depend on a
173        /// system library it makes no calls into.
174        #[serde(default, skip_serializing_if = "Option::is_none")]
175        module: Option<String>,
176    },
177}
178
179/// One runtime module a kit can link in.
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181pub struct KitModule {
182    /// The name an app declares the module under in `lumen.toml`.
183    pub name: String,
184    /// The symbol that forces the module onto the line. It is a module's
185    /// registration entry, and the pre-main constructor that calls it sits in
186    /// the same object file, so naming it is what pulls both out of the rlib.
187    /// It is the only symbol a replay names: the module installs itself
188    /// through the registry its constructor reaches, so the install entry is
189    /// never called across this boundary.
190    pub register_symbol: String,
191}
192
193impl KitModule {
194    /// The entry for a module declared under `name`, spelled the way
195    /// `lumen_module!` spelled it when the module was compiled.
196    pub fn new(name: &str) -> KitModule {
197        KitModule {
198            name: name.to_string(),
199            register_symbol: entry_symbol(REGISTER_PREFIX, name),
200        }
201    }
202}
203
204/// One optional subsystem a kit can link in.
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
206pub struct KitCapability {
207    /// The name the app's `[capabilities]` table keys it by.
208    pub name: String,
209    /// The symbol that forces it onto the line: its registration entry, whose
210    /// object also holds the pre-main constructor, exactly as a module's does.
211    pub register_symbol: String,
212    /// When an app that does not name it gets it anyway.
213    pub select: KitSelect,
214}
215
216/// When a package carries a capability the app did not name.
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218#[serde(rename_all = "kebab-case")]
219pub enum KitSelect {
220    /// Every app.
221    Always,
222    /// An app whose sources mention any of these.
223    OnUse(Vec<String>),
224    /// No app; only a `[capabilities]` entry brings it in.
225    OnRequest,
226    /// An app whose `lumen.toml` sets `key` (a dotted path) to one of
227    /// `any_of`, reading `default` when the key is absent.
228    OnConfig {
229        /// Dotted path of the key.
230        key: String,
231        /// The values that select the capability.
232        any_of: Vec<String>,
233        /// The value an absent key reads as.
234        default: String,
235    },
236}
237
238impl KitSelect {
239    /// Whether an app with these sources and this `lumen.toml` gets the
240    /// capability. `opaque` says the sources are not all the app runs, so
241    /// every use rule answers yes.
242    fn selects(&self, sources: &str, config: &toml::Table, opaque: bool) -> bool {
243        match self {
244            KitSelect::Always => true,
245            KitSelect::OnUse(_) if opaque => true,
246            KitSelect::OnUse(markers) => markers.iter().any(|m| sources.contains(m.as_str())),
247            KitSelect::OnRequest => false,
248            KitSelect::OnConfig {
249                key,
250                any_of,
251                default,
252            } => {
253                let value = config_value(config, key).unwrap_or(default.as_str());
254                any_of.iter().any(|v| v == value)
255            }
256        }
257    }
258}
259
260/// The string at dotted path `key` in `config`, when there is one.
261fn config_value<'a>(config: &'a toml::Table, key: &str) -> Option<&'a str> {
262    let mut parts = key.split('.');
263    let mut value = config.get(parts.next()?)?;
264    for part in parts {
265        value = value.as_table()?.get(part)?;
266    }
267    value.as_str()
268}
269
270impl From<&lumen_capability::Capability> for KitCapability {
271    fn from(capability: &lumen_capability::Capability) -> Self {
272        KitCapability {
273            name: capability.name.to_string(),
274            register_symbol: lumen_capability::register_symbol(capability.name),
275            select: match capability.select {
276                lumen_capability::Select::Always => KitSelect::Always,
277                lumen_capability::Select::OnUse(markers) => {
278                    KitSelect::OnUse(markers.iter().map(|m| m.to_string()).collect())
279                }
280                lumen_capability::Select::OnRequest => KitSelect::OnRequest,
281                lumen_capability::Select::OnConfig {
282                    key,
283                    any_of,
284                    default,
285                } => KitSelect::OnConfig {
286                    key: key.to_string(),
287                    any_of: any_of.iter().map(|v| v.to_string()).collect(),
288                    default: default.to_string(),
289                },
290            },
291        }
292    }
293}
294
295impl KitCapability {
296    /// The markers its use rule scans sources for; empty for any other rule.
297    pub fn markers(&self) -> &[String] {
298        match &self.select {
299            KitSelect::OnUse(markers) => markers,
300            _ => &[],
301        }
302    }
303}
304
305/// The capabilities a package of one app carries, out of the ones a kit
306/// offers.
307///
308/// `requested` is the app's `[capabilities]` table, and an entry there
309/// settles that capability outright. Every other one follows its own rule
310/// against `sources`, the app's markup, scripts, styles and config read into
311/// one haystack, and `config`, the app's parsed `lumen.toml`. `opaque` says
312/// the app runs code the scan cannot read (a C++ or Python program driving
313/// the engine), so every use rule answers yes and only the table, a
314/// request-only rule, or a config rule leaves a capability out. A requested
315/// name the kit does not carry is an error naming what it does, so a
316/// misspelling is caught rather than ignored.
317pub fn select_capabilities<'a>(
318    kit: &'a [KitCapability],
319    sources: &str,
320    config: &toml::Table,
321    requested: &BTreeMap<String, bool>,
322    opaque: bool,
323) -> Result<Vec<&'a KitCapability>, String> {
324    if let Some(unknown) = requested
325        .keys()
326        .find(|name| !kit.iter().any(|c| &c.name == *name))
327    {
328        let offered: Vec<&str> = kit.iter().map(|c| c.name.as_str()).collect();
329        return Err(format!(
330            "[capabilities] names '{unknown}', and this link kit carries no capability by \
331             that name. It offers: {}.",
332            if offered.is_empty() {
333                "nothing".to_string()
334            } else {
335                offered.join(", ")
336            }
337        ));
338    }
339    Ok(kit
340        .iter()
341        .filter(|capability| match requested.get(&capability.name) {
342            Some(wanted) => *wanted,
343            None => capability.select.selects(sources, config, opaque),
344        })
345        .collect())
346}
347
348/// The capabilities `requested` turns off although `sources` mention their
349/// builtins, each with the markers found. An app that says `os-tray = false`
350/// and calls `tray_icon` packages without the tray, and the call does
351/// nothing; this is what the package step warns about.
352pub fn dropped_but_used<'a>(
353    kit: &'a [KitCapability],
354    sources: &str,
355    requested: &BTreeMap<String, bool>,
356) -> Vec<(&'a KitCapability, Vec<&'a str>)> {
357    kit.iter()
358        .filter(|capability| requested.get(&capability.name) == Some(&false))
359        .filter_map(|capability| {
360            let found: Vec<&str> = capability
361                .markers()
362                .iter()
363                .map(String::as_str)
364                .filter(|m| sources.contains(m))
365                .collect();
366            (!found.is_empty()).then_some((capability, found))
367        })
368        .collect()
369}
370
371/// How the app's compiled artifact reaches the executable a replay produces.
372#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
373pub struct Artifact {
374    /// The mechanism this platform uses.
375    pub kind: ArtifactKind,
376}
377
378/// How a replay's output carries the app: the two ways a launcher finds the
379/// artifact it runs, and the shared engine, which carries none.
380#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
381#[serde(rename_all = "snake_case")]
382pub enum ArtifactKind {
383    /// Appended to the executable after the link, behind the launcher's
384    /// footer magic.
385    Append,
386    /// Written into the executable by the link itself, as a Mach-O section.
387    /// Appending is not available there: a signature has to cover the whole
388    /// file, so anything added after the link invalidates it. The launcher
389    /// names the segment and section it reads, so the manifest does not.
390    MachoSection,
391    /// The kit links the engine's shared library rather than an executable,
392    /// and the app's artifact goes into the launcher beside it the way an
393    /// unlinked package does. Nothing is appended and nothing is put on the
394    /// line.
395    SharedEngine,
396}
397
398/// One line of the link recorder's JSON Lines output.
399///
400/// `tools/link-recorder` writes these; the fields are the whole of what a
401/// kit is built from. A build runs several links, so a reader picks the one
402/// whose [`Record::out`] is the binary it wants.
403#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
404pub struct Record {
405    /// The `-o` / `/OUT:` value of this link.
406    #[serde(default)]
407    pub out: Option<String>,
408    /// The link's arguments, with every response file expanded.
409    pub argv: Vec<String>,
410    /// The same list with every argument that named a file replaced by the
411    /// name it was staged under. An index where the two lists differ is a
412    /// file the kit has to carry; an index where they agree is not.
413    pub staged_argv: Vec<String>,
414    /// The directory the link ran in, which is what a relative argument in it
415    /// is relative to.
416    #[serde(default)]
417    pub cwd: String,
418    /// The environment entries the line depends on.
419    #[serde(default)]
420    pub env: RecordEnv,
421}
422
423/// Environment the recorded line reads.
424#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
425pub struct RecordEnv {
426    /// `LIB`, the MSVC linker's library search path. Windows records need it:
427    /// the line names the C runtime and the Windows SDK libraries by bare
428    /// file name and resolves them through this variable.
429    #[serde(rename = "LIB", default, skip_serializing_if = "Option::is_none")]
430    pub lib: Option<String>,
431}
432
433#[cfg(test)]
434mod tests {
435    use std::collections::BTreeMap;
436
437    use super::{
438        ArtifactKind, KitCapability, KitModule, KitSelect, LinkArg, Record, select_capabilities,
439    };
440
441    fn capability(name: &str, select: KitSelect) -> KitCapability {
442        KitCapability {
443            name: name.to_string(),
444            register_symbol: lumen_capability::register_symbol(name),
445            select,
446        }
447    }
448
449    fn names(selected: &[&KitCapability]) -> Vec<String> {
450        selected.iter().map(|c| c.name.clone()).collect()
451    }
452
453    /// Each rule against the sources: always in, in when mentioned, in only
454    /// when asked.
455    #[test]
456    fn a_capability_follows_its_own_rule_when_the_app_does_not_name_it() {
457        let kit = vec![
458            capability("os-tray", KitSelect::OnUse(vec!["tray_icon".to_string()])),
459            capability(
460                "os-hotkey",
461                KitSelect::OnUse(vec!["register_hotkey".to_string()]),
462            ),
463            capability("mcp", KitSelect::OnRequest),
464            capability("core-ish", KitSelect::Always),
465        ];
466        let selected = select_capabilities(
467            &kit,
468            "fn on_start() { tray_icon(\"app\", \"icon.png\", \"\"); }",
469            &toml::Table::new(),
470            &BTreeMap::new(),
471            false,
472        )
473        .expect("nothing was requested");
474        assert_eq!(names(&selected), ["os-tray", "core-ish"]);
475    }
476
477    /// A `[capabilities]` entry wins over the rule, either way.
478    #[test]
479    fn a_requested_capability_is_settled_by_the_request() {
480        let kit = vec![
481            capability("os-tray", KitSelect::OnUse(vec!["tray_icon".to_string()])),
482            capability("mcp", KitSelect::OnRequest),
483        ];
484        let requested = BTreeMap::from([("os-tray".to_string(), false), ("mcp".to_string(), true)]);
485        let selected =
486            select_capabilities(&kit, "tray_icon(", &toml::Table::new(), &requested, false)
487                .expect("both are known");
488        assert_eq!(names(&selected), ["mcp"]);
489    }
490
491    /// Sources the scan cannot read in full answer yes to every use rule; a
492    /// request-only rule and the app's own table still decide.
493    #[test]
494    fn opaque_sources_keep_every_capability_a_use_rule_could_want() {
495        let kit = vec![
496            capability("os-tray", KitSelect::OnUse(vec!["tray_icon".to_string()])),
497            capability("os-power", KitSelect::OnUse(vec!["keep_awake".to_string()])),
498            capability("mcp", KitSelect::OnRequest),
499        ];
500        let requested = BTreeMap::from([("os-power".to_string(), false)]);
501        let selected = select_capabilities(&kit, "", &toml::Table::new(), &requested, true)
502            .expect("all known");
503        assert_eq!(names(&selected), ["os-tray"]);
504        let scanned = select_capabilities(&kit, "", &toml::Table::new(), &requested, false)
505            .expect("all known");
506        assert!(
507            scanned.is_empty(),
508            "nothing is mentioned: {:?}",
509            names(&scanned)
510        );
511    }
512
513    /// A capability the table turns off while the sources call into it is
514    /// reported with the markers found, and only that one.
515    #[test]
516    fn a_capability_turned_off_while_used_is_reported_with_its_markers() {
517        let kit = vec![
518            capability(
519                "os-tray",
520                KitSelect::OnUse(vec!["tray_icon".to_string(), "tray_menu".to_string()]),
521            ),
522            capability("os-power", KitSelect::OnUse(vec!["keep_awake".to_string()])),
523            capability("mcp", KitSelect::OnRequest),
524        ];
525        let requested = BTreeMap::from([
526            ("os-tray".to_string(), false),
527            ("os-power".to_string(), false),
528            ("mcp".to_string(), false),
529        ]);
530        let reported =
531            super::dropped_but_used(&kit, "tray_icon(\"id\", \"a.png\", \"\")", &requested);
532        assert_eq!(reported.len(), 1);
533        assert_eq!(reported[0].0.name, "os-tray");
534        assert_eq!(reported[0].1, ["tray_icon"]);
535    }
536
537    /// A name the kit does not carry is refused, naming what it does carry.
538    #[test]
539    fn a_requested_capability_the_kit_lacks_is_an_error_naming_the_offer() {
540        let kit = vec![capability("os-tray", KitSelect::Always)];
541        let requested = BTreeMap::from([("os-trey".to_string(), true)]);
542        let error = select_capabilities(&kit, "", &toml::Table::new(), &requested, false)
543            .expect_err("a misspelling");
544        assert!(error.contains("'os-trey'"), "{error}");
545        assert!(error.contains("os-tray"), "{error}");
546    }
547
548    /// Two interchangeable capabilities keyed on one config value: the value
549    /// picks one, a value both answer to picks both, an absent key reads as
550    /// the default, and a `[capabilities]` entry still wins.
551    #[test]
552    fn a_config_value_picks_between_capabilities() {
553        let on = |any_of: &[&str]| KitSelect::OnConfig {
554            key: "render.backend".to_string(),
555            any_of: any_of.iter().map(|v| v.to_string()).collect(),
556            default: "auto".to_string(),
557        };
558        let kit = vec![
559            capability("render-gpu", on(&["gpu", "auto"])),
560            capability("render-cpu", on(&["cpu", "auto"])),
561        ];
562        let pick = |toml: &str, requested: &[(&str, bool)]| {
563            let config: toml::Table = toml::from_str(toml).expect("valid toml");
564            let requested = requested
565                .iter()
566                .map(|(n, w)| (n.to_string(), *w))
567                .collect::<BTreeMap<_, _>>();
568            names(&select_capabilities(&kit, "", &config, &requested, false).expect("known"))
569        };
570        assert_eq!(pick("[render]\nbackend = \"cpu\"\n", &[]), ["render-cpu"]);
571        assert_eq!(pick("[render]\nbackend = \"gpu\"\n", &[]), ["render-gpu"]);
572        assert_eq!(pick("", &[]), ["render-gpu", "render-cpu"]);
573        assert_eq!(
574            pick("[render]\nbackend = \"auto\"\n", &[("render-gpu", false)]),
575            ["render-cpu"]
576        );
577        // A key that is not a string reads as absent rather than matching.
578        assert_eq!(
579            pick("[render]\nbackend = 3\n", &[]),
580            ["render-gpu", "render-cpu"]
581        );
582    }
583
584    /// The registry's entry becomes the kit's, rule included.
585    #[test]
586    fn a_registered_capability_becomes_a_kit_entry() {
587        fn nothing(_: &mut lumen_core::app::App, _: &lumen_capability::CapabilityEnv) {}
588        let entry = KitCapability::from(&lumen_capability::Capability {
589            name: "os-tray",
590            phase: lumen_capability::Phase::Platform,
591            install: nothing,
592            preflight: None,
593            select: lumen_capability::Select::OnUse(&["tray_icon"]),
594            crate_name: "lumen-os-tray-capability",
595        });
596        assert_eq!(entry.register_symbol, "lumen_capability_register_os_tray");
597        assert_eq!(
598            entry.select,
599            KitSelect::OnUse(vec!["tray_icon".to_string()])
600        );
601    }
602
603    #[test]
604    fn a_module_entry_spells_the_name_spaced_register_symbol() {
605        let module = KitModule::new("lumen-audio");
606        assert_eq!(module.register_symbol, "lumen_module_register_lumen_audio");
607    }
608
609    #[test]
610    fn an_argument_round_trips_through_its_tag() {
611        let args = vec![
612            LinkArg::Lit {
613                value: "-pie".to_string(),
614            },
615            LinkArg::Out {
616                prefix: String::new(),
617            },
618            LinkArg::File {
619                path: "aabbccdd-liblumen_fs.rlib".to_string(),
620                module: Some("lumen-fs".to_string()),
621            },
622            LinkArg::SysDir {
623                prefix: "-B".to_string(),
624                path: "bin".to_string(),
625                staged: true,
626            },
627            LinkArg::SysLib {
628                prefix: "-l".to_string(),
629                name: "asound".to_string(),
630                module: None,
631            },
632            LinkArg::ExportList {
633                prefix: "-Wl,--version-script=".to_string(),
634                path: "eeff0011-list".to_string(),
635            },
636        ];
637        let json = serde_json::to_string(&args).expect("the arguments encode");
638        assert!(json.contains(r#"{"kind":"out","prefix":""}"#), "{json}");
639        assert!(json.contains(r#""kind":"sysdir""#), "{json}");
640        assert!(json.contains(r#""kind":"syslib""#), "{json}");
641        assert!(json.contains(r#""kind":"export_list""#), "{json}");
642        // An unattributed entry writes no `module` key at all, so a manifest
643        // reads as the short list of what is attributed.
644        assert!(!json.contains(r#""module":null"#), "{json}");
645        assert_eq!(
646            serde_json::from_str::<Vec<LinkArg>>(&json).expect("and decode"),
647            args
648        );
649    }
650
651    #[test]
652    fn an_artifact_kind_is_spelled_in_snake_case() {
653        assert_eq!(
654            serde_json::to_string(&ArtifactKind::MachoSection).expect("encodes"),
655            r#""macho_section""#
656        );
657        assert_eq!(
658            serde_json::to_string(&ArtifactKind::SharedEngine).expect("encodes"),
659            r#""shared_engine""#
660        );
661    }
662
663    #[test]
664    fn a_record_reads_without_the_windows_only_fields() {
665        let record: Record = serde_json::from_str(
666            r#"{"out":"app","argv":["-o","app"],"staged_argv":["-o","app"],"cwd":"/tmp"}"#,
667        )
668        .expect("the Unix recorder writes no LIB");
669        assert_eq!(record.out.as_deref(), Some("app"));
670        assert_eq!(record.env.lib, None);
671    }
672}