use std::{
env, fs,
path::{Path, PathBuf},
};
use anyhow::{Context, Result, bail};
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use serde::{Deserialize, Deserializer, de::Error as _};
use crate::{
cli::Args,
diff::DiffSource,
git,
model::{GateRule, MetricKind},
};
const CONFIG_FILE_NAME: &str = "covgate.toml";
#[derive(Debug)]
pub struct Config {
pub coverage_report: PathBuf,
pub diff_source: DiffSource,
pub gates: Vec<ConfiguredGate>,
pub markdown_output: Option<OutputSink>,
pub no_github_summary: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OutputSink {
File(PathBuf),
Stdout,
}
#[derive(Debug)]
pub struct ConfiguredGate {
pub label: Option<String>,
pub rules: Vec<GateRule>,
matcher: Option<PathMatcher>,
}
impl ConfiguredGate {
#[must_use]
pub fn is_fallback(&self) -> bool {
self.matcher.is_none()
}
#[must_use]
pub fn matches(&self, path: &Path) -> bool {
self.matcher
.as_ref()
.is_some_and(|matcher| matcher.matches(path))
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "kebab-case")]
struct FileConfig {
base: Option<String>,
markdown_output: Option<PathBuf>,
#[serde(default)]
gates: Vec<GateEntryConfig>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "kebab-case")]
struct GateEntryConfig {
name: Option<String>,
#[serde(default, deserialize_with = "deserialize_pattern_vec")]
include: Vec<String>,
#[serde(default, deserialize_with = "deserialize_pattern_vec")]
exclude: Vec<String>,
#[serde(flatten)]
rules: GateRuleConfig,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum PatternList {
Single(String),
Multiple(Vec<String>),
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "kebab-case")]
struct GateRuleConfig {
fail_under_regions: Option<f64>,
fail_under_lines: Option<f64>,
fail_under_branches: Option<f64>,
fail_under_functions: Option<f64>,
fail_under_named_functions: Option<f64>,
fail_uncovered_regions: Option<usize>,
fail_uncovered_lines: Option<usize>,
fail_uncovered_branches: Option<usize>,
fail_uncovered_functions: Option<usize>,
fail_uncovered_named_functions: Option<usize>,
}
impl TryFrom<Args> for Config {
type Error = anyhow::Error;
fn try_from(args: Args) -> Result<Self> {
let Args {
coverage_report,
markdown_output,
base: _,
diff_file: _,
no_github_summary,
} = &args;
let dir = env::current_dir()
.context("failed to determine current directory for covgate config discovery")?;
let repo_root = git::resolve_repo_root().ok().flatten();
let file_config = load_file_config_from_with_repo_root(&dir, repo_root.as_deref())?;
let match_root = repo_root.unwrap_or(dir);
let diff_source = resolve_diff_source(&args, file_config.as_ref())?;
let gates = resolve_gates(file_config.as_ref(), &match_root)?;
let markdown_output = markdown_output
.clone()
.or_else(|| {
file_config
.as_ref()
.and_then(|config| config.markdown_output.clone())
})
.map(resolve_output_sink);
Ok(Self {
coverage_report: coverage_report.clone(),
diff_source,
gates,
markdown_output,
no_github_summary: *no_github_summary,
})
}
}
fn resolve_output_sink(path: PathBuf) -> OutputSink {
if path == Path::new("-") {
OutputSink::Stdout
} else {
OutputSink::File(path)
}
}
fn config_candidate_paths(dir: &Path, repo_root: Option<&Path>) -> Vec<PathBuf> {
let mut candidates = Vec::new();
for candidate_dir in dir.ancestors() {
candidates.push(candidate_dir.join(CONFIG_FILE_NAME));
if repo_root.is_some_and(|root| candidate_dir == root) {
break;
}
}
candidates
}
fn parse_file_config(text: &str) -> Result<FileConfig> {
let parsed =
toml::from_str::<toml::Value>(text).context("failed to parse covgate config text")?;
if parsed
.get("gates")
.is_some_and(|value| matches!(value, toml::Value::Table(_)))
{
bail!("legacy [gates] table is no longer supported; use [[gates]] entries instead");
}
let config =
toml::from_str::<FileConfig>(text).context("failed to parse covgate config text")?;
validate_file_config(&config)?;
Ok(config)
}
fn deserialize_pattern_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
let patterns = PatternList::deserialize(deserializer)
.map_err(|_| D::Error::custom("expected string or list of strings"))?;
match patterns {
PatternList::Single(pattern) => Ok(vec![pattern]),
PatternList::Multiple(patterns) => Ok(patterns),
}
}
fn load_file_config_from_with_repo_root(
dir: &Path,
repo_root: Option<&Path>,
) -> Result<Option<FileConfig>> {
for path in config_candidate_paths(dir, repo_root) {
if !path.exists() {
continue;
}
let text = fs::read_to_string(&path)
.with_context(|| format!("failed to read config file: {}", path.display()))?;
let config = parse_file_config(&text)
.context("failed to parse covgate config text")
.with_context(|| format!("failed to parse config file: {}", path.display()))?;
return Ok(Some(config));
}
Ok(None)
}
fn resolve_diff_source(args: &Args, file_config: Option<&FileConfig>) -> Result<DiffSource> {
match (args.base.clone(), args.diff_file.clone()) {
(Some(base), None) => Ok(DiffSource::GitBase(base)),
(None, Some(path)) => Ok(DiffSource::DiffFile(path)),
(Some(_), Some(_)) => bail!("--base and --diff-file are mutually exclusive"),
(None, None) => {
if let Some(base) = file_config.and_then(|config| config.base.clone()) {
Ok(DiffSource::GitBase(base))
} else if let Some(base) = git::discover_base_ref()? {
Ok(DiffSource::GitBase(base))
} else {
bail!(
"unable to determine a base ref automatically. Try one of: pass --base <REF>; run covgate record-base; create {} manually with `git update-ref {} HEAD`; or configure {} with a base value",
git::RECORDED_BASE_REF,
git::RECORDED_BASE_REF,
CONFIG_FILE_NAME,
)
}
}
}
}
fn validate_file_config(config: &FileConfig) -> Result<()> {
let mut seen_names = std::collections::BTreeSet::new();
let mut fallback_count = 0usize;
for gate in &config.gates {
if gate.include.is_empty() {
fallback_count += 1;
if !gate.exclude.is_empty() {
bail!("gate `exclude` requires `include`");
}
}
if let Some(name) = &gate.name
&& !seen_names.insert(name.clone())
{
bail!("duplicate gate name `{name}`");
}
}
if fallback_count > 1 {
bail!("only one fallback gate is allowed");
}
Ok(())
}
fn resolve_gates(
file_config: Option<&FileConfig>,
match_root: &Path,
) -> Result<Vec<ConfiguredGate>> {
let mut configured = Vec::new();
if let Some(config) = file_config {
for (index, gate) in config.gates.iter().enumerate() {
let is_fallback = gate.include.is_empty();
let rules = gate_rules_from_config(&gate.rules);
if rules.is_empty() {
let label = match &gate.name {
Some(label) => label.clone(),
None => format!("gate #{}", index + 1),
};
bail!("gate `{label}` has no rules configured");
}
let matcher = if is_fallback {
None
} else {
Some(PathMatcher::new(match_root, &gate.include, &gate.exclude)?)
};
configured.push(ConfiguredGate {
label: gate
.name
.clone()
.or_else(|| derive_scoped_gate_label(&gate.include, index + 1)),
rules,
matcher,
});
}
}
if configured.is_empty() {
bail!("at least one rule is required; configure a [[gates]] entry in {CONFIG_FILE_NAME}")
}
let configured = configured;
Ok(configured)
}
fn gate_rules_from_config(config: &GateRuleConfig) -> Vec<GateRule> {
let mut configured = Vec::new();
push_percent_rule(
&mut configured,
MetricKind::Region,
config.fail_under_regions,
);
push_percent_rule(&mut configured, MetricKind::Line, config.fail_under_lines);
push_percent_rule(
&mut configured,
MetricKind::Branch,
config.fail_under_branches,
);
push_percent_rule(
&mut configured,
MetricKind::Function,
config.fail_under_functions,
);
push_percent_rule(
&mut configured,
MetricKind::NamedFunction,
config.fail_under_named_functions,
);
push_uncovered_rule(
&mut configured,
MetricKind::Region,
config.fail_uncovered_regions,
);
push_uncovered_rule(
&mut configured,
MetricKind::Line,
config.fail_uncovered_lines,
);
push_uncovered_rule(
&mut configured,
MetricKind::Branch,
config.fail_uncovered_branches,
);
push_uncovered_rule(
&mut configured,
MetricKind::Function,
config.fail_uncovered_functions,
);
push_uncovered_rule(
&mut configured,
MetricKind::NamedFunction,
config.fail_uncovered_named_functions,
);
configured
}
fn push_percent_rule(configured: &mut Vec<GateRule>, metric: MetricKind, value: Option<f64>) {
if let Some(minimum_percent) = value {
configured.push(GateRule::Percent {
metric,
minimum_percent,
});
}
}
fn push_uncovered_rule(configured: &mut Vec<GateRule>, metric: MetricKind, value: Option<usize>) {
if let Some(maximum_count) = value {
configured.push(GateRule::UncoveredCount {
metric,
maximum_count,
});
}
}
fn derive_scoped_gate_label(include: &[String], index: usize) -> Option<String> {
if include.is_empty() {
return None;
}
let joined = include.join(", ");
if joined.chars().count() <= 40 {
Some(joined)
} else {
Some(format!("gate #{index}"))
}
}
#[derive(Debug, Clone)]
struct PathMatcher {
include: Gitignore,
exclude: Gitignore,
}
impl PathMatcher {
fn new(root: &Path, include: &[String], exclude: &[String]) -> Result<Self> {
let include = build_pattern_matcher(root, include)?;
let exclude = build_pattern_matcher(root, exclude)?;
Ok(Self { include, exclude })
}
fn matches(&self, path: &Path) -> bool {
let include_match = self.include.matched(path, false);
if !include_match.is_ignore() && !include_match.is_whitelist() {
return false;
}
let exclude_match = self.exclude.matched(path, false);
if exclude_match.is_ignore() {
return false;
}
true
}
}
fn build_pattern_matcher(root: &Path, patterns: &[String]) -> Result<Gitignore> {
let mut builder = GitignoreBuilder::new(root);
for pattern in patterns {
builder
.add_line(None, pattern)
.context(format!("invalid gate pattern `{pattern}`"))?;
}
builder.build().context("failed to build gate matcher")
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::{
PathMatcher, config_candidate_paths, derive_scoped_gate_label, parse_file_config,
resolve_diff_source, resolve_gates,
};
use crate::{
cli::Args,
diff::DiffSource,
model::{GateRule, MetricKind},
};
fn gate_rules(gates: &[super::ConfiguredGate]) -> Vec<GateRule> {
gates
.iter()
.flat_map(|gate| gate.rules.iter().cloned())
.collect()
}
#[test]
fn loads_defaults_from_repo_config() {
let file_config = parse_file_config(
"base = \"main\"\n[[gates]]\nfail-under-regions = 75\nfail-uncovered-lines = 2\n",
)
.expect("config should parse");
let args = Args {
coverage_report: "coverage.json".into(),
base: None,
diff_file: None,
markdown_output: None,
no_github_summary: false,
};
let diff_source =
resolve_diff_source(&args, Some(&file_config)).expect("diff source should resolve");
let rules = gate_rules(
&resolve_gates(Some(&file_config), std::path::Path::new("."))
.expect("gates should resolve"),
);
match diff_source {
DiffSource::GitBase(base) => assert_eq!(base, "main"),
DiffSource::DiffFile(_) => panic!("expected git base"),
}
assert_eq!(rules.len(), 2);
assert!(rules.contains(&GateRule::Percent {
metric: MetricKind::Region,
minimum_percent: 75.0
}));
assert!(rules.contains(&GateRule::UncoveredCount {
metric: MetricKind::Line,
maximum_count: 2
}));
}
#[test]
fn loads_function_rules_from_repo_config() {
let file_config = parse_file_config(
"base = \"main\"\n[[gates]]\nfail-under-functions = 100\nfail-uncovered-functions = 0\n",
)
.expect("config should parse");
let rules = gate_rules(
&resolve_gates(Some(&file_config), std::path::Path::new("."))
.expect("gates should resolve"),
);
assert_eq!(rules.len(), 2);
assert!(rules.contains(&GateRule::Percent {
metric: MetricKind::Function,
minimum_percent: 100.0
}));
assert!(rules.contains(&GateRule::UncoveredCount {
metric: MetricKind::Function,
maximum_count: 0
}));
}
#[test]
fn resolve_gates_rejects_named_gate_without_rules() {
let file_config =
parse_file_config("[[gates]]\nname = \"js-ui\"\ninclude = [\"**/*.tsx\"]\n")
.expect("config should parse");
let error = resolve_gates(Some(&file_config), std::path::Path::new("."))
.expect_err("gate without rules should fail");
assert!(error.to_string().contains("js-ui"));
assert!(error.to_string().contains("no rules"));
}
#[test]
fn derive_scoped_gate_label_falls_back_for_long_globs() {
let label = derive_scoped_gate_label(
&[
"**/*.really-long-typescript-file-pattern.ts".to_string(),
"**/*.equally-long-component-pattern.tsx".to_string(),
],
3,
);
assert_eq!(label.as_deref(), Some("gate #3"));
}
#[test]
fn resolve_gates_rejects_invalid_pattern() {
let file_config = parse_file_config(
"[[gates]]\nname = \"invalid\"\ninclude = [\"foo\\\\\"]\nfail-under-lines = 90\n",
)
.expect("config should parse");
let error = resolve_gates(Some(&file_config), std::path::Path::new("."))
.expect_err("invalid pattern should fail");
assert!(error.to_string().contains("invalid gate pattern"));
}
#[test]
fn path_matcher_matches_nested_tsx_paths() {
let temp = tempfile::tempdir().expect("tempdir should exist");
let matcher = PathMatcher::new(temp.path(), &["**/*.tsx".to_string()], &[])
.expect("matcher should build");
assert!(matcher.matches(std::path::Path::new("web/src/features/chat/Chat.tsx")));
}
#[test]
fn config_candidate_paths_stop_at_repo_root() {
let dir = std::path::Path::new("/workspace/repo/nested/deeper");
let repo_root = std::path::Path::new("/workspace/repo");
let candidates = config_candidate_paths(dir, Some(repo_root));
assert_eq!(
candidates,
vec![
PathBuf::from("/workspace/repo/nested/deeper/covgate.toml"),
PathBuf::from("/workspace/repo/nested/covgate.toml"),
PathBuf::from("/workspace/repo/covgate.toml"),
]
);
}
#[test]
fn config_candidate_paths_walk_to_filesystem_root_when_repo_root_is_unknown() {
let dir = std::path::Path::new("/workspace/repo/nested");
let candidates = config_candidate_paths(dir, None);
assert_eq!(
candidates,
vec![
PathBuf::from("/workspace/repo/nested/covgate.toml"),
PathBuf::from("/workspace/repo/covgate.toml"),
PathBuf::from("/workspace/covgate.toml"),
PathBuf::from("/covgate.toml"),
]
);
}
#[test]
fn parses_file_config_from_toml_text() {
let config = parse_file_config(
"base = \"main\"\nmarkdown-output = \"summary.md\"\n[[gates]]\nfail-under-regions = 80\n",
)
.expect("config should parse");
assert_eq!(config.base.as_deref(), Some("main"));
assert_eq!(config.markdown_output, Some(PathBuf::from("summary.md")));
assert_eq!(config.gates.len(), 1);
assert_eq!(config.gates[0].rules.fail_under_regions, Some(80.0));
}
#[test]
fn parse_file_config_reports_invalid_toml() {
let error = parse_file_config("not = [valid toml").expect_err("config should fail");
assert!(
error
.to_string()
.contains("failed to parse covgate config text")
);
}
#[test]
fn file_config_defaults_empty_gates() {
let config = parse_file_config("").expect("empty config should parse");
assert!(config.base.is_none());
assert!(resolve_gates(Some(&config), std::path::Path::new(".")).is_err());
}
#[test]
fn parses_path_scoped_gates_from_toml_text() {
let config = parse_file_config(
"base = \"main\"\n[[gates]]\nname = \"js-logic\"\ninclude = [\"**/*.ts\"]\nfail-under-lines = 95\n\n[[gates]]\nfail-under-lines = 90\n",
)
.expect("config should parse");
assert_eq!(config.base.as_deref(), Some("main"));
}
#[test]
fn parse_file_config_rejects_duplicate_gate_names() {
let error = parse_file_config(
"[[gates]]\nname = \"dup\"\ninclude = [\"**/*.ts\"]\nfail-under-lines = 95\n\n[[gates]]\nname = \"dup\"\ninclude = [\"**/*.tsx\"]\nfail-under-lines = 80\n",
)
.expect_err("config should fail");
let error_text = error.to_string();
assert!(error_text.contains("duplicate"));
assert!(error_text.contains("dup"));
}
#[test]
fn parse_file_config_rejects_exclude_without_include() {
let error =
parse_file_config("[[gates]]\nexclude = [\"**/*.tsx\"]\nfail-under-lines = 80\n")
.expect_err("config should fail");
let error_text = error.to_string();
assert!(error_text.contains("exclude"));
assert!(error_text.contains("include"));
}
#[test]
fn parses_single_string_include_and_exclude() {
let config = parse_file_config(
"[[gates]]\ninclude = \"**/*.ts\"\nexclude = \"**/*.test.ts\"\nfail-under-lines = 90\n",
)
.expect("config should parse with single string include/exclude");
assert_eq!(config.gates[0].include, ["**/*.ts"]);
assert_eq!(config.gates[0].exclude, ["**/*.test.ts"]);
}
#[test]
fn parses_sequence_include_and_exclude() {
let config = parse_file_config(
"[[gates]]\ninclude = [\"**/*.ts\", \"**/*.js\"]\nexclude = [\"**/*.test.ts\", \"**/*.test.js\"]\nfail-under-lines = 90\n",
)
.expect("config should parse with sequence include/exclude");
assert_eq!(config.gates[0].include, ["**/*.ts", "**/*.js"]);
assert_eq!(config.gates[0].exclude, ["**/*.test.ts", "**/*.test.js"]);
}
#[test]
fn parse_file_config_rejects_invalid_include_type() {
let error = parse_file_config("[[gates]]\ninclude = 42\nfail-under-lines = 90\n")
.expect_err("config should fail with invalid include type");
assert!(format!("{error:#}").contains("include"));
}
#[test]
fn parse_file_config_rejects_invalid_include_list_item_type() {
let error =
parse_file_config("[[gates]]\ninclude = [\"**/*.rs\", 42]\nfail-under-lines = 90\n")
.expect_err("config should fail with invalid include item type");
assert!(format!("{error:#}").contains("include"));
}
#[test]
fn parse_file_config_rejects_multiple_fallback_gates() {
let error = parse_file_config(
"[[gates]]\nfail-under-lines = 90\n\n[[gates]]\nfail-under-branches = 80\n",
)
.expect_err("config should fail");
let error_text = error.to_string();
assert!(error_text.contains("fallback"));
assert!(error_text.contains("one"));
}
#[test]
fn parse_file_config_rejects_legacy_gate_table_with_actionable_error() {
let error = parse_file_config("[gates]\nfail-under-lines = 90\n")
.expect_err("legacy config should fail");
let error_text = error.to_string();
assert!(error_text.contains("[gates]"));
assert!(error_text.contains("[[gates]]"));
}
}