use std::path::Path;
use std::process::Command;
use runemark::Verdict;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Gate {
WorkingTreeClean,
VersionUnreleased,
}
impl Gate {
pub fn name(self) -> &'static str {
match self {
Self::WorkingTreeClean => "Working tree clean",
Self::VersionUnreleased => "Version not yet tagged",
}
}
pub fn check(self, root: &Path, version: Option<&str>) -> GateResult {
match self {
Self::WorkingTreeClean => match git(root, &["status", "--porcelain"]) {
Some(output) if output.trim().is_empty() => GateResult::pass(),
Some(output) => {
let changed = output.lines().count();
GateResult::fail(format!("{changed} uncommitted change(s)"))
}
None => GateResult::skip("not a git repository"),
},
Self::VersionUnreleased => {
let Some(version) = version else {
return GateResult::skip("package.json has no version");
};
match git(root, &["tag", "--list", &format!("v{version}")]) {
Some(output) if output.trim().is_empty() => GateResult::pass(),
Some(_) => GateResult::fail(format!("v{version} is already tagged")),
None => GateResult::skip("not a git repository"),
}
}
}
}
}
#[derive(Debug, Clone)]
pub struct GateResult {
pub verdict: Verdict,
pub detail: Option<String>,
}
impl GateResult {
fn pass() -> Self {
Self {
verdict: Verdict::Passed,
detail: None,
}
}
fn fail(detail: impl Into<String>) -> Self {
Self {
verdict: Verdict::Failed,
detail: Some(detail.into()),
}
}
fn skip(reason: impl Into<String>) -> Self {
Self {
verdict: Verdict::Skipped,
detail: Some(reason.into()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Workflow {
Commit,
Release,
}
impl Workflow {
pub fn parse(name: &str) -> Option<Self> {
match name {
"commit" => Some(Self::Commit),
"release" => Some(Self::Release),
_ => None,
}
}
pub fn label(self) -> &'static str {
match self {
Self::Commit => "before commit",
Self::Release => "before release",
}
}
pub fn gates(self) -> &'static [Gate] {
match self {
Self::Commit => &[],
Self::Release => &[Gate::WorkingTreeClean, Gate::VersionUnreleased],
}
}
pub fn includes(self, check: &str) -> bool {
match self {
Self::Release => true,
Self::Commit => !matches!(check, "Tests" | "Dead code"),
}
}
}
fn git(root: &Path, args: &[&str]) -> Option<String> {
let output = Command::new("git")
.args(args)
.current_dir(root)
.output()
.ok()?;
output
.status
.success()
.then(|| String::from_utf8_lossy(&output.stdout).into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn workflows_are_named() {
assert_eq!(Workflow::parse("commit"), Some(Workflow::Commit));
assert_eq!(Workflow::parse("release"), Some(Workflow::Release));
assert_eq!(Workflow::parse("deploy"), None);
}
#[test]
fn commit_leaves_out_the_slow_checks() {
assert!(!Workflow::Commit.includes("Tests"));
assert!(!Workflow::Commit.includes("Dead code"));
assert!(Workflow::Commit.includes("Lint & format"));
assert!(Workflow::Commit.includes("TypeScript"));
assert!(Workflow::Commit.includes("Secrets"));
}
#[test]
fn release_runs_everything() {
for check in ["Tests", "Dead code", "TypeScript", "Secrets"] {
assert!(Workflow::Release.includes(check));
}
}
#[test]
fn only_release_asks_about_the_repository() {
assert!(Workflow::Commit.gates().is_empty());
assert_eq!(Workflow::Release.gates().len(), 2);
}
#[test]
fn a_gate_outside_a_repository_is_skipped_not_failed() {
let dir = tempfile::tempdir().expect("temp dir");
let result = Gate::WorkingTreeClean.check(dir.path(), None);
assert_eq!(result.verdict, Verdict::Skipped);
}
#[test]
fn a_missing_version_skips_the_tag_question() {
let dir = tempfile::tempdir().expect("temp dir");
let result = Gate::VersionUnreleased.check(dir.path(), None);
assert_eq!(result.verdict, Verdict::Skipped);
assert_eq!(
result.detail.as_deref(),
Some("package.json has no version")
);
}
#[test]
fn a_dirty_tree_fails_and_says_how_dirty() {
let dir = tempfile::tempdir().expect("temp dir");
assert!(
git(dir.path(), &["init"]).is_some(),
"git must be available"
);
std::fs::write(dir.path().join("new.txt"), "x").expect("write");
let result = Gate::WorkingTreeClean.check(dir.path(), None);
assert_eq!(result.verdict, Verdict::Failed);
assert!(result.detail.expect("detail").contains("1 uncommitted"));
}
#[test]
fn a_clean_tree_passes() {
let dir = tempfile::tempdir().expect("temp dir");
assert!(git(dir.path(), &["init"]).is_some());
assert_eq!(
Gate::WorkingTreeClean.check(dir.path(), None).verdict,
Verdict::Passed
);
}
}