Skip to main content

irbt_build_info/
lib.rs

1//! Build-script helper shared by the firmware and the `irbt` CLI: embeds the
2//! git commit, the compiler version and selected dependency versions as
3//! compile-time environment variables.
4//!
5//! Call [`emit`] from `build.rs`, then read the values with `env!`:
6//!
7//! - `IRBT_GIT`: `c7f30e8 2026-10-27`, `c7f30e8-dirty 2026-10-27` or `unknown`;
8//!   just `c7f30e8` when built from a published package (no git repo, so the
9//!   commit comes from cargo's `.cargo_vcs_info.json`)
10//! - `IRBT_RUSTC`: `rustc 1.82.0 (f6e511eec 2024-10-15)`
11//! - `IRBT_DEPS`: `name=version;name=version`, in the order requested
12
13use std::env;
14use std::fs;
15use std::path::{Path, PathBuf};
16use std::process::Command;
17
18/// Prints the `cargo:` directives. `lockfile` is relative to the package
19/// directory; `crates` are looked up in it, and missing ones are skipped.
20pub fn emit(lockfile: &str, crates: &[&str]) {
21    println!(
22        "cargo:rustc-env=IRBT_GIT={}",
23        git().as_deref().unwrap_or("unknown")
24    );
25    println!("cargo:rustc-env=IRBT_RUSTC={}", rustc());
26    println!(
27        "cargo:rustc-env=IRBT_DEPS={}",
28        deps(Path::new(lockfile), crates)
29    );
30
31    // Any rerun-if-changed disables cargo's default of rerunning on every
32    // package file change, so the sources must be listed too (for `-dirty`).
33    // Missing paths would force a rerun on every build, so skip them.
34    let mut watched = vec![
35        PathBuf::from("src"),
36        PathBuf::from("Cargo.toml"),
37        PathBuf::from(lockfile),
38    ];
39    watched.extend(git_paths());
40    for path in watched.iter().filter(|p| p.exists()) {
41        println!("cargo:rerun-if-changed={}", path.display());
42    }
43}
44
45fn run(program: &str, args: &[&str]) -> Option<String> {
46    let output = Command::new(program).args(args).output().ok()?;
47    let stdout = String::from_utf8(output.stdout).ok()?;
48    output.status.success().then(|| stdout.trim().to_owned())
49}
50
51fn git() -> Option<String> {
52    // Only packages from a registry have this file. Check it first: such a
53    // package may sit inside some unrelated git repo (e.g. a dotfiles home).
54    if let Ok(info) = fs::read_to_string(".cargo_vcs_info.json") {
55        return vcs_sha1(&info).map(|sha| sha.chars().take(7).collect());
56    }
57    let describe = run(
58        "git",
59        &["describe", "--tags", "--always", "--dirty", "--abbrev=7"],
60    )?;
61    let date = run("git", &["log", "-1", "--format=%cs"])?;
62    Some(format!("{describe} {date}"))
63}
64
65/// Files whose change means the commit, the dirty state or the tag that
66/// `git describe` picks may have changed. `refs/tags` is a directory, which
67/// cargo scans recursively, so new loose tags trigger a rerun; fetched tags
68/// usually land in `packed-refs`. `--git-path` resolves these correctly
69/// inside worktrees too.
70fn git_paths() -> Vec<PathBuf> {
71    let mut names = vec![
72        "HEAD".to_owned(),
73        "index".to_owned(),
74        "packed-refs".to_owned(),
75        "refs/tags".to_owned(),
76    ];
77    if let Some(head_ref) = run("git", &["symbolic-ref", "-q", "HEAD"]) {
78        names.push(head_ref);
79    }
80    names
81        .iter()
82        .filter_map(|name| run("git", &["rev-parse", "--git-path", name]))
83        .map(PathBuf::from)
84        .collect()
85}
86
87/// `git.sha1` of the `.cargo_vcs_info.json` that `cargo package` writes:
88/// `{"git": {"sha1": "<hex>", ...}, "path_in_vcs": "..."}`.
89fn vcs_sha1(info: &str) -> Option<&str> {
90    let rest = &info[info.find("\"sha1\"")? + "\"sha1\"".len()..];
91    let rest = rest.trim_start().strip_prefix(':')?.trim_start();
92    let sha = rest.strip_prefix('"')?.split('"').next()?;
93    (!sha.is_empty() && sha.bytes().all(|b| b.is_ascii_hexdigit())).then_some(sha)
94}
95
96fn rustc() -> String {
97    let rustc = env::var("RUSTC").unwrap_or_else(|_| "rustc".to_owned());
98    run(&rustc, &["-V"]).unwrap_or_else(|| "rustc unknown".to_owned())
99}
100
101fn deps(lockfile: &Path, crates: &[&str]) -> String {
102    let lock = fs::read_to_string(lockfile).unwrap_or_default();
103    let packages = parse_lock(&lock);
104    crates
105        .iter()
106        .filter_map(|&name| {
107            let versions: Vec<&str> = packages
108                .iter()
109                .filter(|(n, _)| *n == name)
110                .map(|&(_, v)| v)
111                .collect();
112            (!versions.is_empty()).then(|| format!("{name}={}", versions.join(", ")))
113        })
114        .collect::<Vec<_>>()
115        .join(";")
116}
117
118/// `(name, version)` of every `[[package]]` entry. `Cargo.lock` always writes
119/// `name` then `version` as the first two keys, one per line.
120fn parse_lock(lock: &str) -> Vec<(&str, &str)> {
121    let mut packages = Vec::new();
122    let mut name = None;
123    for line in lock.lines() {
124        if let Some(n) = value(line, "name") {
125            name = Some(n);
126        } else if let (Some(n), Some(v)) = (name, value(line, "version")) {
127            packages.push((n, v));
128            name = None;
129        }
130    }
131    packages
132}
133
134/// The string value of a `key = "value"` line.
135fn value<'a>(line: &'a str, key: &str) -> Option<&'a str> {
136    line.strip_prefix(key)?
137        .trim_start()
138        .strip_prefix('=')?
139        .trim()
140        .strip_prefix('"')?
141        .strip_suffix('"')
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn reads_vcs_info_sha1() {
150        let info = r#"{
151  "git": {
152    "sha1": "16e4ae9c0ffee0123456789abcdef0123456789a",
153    "dirty": true
154  },
155  "path_in_vcs": "host/irbt-cli"
156}"#;
157        assert_eq!(
158            vcs_sha1(info),
159            Some("16e4ae9c0ffee0123456789abcdef0123456789a")
160        );
161        assert_eq!(vcs_sha1(r#"{"path_in_vcs": ""}"#), None);
162    }
163
164    #[test]
165    fn parses_lockfile_packages() {
166        let lock = r#"
167version = 4
168
169[[package]]
170name = "a"
171version = "1.0.0"
172source = "registry+https://github.com/rust-lang/crates.io-index"
173
174[[package]]
175name = "b"
176version = "0.2.0"
177dependencies = [
178 "a",
179]
180
181[[package]]
182name = "a"
183version = "2.0.0"
184"#;
185        assert_eq!(
186            parse_lock(lock),
187            [("a", "1.0.0"), ("b", "0.2.0"), ("a", "2.0.0")]
188        );
189    }
190}