use std::io::Read;
use std::path::Path;
use anyhow::{Context, Result};
use clap::Parser;
use crate::data::check::{CheckReport, CommitCheckResult, OutputFormat};
#[derive(Parser)]
pub struct LintCommand {
#[arg(value_name = "COMMIT_RANGE")]
pub commit_range: Option<String>,
#[arg(long)]
pub context_dir: Option<std::path::PathBuf>,
#[arg(long)]
pub guidelines: Option<std::path::PathBuf>,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Text)]
pub output: OutputFormat,
#[arg(long)]
pub strict: bool,
#[arg(long)]
pub quiet: bool,
#[arg(long)]
pub verbose: bool,
#[arg(long)]
pub show_passing: bool,
#[arg(long)]
pub stdin: bool,
}
impl LintCommand {
pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
let repo_root = match repo {
Some(p) => p.to_path_buf(),
None => std::env::current_dir().context("Failed to determine current directory")?,
};
let repo_root = repo_root.as_path();
let output_format = self.output;
let context_dir =
crate::claude::context::resolve_context_dir_at(self.context_dir.as_deref(), repo_root);
let valid_scopes = crate::claude::context::load_project_scopes(&context_dir, repo_root);
let rules = crate::claude::context::load_commit_rules(&context_dir);
if self.verbose && output_format == OutputFormat::Text {
self.show_config_status(repo_root, &context_dir, &valid_scopes, &rules);
}
let report = if self.stdin {
let mut message = String::new();
std::io::stdin()
.read_to_string(&mut message)
.context("Failed to read commit message from stdin")?;
lint_report_for_message(&message, &rules, &valid_scopes)
} else {
let range = self.resolve_range(repo_root)?;
lint_report_for_range(repo_root, &range, &rules, &valid_scopes)?
};
self.output_report(&report, output_format)?;
let exit_code = report.exit_code(self.strict);
if exit_code != 0 {
std::process::exit(exit_code);
}
Ok(())
}
fn resolve_range(&self, repo_root: &Path) -> Result<String> {
if let Some(range) = &self.commit_range {
return Ok(range.clone());
}
let repo = crate::git::GitRepository::open_at(repo_root)
.context("Failed to open git repository at the given path")?;
super::default_commit_range(&repo)
}
fn show_config_status(
&self,
_repo_root: &Path,
context_dir: &Path,
valid_scopes: &[crate::data::context::ScopeDefinition],
rules: &crate::data::context::CommitRules,
) {
use crate::claude::context::{config_source_label, ConfigSourceLabel};
println!("📋 Lint configuration:");
println!(" 📂 Config dir: {}", context_dir.display());
let scopes_source = if valid_scopes.is_empty() {
"⚪ None found (any scope accepted)".to_string()
} else {
match config_source_label(context_dir, "scopes.yaml") {
ConfigSourceLabel::NotFound => {
format!(
"✅ (ecosystem defaults only) ({} scopes)",
valid_scopes.len()
)
}
label => format!("✅ {label} ({} scopes)", valid_scopes.len()),
}
};
println!(" 🎯 Valid scopes: {scopes_source}");
let rules_source = match config_source_label(context_dir, "commit-rules.yaml") {
ConfigSourceLabel::NotFound => "⚪ Using built-in defaults".to_string(),
label => format!("✅ {label}"),
};
println!(" 📏 Commit rules: {rules_source}");
println!(
" subject_max_len={}, require_scope={}, types={}",
rules.subject_max_len,
rules.require_scope,
rules.types.len()
);
println!();
}
fn output_report(&self, report: &CheckReport, format: OutputFormat) -> Result<()> {
match format {
OutputFormat::Text => self.output_text_report(report),
OutputFormat::Json => {
let json = serde_json::to_string_pretty(report)
.context("Failed to serialize report to JSON")?;
println!("{json}");
Ok(())
}
OutputFormat::Yaml => {
let yaml =
crate::data::to_yaml(report).context("Failed to serialize report to YAML")?;
println!("{yaml}");
Ok(())
}
}
}
fn output_text_report(&self, report: &CheckReport) -> Result<()> {
use crate::data::check::IssueSeverity;
println!();
for result in &report.commits {
if result.passes && !self.show_passing {
continue;
}
if self.quiet && !has_errors_or_warnings(&result.issues) {
continue;
}
let icon = super::formatting::determine_commit_icon(result.passes, &result.issues);
let short_hash = super::formatting::truncate_hash(&result.hash);
println!("{icon} {short_hash} - \"{}\"", result.message);
for issue in &result.issues {
if self.quiet && issue.severity == IssueSeverity::Info {
continue;
}
let severity_str = super::formatting::format_severity_label(issue.severity);
println!(
" {} [{}] {}",
severity_str, issue.section, issue.explanation
);
}
println!();
}
println!(
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\
Summary: {} commits linted\n\
\x20 {} errors, {} warnings\n\
\x20 {} passed, {} with issues",
report.summary.total_commits,
report.summary.error_count,
report.summary.warning_count,
report.summary.passing_commits,
report.summary.failing_commits,
);
Ok(())
}
}
fn has_errors_or_warnings(issues: &[crate::data::check::CommitIssue]) -> bool {
use crate::data::check::IssueSeverity;
issues
.iter()
.any(|i| matches!(i.severity, IssueSeverity::Error | IssueSeverity::Warning))
}
fn lint_report_for_message(
message: &str,
rules: &crate::data::context::CommitRules,
valid_scopes: &[crate::data::context::ScopeDefinition],
) -> CheckReport {
let issues = crate::git::lint_message(message, rules, valid_scopes);
let passes = crate::git::lint_passes(&issues);
let result = CommitCheckResult {
hash: "-".to_string(),
message: message.lines().next().unwrap_or("").to_string(),
issues,
suggestion: None,
passes,
summary: None,
};
CheckReport::new(vec![result])
}
fn lint_report_for_range(
repo_root: &Path,
range: &str,
rules: &crate::data::context::CommitRules,
valid_scopes: &[crate::data::context::ScopeDefinition],
) -> Result<CheckReport> {
let repo = crate::git::GitRepository::open_at(repo_root)
.context("Failed to open git repository at the given path")?;
let commits = repo.get_commits_in_range(range)?;
let results = commits
.iter()
.map(|commit| {
let issues = crate::git::lint_message(&commit.original_message, rules, valid_scopes);
let passes = crate::git::lint_passes(&issues);
CommitCheckResult {
hash: commit.hash.clone(),
message: commit
.original_message
.lines()
.next()
.unwrap_or("")
.to_string(),
issues,
suggestion: None,
passes,
summary: None,
}
})
.collect();
Ok(CheckReport::new(results))
}
#[derive(Debug, Clone)]
pub struct LintOutcome {
pub report_yaml: String,
pub has_errors: bool,
pub has_warnings: bool,
pub total_commits: usize,
pub strict: bool,
pub exit_code: i32,
}
pub enum LintInput {
Range(Option<String>),
Message(String),
}
pub async fn run_lint(
input: LintInput,
repo_path: Option<&Path>,
context_dir: Option<&Path>,
strict: bool,
) -> Result<LintOutcome> {
let repo_root = match repo_path {
Some(p) => p.to_path_buf(),
None => std::env::current_dir().context("Failed to determine current directory")?,
};
let repo_root = repo_root.as_path();
let ctx_dir = crate::claude::context::resolve_context_dir_at(context_dir, repo_root);
let valid_scopes = crate::claude::context::load_project_scopes(&ctx_dir, repo_root);
let rules = crate::claude::context::load_commit_rules(&ctx_dir);
let report = match input {
LintInput::Message(message) => lint_report_for_message(&message, &rules, &valid_scopes),
LintInput::Range(range) => {
let range = if let Some(r) = range {
r
} else {
let repo = crate::git::GitRepository::open_at(repo_root)
.context("Failed to open git repository at the given path")?;
super::default_commit_range(&repo)?
};
lint_report_for_range(repo_root, &range, &rules, &valid_scopes)?
}
};
let report_yaml = crate::data::to_yaml(&report).context("Failed to serialise CheckReport")?;
let has_errors = report.has_errors();
let has_warnings = report.has_warnings();
let exit_code = report.exit_code(strict);
let total_commits = report.commits.len();
Ok(LintOutcome {
report_yaml,
has_errors,
has_warnings,
total_commits,
strict,
exit_code,
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn init_test_repo() -> tempfile::TempDir {
let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
std::fs::create_dir_all(&tmp_root).unwrap();
let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
for args in [
vec!["init"],
vec!["checkout", "-b", "main"],
vec!["commit", "--allow-empty", "-m", "feat(cli): first commit"],
] {
let output = std::process::Command::new("git")
.current_dir(temp_dir.path())
.args([
"-c",
"user.email=test@example.com",
"-c",
"user.name=Test",
"-c",
"commit.gpgsign=false",
])
.args(&args)
.output()
.unwrap();
assert!(output.status.success(), "git {args:?} failed");
}
temp_dir
}
fn commit(dir: &Path, message: &str) {
let output = std::process::Command::new("git")
.current_dir(dir)
.args([
"-c",
"user.email=test@example.com",
"-c",
"user.name=Test",
"-c",
"commit.gpgsign=false",
"commit",
"--allow-empty",
"-m",
message,
])
.output()
.unwrap();
assert!(output.status.success(), "commit failed: {message}");
}
fn merge_dummy_branch(dir: &Path) {
let sh = |args: &[&str]| {
let output = std::process::Command::new("git")
.current_dir(dir)
.args([
"-c",
"user.email=test@example.com",
"-c",
"user.name=Test",
"-c",
"commit.gpgsign=false",
])
.args(args)
.output()
.unwrap();
assert!(output.status.success(), "git {args:?} failed");
};
sh(&["checkout", "-b", "side"]);
sh(&["commit", "--allow-empty", "-m", "feat(cli): side change"]);
sh(&["checkout", "main"]);
sh(&["commit", "--allow-empty", "-m", "feat(cli): main change"]);
sh(&["merge", "side", "--no-ff", "-m", "Merge branch 'side'"]);
}
#[tokio::test]
async fn run_lint_message_flags_known_issues() {
let outcome = run_lint(
LintInput::Message("feature(bogus): Bad Message.".to_string()),
None,
None,
false,
)
.await
.unwrap();
assert!(outcome.has_errors);
assert_eq!(outcome.exit_code, 1);
assert_eq!(outcome.total_commits, 1);
assert!(outcome.report_yaml.contains("commits:"));
}
#[tokio::test]
async fn run_lint_message_clean_passes() {
let outcome = run_lint(
LintInput::Message("feat(cli): add thing".to_string()),
None,
None,
false,
)
.await
.unwrap();
assert!(!outcome.has_errors);
assert_eq!(outcome.exit_code, 0);
}
#[tokio::test]
async fn run_lint_range_merge_commit_excluded() {
let temp_dir = init_test_repo();
merge_dummy_branch(temp_dir.path());
let outcome = run_lint(
LintInput::Range(Some("HEAD~2..HEAD".to_string())),
Some(temp_dir.path()),
None,
false,
)
.await
.unwrap();
assert!(!outcome.report_yaml.contains("Merge branch"));
}
#[tokio::test]
async fn run_lint_range_empty_is_clean_not_an_error() {
let temp_dir = init_test_repo();
let outcome = run_lint(
LintInput::Range(Some("HEAD..HEAD".to_string())),
Some(temp_dir.path()),
None,
false,
)
.await
.unwrap();
assert_eq!(outcome.total_commits, 0);
assert!(!outcome.has_errors);
assert_eq!(outcome.exit_code, 0);
}
#[tokio::test]
async fn run_lint_range_strict_promotes_warnings() {
let temp_dir = init_test_repo();
commit(
temp_dir.path(),
"feat(cli): add thing\n\nCo-Authored-By: Bot <bot@example.com>",
);
let outcome = run_lint(
LintInput::Range(Some("HEAD~1..HEAD".to_string())),
Some(temp_dir.path()),
None,
true,
)
.await
.unwrap();
assert!(!outcome.has_errors);
assert!(outcome.has_warnings);
assert_eq!(outcome.exit_code, 2);
}
#[tokio::test]
async fn run_lint_range_and_message_agree_on_same_content() {
let temp_dir = init_test_repo();
commit(temp_dir.path(), "feature(bogus): Bad Message.");
let range_outcome = run_lint(
LintInput::Range(Some("HEAD~1..HEAD".to_string())),
Some(temp_dir.path()),
None,
false,
)
.await
.unwrap();
let message_outcome = run_lint(
LintInput::Message("feature(bogus): Bad Message.".to_string()),
None,
None,
false,
)
.await
.unwrap();
assert_eq!(range_outcome.has_errors, message_outcome.has_errors);
assert_eq!(range_outcome.exit_code, message_outcome.exit_code);
}
#[test]
fn cli_execute_json_output_matches_check_report_shape() {
let temp_dir = init_test_repo();
commit(temp_dir.path(), "feat(cli): second commit");
let cmd = LintCommand {
commit_range: Some("HEAD~1..HEAD".to_string()),
context_dir: None,
guidelines: None,
output: OutputFormat::Json,
strict: false,
quiet: true,
verbose: false,
show_passing: true,
stdin: false,
};
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
assert!(result.is_ok());
}
#[test]
fn cli_execute_yaml_output_matches_check_report_shape() {
let temp_dir = init_test_repo();
commit(temp_dir.path(), "feat(cli): second commit");
let cmd = LintCommand {
commit_range: Some("HEAD~1..HEAD".to_string()),
context_dir: None,
guidelines: None,
output: OutputFormat::Yaml,
strict: false,
quiet: true,
verbose: false,
show_passing: true,
stdin: false,
};
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
assert!(result.is_ok());
}
#[test]
fn cli_execute_range_none_uses_default_commit_range() {
let temp_dir = init_test_repo();
let cmd = LintCommand {
commit_range: None,
context_dir: None,
guidelines: None,
output: OutputFormat::Json,
strict: false,
quiet: true,
verbose: false,
show_passing: true,
stdin: false,
};
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
assert!(result.is_ok(), "expected clean exit, got: {result:?}");
}
#[tokio::test]
async fn run_lint_range_none_uses_default_commit_range() {
let temp_dir = init_test_repo();
let outcome = run_lint(LintInput::Range(None), Some(temp_dir.path()), None, false)
.await
.unwrap();
assert_eq!(outcome.total_commits, 0);
}
#[test]
fn cli_execute_verbose_config_status_empty_scopes_rules_not_found() {
let temp_dir = init_test_repo();
let context_dir = temp_dir.path().join(".omni-dev");
let cmd = LintCommand {
commit_range: Some("HEAD..HEAD".to_string()),
context_dir: Some(context_dir),
guidelines: None,
output: OutputFormat::Text,
strict: false,
quiet: false,
verbose: true,
show_passing: false,
stdin: false,
};
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
assert!(result.is_ok());
}
#[test]
fn cli_execute_verbose_config_status_scopes_and_rules_found() {
let temp_dir = init_test_repo();
let context_dir = temp_dir.path().join(".omni-dev");
std::fs::create_dir_all(&context_dir).unwrap();
std::fs::write(
context_dir.join("scopes.yaml"),
"scopes:\n - name: custom\n description: Custom scope\n examples: []\n file_patterns: []\n",
)
.unwrap();
std::fs::write(
context_dir.join("commit-rules.yaml"),
"subject_max_len: 72\ntypes:\n - feat\nrequire_scope: false\nforbidden_footers: []\n",
)
.unwrap();
let cmd = LintCommand {
commit_range: Some("HEAD..HEAD".to_string()),
context_dir: Some(context_dir),
guidelines: None,
output: OutputFormat::Text,
strict: false,
quiet: false,
verbose: true,
show_passing: false,
stdin: false,
};
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
assert!(result.is_ok());
}
#[test]
fn cli_execute_verbose_config_status_ecosystem_scopes_no_file() {
let temp_dir = init_test_repo();
std::fs::write(temp_dir.path().join("Cargo.toml"), "[package]\n").unwrap();
let context_dir = temp_dir.path().join(".omni-dev");
let cmd = LintCommand {
commit_range: Some("HEAD..HEAD".to_string()),
context_dir: Some(context_dir),
guidelines: None,
output: OutputFormat::Text,
strict: false,
quiet: false,
verbose: true,
show_passing: false,
stdin: false,
};
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
assert!(result.is_ok());
}
#[test]
fn output_text_report_show_passing_false_hides_warning_only_commits() {
let temp_dir = init_test_repo();
commit(temp_dir.path(), "feat(cli): clean second commit");
commit(
temp_dir.path(),
"feat(cli): add thing\n\nCo-Authored-By: Bot <bot@example.com>",
);
let cmd = LintCommand {
commit_range: Some("HEAD~2..HEAD".to_string()),
context_dir: None,
guidelines: None,
output: OutputFormat::Text,
strict: false,
quiet: false,
verbose: false,
show_passing: false,
stdin: false,
};
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
assert!(result.is_ok(), "expected clean exit, got: {result:?}");
}
#[test]
fn output_text_report_quiet_mode_filters_clean_and_info_issues() {
let temp_dir = init_test_repo();
commit(temp_dir.path(), "feat(cli): clean thing");
commit(
temp_dir.path(),
"feat(cli): Add thing.\n\nCo-Authored-By: Bot <bot@example.com>",
);
let cmd = LintCommand {
commit_range: Some("HEAD~2..HEAD".to_string()),
context_dir: None,
guidelines: None,
output: OutputFormat::Text,
strict: false,
quiet: true,
verbose: false,
show_passing: true,
stdin: false,
};
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
assert!(result.is_ok(), "expected clean exit, got: {result:?}");
}
}