1use crate::config::recipe::{DeviceDef, EnvInstance, FileConfig, FlashBlockDef, FlashFileBlockDef};
2use crate::load::{
3 FlashParseError, LoadSpec, ResolvedFlashRegion, encode_inline_bool, encode_inline_f32,
4 encode_inline_i32, encode_inline_u32, merge_regions, parse_address, resolve_flash_file,
5};
6use std::collections::BTreeMap;
7use std::path::{Path, PathBuf};
8use thiserror::Error;
9
10#[derive(Debug, Error)]
11pub enum LoadResolveError {
12 #[error("{0}")]
13 Message(String),
14 #[error(transparent)]
15 Flash(#[from] FlashParseError),
16}
17
18pub fn resolve_standalone_load_spec(
19 file_config: &FileConfig,
20 config_base_dir: Option<&Path>,
21 cli_libpath: Option<&str>,
22 cli_flash: &[String],
23 env_tag: Option<String>,
24) -> Result<LoadSpec, LoadResolveError> {
25 let defaults = file_config
26 .defaults
27 .as_ref()
28 .and_then(|defaults| defaults.load.as_ref());
29 let raw_libpath = cli_libpath
30 .or_else(|| defaults.and_then(|defaults| defaults.lib.as_deref()))
31 .ok_or_else(|| {
32 LoadResolveError::Message(
33 "load requires a library path or [defaults.load].lib in config".to_string(),
34 )
35 })?;
36 let libpath = canonicalize_runtime_path(raw_libpath, config_base_dir, "shared library")
37 .map_err(LoadResolveError::Message)?;
38
39 let flash = if cli_flash.is_empty() {
40 resolve_flash_blocks(
41 defaults
42 .map(|defaults| defaults.flash.as_slice())
43 .unwrap_or_default(),
44 config_base_dir,
45 )?
46 } else {
47 resolve_cli_flash_entries(cli_flash, config_base_dir)?
48 };
49
50 Ok(LoadSpec {
51 libpath,
52 env_tag,
53 flash,
54 })
55}
56
57pub fn resolve_env_load_specs(
58 env_name: &str,
59 env_members: &[EnvInstance],
60 devices: &BTreeMap<String, DeviceDef>,
61 config_base_dir: Option<&Path>,
62) -> Result<Vec<(String, LoadSpec)>, LoadResolveError> {
63 let mut specs = Vec::with_capacity(env_members.len());
64 for member in env_members {
65 specs.push((
66 member.name.clone(),
67 resolve_env_member_load_spec(
68 member,
69 devices,
70 config_base_dir,
71 Some(env_name.to_string()),
72 )?,
73 ));
74 }
75 Ok(specs)
76}
77
78pub fn resolve_env_member_load_spec(
79 member: &EnvInstance,
80 devices: &BTreeMap<String, DeviceDef>,
81 config_base_dir: Option<&Path>,
82 env_tag: Option<String>,
83) -> Result<LoadSpec, LoadResolveError> {
84 let spec = match (member.lib.as_deref(), member.device.as_deref()) {
85 (Some(_), Some(_)) => {
86 return Err(LoadResolveError::Message(format!(
87 "env member '{}' cannot define both 'lib' and 'device'",
88 member.name
89 )));
90 }
91 (None, None) => {
92 return Err(LoadResolveError::Message(format!(
93 "env member '{}' must define exactly one of 'lib' or 'device'",
94 member.name
95 )));
96 }
97 (Some(lib), None) => LoadSpec {
98 libpath: canonicalize_runtime_path(lib, config_base_dir, "shared library")
99 .map_err(LoadResolveError::Message)?,
100 env_tag,
101 flash: Vec::new(),
102 },
103 (None, Some(device_name)) => {
104 let device = devices.get(device_name).ok_or_else(|| {
105 LoadResolveError::Message(format!(
106 "env member '{}' references missing device '{}'",
107 member.name, device_name
108 ))
109 })?;
110 LoadSpec {
111 libpath: canonicalize_runtime_path(&device.lib, config_base_dir, "shared library")
112 .map_err(LoadResolveError::Message)?,
113 env_tag,
114 flash: resolve_flash_blocks(&device.flash, config_base_dir)?,
115 }
116 }
117 };
118 Ok(spec)
119}
120
121pub fn resolve_flash_blocks(
122 blocks: &[FlashBlockDef],
123 config_base_dir: Option<&Path>,
124) -> Result<Vec<ResolvedFlashRegion>, LoadResolveError> {
125 let mut regions = Vec::new();
126 for block in blocks {
127 match block {
128 FlashBlockDef::File(file) => {
129 regions.extend(resolve_flash_file_block(file, config_base_dir)?);
130 }
131 FlashBlockDef::InlineU32 { u32, addr } => regions.push(ResolvedFlashRegion {
132 base_addr: parse_address(addr)?,
133 data: encode_inline_u32(*u32),
134 }),
135 FlashBlockDef::InlineI32 { i32, addr } => regions.push(ResolvedFlashRegion {
136 base_addr: parse_address(addr)?,
137 data: encode_inline_i32(*i32),
138 }),
139 FlashBlockDef::InlineF32 { f32, addr } => regions.push(ResolvedFlashRegion {
140 base_addr: parse_address(addr)?,
141 data: encode_inline_f32(*f32),
142 }),
143 FlashBlockDef::InlineBool { bool, addr } => regions.push(ResolvedFlashRegion {
144 base_addr: parse_address(addr)?,
145 data: encode_inline_bool(*bool),
146 }),
147 }
148 }
149 Ok(merge_regions(®ions)?)
150}
151
152pub fn resolve_cli_flash_entries(
153 entries: &[String],
154 config_base_dir: Option<&Path>,
155) -> Result<Vec<ResolvedFlashRegion>, LoadResolveError> {
156 let mut regions = Vec::new();
157 for entry in entries {
158 let (path_raw, explicit_base) = parse_cli_flash_entry(entry)?;
159 let path = resolve_runtime_path(path_raw, config_base_dir);
160 let path =
161 canonicalize_existing_path(&path, "flash file").map_err(LoadResolveError::Message)?;
162 let base_addr = explicit_base.map(parse_address).transpose()?;
163 regions.extend(resolve_flash_file(&path, None, base_addr)?);
164 }
165 Ok(merge_regions(®ions)?)
166}
167
168pub fn canonicalize_runtime_path(
169 raw_path: &str,
170 config_base_dir: Option<&Path>,
171 kind: &str,
172) -> Result<String, String> {
173 let candidate = resolve_runtime_path(raw_path, config_base_dir);
174 let canonical = canonicalize_runtime_candidate(&candidate, kind)?;
175 Ok(canonical.to_string_lossy().into_owned())
176}
177
178fn resolve_flash_file_block(
179 block: &FlashFileBlockDef,
180 config_base_dir: Option<&Path>,
181) -> Result<Vec<ResolvedFlashRegion>, LoadResolveError> {
182 let path = resolve_runtime_path(&block.file, config_base_dir);
183 let path =
184 canonicalize_existing_path(&path, "flash file").map_err(LoadResolveError::Message)?;
185 let base_addr = block.base.as_deref().map(parse_address).transpose()?;
186 Ok(resolve_flash_file(
187 &path,
188 block.format.as_deref(),
189 base_addr,
190 )?)
191}
192
193fn resolve_runtime_path(raw_path: &str, config_base_dir: Option<&Path>) -> PathBuf {
194 let path = Path::new(raw_path);
195 if path.is_absolute() {
196 path.to_path_buf()
197 } else if let Some(base_dir) = config_base_dir {
198 base_dir.join(path)
199 } else {
200 std::env::current_dir()
201 .unwrap_or_else(|_| PathBuf::from("."))
202 .join(path)
203 }
204}
205
206fn canonicalize_existing_path(path: &Path, kind: &str) -> Result<PathBuf, String> {
207 std::fs::canonicalize(path).map_err(|err| {
208 format!(
209 "failed to resolve {kind} path '{}' to an absolute path: {err}",
210 path.display()
211 )
212 })
213}
214
215fn canonicalize_runtime_candidate(path: &Path, kind: &str) -> Result<PathBuf, String> {
216 if let Ok(canonical) = std::fs::canonicalize(path) {
217 return Ok(canonical);
218 }
219 if kind == "shared library" {
220 for fallback in shared_library_fallbacks(path) {
221 if let Ok(canonical) = std::fs::canonicalize(&fallback) {
222 return Ok(canonical);
223 }
224 }
225 }
226 canonicalize_existing_path(path, kind)
227}
228
229fn shared_library_fallbacks(path: &Path) -> Vec<PathBuf> {
230 let ext = native_shared_library_extension();
231 let mut out = Vec::new();
232 if path.extension().is_some() && path.extension().and_then(|value| value.to_str()) != Some(ext)
233 {
234 out.push(path.with_extension(ext));
235 }
236 if path.extension().is_none() {
237 out.push(PathBuf::from(format!("{}.{}", path.display(), ext)));
238 }
239 out
240}
241
242fn native_shared_library_extension() -> &'static str {
243 #[cfg(target_os = "windows")]
244 {
245 "dll"
246 }
247 #[cfg(target_os = "macos")]
248 {
249 "dylib"
250 }
251 #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
252 {
253 "so"
254 }
255}
256
257fn parse_cli_flash_entry(raw: &str) -> Result<(&str, Option<&str>), LoadResolveError> {
258 let Some((path, maybe_base)) = raw.rsplit_once(':') else {
259 return Ok((raw, None));
260 };
261 if maybe_base.starts_with("0x")
262 || maybe_base.starts_with("0X")
263 || (!maybe_base.is_empty() && maybe_base.chars().all(|ch| ch.is_ascii_digit()))
264 {
265 Ok((path, Some(maybe_base)))
266 } else {
267 Ok((raw, None))
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use crate::config::recipe::{DefaultsConfig, FlashFileBlockDef, LoadDefaults};
275
276 #[test]
277 fn resolve_env_member_rejects_missing_device_reference() {
278 let member = EnvInstance {
279 name: "ecu-a".to_string(),
280 lib: None,
281 device: Some("missing".to_string()),
282 };
283 let err = resolve_env_member_load_spec(&member, &BTreeMap::new(), None, None)
284 .expect_err("missing device should fail");
285 assert!(err.to_string().contains("missing device"));
286 }
287
288 #[test]
289 fn resolve_env_member_rejects_lib_and_device_together() {
290 let member = EnvInstance {
291 name: "ecu-a".to_string(),
292 lib: Some("./libecu.so".to_string()),
293 device: Some("ecu".to_string()),
294 };
295 let err = resolve_env_member_load_spec(&member, &BTreeMap::new(), None, None)
296 .expect_err("lib+device should fail");
297 assert!(err.to_string().contains("both 'lib' and 'device'"));
298 }
299
300 #[test]
301 fn resolve_flash_blocks_merges_overlaps_in_order() {
302 let blocks = vec![
303 FlashBlockDef::InlineU32 {
304 u32: 0x1122_3344,
305 addr: "0x1000".to_string(),
306 },
307 FlashBlockDef::InlineBool {
308 bool: true,
309 addr: "0x1001".to_string(),
310 },
311 ];
312 let regions = resolve_flash_blocks(&blocks, None).expect("inline blocks should resolve");
313 assert_eq!(
314 regions,
315 vec![ResolvedFlashRegion {
316 base_addr: 0x1000,
317 data: vec![0x44, 0x01, 0x22, 0x11],
318 }]
319 );
320 }
321
322 #[test]
323 fn resolve_standalone_load_spec_uses_defaults_when_cli_path_missing() {
324 let temp = tempfile::tempdir().expect("tempdir should be creatable");
325 let lib = temp.path().join("libfoo.so");
326 std::fs::write(&lib, b"fake").expect("lib placeholder should be writable");
327 let file_config = FileConfig {
328 defaults: Some(DefaultsConfig {
329 json: None,
330 speed: None,
331 load: Some(LoadDefaults {
332 lib: Some(lib.to_string_lossy().into_owned()),
333 flash: Vec::new(),
334 }),
335 }),
336 ..FileConfig::default()
337 };
338
339 let spec = resolve_standalone_load_spec(&file_config, None, None, &[], None)
340 .expect("defaults should resolve");
341 assert_eq!(
342 spec.libpath,
343 std::fs::canonicalize(&lib)
344 .expect("lib should canonicalize")
345 .to_string_lossy()
346 );
347 }
348
349 #[test]
350 fn parse_cli_flash_entry_handles_optional_binary_base() {
351 assert_eq!(
352 parse_cli_flash_entry("./cal.hex").expect("hex entry should parse"),
353 ("./cal.hex", None)
354 );
355 assert_eq!(
356 parse_cli_flash_entry("./blob.bin:0x08040000").expect("bin entry should parse"),
357 ("./blob.bin", Some("0x08040000"))
358 );
359 assert_eq!(
360 parse_cli_flash_entry("./blob.bin:").expect("trailing colon should be treated as path"),
361 ("./blob.bin:", None)
362 );
363 }
364
365 #[test]
366 fn resolve_flash_file_block_requires_base_for_binary() {
367 let temp = tempfile::tempdir().expect("tempdir should be creatable");
368 let bin = temp.path().join("blob.bin");
369 std::fs::write(&bin, [1_u8, 2, 3]).expect("binary blob should be writable");
370 let err = resolve_flash_file_block(
371 &FlashFileBlockDef {
372 file: bin.to_string_lossy().into_owned(),
373 format: Some("bin".to_string()),
374 base: None,
375 },
376 None,
377 )
378 .expect_err("binary flash block without base must fail");
379 assert!(matches!(
380 err,
381 LoadResolveError::Flash(FlashParseError::MissingBinaryBase)
382 ));
383 }
384
385 #[test]
386 fn canonicalize_runtime_path_resolves_extensionless_shared_library() {
387 let temp = tempfile::tempdir().expect("tempdir should be creatable");
388 let lib = temp
389 .path()
390 .join(format!("libdemo.{}", native_shared_library_extension()));
391 std::fs::write(&lib, b"fake").expect("shared library placeholder should be writable");
392
393 let resolved = canonicalize_runtime_path(
394 &temp.path().join("libdemo").to_string_lossy(),
395 None,
396 "shared library",
397 )
398 .expect("extensionless shared library path should resolve");
399
400 assert_eq!(
401 resolved,
402 std::fs::canonicalize(&lib)
403 .expect("shared library should canonicalize")
404 .to_string_lossy()
405 );
406 }
407
408 #[test]
409 fn canonicalize_runtime_path_falls_back_to_native_shared_library_suffix() {
410 let temp = tempfile::tempdir().expect("tempdir should be creatable");
411 let lib = temp
412 .path()
413 .join(format!("libdemo.{}", native_shared_library_extension()));
414 std::fs::write(&lib, b"fake").expect("shared library placeholder should be writable");
415
416 let requested = temp
417 .path()
418 .join(format!("libdemo.{}", non_native_shared_library_extension()));
419 let resolved =
420 canonicalize_runtime_path(&requested.to_string_lossy(), None, "shared library")
421 .expect("mismatched shared library suffix should resolve to native artifact");
422
423 assert_eq!(
424 resolved,
425 std::fs::canonicalize(&lib)
426 .expect("shared library should canonicalize")
427 .to_string_lossy()
428 );
429 }
430
431 fn non_native_shared_library_extension() -> &'static str {
432 ["so", "dylib", "dll"]
433 .into_iter()
434 .find(|ext| *ext != native_shared_library_extension())
435 .expect("a non-native shared library extension should exist")
436 }
437}