1use super::{
2 test::{ProjectPathsAwareFilter, TestArgs, TestExecutionOptions},
3 watch::WatchArgs,
4};
5use crate::coverage::{
6 BytecodeReporter, ContractId, CoverageAttributionReporter, CoverageReport, CoverageReporter,
7 CoverageSummaryReporter, DebugReporter, ItemAnchors, LcovReporter, ResolvedHitMap,
8 ResolvedHitMaps,
9 analysis::{SourceAnalysis, SourceFiles},
10 anchors::{find_anchors, find_execution_anchors},
11};
12use alloy_json_abi::StateMutability;
13use alloy_primitives::{
14 Address, Bytes, U256, keccak256,
15 map::{HashMap, HashSet},
16};
17use clap::{Parser, ValueHint};
18use eyre::Result;
19use foundry_cli::utils::{FoundryPathExt, LoadConfig, STATIC_FUZZ_SEED};
20use foundry_common::{
21 TestFilter, compile::ProjectCompiler, errors::convert_solar_errors, version::SHORT_VERSION,
22};
23use foundry_compilers::{
24 Artifact, ArtifactId, Graph, Project, ProjectCompileOutput, ProjectPathsConfig,
25 VYPER_EXTENSIONS,
26 artifacts::{CompactBytecode, CompactDeployedBytecode, Source, sourcemap::SourceMap},
27 cache::SOLIDITY_FILES_CACHE_FILENAME,
28 compilers::{
29 Language,
30 multi::{MultiCompilerLanguage, MultiCompilerParser},
31 },
32 error::SolcError,
33 utils::source_files_iter,
34};
35use foundry_config::{
36 Config, CoverageConfig, CoverageReportKind, InlineConfig, parse_lcov_version,
37};
38use foundry_evm::{core::ic::IcPcMap, opts::EvmOpts};
39use globset::{Glob, GlobSetBuilder};
40use rayon::prelude::*;
41use semver::Version;
42use std::{
43 collections::{BTreeMap, BTreeSet},
44 fs,
45 path::{Path, PathBuf},
46 sync::Arc,
47};
48
49foundry_config::impl_figment_convert!(CoverageArgs, test);
51
52#[derive(Parser)]
58#[command(after_long_help = r#"Source attribution:
59 Coverage follows compiler source maps. Inherited modifier code is reported under the
60 source where the modifier is declared. Dependency sources are excluded by default;
61 use `--include-libs` to include their coverage.
62
63Compatibility:
64 `forge coverage` supports test filters and `--watch`, but not test-only output or
65 execution modes such as `--json`, `--junit`, `--list`, `--debug`, flame profiles,
66 symbolic artifact replay, showmap replay, brutalization, or mutation testing. Use
67 `--report lcov` for interoperable coverage data or `--report attribution` for
68 Foundry's per-test JSON attribution report."#)]
69pub struct CoverageArgs {
70 #[arg(long, value_enum)]
76 report: Vec<CoverageReportKind>,
77
78 #[arg(long = "lcov-version", value_parser = parse_lcov_version)]
90 lcov_version_cli: Option<Version>,
91
92 #[arg(skip = Version::new(1, 0, 0))]
94 lcov_version: Version,
95
96 #[arg(long)]
101 ir_minimum: bool,
102
103 #[arg(
108 long,
109 value_hint = ValueHint::FilePath,
110 value_name = "PATH"
111 )]
112 report_file: Option<PathBuf>,
113
114 #[arg(long)]
116 include_libs: bool,
117
118 #[arg(long)]
120 exclude_tests: bool,
121
122 #[arg(skip)]
124 reporters: Vec<Box<dyn CoverageReporter>>,
125
126 #[arg(skip)]
130 skip_files: Vec<String>,
131
132 #[command(flatten)]
133 test: TestArgs,
134}
135
136impl CoverageArgs {
137 fn report_path(&self, root: &Path, default_file_name: &str) -> PathBuf {
138 let report_file =
139 (self.file_report_count() == 1).then_some(self.report_file.as_deref()).flatten();
140 root.join(report_file.unwrap_or_else(|| Path::new(default_file_name)))
141 }
142
143 fn file_report_count(&self) -> usize {
144 let has_lcov = self.report.iter().any(|kind| matches!(kind, CoverageReportKind::Lcov));
145 let has_attribution =
146 self.report.iter().any(|kind| matches!(kind, CoverageReportKind::Attribution));
147 usize::from(has_lcov) + usize::from(has_attribution)
148 }
149
150 pub(crate) fn ensure_mode_compatible(&self) -> Result<()> {
151 self.test.ensure_coverage_mode_compatible()
152 }
153
154 pub async fn run(mut self) -> Result<()> {
155 self.ensure_mode_compatible()?;
156
157 let (mut config, evm_opts) = self.load_config_and_evm_opts()?;
158
159 self.install_missing_dependencies(&mut config)?;
161
162 if config.fuzz.seed.is_none() {
165 config.fuzz.seed = Some(U256::from_be_bytes(STATIC_FUZZ_SEED));
166 }
167
168 self.resolve_with(&config.coverage);
171 let filter = self.test.filter(&config)?;
172
173 let (paths, mut output) = {
174 let (project, output) = self.build(&config, &filter)?;
175 (project.paths, output)
176 };
177
178 if self.report_file.is_some() && self.file_report_count() > 1 {
179 sh_warn!(
180 "`--report-file` is ignored when multiple file reports are requested; \
181 each report will use its default output path"
182 )?;
183 }
184
185 self.populate_reporters(&paths.root);
186
187 sh_println!("Analysing contracts...")?;
188 let report = self.prepare(&paths, &mut output)?;
189
190 sh_println!("Running tests...")?;
191 self.collect(&paths.root, &output, report, config, evm_opts, filter).await
192 }
193
194 fn resolve_with(&mut self, config: &CoverageConfig) {
203 if self.report.is_empty() {
204 self.report.clone_from(&config.report);
205 }
206 self.lcov_version =
207 self.lcov_version_cli.clone().unwrap_or_else(|| config.lcov_version.clone());
208 if !self.ir_minimum {
209 self.ir_minimum = config.ir_minimum;
210 }
211 if self.report_file.is_none() {
212 self.report_file.clone_from(&config.report_file);
213 }
214 if !self.include_libs {
215 self.include_libs = config.include_libs;
216 }
217 if !self.exclude_tests {
218 self.exclude_tests = config.exclude_tests;
219 }
220 self.skip_files.clone_from(&config.skip_files);
223 }
224
225 fn populate_reporters(&mut self, root: &Path) {
226 self.reporters = self
227 .report
228 .iter()
229 .filter_map(|report_kind| match report_kind {
230 CoverageReportKind::Summary => {
231 Some(Box::<CoverageSummaryReporter>::default() as Box<dyn CoverageReporter>)
232 }
233 CoverageReportKind::Lcov => {
234 let path = self.report_path(root, "lcov.info");
235 Some(Box::new(LcovReporter::new(path, self.lcov_version.clone())))
236 }
237 CoverageReportKind::Bytecode => Some(Box::new(BytecodeReporter::new(
238 root.to_path_buf(),
239 root.join("bytecode-coverage"),
240 ))),
241 CoverageReportKind::Debug => Some(Box::new(DebugReporter)),
242 CoverageReportKind::Attribution => None,
243 })
244 .collect::<Vec<_>>();
245 }
246
247 fn build(
249 &self,
250 config: &Config,
251 filter: &ProjectPathsAwareFilter,
252 ) -> Result<(Project, ProjectCompileOutput)> {
253 let mut project = config.ephemeral_project()?;
254 let cache = (config.cache && !config.deny.warnings())
257 .then(|| config.coverage_cache_path())
258 .flatten()
259 .and_then(|path| {
260 fs::create_dir_all(path.parent()?).ok()?;
261 let lock = Config::lock_coverage_cache(&path).ok()?;
262 match fs::create_dir(&path) {
263 Ok(()) => {
264 if fs::File::create_new(path.join(Config::COVERAGE_CACHE_MARKER)).is_err() {
265 let _ = fs::remove_dir(&path);
266 return None;
267 }
268 }
269 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
270 if !path.join(Config::COVERAGE_CACHE_MARKER).is_file() {
271 return None;
272 }
273 }
274 Err(_) => return None,
275 }
276 Some((path, lock))
277 });
278 let original_paths = project.paths.clone();
279 if let Some((path, _)) = &cache {
280 project.cached = true;
281 project.no_artifacts = false;
282 project.paths.cache = path.join(SOLIDITY_FILES_CACHE_FILENAME);
283 project.paths.artifacts = path.join("artifacts");
284 project.paths.build_infos = path.join("build-info");
285 project.paths.slash_paths();
286 }
287
288 if self.ir_minimum {
289 sh_warn!(
290 "`--ir-minimum` enables `viaIR` with minimum optimization, \
291 which can result in inaccurate source mappings.\n\
292 Only use this flag as a workaround if you are experiencing \"stack too deep\" errors.\n\
293 Note that `viaIR` is production ready since Solidity 0.8.13 and above.\n\
294 See more: https://book.getfoundry.sh/guides/best-practices/stack-too-deep"
295 )?;
296 } else {
297 sh_warn!(
298 "optimizer settings and `viaIR` have been disabled for accurate coverage reports.\n\
299 If you encounter \"stack too deep\" errors, consider using `--ir-minimum` which \
300 enables `viaIR` with minimum optimization resolving most of the errors.\n\
301 See more: https://book.getfoundry.sh/guides/best-practices/stack-too-deep"
302 )?;
303 }
304
305 config.disable_optimizations(&mut project, self.ir_minimum);
306
307 let files = (filter.args().path_pattern.is_some()
308 || filter.args().path_pattern_inverse.is_some())
309 .then(|| {
310 source_files_iter(&config.src, MultiCompilerLanguage::FILE_EXTENSIONS)
311 .chain(
312 source_files_iter(&config.test, MultiCompilerLanguage::FILE_EXTENSIONS)
313 .filter(|path| !path.is_sol_test() || filter.matches_path(path)),
316 )
317 .chain(source_files_iter(&config.script, MultiCompilerLanguage::FILE_EXTENSIONS))
319 .collect::<BTreeSet<_>>()
320 });
321 let compile = |project: &Project| {
322 let mut compiler = ProjectCompiler::new()
323 .external_compilers(config)
324 .external_artifacts(false)
325 .dynamic_test_linking(config.dynamic_test_linking);
326 if let Some(files) = &files {
327 compiler = compiler.files(files.iter().cloned());
328 }
329 compiler.compile(project)
330 };
331 let cached_output = (|| -> Result<Option<ProjectCompileOutput>> {
335 let Some((path, _)) = &cache else { return Ok(None) };
336 let mut sources = if let Some(files) = &files
337 && !files.is_empty()
338 {
339 Source::read_all(files)?
340 } else {
341 project.paths.read_input_files()?
342 };
343 if let Some(filter) = &project.sparse_output {
344 sources.retain(|path, _| filter.is_match(path));
345 }
346 let graph = Graph::<MultiCompilerParser>::resolve_sources(&project.paths, sources)?;
347 let resolved = graph.into_sources_by_version(&project)?;
348 let mut jobs = resolved
349 .sources
350 .iter()
351 .flat_map(|(language, jobs)| {
352 jobs.iter().map(move |(version, sources, (profile, settings))| {
353 let sources = sources
354 .iter()
355 .map(|(path, source)| {
356 (path, source.content_hash(), source.kind.is_dirty())
357 })
358 .collect::<Vec<_>>();
359 serde_json::to_value((language, version, profile, settings, sources))
360 })
361 })
362 .collect::<Result<Vec<_>, _>>()?;
363 jobs.sort_unstable_by_key(serde_json::Value::to_string);
364 let primary_profiles = resolved.primary_profiles.iter().collect::<BTreeMap<_, _>>();
365 let mut identity = serde_json::to_value((
366 SHORT_VERSION,
367 &project.paths,
368 (&config.solc, &config.vyper, &config.extra_args),
369 (&config.extra_output, &config.extra_output_files),
370 config.dynamic_test_linking,
371 jobs,
372 primary_profiles,
373 ))?;
374 identity.sort_all_objects();
375 let fingerprint = keccak256(serde_json::to_vec(&identity)?).to_string();
376 let marker = path.join(Config::COVERAGE_CACHE_MARKER);
377 if fs::read_to_string(&marker).ok().as_deref() != Some(&fingerprint) {
378 fs::write(&marker, "")?;
380 for entry in fs::read_dir(path)? {
381 let entry = entry?;
382 if entry.file_name() == Config::COVERAGE_CACHE_MARKER {
383 continue;
384 }
385 if entry.file_type()?.is_dir() {
386 fs::remove_dir_all(entry.path())?;
387 } else {
388 fs::remove_file(entry.path())?;
389 }
390 }
391 }
392 let output = compile(&project)?;
393 let mapped_sources = output
396 .builds()
397 .flat_map(|(_, build)| build.source_id_to_path.values().map(PathBuf::as_path))
398 .collect::<HashSet<_>>();
399 if output
400 .graph()
401 .files()
402 .any(|idx| !mapped_sources.contains(output.graph().node_path(idx)))
403 {
404 return Ok(None);
405 }
406 if let Err(err) = fs::write(marker, fingerprint) {
408 debug!(%err, "failed to publish coverage cache fingerprint");
409 }
410 Ok(Some(output))
411 })();
412 let output = match cached_output {
413 Ok(Some(output)) => output,
414 Err(err)
415 if !err.chain().any(|cause| {
416 cause.is::<std::io::Error>()
417 || matches!(cause.downcast_ref(), Some(SolcError::Io(_)))
418 }) =>
419 {
420 return Err(err);
421 }
422 result => {
423 if let Err(err) = result {
424 debug!(%err, "coverage cache unavailable; compiling without persistence");
425 }
426 project.cached = false;
427 project.no_artifacts = true;
428 project.paths = original_paths;
429 compile(&project)?
430 }
431 };
432 let output = output.with_stripped_file_prefixes(project.root());
433
434 Ok((project, output))
435 }
436
437 #[instrument(name = "Coverage::prepare", skip_all)]
439 fn prepare(
440 &self,
441 project_paths: &ProjectPathsConfig,
442 output: &mut ProjectCompileOutput,
443 ) -> Result<CoverageReport> {
444 let mut report = CoverageReport::default();
445
446 output.parser_mut().solc_mut().compiler_mut().enter_mut(|compiler| {
447 if compiler.gcx().stage() < Some(solar::config::CompilerStage::Lowering) {
448 let _ = compiler.lower_asts();
449 }
450 convert_solar_errors(compiler.dcx())
451 })?;
452 let output = &*output;
453
454 let mut sources_by_build = HashMap::<String, SourceFiles>::default();
456 for (build_id, build) in output.builds() {
457 for (source_id, path) in &build.source_id_to_path {
458 if output.graph().get_parsed_source(path).is_none()
459 || path
460 .extension()
461 .and_then(|s| s.to_str())
462 .is_some_and(|ext| VYPER_EXTENSIONS.contains(&ext))
463 {
464 continue;
465 }
466 let path = path.strip_prefix(&project_paths.root).unwrap_or(path);
467 report.add_source(build_id.clone(), *source_id as usize, path.to_path_buf());
468
469 if (!self.include_libs && project_paths.has_library_ancestor(path))
470 || (self.exclude_tests && project_paths.is_test(path))
471 {
472 continue;
473 }
474
475 sources_by_build
476 .entry(build_id.clone())
477 .or_default()
478 .sources
479 .insert(*source_id, project_paths.root.join(path));
480 }
481 }
482
483 let artifacts: Vec<ArtifactData> = output
485 .artifact_ids()
486 .par_bridge() .filter_map(|(id, artifact)| {
488 let source_id = report.get_source_id(&id.build_id, &id.source)?;
489 ArtifactData::new(&id, source_id, artifact)
490 })
491 .collect();
492
493 for (build_id, sources) in &sources_by_build {
495 let source_analysis = SourceAnalysis::new(sources, output)?;
496 let anchors = artifacts
497 .par_iter()
498 .filter(|artifact| artifact.contract_id.build_id == *build_id)
499 .map(|artifact| {
500 let creation_code_anchors = artifact.creation.find_anchors(&source_analysis);
501 let deployed_code_anchors = artifact.deployed.find_anchors(&source_analysis);
502 (artifact.contract_id.clone(), (creation_code_anchors, deployed_code_anchors))
503 })
504 .collect_vec_list();
505 report.add_anchors(anchors.into_iter().flatten());
506 for artifact in
507 artifacts.iter().filter(|artifact| artifact.contract_id.build_id == *build_id)
508 {
509 let execution_anchors = find_execution_anchors(
510 artifact.contract_id.source_id as u32,
511 &artifact.contract_id.contract_name,
512 &source_analysis,
513 );
514 report.add_execution_anchors(
515 artifact.contract_id.clone(),
516 execution_anchors,
517 artifact.function_selectors.iter().copied(),
518 artifact.has_receive,
519 artifact.fallback_payable,
520 );
521 }
522 report.add_analysis(build_id.clone(), source_analysis);
523 }
524
525 if self.reporters.iter().any(|reporter| reporter.needs_source_maps()) {
526 report.add_source_maps(artifacts.into_iter().map(|artifact| {
527 (artifact.contract_id, (artifact.creation.source_map, artifact.deployed.source_map))
528 }));
529 }
530
531 Ok(report)
532 }
533
534 #[instrument(name = "Coverage::collect", skip_all)]
536 async fn collect(
537 mut self,
538 project_root: &Path,
539 output: &ProjectCompileOutput,
540 mut report: CoverageReport,
541 config: Config,
542 evm_opts: EvmOpts,
543 filter: ProjectPathsAwareFilter,
544 ) -> Result<()> {
545 let inline_config = Arc::new(InlineConfig::new_parsed(output, &config)?);
546 let outcome = self
547 .test
548 .run_tests(
549 project_root,
550 config,
551 evm_opts,
552 output,
553 &filter,
554 TestExecutionOptions::coverage(inline_config),
555 )
556 .await?;
557
558 let known_contracts = outcome.known_contracts.as_ref().unwrap();
559 let mut resolved_hit_maps = ResolvedHitMaps::default();
560
561 for suite in outcome.results.values() {
563 for result in suite.test_results.values() {
564 let Some(hit_maps) = result.line_coverage.as_ref() else { continue };
565
566 for (code_hash, map) in &hit_maps.0 {
567 if let Some(resolved) = resolved_hit_maps.get(code_hash) {
568 report.add_hit_map(
569 &resolved.contract_id,
570 map,
571 resolved.is_deployed_code,
572 )?;
573 continue;
574 }
575
576 let Some((artifact_id, is_deployed_code)) = known_contracts
577 .find_by_deployed_code(map.bytecode())
578 .map(|(id, _)| (id, true))
579 .or_else(|| {
580 known_contracts
581 .find_by_creation_code(map.bytecode())
582 .map(|(id, _)| (id, false))
583 })
584 else {
585 continue;
586 };
587
588 let Some(source_id) =
589 report.get_source_id(&artifact_id.build_id, &artifact_id.source)
590 else {
591 continue;
592 };
593 let contract_id = ContractId {
594 version: artifact_id.version.clone(),
595 build_id: artifact_id.build_id.clone(),
596 source_id,
597 contract_name: artifact_id.name.as_str().into(),
598 };
599
600 report.add_hit_map(&contract_id, map, is_deployed_code)?;
601
602 resolved_hit_maps
603 .entry(*code_hash)
604 .or_insert(ResolvedHitMap { contract_id, is_deployed_code });
605 }
606 }
607 }
608
609 let file_root = filter.paths().root.as_path();
611 if let Some(not_re) = &filter.args().coverage_pattern_inverse {
612 report.retain_sources(|path: &Path| {
613 let path = path.strip_prefix(file_root).unwrap_or(path);
614 !not_re.is_match(&path.to_string_lossy())
615 });
616 }
617 if !self.skip_files.is_empty() {
618 let mut builder = GlobSetBuilder::new();
619 for pattern in &self.skip_files {
620 let glob = Glob::new(pattern).map_err(|e| {
621 eyre::eyre!("invalid glob in coverage.skip_files: '{pattern}': {e}")
622 })?;
623 builder.add(glob);
624 }
625 let set = builder
626 .build()
627 .map_err(|e| eyre::eyre!("failed to build coverage.skip_files glob set: {e}"))?;
628 report.retain_sources(|path: &Path| {
629 let path = path.strip_prefix(file_root).unwrap_or(path);
630 !set.is_match(path)
631 });
632 }
633
634 self.report(&report)?;
636
637 if self.report.iter().any(|kind| matches!(kind, CoverageReportKind::Attribution)) {
638 let reporter = CoverageAttributionReporter::new(
639 self.report_path(project_root, "coverage-attribution.json"),
640 );
641 reporter.report(&report, &outcome, &resolved_hit_maps)?;
642 }
643
644 outcome.ensure_ok(false)?;
647
648 Ok(())
649 }
650
651 #[instrument(name = "Coverage::report", skip_all)]
652 fn report(&mut self, report: &CoverageReport) -> Result<()> {
653 for reporter in &mut self.reporters {
654 let _guard = debug_span!("reporter.report", kind=%reporter.name()).entered();
655 reporter.report(report)?;
656 }
657 Ok(())
658 }
659
660 pub const fn is_watch(&self) -> bool {
661 self.test.is_watch()
662 }
663
664 pub const fn watch(&self) -> &WatchArgs {
665 &self.test.watch
666 }
667}
668
669fn dummy_link_bytecode(mut obj: CompactBytecode) -> Option<Bytes> {
673 let link_references = obj.link_references.clone();
674 for (file, libraries) in link_references {
675 for library in libraries.keys() {
676 obj.link(&file, library, Address::ZERO);
677 }
678 }
679
680 obj.object.resolve();
681 obj.object.into_bytes()
682}
683
684fn dummy_link_deployed_bytecode(obj: CompactDeployedBytecode) -> Option<Bytes> {
688 obj.bytecode.and_then(dummy_link_bytecode)
689}
690
691pub struct ArtifactData {
692 pub contract_id: ContractId,
693 pub creation: BytecodeData,
694 pub deployed: BytecodeData,
695 pub function_selectors: Vec<[u8; 4]>,
696 pub has_receive: bool,
697 pub fallback_payable: bool,
698}
699
700impl ArtifactData {
701 pub fn new(id: &ArtifactId, source_id: usize, artifact: &impl Artifact) -> Option<Self> {
702 let abi = artifact.get_abi();
703 let function_selectors = abi
704 .as_ref()
705 .map(|abi| abi.functions().map(|function| function.selector().into()).collect())
706 .unwrap_or_default();
707 let has_receive = abi.as_ref().is_some_and(|abi| abi.receive.is_some());
708 let fallback_payable = abi
709 .as_ref()
710 .and_then(|abi| abi.fallback)
711 .is_some_and(|fallback| fallback.state_mutability == StateMutability::Payable);
712 Some(Self {
713 contract_id: ContractId {
714 version: id.version.clone(),
715 build_id: id.build_id.clone(),
716 source_id,
717 contract_name: id.name.as_str().into(),
718 },
719 creation: BytecodeData::new(
720 artifact.get_source_map()?.ok()?,
721 artifact
722 .get_bytecode()
723 .and_then(|bytecode| dummy_link_bytecode(bytecode.into_owned()))?,
724 ),
725 deployed: BytecodeData::new(
726 artifact.get_source_map_deployed()?.ok()?,
727 artifact
728 .get_deployed_bytecode()
729 .and_then(|bytecode| dummy_link_deployed_bytecode(bytecode.into_owned()))?,
730 ),
731 function_selectors,
732 has_receive,
733 fallback_payable,
734 })
735 }
736}
737
738pub struct BytecodeData {
739 source_map: SourceMap,
740 bytecode: Bytes,
741 ic_pc_map: IcPcMap,
749}
750
751impl BytecodeData {
752 fn new(source_map: SourceMap, bytecode: Bytes) -> Self {
753 let ic_pc_map = IcPcMap::new(&bytecode);
754 Self { source_map, bytecode, ic_pc_map }
755 }
756
757 pub fn find_anchors(&self, source_analysis: &SourceAnalysis) -> ItemAnchors {
758 find_anchors(&self.bytecode, &self.source_map, &self.ic_pc_map, source_analysis)
759 }
760}
761
762#[cfg(test)]
763mod tests {
764 use super::*;
765
766 #[test]
767 fn lcov_version() {
768 assert_eq!(parse_lcov_version("0").unwrap(), Version::new(0, 0, 0));
769 assert_eq!(parse_lcov_version("1").unwrap(), Version::new(1, 0, 0));
770 assert_eq!(parse_lcov_version("1.0").unwrap(), Version::new(1, 0, 0));
771 assert_eq!(parse_lcov_version("1.1").unwrap(), Version::new(1, 1, 0));
772 assert_eq!(parse_lcov_version("1.11").unwrap(), Version::new(1, 11, 0));
773 }
774
775 #[test]
776 fn resolve_lcov_version_uses_config_when_cli_absent() {
777 let mut args = CoverageArgs::parse_from(["coverage"]);
778 let config = CoverageConfig { lcov_version: Version::new(2, 2, 0), ..Default::default() };
779
780 args.resolve_with(&config);
781
782 assert_eq!(args.lcov_version, Version::new(2, 2, 0));
783 }
784
785 #[test]
786 fn resolve_lcov_version_keeps_explicit_cli_default() {
787 let mut args = CoverageArgs::parse_from(["coverage", "--lcov-version", "1"]);
788 let config = CoverageConfig { lcov_version: Version::new(2, 2, 0), ..Default::default() };
789
790 args.resolve_with(&config);
791
792 assert_eq!(args.lcov_version, Version::new(1, 0, 0));
793 }
794
795 #[test]
796 fn resolve_lcov_version_keeps_explicit_cli_value() {
797 let mut args = CoverageArgs::parse_from(["coverage", "--lcov-version", "2"]);
798 let config = CoverageConfig { lcov_version: Version::new(2, 2, 0), ..Default::default() };
799
800 args.resolve_with(&config);
801
802 assert_eq!(args.lcov_version, Version::new(2, 0, 0));
803 }
804}