Skip to content

Commit 1a4d661

Browse files
authored
feat(build-info): emit build_info_<env>.json after successful link (fixes #297) (#309)
Adds a post-link emitter that writes a PlatformIO-compatible build_info_<env>.json (and the no-example-fallback build_info.json) to the project directory after a successful sequential build. Outer dict is keyed by env name (matching `pio project metadata --json-output`); inner dict carries prog_path plus toolchain binaries (cc/cxx/ar/objcopy /size) and the compile/link flags fbuild already knows. This unblocks every FastLED size-check and symbol-analysis workflow that was silently failing because fbuild compiles succeeded but downstream _find_build_info() calls couldn't locate the metadata file. Same shape as `pio project metadata --json-output` so FastLED's ci/compiled_size.py::_create_board_info accepts it unmodified. Hooked into pipeline/sequential.rs only -- covers AVR, Teensy, RP2040, STM32, NRF52, SAM, Renesas, Apollo3, CH32V, ESP8266. ESP32 (different pipeline) and WASM (no firmware.elf) intentionally deferred to a follow-up. Linker trait gains three optional accessors (ar_tool_path, objcopy_tool_path, link_driver_path) defaulting to None -- per-platform linkers override to expose the toolchain binaries they already hold. Closes #297.
1 parent ab085c4 commit 1a4d661

14 files changed

Lines changed: 488 additions & 0 deletions

File tree

crates/fbuild-build/src/avr/avr_linker.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,18 @@ impl Linker for AvrLinker {
158158
&self.size_path
159159
}
160160

161+
fn ar_tool_path(&self) -> Option<&Path> {
162+
Some(&self.ar_path)
163+
}
164+
165+
fn objcopy_tool_path(&self) -> Option<&Path> {
166+
Some(&self.objcopy_path)
167+
}
168+
169+
fn link_driver_path(&self) -> Option<&Path> {
170+
Some(&self.gcc_path)
171+
}
172+
161173
fn report_size(&self, elf_path: &Path) -> Result<SizeInfo> {
162174
crate::linker::LinkerBase::report_size(
163175
&self.size_path,
Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
//! Post-link emitter for `build_info_<env>.json`.
2+
//!
3+
//! Writes a PlatformIO-compatible `build_info_<env>.json` (and a duplicate
4+
//! `build_info.json` fallback) to the project directory after a successful
5+
//! link. The schema matches `pio project metadata --json-output`: the outer
6+
//! object is keyed by environment name, and the inner object carries the
7+
//! toolchain binaries and effective flags fbuild already knows about.
8+
//!
9+
//! FastLED's `ci/compiled_size.py::_create_board_info`, `ci/inspect_binary.py`,
10+
//! `ci/symbol_analysis_runner.py`, and similar size/symbol tooling consume
11+
//! this file unmodified. Without it, every fbuild-driven size check silently
12+
//! fails because the consumer's `_find_build_info()` lookup can't locate the
13+
//! metadata file PlatformIO would have written.
14+
//!
15+
//! See FastLED/fbuild#297.
16+
17+
use std::path::{Path, PathBuf};
18+
19+
use fbuild_core::Result;
20+
use serde::{Deserialize, Serialize};
21+
22+
/// PlatformIO-shape build metadata for one environment.
23+
///
24+
/// All paths are emitted as strings (matching `pio project metadata`
25+
/// output); empty / missing toolchain entries are emitted as empty strings
26+
/// so consumers that do `Path(board_info["objcopy_path"])` never KeyError.
27+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28+
pub struct BuildInfo {
29+
/// Absolute path to the final firmware/program file (`.elf` if no
30+
/// `.hex`/`.bin` was produced, otherwise the converted firmware).
31+
pub prog_path: String,
32+
/// Absolute path to the C compiler (`gcc`).
33+
pub cc_path: String,
34+
/// Absolute path to the C++ compiler (`g++`).
35+
pub cxx_path: String,
36+
/// Absolute path to `ar` (empty when the linker doesn't expose it).
37+
pub ar_path: String,
38+
/// Absolute path to `objcopy` (empty when the platform has no objcopy step,
39+
/// e.g. ESP8266 which produces ELF directly).
40+
pub objcopy_path: String,
41+
/// Absolute path to `size`.
42+
pub size_path: String,
43+
/// Effective C compile flags as seen by the compiler driver.
44+
pub cc_flags: Vec<String>,
45+
/// Effective C++ compile flags as seen by the compiler driver.
46+
pub cxx_flags: Vec<String>,
47+
/// Effective link flags (does not include object files or `-l<lib>` libs).
48+
pub link_flags: Vec<String>,
49+
/// `-D` defines extracted from `cxx_flags`.
50+
pub defines: Vec<String>,
51+
/// `-I` includes extracted from `cxx_flags`.
52+
pub includes: Vec<String>,
53+
/// Libraries passed to the linker (e.g. `-lc`, `-lm`).
54+
pub libs: Vec<String>,
55+
/// Platform identifier (e.g. `atmelavr`, `ststm32`).
56+
pub platform: String,
57+
/// Board identifier (e.g. `uno`, `teensy41`).
58+
pub board: String,
59+
/// PlatformIO env name (e.g. `uno`, `teensy41-debug`).
60+
pub env: String,
61+
}
62+
63+
impl BuildInfo {
64+
/// Construct a `BuildInfo` from already-collected pieces. Splits
65+
/// `-D` defines and `-I` includes out of `cxx_flags` (matching
66+
/// PlatformIO's metadata-emitter convention) without removing them
67+
/// from `cxx_flags` itself.
68+
#[allow(clippy::too_many_arguments)]
69+
pub fn new(
70+
prog_path: &Path,
71+
cc_path: Option<&Path>,
72+
cxx_path: Option<&Path>,
73+
ar_path: Option<&Path>,
74+
objcopy_path: Option<&Path>,
75+
size_path: &Path,
76+
cc_flags: Vec<String>,
77+
cxx_flags: Vec<String>,
78+
link_flags: Vec<String>,
79+
libs: Vec<String>,
80+
platform: String,
81+
board: String,
82+
env: String,
83+
) -> Self {
84+
let defines = extract_prefixed(&cxx_flags, "-D");
85+
let includes = extract_prefixed(&cxx_flags, "-I");
86+
Self {
87+
prog_path: path_to_string(Some(prog_path)),
88+
cc_path: path_to_string(cc_path),
89+
cxx_path: path_to_string(cxx_path),
90+
ar_path: path_to_string(ar_path),
91+
objcopy_path: path_to_string(objcopy_path),
92+
size_path: path_to_string(Some(size_path)),
93+
cc_flags,
94+
cxx_flags,
95+
link_flags,
96+
defines,
97+
includes,
98+
libs,
99+
platform,
100+
board,
101+
env,
102+
}
103+
}
104+
}
105+
106+
fn path_to_string(p: Option<&Path>) -> String {
107+
p.map(|p| p.to_string_lossy().to_string())
108+
.unwrap_or_default()
109+
}
110+
111+
fn extract_prefixed(flags: &[String], prefix: &str) -> Vec<String> {
112+
flags
113+
.iter()
114+
.filter_map(|f| f.strip_prefix(prefix).map(|s| s.to_string()))
115+
.filter(|s| !s.is_empty())
116+
.collect()
117+
}
118+
119+
/// Emit `build_info_<env>.json` and `build_info.json` next to the project's
120+
/// `platformio.ini`.
121+
///
122+
/// Both files carry the same payload — `build_info.json` is the
123+
/// no-example-name fallback FastLED's `_find_build_info()` walks. Writing
124+
/// failures degrade to `tracing::warn!` rather than failing the build; an
125+
/// otherwise-successful link should never be reported as failed because the
126+
/// downstream metadata file couldn't be written.
127+
pub fn emit_build_info(project_dir: &Path, env_name: &str, info: &BuildInfo) -> Result<()> {
128+
let outer = std::collections::BTreeMap::from([(env_name.to_string(), info.clone())]);
129+
let json = serde_json::to_string_pretty(&outer).map_err(|e| {
130+
fbuild_core::FbuildError::BuildFailed(format!("failed to serialize build_info: {e}"))
131+
})?;
132+
133+
let env_specific = project_dir.join(format!("build_info_{env_name}.json"));
134+
let generic = project_dir.join("build_info.json");
135+
136+
for path in [&env_specific, &generic] {
137+
if let Err(e) = std::fs::write(path, &json) {
138+
tracing::warn!("failed to write {}: {}", path.display(), e);
139+
}
140+
}
141+
Ok(())
142+
}
143+
144+
/// Build a `prog_path` candidate by preferring the firmware file (`.hex`/`.bin`)
145+
/// when present and falling back to the ELF. Matches FastLED's expectation
146+
/// that `prog_path.parent / firmware.{bin,uf2,hex}` resolves to actual flash.
147+
pub fn pick_prog_path(
148+
elf: Option<&Path>,
149+
hex: Option<&Path>,
150+
bin: Option<&Path>,
151+
) -> Option<PathBuf> {
152+
bin.map(Path::to_path_buf)
153+
.or_else(|| hex.map(Path::to_path_buf))
154+
.or_else(|| elf.map(Path::to_path_buf))
155+
}
156+
157+
#[cfg(test)]
158+
mod tests {
159+
use super::*;
160+
use std::path::PathBuf;
161+
162+
fn sample_info() -> BuildInfo {
163+
BuildInfo::new(
164+
Path::new("/build/firmware.elf"),
165+
Some(Path::new("/bin/avr-gcc")),
166+
Some(Path::new("/bin/avr-g++")),
167+
Some(Path::new("/bin/avr-ar")),
168+
Some(Path::new("/bin/avr-objcopy")),
169+
Path::new("/bin/avr-size"),
170+
vec!["-Os".to_string(), "-DUSER=1".to_string()],
171+
vec![
172+
"-Os".to_string(),
173+
"-I/inc".to_string(),
174+
"-DFOO=bar".to_string(),
175+
"-DUSER=1".to_string(),
176+
],
177+
vec!["-Wl,--gc-sections".to_string()],
178+
vec!["-lm".to_string(), "-lc".to_string()],
179+
"atmelavr".to_string(),
180+
"uno".to_string(),
181+
"uno".to_string(),
182+
)
183+
}
184+
185+
#[test]
186+
fn build_info_splits_defines_and_includes() {
187+
let info = sample_info();
188+
assert_eq!(
189+
info.defines,
190+
vec!["FOO=bar".to_string(), "USER=1".to_string()]
191+
);
192+
assert_eq!(info.includes, vec!["/inc".to_string()]);
193+
// cxx_flags must still carry the originals (defines/includes are a
194+
// *projection* — PlatformIO's metadata-emitter emits both).
195+
assert!(info.cxx_flags.iter().any(|f| f == "-DFOO=bar"));
196+
assert!(info.cxx_flags.iter().any(|f| f == "-I/inc"));
197+
}
198+
199+
#[test]
200+
fn build_info_handles_missing_optional_tools() {
201+
let info = BuildInfo::new(
202+
Path::new("/build/firmware.elf"),
203+
Some(Path::new("/bin/gcc")),
204+
Some(Path::new("/bin/g++")),
205+
None, // no ar
206+
None, // no objcopy
207+
Path::new("/bin/size"),
208+
vec![],
209+
vec![],
210+
vec![],
211+
vec![],
212+
"esp8266".to_string(),
213+
"nodemcuv2".to_string(),
214+
"nodemcuv2".to_string(),
215+
);
216+
assert_eq!(info.ar_path, "");
217+
assert_eq!(info.objcopy_path, "");
218+
}
219+
220+
#[test]
221+
fn emit_writes_both_files() {
222+
let tmp = tempfile::TempDir::new().unwrap();
223+
let info = sample_info();
224+
emit_build_info(tmp.path(), "uno", &info).unwrap();
225+
assert!(tmp.path().join("build_info_uno.json").exists());
226+
assert!(tmp.path().join("build_info.json").exists());
227+
}
228+
229+
#[test]
230+
fn emit_outer_dict_has_single_env_key() {
231+
// FastLED's _create_board_info asserts exactly one outer key
232+
// and pulls the inner value via next(iter(...)).
233+
let tmp = tempfile::TempDir::new().unwrap();
234+
let info = sample_info();
235+
emit_build_info(tmp.path(), "uno", &info).unwrap();
236+
237+
let bytes = std::fs::read(tmp.path().join("build_info_uno.json")).unwrap();
238+
let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
239+
let obj = parsed.as_object().expect("outer is object");
240+
assert_eq!(obj.len(), 1);
241+
assert!(obj.contains_key("uno"));
242+
243+
let inner = obj
244+
.get("uno")
245+
.unwrap()
246+
.as_object()
247+
.expect("inner is object");
248+
// Every key FastLED's _create_board_info / check_firmware_size reaches for.
249+
for required in [
250+
"prog_path",
251+
"cc_path",
252+
"cxx_path",
253+
"ar_path",
254+
"objcopy_path",
255+
"size_path",
256+
] {
257+
assert!(inner.contains_key(required), "missing key: {required}");
258+
}
259+
}
260+
261+
#[test]
262+
fn emit_round_trips_to_struct() {
263+
let tmp = tempfile::TempDir::new().unwrap();
264+
let info = sample_info();
265+
emit_build_info(tmp.path(), "uno", &info).unwrap();
266+
267+
let bytes = std::fs::read(tmp.path().join("build_info_uno.json")).unwrap();
268+
let parsed: std::collections::BTreeMap<String, BuildInfo> =
269+
serde_json::from_slice(&bytes).unwrap();
270+
let inner = parsed.get("uno").unwrap();
271+
assert_eq!(inner, &info);
272+
}
273+
274+
#[test]
275+
fn pick_prog_path_prefers_bin_then_hex_then_elf() {
276+
let elf = PathBuf::from("/b/firmware.elf");
277+
let hex = PathBuf::from("/b/firmware.hex");
278+
let bin = PathBuf::from("/b/firmware.bin");
279+
280+
assert_eq!(
281+
pick_prog_path(Some(&elf), Some(&hex), Some(&bin)),
282+
Some(bin.clone())
283+
);
284+
assert_eq!(
285+
pick_prog_path(Some(&elf), Some(&hex), None),
286+
Some(hex.clone())
287+
);
288+
assert_eq!(pick_prog_path(Some(&elf), None, None), Some(elf.clone()));
289+
assert_eq!(pick_prog_path(None, None, None), None);
290+
}
291+
292+
#[test]
293+
fn extract_prefixed_handles_empty_and_missing() {
294+
assert_eq!(extract_prefixed(&[], "-D"), Vec::<String>::new());
295+
assert_eq!(
296+
extract_prefixed(&["-Os".to_string()], "-D"),
297+
Vec::<String>::new()
298+
);
299+
// Bare "-D" (no value) is filtered out — empty defines aren't useful.
300+
assert_eq!(
301+
extract_prefixed(&["-D".to_string(), "-DX".to_string()], "-D"),
302+
vec!["X".to_string()]
303+
);
304+
}
305+
}

crates/fbuild-build/src/ch32v/ch32v_linker.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,18 @@ impl Linker for Ch32vLinker {
137137
&self.size_path
138138
}
139139

140+
fn ar_tool_path(&self) -> Option<&Path> {
141+
Some(&self.ar_path)
142+
}
143+
144+
fn objcopy_tool_path(&self) -> Option<&Path> {
145+
Some(&self.objcopy_path)
146+
}
147+
148+
fn link_driver_path(&self) -> Option<&Path> {
149+
Some(&self.gcc_path)
150+
}
151+
140152
fn report_size(&self, elf_path: &Path) -> Result<SizeInfo> {
141153
crate::linker::LinkerBase::report_size(
142154
&self.size_path,

crates/fbuild-build/src/esp32/esp32_linker.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,18 @@ impl Linker for Esp32Linker {
379379
&self.size_path
380380
}
381381

382+
fn ar_tool_path(&self) -> Option<&Path> {
383+
Some(&self.ar_path)
384+
}
385+
386+
fn objcopy_tool_path(&self) -> Option<&Path> {
387+
Some(&self.objcopy_path)
388+
}
389+
390+
fn link_driver_path(&self) -> Option<&Path> {
391+
Some(&self.gcc_path)
392+
}
393+
382394
fn report_size(&self, elf_path: &Path) -> Result<SizeInfo> {
383395
if let Some(size_info) = self.load_cached_size(elf_path) {
384396
tracing::info!("size: firmware.elf is unchanged, reusing cached size report");

crates/fbuild-build/src/esp8266/esp8266_linker.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,18 @@ impl Linker for Esp8266Linker {
297297
&self.size_path
298298
}
299299

300+
fn ar_tool_path(&self) -> Option<&Path> {
301+
Some(&self.ar_path)
302+
}
303+
304+
fn objcopy_tool_path(&self) -> Option<&Path> {
305+
Some(&self.objcopy_path)
306+
}
307+
308+
fn link_driver_path(&self) -> Option<&Path> {
309+
Some(&self.gcc_path)
310+
}
311+
300312
fn report_size(&self, elf_path: &Path) -> Result<SizeInfo> {
301313
crate::linker::LinkerBase::report_size(
302314
&self.size_path,

0 commit comments

Comments
 (0)