mod artifacts;
mod error;
mod message;
mod process;
mod review;
mod review_context;
mod risk;
mod settings;
mod text;
use self::{
artifacts::{
ARTIFACT_DIR, ATTEMPT_DIR, AttemptPaths, ReviewCachePaths, archive_old_attempt_artifacts,
create_artifact_dirs,
},
error::{ElenchusError, Result},
message::CommitMessage,
process::{
checked_command, diff_line_count, git_checked, git_diff_binary_to, git_hash_file,
git_hash_stdin, git_status, git_status_silent, git_text, is_non_empty_file, meta_contains,
random_hex, require_on_path, same_file_bytes,
},
risk::{RiskState, detect_risk_reasons},
settings::Settings,
text::{prefix_lines, shell_quote, short_hash},
};
use std::{
env, fs,
path::{Path, PathBuf},
process::{Command, ExitCode},
};
pub struct ElenchusExit {
code: ExitCode,
}
impl ElenchusExit {
#[must_use]
pub const fn into_exit_code(self) -> ExitCode {
self.code
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Args {
words: Vec<String>,
}
impl Args {
#[must_use]
pub const fn new(words: Vec<String>) -> Self {
Self { words }
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum Request {
Elenchus {
message: Option<String>,
},
Approve,
}
impl Request {
fn parse(args: Args) -> Result<Self> {
let Args { words } = args;
match words.as_slice() {
[command] if command == "approve" => Ok(Self::Approve),
[message] => Ok(Self::Elenchus {
message: Some(message.clone()),
}),
[] => Ok(Self::Elenchus { message: None }),
_ => Err(ElenchusError::usage(
"error: elenchus expects one commit message or `alma elenchus approve`",
)),
}
}
}
#[must_use]
pub fn run(args: Args) -> ElenchusExit {
let result = Request::parse(args).and_then(|request| Gate::new(request)?.run());
match result {
Ok(()) => ElenchusExit {
code: ExitCode::SUCCESS,
},
Err(error) => {
println!("{error}");
print_failure_worktree_status();
ElenchusExit {
code: ExitCode::from(error.code),
}
}
}
}
fn print_failure_worktree_status() {
let Ok(output) = Command::new("git").args(["status", "--short"]).output() else {
return;
};
if !output.status.success() || output.stdout.is_empty() {
return;
}
println!();
println!("elenchus failed; uncommitted changes remain:");
print!("{}", String::from_utf8_lossy(&output.stdout));
}
fn metadata_value<'a>(metadata: &'a str, label: &str) -> Option<&'a str> {
let prefix = format!("{label}: ");
metadata.lines().find_map(|line| line.strip_prefix(&prefix))
}
struct Gate {
message: Option<CommitMessage>,
approve: bool,
settings: Settings,
repo_root: PathBuf,
paths: AttemptPaths,
index_prepared: bool,
committed: bool,
}
impl Gate {
fn new(request: Request) -> Result<Self> {
let _git_path = require_on_path("git")?;
let _cargo_path = require_on_path("cargo")?;
let (message, approve) = match request {
Request::Elenchus { message } => (Some(CommitMessage::parse(message)?), false),
Request::Approve => (None, true),
};
let repo_root = PathBuf::from(git_text(["rev-parse", "--show-toplevel"])?);
env::set_current_dir(&repo_root).map_err(|error| {
ElenchusError::failure(format!(
"error: failed to enter repo root {}: {error}",
repo_root.display()
))
})?;
create_artifact_dirs()?;
Ok(Self {
message,
approve,
settings: Settings::from_env()?,
repo_root,
paths: AttemptPaths::new()?,
index_prepared: false,
committed: false,
})
}
fn run(&mut self) -> Result<()> {
println!("==> elenchus: validating working tree");
println!("==> elenchus: reviewer setup: deferred until review is needed");
self.validate_git_state()?;
self.prepare_index()?;
println!("==> elenchus: recording diff");
let base_head = git_text(["rev-parse", "HEAD"])?;
git_diff_binary_to(&self.paths.diff)?;
let diff_fingerprint = git_hash_file(&self.paths.diff)?;
let cache_paths = ReviewCachePaths::new(&diff_fingerprint);
cache_paths.create_dir()?;
if self.approve {
self.load_approval_from_cache(&cache_paths)?;
}
let commit_message_hash = git_hash_stdin(self.message().as_str())?;
let mut risk = RiskState::new();
self.validate_risk_token(
&mut risk,
&base_head,
&commit_message_hash,
&diff_fingerprint,
&cache_paths,
)?;
if risk.override_accepted {
println!(
"==> elenchus: risky-change override: {}",
risk.override_for_summary
);
}
let changed_lines = diff_line_count()?;
println!("changed lines: {changed_lines}");
if changed_lines > self.settings.max_lines && !risk.override_accepted {
return Err(ElenchusError::failure(format!(
"error: diff is too large for automatic elenchus: {changed_lines} lines > {}\n\
Split the work or raise ELENCHUS_MAX_LINES for a fresh elenchus review.",
self.settings.max_lines
)));
}
println!("==> elenchus: checking risky changes");
let changed_files = git_text(["diff", "--name-only"])?;
risk.reasons = detect_risk_reasons(&changed_files, &git_text(["diff", "-U0"])?);
Self::report_risk(&risk);
self.run_local_checks()?;
println!("==> elenchus: verifying checks did not mutate the worktree");
git_diff_binary_to(&self.paths.post_checks_diff)?;
if !same_file_bytes(&self.paths.diff, &self.paths.post_checks_diff)? {
return Err(ElenchusError::failure(
"error: working tree changed while running checks\n\
This can happen if tests/codegen/snapshots modified files.\n\
Review and rerun elenchus.",
));
}
self.run_or_reuse_review(&mut risk, &changed_files, &cache_paths, &diff_fingerprint)?;
self.pause_for_risk_if_needed(
&mut risk,
&cache_paths,
&base_head,
&commit_message_hash,
&diff_fingerprint,
)?;
self.stage_and_commit(&risk)?;
Ok(())
}
const fn message(&self) -> &CommitMessage {
self.message
.as_ref()
.expect("elenchus message is resolved before use")
}
fn load_approval_from_cache(&mut self, cache_paths: &ReviewCachePaths) -> Result<()> {
let metadata = fs::read_to_string(&cache_paths.meta).map_err(|error| {
ElenchusError::failure(format!(
"error: no elenchus approval cache for the current diff: {error}\n\
Run `alma elenchus \"<message>\"` first and review the paused risky diff."
))
})?;
let token = metadata_value(&metadata, "Risk acceptance token").ok_or_else(|| {
ElenchusError::failure(
"error: elenchus approval cache is missing its risk token\n\
Rerun the original elenchus to refresh the approval cache.",
)
})?;
let message = metadata_value(&metadata, "Commit message").ok_or_else(|| {
ElenchusError::failure(
"error: elenchus approval cache is missing its reviewed commit message\n\
Rerun the original elenchus to refresh the approval cache.",
)
})?;
self.message = Some(CommitMessage::parse(Some(message.to_owned()))?);
self.settings.risk_accepted_token = Some(token.to_owned());
println!("==> elenchus: approval: using cached review for current diff");
Ok(())
}
fn validate_git_state(&self) -> Result<()> {
let git_dir = PathBuf::from(git_text(["rev-parse", "--git-dir"])?);
let git_dir = if git_dir.is_absolute() {
git_dir
} else {
self.repo_root.join(git_dir)
};
if git_dir.join("rebase-merge").is_dir()
|| git_dir.join("rebase-apply").is_dir()
|| git_dir.join("MERGE_HEAD").is_file()
|| git_dir.join("CHERRY_PICK_HEAD").is_file()
{
return Err(ElenchusError::failure(
"error: merge/rebase/cherry-pick in progress; refusing elenchus",
));
}
let current_branch = git_text(["branch", "--show-current"])?;
if !self.settings.commit_policy.is_dry_run()
&& (current_branch == "main" || current_branch == "master")
&& !self.settings.main_branch_policy.allows_main()
{
return Err(ElenchusError::failure(format!(
"error: refusing automatic elenchus commit on {current_branch}\n\
Set ELENCHUS_ALLOW_MAIN=1 to override."
)));
}
if git_status(["check-ignore", "-q", ".helen/elenchus/test-ignore"])?.code() != Some(0) {
println!("warning: {ARTIFACT_DIR} is not ignored");
println!("Consider adding '.helen/elenchus/' to .gitignore.");
}
if git_status(["diff", "--cached", "--quiet", "--exit-code"])?.code() != Some(0) {
return Err(ElenchusError::failure(
"error: index already has staged changes\n\
Please unstage them or commit them before running elenchus:\n\
git restore --staged .",
));
}
Ok(())
}
fn prepare_index(&mut self) -> Result<()> {
git_checked(["add", "-N", "."])?;
self.index_prepared = true;
if git_status(["diff", "--quiet", "--exit-code"])?.code() == Some(0) {
return Err(ElenchusError::failure(
"error: no working tree changes to elenchus",
));
}
Ok(())
}
fn validate_risk_token(
&self,
risk: &mut RiskState,
base_head: &str,
commit_message_hash: &str,
diff_fingerprint: &str,
cache_paths: &ReviewCachePaths,
) -> Result<()> {
let Some(token) = self.settings.risk_accepted_token.as_deref() else {
return Ok(());
};
let review_cache_hash = if is_non_empty_file(&cache_paths.review) {
git_hash_file(&cache_paths.review)?
} else {
String::new()
};
let token_prefix = format!(
"alma-risk:{}:{}:{}:",
short_hash(diff_fingerprint),
short_hash(commit_message_hash),
short_hash(&review_cache_hash)
);
let token_valid = !review_cache_hash.is_empty()
&& is_non_empty_file(&cache_paths.review)
&& is_non_empty_file(&cache_paths.meta)
&& meta_contains(&cache_paths.meta, &format!("Base HEAD: {base_head}"))?
&& meta_contains(
&cache_paths.meta,
&format!("Commit message hash: {commit_message_hash}"),
)?
&& meta_contains(
&cache_paths.meta,
&format!("Diff fingerprint: {diff_fingerprint}"),
)?
&& meta_contains(
&cache_paths.meta,
&format!("Review hash: {review_cache_hash}"),
)?
&& meta_contains(
&cache_paths.meta,
&format!("Risk acceptance token: {token}"),
)?
&& token.starts_with(&token_prefix);
if !token_valid {
return Err(ElenchusError::failure(
"error: accepted risk token does not match a reviewed passing cache for the current diff\n\
Rerun the original elenchus command to perform a fresh review.",
));
}
risk.override_accepted = true;
risk.override_for_summary = if self.approve {
"elenchus-approve"
} else {
"accepted-token"
};
token.clone_into(&mut risk.acceptance_token);
risk.review_cache_hash = review_cache_hash;
Ok(())
}
fn report_risk(risk: &RiskState) {
if risk.has_reasons() && !risk.override_accepted {
println!(
"warning: risky change detected; elenchus will stop before commit unless accepted"
);
for reason in &risk.reasons {
println!(" - {reason}");
}
println!();
println!(
"Checks and read-only review will still run. If they pass and a human approves the risk,"
);
println!("elenchus will print a copyable approval command after the passing review.");
}
if risk.has_reasons() && risk.override_accepted {
println!("==> elenchus: risky changes accepted for review/commit");
for reason in &risk.reasons {
println!(" - {reason}");
}
}
}
fn run_local_checks(&self) -> Result<()> {
println!("==> elenchus: formatting check");
checked_command("cargo", ["fmt", "--all", "--check"])?;
println!("==> elenchus: clippy");
checked_command(
"cargo",
[
"clippy",
"--workspace",
"--all-targets",
"--all-features",
"--",
"-D",
"warnings",
],
)?;
if self.settings.test_policy.skips_tests() {
println!("==> elenchus: tests skipped via ELENCHUS_SKIP_TESTS=1");
} else {
println!("==> elenchus: tests: {}", self.settings.test_cmd);
checked_command("bash", ["-lc", self.settings.test_cmd.as_str()])?;
}
Ok(())
}
fn pause_for_risk_if_needed(
&self,
risk: &mut RiskState,
cache_paths: &ReviewCachePaths,
base_head: &str,
commit_message_hash: &str,
diff_fingerprint: &str,
) -> Result<()> {
if !risk.has_reasons() || risk.override_accepted {
return Ok(());
}
let review_cache_hash = git_hash_file(&cache_paths.review)?;
let token = format!(
"alma-risk:{}:{}:{}:{}",
short_hash(diff_fingerprint),
short_hash(commit_message_hash),
short_hash(&review_cache_hash),
random_hex()?
);
risk.acceptance_token = token;
fs::write(
&cache_paths.meta,
format!(
"Diff fingerprint: {diff_fingerprint}\n\
Base HEAD: {base_head}\n\
Commit message: {}\n\
Commit message hash: {commit_message_hash}\n\
Review hash: {review_cache_hash}\n\
Risk acceptance token: {}\n\
Review cache: {}\n",
self.message().as_str(),
risk.acceptance_token,
cache_paths.review.display()
),
)
.map_err(|error| {
ElenchusError::failure(format!(
"error: failed to write review cache metadata: {error}"
))
})?;
println!(
"elenchus paused: risky change passed checks/review but needs human acceptance before commit"
);
for reason in &risk.reasons {
println!(" - {reason}");
}
println!();
println!("review: {}", self.paths.review_out.display());
println!();
println!("After reviewing the diff and reviewer output, approve this exact diff with:");
println!(" alma elenchus approve");
println!();
println!("Approval token for audit/scripts:");
println!(" APPROVE_ELENCHUS_RISK {}", risk.acceptance_token);
println!(
" ELENCHUS_RISK_ACCEPTED={} alma elenchus {}",
shell_quote(&risk.acceptance_token),
shell_quote(self.message().as_str())
);
Err(ElenchusError::failure(String::new()))
}
fn stage_and_commit(&mut self, risk: &RiskState) -> Result<()> {
println!("==> elenchus: staging changes");
git_checked(["add", "-A"])?;
let _status = git_status_silent(["restore", "--staged", ARTIFACT_DIR])?;
if git_status(["diff", "--cached", "--quiet", "--exit-code"])?.code() == Some(0) {
return Err(ElenchusError::failure(
"error: no staged changes after excluding elenchus logs",
));
}
if self.settings.commit_policy.is_dry_run() {
git_checked(["reset", "-q", "--", "."])?;
self.archive_old_artifacts();
println!("dry run: elenchus passed, but not committing");
return Ok(());
}
println!("==> elenchus: committing");
git_checked(["commit", "-m", self.message().as_str()])?;
self.committed = true;
let commit_sha = git_text(["rev-parse", "--short", "HEAD"])?;
self.write_summary(&commit_sha, risk)?;
self.archive_old_artifacts();
println!();
println!("elenchus passed");
println!("committed: {commit_sha} {}", self.message().as_str());
println!("review: {}", self.paths.review_out.display());
println!("summary: {}", self.paths.summary.display());
Ok(())
}
fn archive_old_artifacts(&self) {
let result = archive_old_attempt_artifacts(
Path::new(ATTEMPT_DIR),
&self.paths.stamp,
self.settings.retained_attempts,
);
match result {
Ok(report) if report.archived_any() => {
println!(
"==> elenchus: archived {} old elenchus attempt(s), {} file(s)",
report.attempts, report.files
);
}
Ok(_) => {}
Err(error) => {
println!("warning: failed to archive old elenchus artifacts: {error}");
}
}
}
fn write_summary(&self, commit_sha: &str, risk: &RiskState) -> Result<()> {
let test_summary = if self.settings.test_policy.skips_tests() {
format!("{}: skipped", self.settings.test_cmd)
} else {
format!("{}: passed", self.settings.test_cmd)
};
let review = fs::read_to_string(&self.paths.review_out).map_err(|error| {
ElenchusError::failure(format!("error: failed to read review output: {error}"))
})?;
fs::write(
&self.paths.summary,
format!(
"# Elenchus {}\n\n\
Commit: {commit_sha}\n\
Message: {}\n\n\
Checks:\n\
- cargo fmt --all --check\n\
- cargo clippy --workspace --all-targets --all-features -- -D warnings\n\
- {test_summary}\n\n\
Risk:\n\
- Override: {}\n\
- Acceptance token: {}\n\
- Reasons:\n{}\n\n\
Review:\n\
- Reused prior pass: {}\n{}",
self.paths.stamp,
self.message().as_str(),
risk.override_for_summary,
risk.acceptance_token,
prefix_lines(&risk.reasons_for_review(), " "),
usize::from(risk.review_reused),
prefix_lines(&review, "> ")
),
)
.map_err(|error| {
ElenchusError::failure(format!("error: failed to write elenchus summary: {error}"))
})
}
}
impl Drop for Gate {
fn drop(&mut self) {
if self.index_prepared && !self.committed {
let _status = Command::new("git")
.args(["reset", "-q", "--", "."])
.current_dir(&self.repo_root)
.status();
}
}
}