Skip to main content

agent_sim/load/
resolve.rs

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(&regions)?)
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(&regions)?)
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}