//! Configuration for `forge coverage`.

use clap::ValueEnum;
use semver::{Version, VersionReq};
use serde::{Deserialize, Deserializer, Serialize};
use std::path::PathBuf;

/// Coverage report kinds that can be generated by `forge coverage`.
///
/// Used both as a CLI value (`--report <kind>`) and as a TOML configuration
/// value under `[profile.<name>.coverage] report = ["..."]`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum CoverageReportKind {
    #[default]
    Summary,
    Lcov,
    Debug,
    Bytecode,
    /// JSON report mapping each test to the source items it covers.
    Attribution,
}

/// Configuration for `forge coverage`, exposed under `[coverage]` and
/// `[profile.<name>.coverage]` in `foundry.toml`.
///
/// Fields here mirror the CLI flags accepted by `forge coverage`. CLI flags
/// take precedence over configuration values when both are provided.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CoverageConfig {
    /// The report kinds to generate. Defaults to `["summary"]`.
    #[serde(default = "default_report_kinds")]
    pub report: Vec<CoverageReportKind>,

    /// The version of the LCOV "tracefile" format to use.
    ///
    /// Defaults to `1` (parsed as `1.0.0`). See `forge coverage --help` for the
    /// supported variants and their differences.
    #[serde(default = "default_lcov_version", deserialize_with = "deserialize_lcov_version")]
    pub lcov_version: Version,

    /// Whether to enable `viaIR` with minimum optimization. Useful as a
    /// workaround for "stack too deep" errors at the cost of source map
    /// accuracy.
    #[serde(default)]
    pub ir_minimum: bool,

    /// Path to write the coverage report to (relative to the project root). If
    /// unset, the report is written to the project root.
    #[serde(default)]
    pub report_file: Option<PathBuf>,

    /// Whether to include library dependencies in the coverage report.
    #[serde(default)]
    pub include_libs: bool,

    /// Whether to exclude tests from the coverage report.
    #[serde(default)]
    pub exclude_tests: bool,

    /// Glob patterns of source files to exclude from the coverage report.
    ///
    /// Patterns are matched against project-root-relative paths using the
    /// `globset` crate's `Glob` semantics (`**`, `*`, `?`, character classes).
    /// A file is skipped if any pattern matches.
    ///
    /// Example:
    /// ```toml
    /// [profile.default.coverage]
    /// skip_files = ["test/**", "script/**", "src/mocks/**"]
    /// ```
    #[serde(default)]
    pub skip_files: Vec<String>,
}

impl Default for CoverageConfig {
    fn default() -> Self {
        Self {
            report: default_report_kinds(),
            lcov_version: default_lcov_version(),
            ir_minimum: false,
            report_file: None,
            include_libs: false,
            exclude_tests: false,
            skip_files: Vec::new(),
        }
    }
}

fn default_report_kinds() -> Vec<CoverageReportKind> {
    vec![CoverageReportKind::Summary]
}

const fn default_lcov_version() -> Version {
    Version::new(1, 0, 0)
}

fn deserialize_lcov_version<'de, D>(deserializer: D) -> Result<Version, D::Error>
where
    D: Deserializer<'de>,
{
    let version = String::deserialize(deserializer)?;
    parse_lcov_version(&version).map_err(serde::de::Error::custom)
}

pub fn parse_lcov_version(s: &str) -> Result<Version, String> {
    let vr = VersionReq::parse(&format!("={s}")).map_err(|e| e.to_string())?;
    let [c] = &vr.comparators[..] else {
        return Err("invalid version".to_string());
    };
    if c.op != semver::Op::Exact {
        return Err("invalid version".to_string());
    }
    if !c.pre.is_empty() {
        return Err("pre-releases are not supported".to_string());
    }
    Ok(Version::new(c.major, c.minor.unwrap_or(0), c.patch.unwrap_or(0)))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_matches_cli_defaults() {
        let cfg = CoverageConfig::default();
        assert_eq!(cfg.report, vec![CoverageReportKind::Summary]);
        assert_eq!(cfg.lcov_version, Version::new(1, 0, 0));
        assert!(!cfg.ir_minimum);
        assert!(cfg.report_file.is_none());
        assert!(!cfg.include_libs);
        assert!(!cfg.exclude_tests);
        assert!(cfg.skip_files.is_empty());
    }

    #[test]
    fn deserialize_from_toml() {
        let toml = r#"
            report = ["summary", "lcov"]
            lcov_version = "2.2.0"
            ir_minimum = true
            report_file = "out/lcov.info"
            include_libs = true
            exclude_tests = true
            skip_files = ["test/**", "src/mocks/**"]
        "#;
        let cfg: CoverageConfig = toml::from_str(toml).unwrap();
        assert_eq!(cfg.report, vec![CoverageReportKind::Summary, CoverageReportKind::Lcov]);
        assert_eq!(cfg.lcov_version, Version::new(2, 2, 0));
        assert!(cfg.ir_minimum);
        assert_eq!(cfg.report_file.as_deref(), Some(std::path::Path::new("out/lcov.info")));
        assert!(cfg.include_libs);
        assert!(cfg.exclude_tests);
        assert_eq!(cfg.skip_files, vec!["test/**".to_string(), "src/mocks/**".to_string()]);
    }

    #[test]
    fn deserialize_partial_uses_defaults() {
        let toml = r#"skip_files = ["src/mocks/**"]"#;
        let cfg: CoverageConfig = toml::from_str(toml).unwrap();
        // Defaulted fields keep their default values.
        assert_eq!(cfg.report, vec![CoverageReportKind::Summary]);
        assert_eq!(cfg.lcov_version, Version::new(1, 0, 0));
        assert!(!cfg.ir_minimum);
        // Set field came through.
        assert_eq!(cfg.skip_files, vec!["src/mocks/**".to_string()]);
    }

    #[test]
    fn deserialize_lcov_version_accepts_cli_formats() {
        for (input, expected) in [
            ("0", Version::new(0, 0, 0)),
            ("1", Version::new(1, 0, 0)),
            ("1.0", Version::new(1, 0, 0)),
            ("1.1", Version::new(1, 1, 0)),
            ("2", Version::new(2, 0, 0)),
            ("2.2", Version::new(2, 2, 0)),
        ] {
            let cfg: CoverageConfig =
                toml::from_str(&format!(r#"lcov_version = "{input}""#)).unwrap();
            assert_eq!(cfg.lcov_version, expected);
        }
    }
}
