Skip to main content

soldb_ethdebug/
artifacts.rs

1//! Finding and loading one contract's debug artifacts from a directory.
2//!
3//! An output directory holds the global resource file (`ethdebug.json` from older
4//! compilers, `ethdebug_resources.json` from modern ones), one program artifact per
5//! contract and environment (`<Contract>_ethdebug.json` for creation code,
6//! `<Contract>_ethdebug-runtime.json` for runtime code), and, for compilers that predate
7//! ETHDebug, a `combined.json` with legacy source maps. [`load_debug_program`] is the one
8//! path through all of that: ETHDebug first, the legacy map as a fallback, and the
9//! source text read from the compilation record or from disk. The CLI and the DAP server
10//! both load through it, so a directory that works in one works in the other.
11
12use std::collections::BTreeMap;
13use std::fs;
14use std::path::{Path, PathBuf};
15
16use serde::{Deserialize, Serialize};
17use serde_json::{json, Value};
18
19use soldb_core::{SoldbError, SoldbResult};
20
21use crate::metadata::{read_compilation_source, EthdebugInfo};
22use crate::source_map::{load_source_map_program_with_sources, SourceMapEnvironment};
23use crate::storage_layout::StorageLayout;
24
25/// Which of solc's code generators produced a program.
26///
27/// The two keep variables in different places: the legacy generator reserves a stack slot
28/// per declaration and frees it at the end of its block, which a debugger can follow
29/// without variable information from the compiler; the via-IR pipeline lays the stack out
30/// as its optimizer sees fit, and only compiler-emitted locations can describe it.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "kebab-case")]
33pub enum CodeGenerator {
34    Legacy,
35    ViaIr,
36}
37
38/// One contract's debug information as loaded from its artifacts.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct DebugProgram {
41    pub info: EthdebugInfo,
42    /// The ETHDebug resources record: the compilation, types, and pointers.
43    pub resources: Value,
44    /// Source text by source id, for every source that could be read.
45    pub source_contents: BTreeMap<u64, String>,
46    /// The file each source was read from; a source taken from the compilation record has none.
47    pub source_files: BTreeMap<u64, PathBuf>,
48    /// True when the program came from a legacy `srcmap` rather than ETHDebug.
49    pub legacy: bool,
50    /// The contract's storage layout, when it was compiled with `--storage-layout`.
51    pub storage_layout: Option<StorageLayout>,
52    /// Where its `transient` state variables live, from `--transient-storage-layout`.
53    pub transient_storage_layout: Option<StorageLayout>,
54    /// Sources the artifact names that could not be read, so a frontend can say why its
55    /// lines are missing instead of degrading to an opcode view without a word.
56    pub missing_sources: Vec<String>,
57}
58
59impl DebugProgram {
60    /// The code generator the program came from. A legacy `srcmap` is taken to come from
61    /// the legacy pipeline, which is the only one that emits nothing else; ETHDebug comes
62    /// from solc's via-IR pipeline or from a compiler built on IR.
63    #[must_use]
64    pub const fn code_generator(&self) -> CodeGenerator {
65        if self.legacy {
66            CodeGenerator::Legacy
67        } else {
68            CodeGenerator::ViaIr
69        }
70    }
71
72    /// Fills in the source text for every source the program names, from the compilation
73    /// record first and from disk next to the artifacts otherwise.
74    fn read_sources(&mut self, root: &Path, source_roots: &[PathBuf]) {
75        for (source_id, source_path) in &self.info.sources {
76            if self.source_contents.contains_key(source_id) {
77                continue;
78            }
79            if let Some(source) = read_compilation_source(&self.info.compilation, *source_id) {
80                self.source_contents.insert(*source_id, source);
81                continue;
82            }
83            match read_debug_source_file(root, source_roots, source_path) {
84                Some((file, source)) => {
85                    self.source_contents.insert(*source_id, source);
86                    self.source_files.insert(*source_id, file);
87                }
88                None => self.missing_sources.push(source_path.clone()),
89            }
90        }
91    }
92}
93
94/// The global ETHDebug resource file in `root`, under either of its names.
95#[must_use]
96pub fn find_ethdebug_metadata(root: &Path) -> Option<PathBuf> {
97    // Modern solc (>= ~0.8.32) renamed the global ETHDebug metadata file from
98    // `ethdebug.json` to `ethdebug_resources.json`; accept either.
99    [
100        root.join("ethdebug.json"),
101        root.join("ethdebug_resources.json"),
102    ]
103    .into_iter()
104    .find(|path| path.exists())
105}
106
107/// The ETHDebug program artifact for one contract and environment.
108///
109/// Prefers `<Contract><suffix>`; otherwise accepts a unique artifact whose name ends in
110/// `_<Contract><suffix>`, or the only artifact with that suffix in the directory, which
111/// is how a directory compiled for one contract loads with no name given.
112#[must_use]
113pub fn find_program_ethdebug(
114    root: &Path,
115    contract_name: &str,
116    environment: SourceMapEnvironment,
117) -> Option<PathBuf> {
118    let suffix = program_suffix(environment);
119    let named = root.join(format!("{contract_name}{suffix}"));
120    if !contract_name.is_empty() && named.exists() {
121        return Some(named);
122    }
123
124    let mut candidates = fs::read_dir(root)
125        .ok()?
126        .filter_map(Result::ok)
127        .map(|entry| entry.path())
128        .filter(|path| {
129            path.file_name()
130                .and_then(|name| name.to_str())
131                .is_some_and(|name| name.ends_with(suffix))
132        })
133        .collect::<Vec<_>>();
134    candidates.sort();
135
136    if !contract_name.is_empty() {
137        let contract_suffix = format!("_{contract_name}{suffix}");
138        let mut matching = candidates.iter().filter(|path| {
139            path.file_name()
140                .and_then(|name| name.to_str())
141                .is_some_and(|name| name.ends_with(&contract_suffix))
142        });
143        let matched = matching.next().cloned();
144        if matched.is_some() && matching.next().is_none() {
145            return matched;
146        }
147    }
148
149    (candidates.len() == 1).then(|| candidates.remove(0))
150}
151
152fn program_suffix(environment: SourceMapEnvironment) -> &'static str {
153    match environment {
154        SourceMapEnvironment::Creation => "_ethdebug.json",
155        SourceMapEnvironment::Runtime => "_ethdebug-runtime.json",
156    }
157}
158
159/// The contract name a program artifact's file name carries.
160#[must_use]
161pub fn contract_name_from_program_path(
162    path: &Path,
163    environment: SourceMapEnvironment,
164) -> Option<String> {
165    path.file_name()
166        .and_then(|name| name.to_str())
167        .and_then(|name| name.strip_suffix(program_suffix(environment)))
168        .filter(|name| !name.is_empty())
169        .map(str::to_owned)
170}
171
172/// Reads a source file named by the compiler, trying it relative to the artifact
173/// directory, to that directory's parent, and as given.
174#[must_use]
175pub fn read_debug_source(root: &Path, source_path: &str) -> Option<String> {
176    let (_, contents) = read_debug_source_file(root, &[], source_path)?;
177    Some(contents)
178}
179
180/// The same, looking in `extra_roots` first, which are directories the user named because
181/// the sources are not where the artifact's paths reach, and returning the file it read.
182fn read_debug_source_file(
183    root: &Path,
184    extra_roots: &[PathBuf],
185    source_path: &str,
186) -> Option<(PathBuf, String)> {
187    let candidates = extra_roots
188        .iter()
189        .flat_map(|extra| source_candidates(extra, source_path))
190        .chain(source_candidates(root, source_path));
191    for candidate in candidates {
192        // A candidate that cannot be read is skipped; the next one may be the file.
193        if let Ok(contents) = fs::read_to_string(&candidate) {
194            return Some((candidate, contents));
195        }
196    }
197    None
198}
199
200/// Where a source path recorded in an artifact might be on disk.
201///
202/// A compiler writes the path it was given, which is relative to the directory the
203/// compilation ran in — usually a project root, with the artifacts a few directories
204/// below it. So the artifact directory is tried first, then its ancestors, then the path
205/// as given, which resolves against the directory soldb runs in.
206pub fn source_candidates(root: &Path, source_path: &str) -> Vec<PathBuf> {
207    let source = Path::new(source_path);
208    if source.is_absolute() {
209        return vec![source.to_path_buf()];
210    }
211    let mut candidates = Vec::new();
212    let mut directory = Some(root);
213    // The project root is rarely more than a few directories above `out/`.
214    for _ in 0..4 {
215        let Some(current) = directory else {
216            break;
217        };
218        candidates.push(current.join(source));
219        directory = current.parent();
220    }
221    candidates.push(source.to_path_buf());
222    candidates
223}
224
225/// Parses a JSON file, naming the file in the error.
226pub fn read_json_file(path: &Path) -> SoldbResult<Value> {
227    let content = fs::read_to_string(path).map_err(|error| {
228        SoldbError::Message(format!("failed to read `{}`: {error}", path.display()))
229    })?;
230    serde_json::from_str(&content).map_err(|error| {
231        SoldbError::Message(format!("invalid JSON in `{}`: {error}", path.display()))
232    })
233}
234
235/// The resources record of an ETHDebug metadata file: its `resources` entry when it has
236/// one, otherwise one assembled from the compilation, types, and pointers it carries.
237pub fn ethdebug_resources_from_metadata(path: &Path, metadata: &Value) -> SoldbResult<Value> {
238    if let Some(resources) = metadata.get("resources") {
239        return Ok(resources.clone());
240    }
241
242    let Some(compilation) = metadata.get("compilation") else {
243        return Err(SoldbError::Message(format!(
244            "ETHDebug metadata {} does not contain resources or compilation",
245            path.display()
246        )));
247    };
248
249    Ok(json!({
250        "compilation": compilation,
251        "types": metadata.get("types").cloned().unwrap_or_else(|| json!({})),
252        "pointers": metadata.get("pointers").cloned().unwrap_or_else(|| json!({})),
253    }))
254}
255
256/// Loads a contract's debug program from `root`: the ETHDebug artifacts when present,
257/// otherwise the legacy source map in `combined.json`.
258///
259/// `Ok(None)` means the directory holds neither for this contract. A present but
260/// unreadable artifact is an error, so a caller never silently loses source attribution.
261/// An empty `contract_name` loads the only program in the directory and takes its name
262/// from the file.
263pub fn load_debug_program(
264    root: &Path,
265    contract_name: &str,
266    environment: SourceMapEnvironment,
267) -> SoldbResult<Option<DebugProgram>> {
268    load_debug_program_with_sources(root, contract_name, environment, &[])
269}
270
271/// The same, also looking for sources in `source_roots`.
272///
273/// A compiler records the source paths it was given, which are relative to the directory
274/// the compilation ran in. When the artifacts are somewhere else entirely, that directory
275/// is the one thing the debugger cannot work out for itself, so a frontend passes it.
276pub fn load_debug_program_with_sources(
277    root: &Path,
278    contract_name: &str,
279    environment: SourceMapEnvironment,
280    source_roots: &[PathBuf],
281) -> SoldbResult<Option<DebugProgram>> {
282    let environment_name = match environment {
283        SourceMapEnvironment::Creation => "create",
284        SourceMapEnvironment::Runtime => "call",
285    };
286    if let Some(program_path) = find_program_ethdebug(root, contract_name, environment) {
287        let metadata_path = find_ethdebug_metadata(root).ok_or_else(|| {
288            SoldbError::Message(format!(
289                "No ETHDebug metadata file (ethdebug.json or ethdebug_resources.json) found in {}",
290                root.display()
291            ))
292        })?;
293        let metadata = read_json_file(&metadata_path)?;
294        let program = read_json_file(&program_path)?;
295        let resources = ethdebug_resources_from_metadata(&metadata_path, &metadata)?;
296        let name = if contract_name.is_empty() {
297            contract_name_from_program_path(&program_path, environment)
298                .unwrap_or_else(|| "Contract".to_owned())
299        } else {
300            contract_name.to_owned()
301        };
302        let info = EthdebugInfo::from_artifacts(&name, environment_name, &metadata, &program)
303            .map_err(|error| SoldbError::Message(format!("{}: {error}", program_path.display())))?;
304        let storage_layout = load_storage_layout(root, &info.contract_name)?;
305        let transient_storage_layout = load_transient_storage_layout(root, &info.contract_name)?;
306        let mut program = DebugProgram {
307            info,
308            resources,
309            source_contents: BTreeMap::new(),
310            source_files: BTreeMap::new(),
311            legacy: false,
312            storage_layout,
313            transient_storage_layout,
314            missing_sources: Vec::new(),
315        };
316        program.read_sources(root, source_roots);
317        return Ok(Some(program));
318    }
319
320    let Some(program) =
321        load_source_map_program_with_sources(root, source_roots, contract_name, environment)?
322    else {
323        return Ok(None);
324    };
325    let storage_layout = match program.storage_layout {
326        Some(layout) => Some(layout),
327        None => load_storage_layout(root, &program.info.contract_name)?,
328    };
329    let transient_storage_layout = match program.transient_storage_layout {
330        Some(layout) => Some(layout),
331        None => load_transient_storage_layout(root, &program.info.contract_name)?,
332    };
333    let mut program = DebugProgram {
334        info: program.info,
335        resources: program.resources,
336        source_contents: program.source_contents,
337        source_files: program.source_files,
338        legacy: true,
339        storage_layout,
340        transient_storage_layout,
341        missing_sources: Vec::new(),
342    };
343    program.read_sources(root, source_roots);
344    Ok(Some(program))
345}
346
347/// The `<Contract>_storage.json` that `solc --storage-layout -o <root>` writes, when it
348/// is there. A missing file is not an error: the layout is optional debug information.
349/// An unreadable one is, because the user compiled with it and would otherwise get a
350/// silent "no storage layout" that looks identical to never having asked for it.
351pub fn load_storage_layout(root: &Path, contract_name: &str) -> SoldbResult<Option<StorageLayout>> {
352    load_layout_file(&root.join(format!("{contract_name}_storage.json")))
353}
354
355/// `<Contract>_transient_storage.json`, under the same rules as [`load_storage_layout`].
356pub fn load_transient_storage_layout(
357    root: &Path,
358    contract_name: &str,
359) -> SoldbResult<Option<StorageLayout>> {
360    load_layout_file(&root.join(format!("{contract_name}_transient_storage.json")))
361}
362
363fn load_layout_file(path: &Path) -> SoldbResult<Option<StorageLayout>> {
364    if !path.exists() {
365        return Ok(None);
366    }
367    let value = read_json_file(path)?;
368    StorageLayout::parse(&value)
369        .map(Some)
370        .map_err(|error| SoldbError::Message(format!("{}: {error}", path.display())))
371}
372
373/// One entry of a contract mapping file: a deployed address and the directory holding
374/// the artifacts that describe its code.
375#[derive(Debug, Clone, PartialEq, Eq)]
376pub struct ContractMapping {
377    pub address: String,
378    pub name: String,
379    pub debug_dir: PathBuf,
380}
381
382/// Reads a contract mapping file, `{"contracts": [{"address", "name", "debug_dir"}]}`.
383pub fn load_contract_mapping(path: &Path) -> SoldbResult<Vec<ContractMapping>> {
384    let value = read_json_file(path)?;
385    parse_contract_mapping(path, &value)
386}
387
388/// The entries of a parsed mapping file. A relative `debug_dir` is relative to the file.
389pub fn parse_contract_mapping(path: &Path, value: &Value) -> SoldbResult<Vec<ContractMapping>> {
390    let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
391    let contracts = value
392        .get("contracts")
393        .and_then(Value::as_array)
394        .ok_or_else(|| {
395            SoldbError::Message(format!(
396                "contract mapping `{}` must contain a `contracts` array",
397                path.display()
398            ))
399        })?;
400
401    Ok(contracts
402        .iter()
403        .filter_map(|contract| {
404            let address = contract.get("address")?.as_str()?.to_owned();
405            let name = contract
406                .get("name")
407                .and_then(Value::as_str)
408                .unwrap_or("Unknown")
409                .to_owned();
410            let debug_dir = contract
411                .get("debug_dir")
412                .and_then(Value::as_str)
413                .map(PathBuf::from)?;
414            let debug_dir = if debug_dir.is_absolute() {
415                debug_dir
416            } else {
417                base_dir.join(debug_dir)
418            };
419            Some(ContractMapping {
420                address,
421                name,
422                debug_dir,
423            })
424        })
425        .collect())
426}
427
428#[cfg(test)]
429mod tests {
430    use std::fs;
431    use std::path::PathBuf;
432    use std::time::{SystemTime, UNIX_EPOCH};
433
434    use serde_json::json;
435
436    use super::{
437        contract_name_from_program_path, ethdebug_resources_from_metadata, find_ethdebug_metadata,
438        find_program_ethdebug, load_debug_program, load_storage_layout,
439        load_transient_storage_layout, read_debug_source, read_json_file,
440    };
441    use crate::source_map::SourceMapEnvironment;
442
443    fn temp_dir(label: &str) -> PathBuf {
444        let nanos = SystemTime::now()
445            .duration_since(UNIX_EPOCH)
446            .expect("clock")
447            .as_nanos();
448        let dir = std::env::temp_dir().join(format!("soldb-artifacts-{label}-{nanos}"));
449        fs::create_dir_all(&dir).expect("create temp dir");
450        dir
451    }
452
453    #[test]
454    fn loads_ethdebug_programs_with_their_sources() {
455        let dir = temp_dir("ethdebug");
456        let source =
457            "contract Counter {\n  function set(uint256 x) public {\n    value = x;\n  }\n}\n";
458        fs::write(dir.join("Counter.sol"), source).expect("source");
459        fs::write(
460            dir.join("ethdebug_resources.json"),
461            json!({"compilation": {"sources": [{"id": 0, "path": "Counter.sol"}]}}).to_string(),
462        )
463        .expect("metadata");
464        fs::write(
465            dir.join("Counter_ethdebug-runtime.json"),
466            json!({"instructions": [{
467                "offset": 7,
468                "operation": {"mnemonic": "STOP"},
469                "context": {"code": {"source": {"id": 0}, "range": {"offset": 59, "length": 9}}}
470            }]})
471            .to_string(),
472        )
473        .expect("program");
474
475        let program = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
476            .expect("load")
477            .expect("present");
478        assert!(!program.legacy);
479        assert_eq!(program.info.contract_name, "Counter");
480        assert_eq!(program.info.environment, "call");
481        assert!(program.info.instruction_at_pc(7).is_some());
482        assert_eq!(
483            program.source_contents.get(&0).map(String::as_str),
484            Some(source)
485        );
486        assert_eq!(program.source_files.get(&0), Some(&dir.join("Counter.sol")));
487        assert!(program.resources.get("compilation").is_some());
488        // No `Counter_storage.json` yet: the layout is optional debug information.
489        assert!(program.storage_layout.is_none());
490
491        // No name: the only program in the directory, named after its file.
492        let inferred = load_debug_program(&dir, "", SourceMapEnvironment::Runtime)
493            .expect("load")
494            .expect("present");
495        assert_eq!(inferred.info.contract_name, "Counter");
496        // Creation code is a different artifact, absent here.
497        assert!(
498            load_debug_program(&dir, "Counter", SourceMapEnvironment::Creation)
499                .expect("load")
500                .is_none()
501        );
502        assert_eq!(
503            contract_name_from_program_path(
504                &dir.join("Counter_ethdebug-runtime.json"),
505                SourceMapEnvironment::Runtime
506            )
507            .as_deref(),
508            Some("Counter")
509        );
510        assert_eq!(
511            find_ethdebug_metadata(&dir),
512            Some(dir.join("ethdebug_resources.json"))
513        );
514        assert_eq!(
515            find_program_ethdebug(&dir, "Other", SourceMapEnvironment::Runtime),
516            Some(dir.join("Counter_ethdebug-runtime.json")),
517            "a single artifact serves any name"
518        );
519        assert_eq!(
520            read_debug_source(&dir, "Counter.sol").as_deref(),
521            Some(source)
522        );
523        assert!(read_debug_source(&dir, "Missing.sol").is_none());
524
525        // `solc --storage-layout -o <dir>` writes one file per contract; the loader picks
526        // it up, and a malformed one is an error rather than a silent miss.
527        fs::write(
528            dir.join("Counter_storage.json"),
529            json!({
530                "storage": [{
531                    "astId": 1,
532                    "contract": "Counter.sol:Counter",
533                    "label": "value",
534                    "offset": 0,
535                    "slot": "1",
536                    "type": "t_uint256"
537                }],
538                "types": {"t_uint256": {
539                    "encoding": "inplace",
540                    "label": "uint256",
541                    "numberOfBytes": "32"
542                }}
543            })
544            .to_string(),
545        )
546        .expect("layout");
547        let program = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
548            .expect("load")
549            .expect("present");
550        let layout = program.storage_layout.as_ref().expect("storage layout");
551        assert_eq!(layout.variable("value").expect("value").slot[31], 1);
552        assert!(load_storage_layout(&dir, "Missing")
553            .expect("absent")
554            .is_none());
555        // Not compiled with `--transient-storage-layout`: no transient layout.
556        assert!(program.transient_storage_layout.is_none());
557
558        // `--transient-storage-layout` writes a second file of the same shape, for the
559        // `transient` variables, whose slots number from zero in their own space.
560        fs::write(
561            dir.join("Counter_transient_storage.json"),
562            json!({
563                "storage": [{
564                    "astId": 2,
565                    "contract": "Counter.sol:Counter",
566                    "label": "locked",
567                    "offset": 0,
568                    "slot": "0",
569                    "type": "t_bool"
570                }],
571                "types": {"t_bool": {
572                    "encoding": "inplace",
573                    "label": "bool",
574                    "numberOfBytes": "1"
575                }}
576            })
577            .to_string(),
578        )
579        .expect("transient layout");
580        let program = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
581            .expect("load")
582            .expect("present");
583        let transient = program
584            .transient_storage_layout
585            .as_ref()
586            .expect("transient layout");
587        assert_eq!(transient.variable("locked").expect("locked").slot, [0; 32]);
588        assert!(transient.variable("value").is_none());
589        assert!(load_transient_storage_layout(&dir, "Missing")
590            .expect("absent")
591            .is_none());
592        fs::write(dir.join("Counter_storage.json"), "{}").expect("layout");
593        let error = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
594            .expect_err("a storage layout that cannot be read is reported");
595        assert!(
596            error.to_string().contains("Counter_storage.json"),
597            "{error}"
598        );
599    }
600
601    #[test]
602    fn a_program_without_metadata_is_an_error_not_a_silent_miss() {
603        let dir = temp_dir("no-metadata");
604        fs::write(dir.join("Counter_ethdebug-runtime.json"), "{}").expect("program");
605        let error = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
606            .expect_err("metadata is required");
607        assert!(
608            error.to_string().contains("No ETHDebug metadata file"),
609            "{error}"
610        );
611
612        fs::write(dir.join("ethdebug.json"), "{not json").expect("metadata");
613        let error = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
614            .expect_err("malformed metadata");
615        assert!(error.to_string().contains("invalid JSON"), "{error}");
616        assert!(read_json_file(&dir.join("absent.json")).is_err());
617    }
618
619    #[test]
620    fn falls_back_to_legacy_source_maps() {
621        let dir = temp_dir("legacy");
622        let source =
623            "contract Counter {\n  function set(uint256 x) public {\n    value = x;\n  }\n}\n";
624        fs::write(dir.join("Counter.sol"), source).expect("source");
625        let offset = source.find("value = x").expect("statement");
626        fs::write(
627            dir.join("combined.json"),
628            json!({
629                "sourceList": ["Counter.sol"],
630                "contracts": {"Counter.sol:Counter": {
631                    "bin-runtime": "60010000",
632                    "srcmap-runtime": format!("{offset}:9:0:-:0;{offset}:9:0;{offset}:9:0")
633                }}
634            })
635            .to_string(),
636        )
637        .expect("combined");
638
639        let program = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
640            .expect("load")
641            .expect("present");
642        assert!(program.legacy);
643        assert_eq!(
644            program.source_contents.get(&0).map(String::as_str),
645            Some(source)
646        );
647        assert_eq!(program.source_files.get(&0), Some(&dir.join("Counter.sol")));
648        assert!(program.info.instruction_at_pc(2).is_some());
649
650        let empty = temp_dir("empty");
651        assert!(
652            load_debug_program(&empty, "Counter", SourceMapEnvironment::Runtime)
653                .expect("load")
654                .is_none()
655        );
656    }
657
658    #[test]
659    fn resources_come_from_the_record_or_are_assembled() {
660        let path = PathBuf::from("ethdebug.json");
661        let explicit = json!({"resources": {"compilation": {}}});
662        assert_eq!(
663            ethdebug_resources_from_metadata(&path, &explicit).expect("resources"),
664            json!({"compilation": {}})
665        );
666        let assembled = json!({"compilation": {"sources": []}, "types": {"a": 1}});
667        assert_eq!(
668            ethdebug_resources_from_metadata(&path, &assembled).expect("resources"),
669            json!({"compilation": {"sources": []}, "types": {"a": 1}, "pointers": {}})
670        );
671        let error = ethdebug_resources_from_metadata(&path, &json!({})).expect_err("neither");
672        assert!(
673            error.to_string().contains("does not contain resources"),
674            "{error}"
675        );
676    }
677}