use std::collections::BTreeMap;
use serde::Deserialize;
use crate::coverage::{MatchSource, VtCheck};
const DEFAULT_TIMEOUT_SECS: u64 = 300;
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case", default)]
pub(crate) struct VerificationConfig {
command: Option<Vec<String>>,
default_source: Option<MatchSource>,
timeout_secs: Option<u64>,
aliases: BTreeMap<String, Vec<String>>,
quick: Option<Vec<String>>,
commit: Option<Vec<String>>,
gate: Option<Vec<String>>,
regression: Option<Vec<String>>,
}
impl VerificationConfig {
pub(crate) fn timeout_secs(&self) -> u64 {
self.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS)
}
pub(crate) fn regression_argv(&self) -> Vec<String> {
self.regression.clone().unwrap_or_else(|| {
DEFAULT_REGRESSION
.iter()
.map(|s| (*s).to_string())
.collect()
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Resolved {
pub(crate) argv: Vec<String>,
pub(crate) source: MatchSource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ResolveError {
BothAliasAndCommand,
UnknownAlias,
NoRunnable,
}
pub(crate) fn resolve(cfg: &VerificationConfig, check: &VtCheck) -> Result<Resolved, ResolveError> {
if check.alias.is_some() && check.command.is_some() {
return Err(ResolveError::BothAliasAndCommand);
}
let mut argv = match (&check.alias, &check.command) {
(Some(alias), _) => cfg
.aliases
.get(alias)
.cloned()
.ok_or(ResolveError::UnknownAlias)?,
(None, Some(command)) => command.clone(),
(None, None) => cfg.command.clone().ok_or(ResolveError::NoRunnable)?,
};
argv.extend(check.extra_args.iter().cloned());
let source = check
.matcher
.as_ref()
.and_then(|m| m.source.clone())
.or_else(|| cfg.default_source.clone())
.unwrap_or(MatchSource::Stdout);
Ok(Resolved { argv, source })
}
const DEFAULT_COMMIT: &[&str] = &["just", "check"];
const DEFAULT_GATE: &[&str] = &["just", "gate"];
const DEFAULT_REGRESSION: &[&str] = &["cargo", "test", "--no-fail-fast"];
const QUICK_UNSET_NOTE: &str = "doctrine check quick: no [verification].quick set — skipping";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CheckKind {
Quick,
Commit,
Gate,
}
impl CheckKind {
pub(crate) fn key(self) -> &'static str {
match self {
CheckKind::Quick => "quick",
CheckKind::Commit => "commit",
CheckKind::Gate => "gate",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum CheckPlan {
Run(Vec<String>),
Noop(&'static str),
Empty(CheckKind),
}
pub(crate) fn resolve_check(cfg: &VerificationConfig, kind: CheckKind) -> CheckPlan {
let override_argv = match kind {
CheckKind::Quick => &cfg.quick,
CheckKind::Commit => &cfg.commit,
CheckKind::Gate => &cfg.gate,
};
match override_argv {
Some(argv) if argv.is_empty() => CheckPlan::Empty(kind),
Some(argv) => CheckPlan::Run(argv.clone()),
None => match kind {
CheckKind::Quick => CheckPlan::Noop(QUICK_UNSET_NOTE),
CheckKind::Commit => CheckPlan::Run(owned(DEFAULT_COMMIT)),
CheckKind::Gate => CheckPlan::Run(owned(DEFAULT_GATE)),
},
}
}
fn owned(argv: &[&str]) -> Vec<String> {
argv.iter().map(|s| (*s).to_owned()).collect()
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
reason = "tests: fail-fast unwrap on parse/resolve is idiomatic"
)]
mod tests {
use super::*;
use crate::coverage::Matcher;
fn vtcheck(
alias: Option<&str>,
command: Option<Vec<&str>>,
extra_args: Vec<&str>,
matcher: Option<Matcher>,
) -> VtCheck {
VtCheck {
alias: alias.map(str::to_owned),
command: command.map(|c| c.into_iter().map(str::to_owned).collect()),
extra_args: extra_args.into_iter().map(str::to_owned).collect(),
matcher,
}
}
fn matcher(source: Option<MatchSource>, pattern: &str) -> Matcher {
Matcher {
source,
pattern: pattern.to_owned(),
regex: false,
}
}
#[test]
fn full_verification_table_parses() {
let cfg = crate::dtoml::parse(
"[verification]\n\
command = [\"just\", \"check\"]\n\
default-source = \"stdout\"\n\
timeout-secs = 120\n\
[verification.aliases]\n\
unit = [\"cargo\", \"test\"]\n",
)
.unwrap()
.verification;
assert_eq!(
cfg.command,
Some(vec!["just".to_owned(), "check".to_owned()])
);
assert_eq!(cfg.default_source, Some(MatchSource::Stdout));
assert_eq!(cfg.timeout_secs(), 120);
assert_eq!(
cfg.aliases.get("unit"),
Some(&vec!["cargo".to_owned(), "test".to_owned()])
);
}
#[test]
fn absent_verification_table_yields_default_and_baked_timeout() {
let cfg = crate::dtoml::parse("title = \"unrelated\"\n")
.unwrap()
.verification;
assert_eq!(cfg, VerificationConfig::default());
assert_eq!(cfg.timeout_secs(), 300);
}
#[test]
fn absent_conduct_still_yields_conduct_defaults_through_dtoml() {
let doc = crate::dtoml::parse("[verification]\ncommand = [\"x\"]\n").unwrap();
assert_eq!(doc.conduct, crate::conduct::ConductConfig::default());
}
#[test]
fn known_alias_resolves_to_its_base_argv() {
let mut aliases = BTreeMap::new();
aliases.insert(
"unit".to_owned(),
vec!["cargo".to_owned(), "test".to_owned()],
);
let cfg = VerificationConfig {
aliases,
..Default::default()
};
let check = vtcheck(Some("unit"), None, vec![], Some(matcher(None, "ok")));
let resolved = resolve(&cfg, &check).unwrap();
assert_eq!(resolved.argv, vec!["cargo".to_owned(), "test".to_owned()]);
assert_eq!(resolved.source, MatchSource::Stdout);
}
#[test]
fn unknown_alias_errors() {
let cfg = VerificationConfig::default();
let check = vtcheck(Some("missing"), None, vec![], Some(matcher(None, "ok")));
assert_eq!(resolve(&cfg, &check), Err(ResolveError::UnknownAlias));
}
#[test]
fn both_alias_and_command_errors() {
let cfg = VerificationConfig::default();
let check = vtcheck(
Some("unit"),
Some(vec!["cargo", "test"]),
vec![],
Some(matcher(None, "ok")),
);
assert_eq!(
resolve(&cfg, &check),
Err(ResolveError::BothAliasAndCommand)
);
}
#[test]
fn default_base_uses_config_command() {
let cfg = VerificationConfig {
command: Some(vec!["just".to_owned(), "check".to_owned()]),
..Default::default()
};
let check = vtcheck(None, None, vec![], Some(matcher(None, "ok")));
let resolved = resolve(&cfg, &check).unwrap();
assert_eq!(resolved.argv, vec!["just".to_owned(), "check".to_owned()]);
}
#[test]
fn default_base_with_no_config_command_errors() {
let cfg = VerificationConfig::default();
let check = vtcheck(None, None, vec![], Some(matcher(None, "ok")));
assert_eq!(resolve(&cfg, &check), Err(ResolveError::NoRunnable));
}
#[test]
fn literal_command_is_taken_verbatim_and_extra_args_append() {
let cfg = VerificationConfig::default();
let check = vtcheck(
None,
Some(vec!["cargo", "test"]),
vec!["--quiet", "--", "mymod"],
None,
);
let resolved = resolve(&cfg, &check).unwrap();
assert_eq!(
resolved.argv,
vec![
"cargo".to_owned(),
"test".to_owned(),
"--quiet".to_owned(),
"--".to_owned(),
"mymod".to_owned(),
],
"argv == base ++ extra_args"
);
}
#[test]
fn extra_args_append_to_alias_base() {
let mut aliases = BTreeMap::new();
aliases.insert(
"unit".to_owned(),
vec!["cargo".to_owned(), "test".to_owned()],
);
let cfg = VerificationConfig {
aliases,
..Default::default()
};
let check = vtcheck(
Some("unit"),
None,
vec!["--release"],
Some(matcher(None, "ok")),
);
let resolved = resolve(&cfg, &check).unwrap();
assert_eq!(
resolved.argv,
vec![
"cargo".to_owned(),
"test".to_owned(),
"--release".to_owned()
]
);
}
#[test]
fn source_precedence_entry_matcher_wins() {
let cfg = VerificationConfig {
command: Some(vec!["x".to_owned()]),
default_source: Some(MatchSource::Stderr),
..Default::default()
};
let check = vtcheck(
None,
None,
vec![],
Some(matcher(Some(MatchSource::Stdout), "ok")),
);
assert_eq!(resolve(&cfg, &check).unwrap().source, MatchSource::Stdout);
}
#[test]
fn source_precedence_falls_to_default_source() {
let cfg = VerificationConfig {
command: Some(vec!["x".to_owned()]),
default_source: Some(MatchSource::Stderr),
..Default::default()
};
let check = vtcheck(None, None, vec![], Some(matcher(None, "ok")));
assert_eq!(resolve(&cfg, &check).unwrap().source, MatchSource::Stderr);
}
#[test]
fn source_precedence_falls_to_stdout() {
let cfg = VerificationConfig {
command: Some(vec!["x".to_owned()]),
..Default::default()
};
let check = vtcheck(None, None, vec![], None);
assert_eq!(resolve(&cfg, &check).unwrap().source, MatchSource::Stdout);
}
#[test]
fn check_override_keys_deserialize_on_verification_config() {
let cfg = crate::dtoml::parse(
"[verification]\n\
quick = [\"echo\", \"q\"]\n\
commit = [\"just\", \"check\"]\n\
gate = [\"just\", \"gate\"]\n",
)
.unwrap()
.verification;
assert_eq!(cfg.quick, Some(vec!["echo".to_owned(), "q".to_owned()]));
assert_eq!(
cfg.commit,
Some(vec!["just".to_owned(), "check".to_owned()])
);
assert_eq!(cfg.gate, Some(vec!["just".to_owned(), "gate".to_owned()]));
}
#[test]
fn absent_table_yields_all_none_check_overrides() {
let cfg = crate::dtoml::parse("title = \"unrelated\"\n")
.unwrap()
.verification;
assert_eq!(cfg.quick, None);
assert_eq!(cfg.commit, None);
assert_eq!(cfg.gate, None);
assert_eq!(cfg.command, None);
}
fn cfg_with(
quick: Option<Vec<&str>>,
commit: Option<Vec<&str>>,
gate: Option<Vec<&str>>,
) -> VerificationConfig {
let own = |o: Option<Vec<&str>>| o.map(|v| v.into_iter().map(str::to_owned).collect());
VerificationConfig {
quick: own(quick),
commit: own(commit),
gate: own(gate),
..Default::default()
}
}
fn run(argv: &[&str]) -> CheckPlan {
CheckPlan::Run(argv.iter().map(|s| (*s).to_owned()).collect())
}
#[test]
fn resolve_check_override_present_runs_it_verbatim() {
let cfg = cfg_with(
Some(vec!["cargo", "test"]),
Some(vec!["make", "ci"]),
Some(vec!["nix", "flake", "check"]),
);
assert_eq!(
resolve_check(&cfg, CheckKind::Quick),
run(&["cargo", "test"])
);
assert_eq!(resolve_check(&cfg, CheckKind::Commit), run(&["make", "ci"]));
assert_eq!(
resolve_check(&cfg, CheckKind::Gate),
run(&["nix", "flake", "check"])
);
}
#[test]
fn resolve_check_unconfigured_quick_is_owned_noop() {
let cfg = cfg_with(None, None, None);
assert_eq!(
resolve_check(&cfg, CheckKind::Quick),
CheckPlan::Noop(QUICK_UNSET_NOTE)
);
}
#[test]
fn resolve_check_unconfigured_commit_and_gate_use_defaults() {
let cfg = cfg_with(None, None, None);
assert_eq!(resolve_check(&cfg, CheckKind::Commit), run(DEFAULT_COMMIT));
assert_eq!(resolve_check(&cfg, CheckKind::Gate), run(DEFAULT_GATE));
}
#[test]
fn resolve_check_empty_override_routes_to_keyed_error_not_run() {
let cfg = cfg_with(Some(vec![]), Some(vec![]), Some(vec![]));
assert_eq!(
resolve_check(&cfg, CheckKind::Quick),
CheckPlan::Empty(CheckKind::Quick)
);
assert_eq!(
resolve_check(&cfg, CheckKind::Commit),
CheckPlan::Empty(CheckKind::Commit)
);
assert_eq!(
resolve_check(&cfg, CheckKind::Gate),
CheckPlan::Empty(CheckKind::Gate)
);
}
#[test]
fn check_kind_key_is_the_config_key_spelling() {
assert_eq!(CheckKind::Quick.key(), "quick");
assert_eq!(CheckKind::Commit.key(), "commit");
assert_eq!(CheckKind::Gate.key(), "gate");
}
}