mod c;
mod clojure;
mod cluster_detector;
mod config;
mod coupling;
mod cpp;
mod csharp;
mod doctor;
mod duplication;
mod engine;
mod error_metrics;
mod explain;
mod external_plugin;
mod git;
mod go;
mod history;
mod inheritance_depth;
mod java;
mod javascript;
mod julia;
mod metrics;
mod mock_detector;
mod model;
mod mutability_metrics;
mod mutation;
mod plugin;
mod primitive_obsession;
mod python;
mod r;
mod roles;
mod ruby;
mod rust;
mod test_report;
mod typescript;
use crate::config::{Band, Bands, Config, Mode};
use crate::model::{Metric, Module};
use crate::roles::{EffectiveThresholds, Role, RoleDetector};
use anyhow::{anyhow, Context, Result};
use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
use genesis::config::{ConfigFile, ConfigStore, ValidationSeverity};
use genesis::envelope::{Envelope, EnvelopeKind};
use genesis::feedback::scratch::{self, ErrorRecord};
use genesis::feedback::{self as genesis_feedback, FeedbackArgs as GenesisFeedbackArgs};
use genesis::git_hooks::{self as genesis_git_hooks, HookName};
use genesis::guide::Guide;
use genesis::managed_block::{BlockDef, BlockInjector, BlockRegistry};
use genesis::status::{StatusContributor, StatusItem, StatusSection};
use genesis::suggestions::SuggestionEngine;
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use std::cmp::Reverse;
use std::collections::HashSet;
use std::fs;
use std::io::{self, IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
const NOT_IMPLEMENTED_EXIT: u8 = 2;
#[derive(Parser)]
#[command(name = "pretender", version)]
struct Cli {
#[command(subcommand)]
command: Commands,
#[arg(short, long, global = true)]
pub verbose: bool,
#[arg(short, long, global = true)]
pub quiet: bool,
#[arg(short = 'j', long, global = true)]
pub json: bool,
#[arg(long, global = true, conflicts_with = "json")]
pub human: bool,
}
impl Cli {
fn verbosity(&self) -> genesis::guide::Verbosity {
if self.quiet {
genesis::guide::Verbosity::Quiet
} else if self.verbose {
genesis::guide::Verbosity::Verbose
} else {
genesis::guide::Verbosity::Normal
}
}
}
#[derive(Subcommand)]
enum Commands {
Init(InitArgs),
Check(CheckArgs),
Complexity(ComplexityArgs),
Report(ReportArgs),
Duplication(DuplicationArgs),
Mutation(MutationArgs),
#[command(subcommand)]
Hooks(HooksCommand),
#[command(subcommand)]
Ci(CiCommand),
#[command(subcommand)]
Plugins(PluginsCommand),
Explain(ExplainArgs),
Doctor(DoctorArgs),
Feedback(FeedbackArgs),
Completions {
#[arg(value_enum)]
shell: clap_complete::Shell,
},
}
const PRETENDER_COMMANDS: &[&str] = &[
"check",
"complexity",
"report",
"doctor",
"init",
"duplication",
"mutation",
"hooks",
"ci",
"plugins",
"explain",
"feedback",
"completions",
];
#[test]
fn test_pretender_commands_list_is_complete() {
assert_eq!(
PRETENDER_COMMANDS.len(),
13,
"PRETENDER_COMMANDS must have one entry per Commands variant"
);
}
#[derive(Parser)]
struct InitArgs {
#[arg(long)]
non_interactive: bool,
#[arg(long, value_enum)]
mode: Option<ModeArg>,
}
#[derive(Parser)]
struct ComplexityArgs {
#[arg(default_values = ["."])]
paths: Vec<PathBuf>,
}
#[derive(Parser)]
struct CheckArgs {
#[arg(default_values = ["."])]
paths: Vec<PathBuf>,
#[arg(long, value_enum, default_value_t = ReportFormat::Human)]
format: ReportFormat,
#[arg(long)]
output: Option<PathBuf>,
#[arg(long, conflicts_with = "diff_only")]
staged: bool,
#[arg(long)]
diff_only: bool,
#[arg(long)]
diff_base: Option<String>,
#[arg(long, value_enum)]
mode: Option<ModeArg>,
#[arg(long)]
show_all: bool,
#[arg(long)]
test_report: Option<PathBuf>,
#[arg(long)]
execute: bool,
}
#[derive(Parser)]
struct ReportArgs {
#[arg(long, value_enum, default_value_t = LongReportFormat::Human)]
format: LongReportFormat,
#[arg(long)]
output: Option<PathBuf>,
}
#[derive(Parser)]
struct DuplicationArgs {
paths: Vec<PathBuf>,
#[arg(long)]
min_nodes: Option<u32>,
#[arg(long)]
cross_file: bool,
}
#[derive(Parser)]
struct MutationArgs {
paths: Vec<PathBuf>,
#[arg(long, default_value_t = 60)]
score_min: u32,
#[arg(long)]
dry_run: bool,
#[arg(long, value_enum, default_value_t = ReportFormat::Human)]
format: ReportFormat,
}
#[derive(Subcommand)]
enum HooksCommand {
Install {
#[arg(default_value = "pre-commit")]
hook: HookNameArg,
},
Uninstall {
#[arg(default_value = "pre-commit")]
hook: HookNameArg,
},
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum HookNameArg {
PreCommit,
PrePush,
}
impl From<HookNameArg> for HookName {
fn from(value: HookNameArg) -> Self {
match value {
HookNameArg::PreCommit => HookName::PreCommit,
HookNameArg::PrePush => HookName::PrePush,
}
}
}
#[derive(Subcommand)]
enum CiCommand {
Generate {
#[arg(value_enum)]
provider: CiProvider,
},
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum CiProvider {
Github,
Gitlab,
Circle,
Azure,
Generic,
}
#[derive(Subcommand)]
enum PluginsCommand {
List,
Add { source: String },
Remove { name: String },
}
#[derive(Parser)]
struct ExplainArgs {
metric: String,
}
#[derive(Parser)]
struct DoctorArgs {}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum ReportFormat {
Human,
Json,
Sarif,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum LongReportFormat {
Human,
Markdown,
Html,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum ModeArg {
Guidance,
Tiered,
Gate,
}
impl From<ModeArg> for Mode {
fn from(value: ModeArg) -> Self {
match value {
ModeArg::Guidance => Mode::Guidance,
ModeArg::Tiered => Mode::Tiered,
ModeArg::Gate => Mode::Gate,
}
}
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum FeedbackKind {
Bug,
Feature,
Question,
}
#[derive(Parser)]
struct FeedbackArgs {
#[arg(value_enum)]
kind: FeedbackKind,
#[arg(long)]
dry_run: bool,
#[arg(long)]
from_last_error: bool,
}
trait Executable {
fn run(&self) -> Result<ExitCode>;
}
impl Executable for Commands {
fn run(&self) -> Result<ExitCode> {
match self {
Commands::Init(args) => args.run(),
Commands::Check(args) => args.run(),
Commands::Complexity(args) => args.run(),
Commands::Report(args) => args.run(),
Commands::Duplication(args) => args.run(),
Commands::Mutation(args) => args.run(),
Commands::Hooks(args) => args.run(),
Commands::Ci(args) => args.run(),
Commands::Plugins(_) => not_implemented("plugins", "pretender-07m"),
Commands::Explain(args) => args.run(),
Commands::Doctor(args) => args.run(),
Commands::Feedback(args) => args.run(),
Commands::Completions { shell } => {
let mut cmd = Cli::command();
genesis::cli::generate_completions(&mut cmd, *shell)?;
Ok(ExitCode::SUCCESS)
}
}
}
}
impl Executable for ExplainArgs {
fn run(&self) -> Result<ExitCode> {
explain::run(&self.metric)?;
Ok(ExitCode::SUCCESS)
}
}
impl Executable for DoctorArgs {
fn run(&self) -> Result<ExitCode> {
let format = resolve_doctor_format();
doctor::run_doctor(format)
}
}
fn not_implemented(name: &str, tracker: &str) -> Result<ExitCode> {
eprintln!("pretender {name}: not yet implemented (tracked: {tracker})");
Ok(ExitCode::from(NOT_IMPLEMENTED_EXIT))
}
impl Executable for InitArgs {
fn run(&self) -> Result<ExitCode> {
let options = if self.non_interactive {
InitOptions {
mode: self.mode.unwrap_or(ModeArg::Tiered),
languages: vec!["auto".to_string()],
install_hook: false,
generate_github_actions: false,
}
} else {
prompt_init_options(self.mode)?
};
let cwd = std::env::current_dir().context("failed to get current directory")?;
let config_content = render_init_config(&options);
let result = genesis::scaffold::Scaffold::new(&cwd)
.default_config("pretender.toml", config_content)
.build()
.context("failed to scaffold init")?;
if result.created.is_empty() && result.existed.iter().any(|p| p.ends_with("pretender.toml"))
{
eprintln!("pretender.toml already exists, skipping");
}
genesis::discovery::register(
&cwd,
"pretender",
"Multi-language structural code-quality checks",
"file",
"pretender.toml",
)
.map_err(|e| anyhow::anyhow!("failed to register in genesis manifest: {e}"))?;
inject_managed_blocks()?;
if options.install_hook {
install_hook(HookName::PreCommit)?;
}
if options.generate_github_actions {
write_github_ci_workflow()?;
}
Ok(ExitCode::SUCCESS)
}
}
impl Executable for FeedbackArgs {
fn run(&self) -> Result<ExitCode> {
let repo = extract_repo_from_cargo_toml()?;
let project_root = std::env::current_dir().context("failed to get current directory")?;
let kind_str = match self.kind {
FeedbackKind::Bug => "bug",
FeedbackKind::Feature => "feature",
FeedbackKind::Question => "question",
};
let genesis_args = GenesisFeedbackArgs::new(kind_str, self.dry_run, self.from_last_error);
let tool_name = env!("CARGO_PKG_NAME");
let tool_version = env!("CARGO_PKG_VERSION");
match genesis_feedback::handle_feedback(
&genesis_args,
tool_name,
tool_version,
&repo,
&project_root,
) {
Ok(result) => match result {
genesis::feedback::gh::GhResult::Created { url, number } => {
println!("Created issue #{number}: {url}");
}
genesis::feedback::gh::GhResult::FallbackUrl(url) => {
println!("Open to create issue: {url}");
}
genesis::feedback::gh::GhResult::LocalFile(path) => {
println!("Issue body written to: {}", path.display());
}
},
Err(msg) => {
eprintln!("Failed to create issue: {msg}");
return Ok(ExitCode::FAILURE);
}
}
Ok(ExitCode::SUCCESS)
}
}
fn extract_repo_from_cargo_toml() -> Result<String> {
let cargo_path = if std::path::Path::new("pretender/Cargo.toml").exists() {
"pretender/Cargo.toml"
} else {
"Cargo.toml"
};
let content =
std::fs::read_to_string(cargo_path).context(format!("failed to read {cargo_path}"))?;
let value: toml::Value = content
.parse::<toml::Value>()
.context("failed to parse Cargo.toml")?;
let package = value
.get("package")
.and_then(|v| v.get("repository"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Cargo.toml missing [package].repository"))?;
let repo_short = package
.trim_start_matches("https://github.com/")
.trim_start_matches("git@github.com:")
.trim_start_matches("ssh://git@github.com/")
.trim_end_matches(".git")
.to_string();
Ok(repo_short)
}
fn inject_managed_blocks() -> Result<()> {
let mut reg = BlockRegistry::new();
reg.register(BlockDef::new("WAI"));
reg.register(BlockDef::new("OPENSPEC"));
reg.register(BlockDef::new("DONT"));
let injector = BlockInjector::new(reg);
let agents_path = std::path::Path::new("AGENTS.md");
let wai_content =
"\n<!-- wai: managed block -->\nRun `wai status` to orient yourself.\n".to_string();
let openspec_content =
"\n<!-- openspec: managed block -->\nSee openspec/ for spec-driven development.\n"
.to_string();
let dont_content =
"\n<!-- dont: managed block -->\nSee .dont/ for grounded-claim workflow.\n".to_string();
injector
.inject(agents_path, "WAI", &wai_content)
.context("failed to inject WAI managed block")?;
injector
.inject(agents_path, "OPENSPEC", &openspec_content)
.context("failed to inject OPENSPEC managed block")?;
injector
.inject(agents_path, "DONT", &dont_content)
.context("failed to inject DONT managed block")?;
Ok(())
}
impl Executable for ComplexityArgs {
fn run(&self) -> Result<ExitCode> {
let config = load_config().unwrap_or_default();
let threshold = config.thresholds.app.cyclomatic_max;
let files = collect_input_files(&self.paths, &config)?;
let multi = files.len() > 1;
for path in &files {
let source = std::fs::read_to_string(path)
.with_context(|| format!("failed to read source file: {}", path.display()))?;
let parser = get_parser(path)?;
let (module, diagnostics) = parser.parse(path, &source)?;
if !diagnostics.is_empty() {
for diag in &diagnostics {
eprintln!("{:?}: {}", diag.severity, diag.message);
if let Some(span) = &diag.span {
eprintln!(" at lines {}-{}", span.start_line, span.end_line);
}
}
eprintln!();
}
if multi {
println!("{}:", path.display());
}
let metric = metrics::CyclomaticComplexity;
let mut results: Vec<(String, u32)> = module
.units
.iter()
.map(|u| (u.name.clone(), metric.calculate(u)))
.collect();
let indent = if multi { " " } else { "" };
results.sort_by_key(|(_, score)| Reverse(*score));
for (name, score) in &results {
if *score > threshold {
println!("{indent}{name}: {score} (threshold: {threshold}) ✗");
} else {
println!("{indent}{name}: {score}");
}
}
}
Ok(ExitCode::SUCCESS)
}
}
impl Executable for CheckArgs {
fn run(&self) -> Result<ExitCode> {
warn_if_no_config();
let mut config = load_config()?;
if let Some(mode) = self.mode {
config.pretender.mode = mode.into();
}
let args: Vec<String> = std::env::args().collect();
let format = if args.iter().any(|a| a == "--json" || a == "-j") {
ReportFormat::Json
} else if args.iter().any(|a| a == "--human") {
ReportFormat::Human
} else {
self.format
};
let detector = RoleDetector::new(&config).context("failed to initialize role detector")?;
let files = if self.staged || self.diff_only || self.diff_base.is_some() {
let cwd = std::env::current_dir().context("failed to get current directory")?;
let allowed = if self.staged {
git::staged_files(&cwd)?
} else {
let base = self.diff_base.as_deref().unwrap_or(&config.scope.diff_base);
git::diff_base_files(&cwd, base)?
};
if allowed.is_empty() {
if self.staged {
println!("No staged files to check.");
} else {
println!("No changed files to check.");
}
return Ok(decide_exit_code(
&CheckReport {
files: vec![],
modules: Vec::new(),
cycles: Vec::new(),
test_findings: Vec::new(),
history: None,
},
config.pretender.mode,
));
}
let all = collect_input_files(&self.paths, &config)?;
apply_file_filter(all, Some(&allowed), &cwd)
} else {
collect_input_files(&self.paths, &config)?
};
let explicit_paths = !self.staged && !self.diff_only && self.diff_base.is_none();
if explicit_paths && !files.iter().any(|f| get_parser(f).is_ok()) {
let unsupported: Vec<String> =
self.paths.iter().map(|p| p.display().to_string()).collect();
eprintln!(
"warning: no files matched a supported grammar in: {}",
unsupported.join(", ")
);
}
let mut reports_and_modules: Vec<(FileReport, Module)> = files
.par_iter()
.filter_map(|path| analyze_path(path, &detector, &config).transpose())
.collect::<Result<_>>()?;
reports_and_modules.sort_by(|a, b| a.0.path.cmp(&b.0.path));
let mut reports: Vec<FileReport> = Vec::with_capacity(reports_and_modules.len());
let mut modules: Vec<(PathBuf, Module)> = Vec::with_capacity(reports_and_modules.len());
for (report, module) in reports_and_modules {
let path = PathBuf::from(&report.path);
reports.push(report);
modules.push((path, module));
}
let plugins = external_plugin::load_plugins(&external_plugin::default_metrics_dir());
if !plugins.is_empty() {
let mut external = external_plugin::run_plugins(&plugins, &files);
for file_report in &mut reports {
if let Some(findings) = external.remove(&file_report.path) {
file_report.external_findings = findings;
}
}
}
let mut report = CheckReport {
files: reports,
modules: Vec::new(),
cycles: Vec::new(),
test_findings: Vec::new(),
history: None,
};
let coupling_result = coupling::analyze(
&modules
.iter()
.map(|(p, m)| (p.clone(), m))
.collect::<Vec<_>>(),
&config.thresholds.coupling,
);
report.modules = coupling_result
.modules
.iter()
.map(|m| ModuleReport {
path: m.path.clone(),
coupling_violations: m
.violations
.iter()
.map(|v| CouplingViolationReport {
metric: v.metric.clone(),
actual: v.actual,
limit: v.limit,
})
.collect(),
})
.collect();
report.cycles = coupling_result
.cycles
.iter()
.map(|c| c.participants.clone())
.collect();
let test_report_path = resolve_test_report_path(self, &config);
if let Some(report_path) = test_report_path {
let timings = test_report::parse_junit(&report_path, config.execute.test_time_unit)
.context("failed to parse JUnit XML report")?;
let search_root = std::env::current_dir().context("failed to get current directory")?;
report.test_findings =
test_report::evaluate_duration(&timings, &detector, &config, &search_root);
}
let writing_to_stdout = self.output.is_none();
let mut sink = open_report_sink(self.output.as_deref())?;
emit_history_events(
&mut report,
&config,
format,
writing_to_stdout,
sink.as_mut(),
);
match format {
ReportFormat::Human => {
let color = writing_to_stdout && color_enabled();
write_human_report(
sink.as_mut(),
&report,
color,
&config.bands,
config.pretender.mode,
self.show_all,
)?;
}
ReportFormat::Json => write_json_report(sink.as_mut(), &report)?,
ReportFormat::Sarif => write_sarif_report(sink.as_mut(), &report)?,
}
persist_last_check_report(&report)?;
sink.flush().context("failed to flush report output")?;
Ok(decide_exit_code(&report, config.pretender.mode))
}
}
impl Executable for DuplicationArgs {
fn run(&self) -> Result<ExitCode> {
warn_if_no_config();
let config = load_config()?;
let file_paths = collect_input_files(&self.paths, &config)?;
let min_nodes = self.min_nodes.unwrap_or(10) as usize;
let mut files: Vec<(PathBuf, String)> = Vec::new();
for path in &file_paths {
if duplication::ts_language_for_path(path).is_none() {
continue;
}
match fs::read_to_string(path) {
Ok(source) => files.push((path.clone(), source)),
Err(e) => eprintln!("warning: skipping {}: {e}", path.display()),
}
}
let groups = duplication::detect_clones(&files, min_nodes, self.cross_file)?;
if groups.is_empty() {
println!("No structural clones detected.");
return Ok(ExitCode::SUCCESS);
}
println!("Found {} clone group(s):\n", groups.len());
for (i, group) in groups.iter().enumerate() {
println!(
"Clone {} ({} nodes, similarity: {}%):",
i + 1,
group.node_count,
group.similarity
);
for loc in &group.locations {
println!(
" {}:{}-{}",
loc.file.display(),
loc.start_line,
loc.end_line
);
}
println!();
}
Ok(ExitCode::SUCCESS)
}
}
impl Executable for MutationArgs {
fn run(&self) -> Result<ExitCode> {
let config = load_config()?;
let files = collect_input_files(&self.paths, &config)?;
let lang = mutation::primary_lang(&files)
.ok_or_else(|| anyhow!("no supported source files found in provided paths"))?;
if self.dry_run {
return run_mutation_dry_run(&lang, &files);
}
let report = mutation::run_mutation(&lang, &files)?;
match self.format {
ReportFormat::Json => {
let env = Envelope::success(
env!("CARGO_PKG_VERSION"),
EnvelopeKind::Check,
&report,
vec![],
vec![],
);
println!("{}", serde_json::to_string_pretty(&env)?);
}
_ => print_mutation_report(&report),
}
if report.passes_threshold(self.score_min) {
Ok(ExitCode::SUCCESS)
} else {
Ok(ExitCode::FAILURE)
}
}
}
fn run_mutation_dry_run(lang: &mutation::MutationLang, paths: &[PathBuf]) -> Result<ExitCode> {
println!(
"Dry run: would use {} on {} file(s)",
lang.tool_name(),
paths.len()
);
let mutants = mutation::list_mutants(lang, paths)?;
if mutants.is_empty() {
println!("No mutation sites found.");
return Ok(ExitCode::SUCCESS);
}
println!("Planned mutants ({}):", mutants.len());
for m in &mutants {
if m.line > 0 {
println!(" {}:{}: {}", m.file, m.line, m.description);
} else {
println!(" {}: {}", m.file, m.description);
}
}
Ok(ExitCode::SUCCESS)
}
fn print_mutation_report(report: &mutation::MutationReport) {
let pass = if report.score_pct >= 60.0 {
"✓"
} else {
"✗"
};
println!(
"Mutation score: {:.1}% ({}/{} killed) {}",
report.score_pct, report.killed, report.total, pass
);
println!("Tool: {}", report.tool);
if report.survivors.is_empty() {
println!("\nAll mutants killed — perfect score!");
return;
}
println!("\nSurviving mutants ({}):", report.survived);
for m in &report.survivors {
if m.line > 0 {
print!(" {}:{}", m.file, m.line);
} else {
print!(" {}", m.file);
}
println!(" {}", m.description);
for test in &m.missed_tests {
println!(" missed by: {test}");
}
}
}
impl Executable for ReportArgs {
fn run(&self) -> Result<ExitCode> {
let report = load_last_check_report()?;
let config = load_config().unwrap_or_default();
let writing_to_stdout = self.output.is_none();
let mut sink = open_report_sink(self.output.as_deref())?;
match self.format {
LongReportFormat::Human => {
let color = writing_to_stdout && color_enabled();
write_human_report(
sink.as_mut(),
&report,
color,
&config.bands,
config.pretender.mode,
true,
)?;
}
LongReportFormat::Markdown => {
write_markdown_report(sink.as_mut(), &report, &config.bands)?
}
LongReportFormat::Html => write_html_report(sink.as_mut(), &report, &config.bands)?,
}
sink.flush().context("failed to flush report output")?;
Ok(ExitCode::SUCCESS)
}
}
impl Executable for HooksCommand {
fn run(&self) -> Result<ExitCode> {
match self {
HooksCommand::Install { hook } => {
install_hook((*hook).into())?;
Ok(ExitCode::SUCCESS)
}
HooksCommand::Uninstall { hook } => {
uninstall_hook((*hook).into())?;
Ok(ExitCode::SUCCESS)
}
}
}
}
impl Executable for CiCommand {
fn run(&self) -> Result<ExitCode> {
match self {
CiCommand::Generate {
provider: CiProvider::Github,
} => {
write_github_ci_workflow()?;
Ok(ExitCode::SUCCESS)
}
CiCommand::Generate { .. } => not_implemented("ci generate", "pretender-fb3"),
}
}
}
fn resolve_test_report_path(args: &CheckArgs, config: &Config) -> Option<PathBuf> {
if let Some(ref path) = args.test_report {
return Some(path.clone());
}
if args.execute {
let cmd = config.execute.test_cmd.as_ref()?;
let report_path = config.execute.test_report_path.as_ref()?;
let timeout = std::time::Duration::from_secs(config.execute.test_timeout_s as u64);
eprintln!("Running test command: {}", cmd);
let mut child = match std::process::Command::new("sh")
.arg("-c")
.arg(cmd)
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.spawn()
{
Ok(c) => c,
Err(e) => {
eprintln!("warning: failed to spawn test_cmd '{}': {}", cmd, e);
return None;
}
};
match wait_with_timeout(&mut child, timeout) {
Ok(Some(status)) => {
if !status.success() {
eprintln!("warning: test_cmd exited with non-zero status ({})", status);
}
}
Ok(None) => {
eprintln!(
"warning: test_cmd timed out after {}s",
config.execute.test_timeout_s
);
let _ = child.kill();
let _ = child.wait();
return None;
}
Err(e) => {
eprintln!("warning: test_cmd failed: {}", e);
return None;
}
}
let report_path = PathBuf::from(report_path);
if report_path.exists() {
return Some(report_path);
}
eprintln!(
"warning: test_report_path '{}' not found after test_cmd execution",
report_path.display()
);
}
None
}
fn wait_with_timeout(
child: &mut std::process::Child,
timeout: std::time::Duration,
) -> std::io::Result<Option<std::process::ExitStatus>> {
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) => return Ok(Some(status)),
Ok(None) => {
if start.elapsed() >= timeout {
return Ok(None);
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
Err(e) => return Err(e),
}
}
}
fn decide_exit_code(report: &CheckReport, mode: Mode) -> ExitCode {
let has_skipped = report
.files
.iter()
.any(|file| file.diagnostics.iter().any(|d| d.severity == "Error"));
let has_violation = report.files.iter().any(|file| {
!file.file_violations.is_empty()
|| file.units.iter().any(|unit| !unit.violations.is_empty())
});
let has_coupling_violation = report
.modules
.iter()
.any(|m| !m.coupling_violations.is_empty());
let has_cycle = !report.cycles.is_empty();
let has_duration_violation = !report.test_findings.is_empty();
match mode {
Mode::Guidance => ExitCode::SUCCESS,
Mode::Tiered => ExitCode::SUCCESS,
Mode::Gate => {
if has_violation
|| has_skipped
|| has_coupling_violation
|| has_cycle
|| has_duration_violation
{
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
}
}
struct InitOptions {
mode: ModeArg,
languages: Vec<String>,
install_hook: bool,
generate_github_actions: bool,
}
fn prompt_init_options(mode_override: Option<ModeArg>) -> Result<InitOptions> {
let mut stdin = io::stdin().lock();
let mut stdout = io::stdout().lock();
let mode = match mode_override {
Some(mode) => mode,
None => match prompt(
&mut stdin,
&mut stdout,
"Mode (guidance/tiered/gate) [tiered]: ",
)?
.trim()
.to_ascii_lowercase()
.as_str()
{
"" | "tiered" => ModeArg::Tiered,
"guidance" => ModeArg::Guidance,
"gate" => ModeArg::Gate,
other => return Err(anyhow!("invalid mode: {other}")),
},
};
let languages_input = prompt(
&mut stdin,
&mut stdout,
"Languages (auto or comma-separated list) [auto]: ",
)?;
let languages = parse_languages(&languages_input);
let install_hook = parse_yes_no(&prompt(
&mut stdin,
&mut stdout,
"Install pre-commit hook? [y/N]: ",
)?);
let generate_github_actions = parse_yes_no(&prompt(
&mut stdin,
&mut stdout,
"Generate GitHub Actions workflow? [y/N]: ",
)?);
Ok(InitOptions {
mode,
languages,
install_hook,
generate_github_actions,
})
}
fn prompt(stdin: &mut dyn Read, stdout: &mut dyn Write, message: &str) -> Result<String> {
write!(stdout, "{message}")?;
stdout.flush()?;
let mut buf = Vec::new();
let mut byte = [0u8; 1];
loop {
match stdin.read(&mut byte)? {
0 => break,
_ if byte[0] == b'\n' => break,
_ => buf.push(byte[0]),
}
}
Ok(String::from_utf8_lossy(&buf).trim().to_string())
}
fn parse_languages(input: &str) -> Vec<String> {
let trimmed = input.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("auto") {
return vec!["auto".to_string()];
}
let mut languages: Vec<String> = trimmed
.split(',')
.map(|value| value.trim().to_ascii_lowercase())
.filter(|value| !value.is_empty())
.collect();
if languages.is_empty() {
languages.push("auto".to_string());
}
languages
}
fn parse_yes_no(input: &str) -> bool {
matches!(input.trim().to_ascii_lowercase().as_str(), "y" | "yes")
}
fn render_init_config(options: &InitOptions) -> String {
let mode = match options.mode {
ModeArg::Guidance => "guidance",
ModeArg::Tiered => "tiered",
ModeArg::Gate => "gate",
};
let languages = options
.languages
.iter()
.map(|language| format!("\"{language}\""))
.collect::<Vec<_>>()
.join(", ");
format!(
concat!(
"[pretender]\n",
"mode = \"{mode}\"\n",
"languages = [{languages}]\n",
"exclude = [\"vendor/**\", \"node_modules/**\", \"**/*_generated.*\"]\n\n",
"[roles.test]\n",
"paths = [\"tests/**\", \"**/*_test.*\", \"spec/**\"]\n\n",
"[roles.library]\n",
"paths = [\"pkg/**\", \"lib/**\"]\n\n",
"[roles.script]\n",
"paths = [\"scripts/**\", \"examples/**\"]\n\n",
"[roles.generated]\n",
"paths = [\"**/*.pb.go\", \"**/*_generated.*\"]\n\n",
"[roles.vendor]\n",
"paths = [\"vendor/**\", \"node_modules/**\"]\n"
),
mode = mode,
languages = languages,
)
}
const PRE_COMMIT_HOOK_MARKER: &str = "# Installed by Pretender.";
fn hook_script() -> String {
"#!/usr/bin/env sh\nexec pretender check . --staged\n".to_string()
}
fn install_hook(hook: HookName) -> Result<PathBuf> {
let root = git_hook_repo_root()?;
let path = genesis_git_hooks::install(&root, hook, PRE_COMMIT_HOOK_MARKER, &hook_script())
.map_err(|e| anyhow!("{}", hook_error_message(e)))?;
Ok(path)
}
fn uninstall_hook(hook: HookName) -> Result<PathBuf> {
let root = git_hook_repo_root()?;
let path = genesis_git_hooks::uninstall(&root, hook, PRE_COMMIT_HOOK_MARKER)
.map_err(|e| anyhow!("{}", hook_error_message(e)))?;
Ok(path)
}
fn hook_error_message(err: genesis_git_hooks::GitHooksError) -> String {
use genesis_git_hooks::GitHooksError as E;
match err {
E::ForeignHook { path, action } => match action {
"remove" => format!(
"refusing to remove hook not installed by Pretender: {}",
path.display()
),
_ => format!(
"refusing to overwrite hook not installed by Pretender: {}",
path.display()
),
},
other => format!("hook operation failed: {other}"),
}
}
fn git_hook_repo_root() -> Result<PathBuf> {
genesis_git_hooks::repo_root().map_err(|e| anyhow!("{}", e))
}
fn github_ci_workflow_path() -> PathBuf {
PathBuf::from(".github/workflows/pretender.yml")
}
fn write_github_ci_workflow() -> Result<()> {
let path = github_ci_workflow_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create workflow dir: {}", parent.display()))?;
}
fs::write(&path, github_ci_workflow_contents())
.with_context(|| format!("failed to write workflow: {}", path.display()))?;
Ok(())
}
fn github_ci_workflow_contents() -> &'static str {
r#"name: Pretender
on:
pull_request:
push:
branches:
- main
jobs:
pretender:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Install Pretender
run: cargo install --git https://github.com/charly-vibes/pretender --locked pretender
- name: Run Pretender
id: pretender
continue-on-error: true
run: pretender check . --format=sarif --output=pretender.sarif
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: pretender.sarif
- name: Append markdown report
if: steps.pretender.outcome == 'failure'
run: pretender report --format=markdown >> $GITHUB_STEP_SUMMARY
- name: Fail on Pretender findings
if: steps.pretender.outcome == 'failure'
run: exit 1
"#
}
fn last_check_report_path() -> PathBuf {
PathBuf::from(".pretender/last-check.json")
}
fn persist_last_check_report(report: &CheckReport) -> Result<()> {
let path = last_check_report_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create report cache dir: {}", parent.display()))?;
}
let bytes = serde_json::to_vec_pretty(report)?;
fs::write(&path, bytes)
.with_context(|| format!("failed to write last check report: {}", path.display()))?;
Ok(())
}
fn load_last_check_report() -> Result<CheckReport> {
let path = last_check_report_path();
let source = fs::read_to_string(&path)
.with_context(|| format!("failed to read last check report: {}", path.display()))?;
serde_json::from_str(&source).context("failed to parse last check report JSON")
}
fn open_report_sink(path: Option<&Path>) -> Result<Box<dyn Write>> {
match path {
Some(path) => {
let file = fs::File::create(path)
.with_context(|| format!("failed to open output path: {}", path.display()))?;
Ok(Box::new(io::BufWriter::new(file)))
}
None => Ok(Box::new(io::stdout().lock())),
}
}
fn analyze_path(
path: &Path,
detector: &RoleDetector,
config: &Config,
) -> Result<Option<(FileReport, Module)>> {
let source = match fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => return Ok(None),
Err(e) => {
return Err(e)
.with_context(|| format!("failed to read source file: {}", path.display()))
}
};
let parser = match get_parser(path) {
Ok(parser) => parser,
Err(_) => return Ok(None),
};
let (module, diagnostics) = parser.parse(path, &source)?;
let role = detector.detect(path, &source);
let thresholds = EffectiveThresholds::for_role(role, &config.thresholds);
let units = module
.units
.iter()
.map(|unit| build_unit_report(unit, &thresholds))
.collect();
let mut file_violations = Vec::new();
push_limit_violation(
&mut file_violations,
"file_lines",
module.lines_total,
thresholds.file_lines_max,
);
let mock_analysis = if role == Role::Test && config.thresholds.test.mock_count_max > 0 {
let analysis = mock_detector::detect_mocks(
&source,
&module.language,
&config.patterns.mock.extra,
path,
);
if analysis.total_usage > config.thresholds.test.mock_count_max {
file_violations.push(ViolationReport {
metric: "mock_count".to_string(),
actual: analysis.total_usage as f64,
limit: config.thresholds.test.mock_count_max as f64,
});
}
analysis
} else {
mock_detector::MockAnalysis::default()
};
if thresholds.void_mutators_max > 0 {
let mutators = mutability_metrics::detect_void_mutators(
&source,
&module.language,
&module.units,
path,
);
if mutators.len() as u32 > thresholds.void_mutators_max {
file_violations.push(ViolationReport {
metric: "void_mutator".to_string(),
actual: mutators.len() as f64,
limit: thresholds.void_mutators_max as f64,
});
}
}
if config.thresholds.app.mut_ratio_max > 0.0 {
let binding_analysis =
mutability_metrics::count_mutable_bindings(&source, &module.language);
let ratio = binding_analysis.ratio();
if ratio > config.thresholds.app.mut_ratio_max {
file_violations.push(ViolationReport {
metric: "mut_ratio".to_string(),
actual: ratio,
limit: config.thresholds.app.mut_ratio_max,
});
}
}
if thresholds.unwrap_max > 0 {
let sites = error_metrics::detect_unwraps(&source, &module.language);
if sites.len() as u32 > thresholds.unwrap_max {
file_violations.push(ViolationReport {
metric: "unwraps".to_string(),
actual: sites.len() as f64,
limit: thresholds.unwrap_max as f64,
});
}
}
if config.thresholds.app.bool_cluster_max > 0 || config.thresholds.app.primitive_param_check {
let po_results = primitive_obsession::analyze(
&module.units,
&module.language,
config.thresholds.app.bool_cluster_max,
config.thresholds.app.primitive_param_check,
&[],
);
for result in &po_results {
if result.bool_param_count > 0
&& config.thresholds.app.bool_cluster_max > 0
&& result.bool_param_count >= config.thresholds.app.bool_cluster_max
{
file_violations.push(ViolationReport {
metric: "bool_cluster".to_string(),
actual: result.bool_param_count as f64,
limit: config.thresholds.app.bool_cluster_max as f64,
});
}
for v in &result.violations {
if v.kind == primitive_obsession::PrimitiveViolationKind::PrimitiveDomainParam {
file_violations.push(ViolationReport {
metric: "primitive_param".to_string(),
actual: 1.0,
limit: 1.0,
});
}
}
}
}
if config.thresholds.app.inheritance_depth_max > 0 {
let ih = inheritance_depth::analyze(&source, &module.language);
for class in &ih.classes {
if class.depth > config.thresholds.app.inheritance_depth_max {
file_violations.push(ViolationReport {
metric: "inheritance_depth".to_string(),
actual: class.depth as f64,
limit: config.thresholds.app.inheritance_depth_max as f64,
});
}
}
}
if role == Role::Test && config.thresholds.test.lazy_cluster_min > 0 {
let clusters =
cluster_detector::detect(&module.units, config.thresholds.test.lazy_cluster_min);
for cluster in &clusters {
file_violations.push(ViolationReport {
metric: "lazy_cluster".to_string(),
actual: cluster.count as f64,
limit: config.thresholds.test.lazy_cluster_min as f64,
});
}
}
let report = FileReport {
path: path.display().to_string(),
role: role_name(role).to_string(),
diagnostics: diagnostics.into_iter().map(Into::into).collect(),
file_violations,
units,
external_findings: vec![],
mock_findings: mock_analysis
.references
.into_iter()
.map(|r| MockFindingReport {
name: r.name,
line: r.line,
kind: format!("{:?}", r.kind),
})
.collect(),
};
Ok(Some((report, module)))
}
fn load_config() -> Result<Config> {
let path = Path::new("pretender.toml");
if !path.exists() {
return Ok(Config::default());
}
if std::fs::metadata(path)
.with_context(|| format!("failed to stat config: {}", path.display()))?
.len()
== 0
{
return Ok(Config::default());
}
let cwd = std::env::current_dir().context("failed to get current directory")?;
let store = ConfigStore::new(config::build_registry());
let parsed: Config = store
.get("pretender", &cwd)
.map_err(|e| anyhow!("failed to read pretender.toml: {e}"))?;
let validations = parsed
.validate()
.map_err(|e| anyhow!("failed to validate pretender.toml: {e}"))?;
if config::has_errors(&validations) {
let first = validations
.iter()
.find(|v| v.severity == ValidationSeverity::Error)
.expect("has_errors guaranteed an error entry");
return Err(anyhow!("invalid pretender.toml: {}", first.message));
}
Ok(parsed)
}
fn warn_if_no_config() {
if !Path::new("pretender.toml").exists() {
eprintln!("no pretender.toml found, using defaults — run `pretender init` to configure");
}
}
fn apply_file_filter(
files: Vec<PathBuf>,
allowed: Option<&HashSet<PathBuf>>,
cwd: &Path,
) -> Vec<PathBuf> {
let Some(allowed) = allowed else {
return files;
};
files
.into_iter()
.filter(|f| {
let abs = if f.is_absolute() {
f.clone()
} else {
cwd.join(f)
};
let canonical = std::fs::canonicalize(&abs).unwrap_or(abs);
allowed.contains(&canonical)
})
.collect()
}
fn collect_input_files(paths: &[PathBuf], config: &Config) -> Result<Vec<PathBuf>> {
let mut builder = globset::GlobSetBuilder::new();
for pattern in &config.pretender.exclude {
let glob = globset::Glob::new(pattern)
.with_context(|| format!("invalid exclude pattern: {}", pattern))?;
builder.add(glob);
}
let exclude_set = builder.build().context("failed to build exclude GlobSet")?;
let mut files = Vec::new();
for path in paths {
collect_path(path, &exclude_set, false, &mut files)?;
}
files.sort();
files.dedup();
Ok(files)
}
fn is_supported_source(path: &Path) -> bool {
path.extension()
.and_then(|s| s.to_str())
.is_some_and(|ext| {
matches!(
ext,
"cs" | "clj"
| "cljs"
| "cljc"
| "edn"
| "c"
| "h"
| "cpp"
| "cc"
| "cxx"
| "hpp"
| "hxx"
| "go"
| "java"
| "jl"
| "js"
| "jsx"
| "mjs"
| "cjs"
| "py"
| "r"
| "R"
| "rb"
| "rs"
| "ts"
| "mts"
| "tsx"
| "cts"
)
})
}
fn collect_path(
path: &Path,
exclude_set: &globset::GlobSet,
walked: bool,
out: &mut Vec<PathBuf>,
) -> Result<()> {
if exclude_set.is_match(path) {
return Ok(());
}
if path.is_file() {
if walked && !is_supported_source(path) {
return Ok(());
}
out.push(path.to_path_buf());
return Ok(());
}
if path.is_dir() {
for entry in fs::read_dir(path)
.with_context(|| format!("failed to read directory: {}", path.display()))?
{
let entry = entry?;
let entry_path = entry.path();
if entry_path.is_file() || entry_path.is_dir() {
collect_path(&entry_path, exclude_set, true, out)?;
}
}
return Ok(());
}
Ok(())
}
fn build_unit_report(unit: &model::CodeUnit, thresholds: &EffectiveThresholds) -> UnitReport {
let metrics = MetricValues {
cyclomatic: metrics::cyclomatic(unit),
cognitive: metrics::cognitive(unit),
assertions: unit.assertions,
function_lines: metrics::function_lines(unit),
params: metrics::params(unit),
nesting_max: metrics::nesting_max(unit),
abc: metrics::abc(unit),
};
let mut violations = Vec::new();
push_limit_violation(
&mut violations,
"cyclomatic",
metrics.cyclomatic,
thresholds.cyclomatic_max,
);
push_limit_violation(
&mut violations,
"cognitive",
metrics.cognitive,
thresholds.cognitive_max,
);
push_limit_violation(
&mut violations,
"function_lines",
metrics.function_lines,
thresholds.function_lines_max,
);
push_limit_violation(
&mut violations,
"params",
metrics.params,
thresholds.params_max,
);
push_limit_violation(
&mut violations,
"nesting_max",
metrics.nesting_max,
thresholds.nesting_max,
);
push_limit_violation_f64(&mut violations, "abc", metrics.abc, thresholds.abc_max);
if let Some(min) = thresholds.min_assertions {
if unit.has_test_attribute || has_test_name(&unit.name) {
push_min_violation(&mut violations, "min_assertions", metrics.assertions, min);
}
}
if unit.is_exported {
if let Some(max) = thresholds.exported_cyclomatic_max {
push_limit_violation(
&mut violations,
"exported_cyclomatic",
metrics.cyclomatic,
max,
);
}
if let Some(max) = thresholds.exported_params_max {
push_limit_violation(&mut violations, "exported_params", metrics.params, max);
}
if let Some(max) = thresholds.exported_lines_max {
push_limit_violation(
&mut violations,
"exported_lines",
metrics.function_lines,
max,
);
}
}
UnitReport {
name: unit.name.clone(),
kind: format!("{:?}", unit.kind),
start_line: unit.span.start_line,
metrics,
violations,
}
}
fn push_limit_violation(
out: &mut Vec<ViolationReport>,
metric: &'static str,
actual: u32,
max: u32,
) {
if actual > max {
out.push(ViolationReport {
metric: metric.to_string(),
actual: actual as f64,
limit: max as f64,
});
}
}
fn push_limit_violation_f64(
out: &mut Vec<ViolationReport>,
metric: &'static str,
actual: f64,
max: u32,
) {
if actual > max as f64 {
out.push(ViolationReport {
metric: metric.to_string(),
actual,
limit: max as f64,
});
}
}
fn push_min_violation(out: &mut Vec<ViolationReport>, metric: &'static str, actual: u32, min: u32) {
if actual < min {
out.push(ViolationReport {
metric: metric.to_string(),
actual: actual as f64,
limit: min as f64,
});
}
}
fn has_test_name(name: &str) -> bool {
name == "test"
|| name.starts_with("test_")
|| name.ends_with("_test")
|| (name.len() > 4 && name.starts_with("test") && name.as_bytes()[4].is_ascii_uppercase())
|| (name.len() >= 4 && name.ends_with("Test"))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
enum Severity {
Green,
Yellow,
Red,
}
struct FileSummary {
severity: Severity,
message: String,
}
fn write_human_report(
sink: &mut dyn Write,
report: &CheckReport,
color: bool,
bands: &Bands,
mode: Mode,
verbose: bool,
) -> Result<()> {
let red = if color { "\u{1b}[31m" } else { "" };
let yellow = if color { "\u{1b}[33m" } else { "" };
let green = if color { "\u{1b}[32m" } else { "" };
let reset = if color { "\u{1b}[0m" } else { "" };
let blocking = matches!(mode, Mode::Gate);
let (violation_icon, violation_label, violation_color) = if blocking {
("✗", "VIOLATION", red)
} else {
("⚠", "ADVISORY", yellow)
};
for file in &report.files {
let summary = summarize_file(file, bands);
let (icon, accent) = match summary.severity {
Severity::Green => ("✓", green),
Severity::Yellow => ("⚠", yellow),
Severity::Red => (violation_icon, if blocking { red } else { yellow }),
};
writeln!(
sink,
"{accent}{icon}{reset} {} — {}",
file.path, summary.message
)?;
writeln!(sink, " role: {}", file.role)?;
for violation in &file.file_violations {
writeln!(
sink,
" {violation_color}{violation_label}{reset} {} {} > {}",
violation.metric,
fmt_metric(violation.actual),
fmt_metric(violation.limit),
)?;
}
for unit in &file.units {
let has_violations = !unit.violations.is_empty();
if verbose || has_violations {
writeln!(
sink,
" {}: cyclomatic={}, cognitive={}, assertions={}, function_lines={}, params={}, nesting_max={}, abc={:.2}",
unit.name,
unit.metrics.cyclomatic,
unit.metrics.cognitive,
unit.metrics.assertions,
unit.metrics.function_lines,
unit.metrics.params,
unit.metrics.nesting_max,
unit.metrics.abc,
)?;
for violation in &unit.violations {
writeln!(
sink,
" {violation_color}{violation_label}{reset} {} {} > {}",
violation.metric,
fmt_metric(violation.actual),
fmt_metric(violation.limit),
)?;
}
}
}
for diagnostic in &file.diagnostics {
eprintln!(" {:?}: {}", diagnostic.severity, diagnostic.message);
}
for finding in &file.external_findings {
let code = finding.code.as_deref().unwrap_or("-");
writeln!(
sink,
" {red}EXTERNAL{reset} {} {} line {}: {}",
finding.source, code, finding.line, finding.message
)?;
}
}
if !report.test_findings.is_empty() {
writeln!(sink, "\nTest duration findings:")?;
for finding in &report.test_findings {
let file_str = finding
.file
.as_ref()
.map(|f| f.display().to_string())
.unwrap_or_else(|| "?".to_string());
writeln!(
sink,
" {red}{violation_icon}{reset} {} {} {}: {}ms > {}ms",
finding.role,
finding.test_name,
file_str,
finding.observed_ms,
finding.threshold_ms,
)?;
}
if blocking {
writeln!(
sink,
"{violation_color}{violation_label}{reset} {} test duration violation(s)",
report.test_findings.len(),
)?;
}
}
Ok(())
}
fn summarize_file(file: &FileReport, bands: &Bands) -> FileSummary {
let mut summary = FileSummary {
severity: Severity::Green,
message: "all green".to_string(),
};
for diagnostic in &file.diagnostics {
if diagnostic.severity == "Error" {
consider_summary(
&mut summary,
Severity::Red,
format!("parse error: {}", diagnostic.message),
);
}
}
for violation in &file.file_violations {
consider_summary(
&mut summary,
Severity::Red,
format!(
"{} {:.0} > {:.0}",
violation.metric, violation.actual, violation.limit
),
);
}
for unit in &file.units {
consider_banded_metric(
&mut summary,
&unit.name,
"cyclomatic",
unit.metrics.cyclomatic,
bands.cyclomatic,
);
consider_banded_metric(
&mut summary,
&unit.name,
"cognitive",
unit.metrics.cognitive,
bands.cognitive,
);
for violation in &unit.violations {
if violation.metric == "cyclomatic" || violation.metric == "cognitive" {
continue;
}
consider_summary(
&mut summary,
Severity::Red,
format!(
"{}(): {} {:.0} > {:.0}",
unit.name, violation.metric, violation.actual, violation.limit
),
);
}
}
summary
}
fn consider_banded_metric(
summary: &mut FileSummary,
unit_name: &str,
metric: &'static str,
actual: u32,
band: Option<Band>,
) {
let severity = band_severity(actual, band);
if severity == Severity::Green {
return;
}
consider_summary(
summary,
severity,
format!(
"{}(): {} {} ({})",
unit_name,
metric,
actual,
severity_label(severity)
),
);
}
fn consider_summary(summary: &mut FileSummary, severity: Severity, message: String) {
if severity > summary.severity {
summary.severity = severity;
summary.message = message;
}
}
fn band_severity(actual: u32, band: Option<Band>) -> Severity {
match band {
Some(band) if actual > band.yellow => Severity::Red,
Some(band) if actual > band.green => Severity::Yellow,
_ => Severity::Green,
}
}
fn severity_label(severity: Severity) -> &'static str {
match severity {
Severity::Green => "green",
Severity::Yellow => "yellow",
Severity::Red => "red",
}
}
fn fmt_metric(v: f64) -> String {
if v == v.floor() {
format!("{:.0}", v)
} else {
format!("{:.2}", v)
}
}
fn color_enabled() -> bool {
if std::env::var_os("NO_COLOR").is_some() {
return false;
}
io::stdout().is_terminal()
}
fn write_markdown_report(sink: &mut dyn Write, report: &CheckReport, bands: &Bands) -> Result<()> {
writeln!(sink, "# Pretender report")?;
writeln!(sink)?;
for file in &report.files {
let summary = summarize_file(file, bands);
let icon = match summary.severity {
Severity::Green => "✓",
Severity::Yellow => "⚠",
Severity::Red => "✗",
};
writeln!(sink, "## `{}` {} {}", file.path, icon, summary.message)?;
writeln!(sink)?;
writeln!(sink, "- Role: `{}`", file.role)?;
for violation in &file.file_violations {
writeln!(
sink,
"- File violation: `{}` actual `{:.0}` > limit `{:.0}`",
violation.metric, violation.actual, violation.limit
)?;
}
for unit in &file.units {
writeln!(
sink,
"- `{}`: cyclomatic `{}`, cognitive `{}`, assertions `{}`, function_lines `{}`, params `{}`, nesting_max `{}`, abc `{:.2}`",
unit.name,
unit.metrics.cyclomatic,
unit.metrics.cognitive,
unit.metrics.assertions,
unit.metrics.function_lines,
unit.metrics.params,
unit.metrics.nesting_max,
unit.metrics.abc,
)?;
for violation in &unit.violations {
writeln!(
sink,
" - violation: `{}` actual `{:.0}` > limit `{:.0}`",
violation.metric, violation.actual, violation.limit
)?;
}
}
if !file.diagnostics.is_empty() {
writeln!(sink)?;
writeln!(sink, "### Diagnostics")?;
for diagnostic in &file.diagnostics {
writeln!(sink, "- {}: {}", diagnostic.severity, diagnostic.message)?;
}
}
writeln!(sink)?;
}
Ok(())
}
fn write_html_report(sink: &mut dyn Write, report: &CheckReport, bands: &Bands) -> Result<()> {
writeln!(sink, "<!doctype html>")?;
writeln!(sink, "<html><head><meta charset=\"utf-8\"><title>Pretender report</title><style>body{{font-family:system-ui,sans-serif;max-width:960px;margin:2rem auto;padding:0 1rem}} .green{{color:#18794e}} .yellow{{color:#9a6700}} .red{{color:#cf222e}} code{{background:#f6f8fa;padding:.1rem .3rem;border-radius:4px}} ul{{line-height:1.5}}</style></head><body>")?;
writeln!(sink, "<h1>Pretender report</h1>")?;
for file in &report.files {
let summary = summarize_file(file, bands);
let (icon, class_name) = match summary.severity {
Severity::Green => ("✓", "green"),
Severity::Yellow => ("⚠", "yellow"),
Severity::Red => ("✗", "red"),
};
writeln!(
sink,
"<section><h2><code>{}</code> <span class=\"{}\">{} {}</span></h2>",
html_escape(&file.path),
class_name,
icon,
html_escape(&summary.message)
)?;
writeln!(
sink,
"<p>Role: <code>{}</code></p><ul>",
html_escape(&file.role)
)?;
for violation in &file.file_violations {
writeln!(
sink,
"<li>File violation: <code>{}</code> actual <code>{:.0}</code> > limit <code>{:.0}</code></li>",
html_escape(&violation.metric),
violation.actual,
violation.limit
)?;
}
for unit in &file.units {
writeln!(
sink,
"<li><code>{}</code>: cyclomatic <code>{}</code>, cognitive <code>{}</code>, assertions <code>{}</code>, function_lines <code>{}</code>, params <code>{}</code>, nesting_max <code>{}</code>, abc <code>{:.2}</code>",
html_escape(&unit.name),
unit.metrics.cyclomatic,
unit.metrics.cognitive,
unit.metrics.assertions,
unit.metrics.function_lines,
unit.metrics.params,
unit.metrics.nesting_max,
unit.metrics.abc,
)?;
if !unit.violations.is_empty() {
writeln!(sink, "<ul>")?;
for violation in &unit.violations {
writeln!(
sink,
"<li>violation: <code>{}</code> actual <code>{:.0}</code> > limit <code>{:.0}</code></li>",
html_escape(&violation.metric),
violation.actual,
violation.limit
)?;
}
writeln!(sink, "</ul>")?;
}
writeln!(sink, "</li>")?;
}
writeln!(sink, "</ul>")?;
if !file.diagnostics.is_empty() {
writeln!(sink, "<h3>Diagnostics</h3><ul>")?;
for diagnostic in &file.diagnostics {
writeln!(
sink,
"<li>{}: {}</li>",
html_escape(&diagnostic.severity),
html_escape(&diagnostic.message)
)?;
}
writeln!(sink, "</ul>")?;
}
writeln!(sink, "</section>")?;
}
writeln!(sink, "</body></html>")?;
Ok(())
}
fn html_escape(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn write_json_report(sink: &mut dyn Write, report: &CheckReport) -> Result<()> {
let env = Envelope::success(
env!("CARGO_PKG_VERSION"),
EnvelopeKind::Check,
report,
vec![],
vec![],
);
serde_json::to_writer_pretty(&mut *sink, &env)?;
writeln!(sink)?;
Ok(())
}
fn write_sarif_report(sink: &mut dyn Write, report: &CheckReport) -> Result<()> {
use serde_sarif::sarif;
use std::collections::HashMap;
let mut rule_index: HashMap<String, i64> = HashMap::new();
let mut rules: Vec<sarif::ReportingDescriptor> = Vec::new();
let mut results: Vec<sarif::Result> = Vec::new();
let push_result = |rules: &mut Vec<sarif::ReportingDescriptor>,
rule_index: &mut HashMap<String, i64>,
results: &mut Vec<sarif::Result>,
violation: &ViolationReport,
file_path: &str,
start_line: i64,
label: &str| {
let idx = if let Some(&i) = rule_index.get(&violation.metric) {
i
} else {
let i = rules.len() as i64;
rules.push(
sarif::ReportingDescriptor::builder()
.id(&violation.metric)
.build(),
);
rule_index.insert(violation.metric.clone(), i);
i
};
let message_text = format!(
"{} in {} exceeds limit: actual={:.0}, limit={:.0}",
violation.metric, label, violation.actual, violation.limit
);
let location = sarif::Location::builder()
.physical_location(
sarif::PhysicalLocation::builder()
.artifact_location(sarif::ArtifactLocation::builder().uri(file_path).build())
.region(sarif::Region::builder().start_line(start_line).build())
.build(),
)
.build();
results.push(
sarif::Result::builder()
.rule_id(&violation.metric)
.rule_index(idx)
.message(sarif::Message::builder().text(message_text).build())
.locations(vec![location])
.level(sarif::ResultLevel::Warning)
.build(),
);
};
for file in &report.files {
for violation in &file.file_violations {
push_result(
&mut rules,
&mut rule_index,
&mut results,
violation,
&file.path,
1,
&file.path,
);
}
for unit in &file.units {
for violation in &unit.violations {
push_result(
&mut rules,
&mut rule_index,
&mut results,
violation,
&file.path,
unit.start_line as i64,
&unit.name,
);
}
}
}
let duration_rule_id = "pretender/test-duration";
let duration_rule_idx = rules.len() as i64;
rules.push(
sarif::ReportingDescriptor::builder()
.id(duration_rule_id)
.build(),
);
for finding in &report.test_findings {
let file_uri = finding
.file
.as_ref()
.map(|f| f.display().to_string())
.unwrap_or_else(|| "?".to_string());
let message_text = format!(
"{} {}: {}ms exceeds {}ms threshold",
finding.role, finding.test_name, finding.observed_ms, finding.threshold_ms
);
let location = sarif::Location::builder()
.physical_location(
sarif::PhysicalLocation::builder()
.artifact_location(sarif::ArtifactLocation::builder().uri(&file_uri).build())
.build(),
)
.build();
results.push(
sarif::Result::builder()
.rule_id(duration_rule_id)
.rule_index(duration_rule_idx)
.message(sarif::Message::builder().text(message_text).build())
.locations(vec![location])
.level(sarif::ResultLevel::Warning)
.build(),
);
}
let tool_component = sarif::ToolComponent::builder()
.name("pretender")
.rules(rules)
.build();
let run = sarif::Run::builder()
.tool(tool_component)
.results(results)
.build();
let sarif_log = sarif::Sarif::builder()
.version(sarif::Version::V2_1_0.to_string())
.schema(sarif::SCHEMA_URL)
.runs(vec![run])
.build();
serde_json::to_writer_pretty(&mut *sink, &sarif_log)?;
writeln!(sink)?;
Ok(())
}
fn role_name(role: Role) -> &'static str {
match role {
Role::App => "app",
Role::Library => "library",
Role::Test => "test",
Role::Script => "script",
Role::Generated => "generated",
Role::Vendor => "vendor",
Role::UnitTest => "unit-test",
Role::IntegrationTest => "integration-test",
}
}
fn get_parser(path: &Path) -> Result<Box<dyn model::Parser>> {
let ext = path
.extension()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow!("missing file extension for path: {}", path.display()))?;
match ext {
"cs" => Ok(Box::new(csharp::CSharpParser)),
"clj" | "cljs" | "cljc" | "edn" => Ok(Box::new(clojure::ClojureParser)),
"c" | "h" => Ok(Box::new(c::CParser)),
"cpp" | "cc" | "cxx" | "hpp" | "hxx" => Ok(Box::new(cpp::CppParser)),
"go" => Ok(Box::new(go::GoParser)),
"java" => Ok(Box::new(java::JavaParser)),
"jl" => Ok(Box::new(julia::JuliaParser)),
"js" | "jsx" | "mjs" | "cjs" => Ok(Box::new(javascript::JavaScriptParser)),
"py" => Ok(Box::new(python::PythonParser)),
"r" | "R" => Ok(Box::new(r::RParser)),
"rb" => Ok(Box::new(ruby::RubyParser)),
"rs" => Ok(Box::new(rust::RustParser)),
"ts" | "mts" => Ok(Box::new(typescript::TypeScriptParser)),
"tsx" | "cts" => Ok(Box::new(typescript::TypeScriptXParser)),
_ => Err(anyhow!("unsupported file extension '.{}'", ext)),
}
}
#[derive(Debug, Serialize, Deserialize)]
struct CheckReport {
files: Vec<FileReport>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
modules: Vec<ModuleReport>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
cycles: Vec<Vec<String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
test_findings: Vec<test_report::TestDurationFinding>,
#[serde(skip_serializing_if = "Option::is_none")]
history: Option<history::HistorySummary>,
}
#[derive(Debug, Serialize, Deserialize)]
struct FileReport {
path: String,
role: String,
diagnostics: Vec<DiagnosticReport>,
file_violations: Vec<ViolationReport>,
units: Vec<UnitReport>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
external_findings: Vec<external_plugin::ExternalFinding>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
mock_findings: Vec<MockFindingReport>,
}
#[derive(Debug, Serialize, Deserialize)]
struct DiagnosticReport {
severity: String,
message: String,
start_line: Option<u32>,
end_line: Option<u32>,
}
impl From<model::Diagnostic> for DiagnosticReport {
fn from(value: model::Diagnostic) -> Self {
Self {
severity: format!("{:?}", value.severity),
message: value.message,
start_line: value.span.as_ref().map(|span| span.start_line),
end_line: value.span.as_ref().map(|span| span.end_line),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
struct UnitReport {
name: String,
kind: String,
start_line: u32,
metrics: MetricValues,
violations: Vec<ViolationReport>,
}
#[derive(Debug, Serialize, Deserialize)]
struct MetricValues {
cyclomatic: u32,
cognitive: u32,
assertions: u32,
function_lines: u32,
params: u32,
nesting_max: u32,
abc: f64,
}
#[derive(Debug, Serialize, Deserialize)]
struct ViolationReport {
metric: String,
actual: f64,
limit: f64,
}
#[derive(Debug, Serialize, Deserialize)]
struct ModuleReport {
path: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
coupling_violations: Vec<CouplingViolationReport>,
}
#[derive(Debug, Serialize, Deserialize)]
struct CouplingViolationReport {
metric: String,
actual: f64,
limit: f64,
}
#[derive(Debug, Serialize, Deserialize)]
struct MockFindingReport {
name: String,
line: u32,
kind: String,
}
fn emit_history_events(
report: &mut CheckReport,
config: &config::Config,
format: ReportFormat,
writing_to_stdout: bool,
sink: &mut dyn Write,
) {
let timestamp = history::now_iso8601();
let run_id = history::make_run_id();
let mode_str = format!("{:?}", config.pretender.mode).to_ascii_lowercase();
let new_events = all_violation_events(report, &run_id, ×tamp, &mode_str);
let current_ids: Vec<String> = new_events.iter().map(|e| e.fingerprint.clone()).collect();
let store = history::EventStore::new(Path::new(".pretender"));
let previous = match store.load_last_snapshot() {
Ok(prev) => prev,
Err(err) => {
eprintln!("pretender: history read failed: {err}");
return;
}
};
let delta = history::compute_delta(
previous.as_ref().map(|s| s.finding_ids.as_slice()),
¤t_ids,
);
let mut first_seen = previous
.as_ref()
.map(|s| s.first_seen.clone())
.unwrap_or_default();
for id in ¤t_ids {
first_seen
.entry(id.clone())
.or_insert_with(|| timestamp.clone());
}
let snapshot = history::FindingsSnapshot {
schema_version: 1,
run_id: run_id.clone(),
timestamp: timestamp.clone(),
finding_ids: current_ids.clone(),
first_seen: first_seen.clone(),
};
if let Err(err) = store.persist_last_snapshot(&snapshot) {
eprintln!("pretender: history write failed: {err}");
}
if new_events.is_empty() {
if !delta.fixed.is_empty() && matches!(format, ReportFormat::Human) {
let color = writing_to_stdout && color_enabled();
let (green, reset) = if color {
("\u{1b}[32m", "\u{1b}[0m")
} else {
("", "")
};
let _ = writeln!(
sink,
"{green}✓{reset} resolution: {} fixed since last check",
delta.fixed.len()
);
}
return;
}
let all_events = match store.append_and_prune(&new_events) {
Ok(events) => events,
Err(err) => {
eprintln!("pretender: history write failed: {err}");
return;
}
};
let mut summary = history::compute_summary(&all_events);
summary.resolution = Some(history::ResolutionStats {
fixed: delta.fixed.len(),
still_open: delta.still_open.len(),
new: delta.new.len(),
rate: delta.resolution_rate,
});
let _ = store.persist_summary(&summary);
if matches!(format, ReportFormat::Human) {
let color = writing_to_stdout && color_enabled();
let _ = write_resolution_delta(sink, &delta, color);
}
if !summary.top_hotspots.is_empty()
|| !summary.top_patterns.is_empty()
|| summary.resolution.is_some()
{
report.history = Some(summary.clone());
if matches!(format, ReportFormat::Human) {
let color = writing_to_stdout && color_enabled();
let _ = write_recurrence_hints(sink, &summary, color);
}
}
}
fn write_resolution_delta(
sink: &mut dyn Write,
delta: &history::FindingDelta,
color: bool,
) -> Result<()> {
let (cyan, reset) = if color {
("\u{1b}[36m", "\u{1b}[0m")
} else {
("", "")
};
let rate = delta
.resolution_rate
.map(|r| format!(" ({:.0}%)", r * 100.0))
.unwrap_or_default();
writeln!(
sink,
"{cyan}→{reset} resolution: {} fixed, {} still open, {} new{rate}",
delta.fixed.len(),
delta.still_open.len(),
delta.new.len()
)?;
Ok(())
}
fn metric_to_rule_key(metric: &str) -> &str {
match metric {
"cognitive" => "cognitive_max",
"cyclomatic" => "cyclomatic_max",
"params" => "params_max",
"nesting" => "nesting_max",
"function_lines" => "function_lines_max",
"abc" => "abc_max",
"duplication_pct" => "duplication_pct_max",
"min_assertions" => "min_assertions",
other => other,
}
}
fn metric_to_family(metric: &str) -> &str {
match metric {
"cognitive" | "cyclomatic" | "nesting" | "abc" => "complexity",
"params" | "function_lines" => "size",
"duplication_pct" => "duplication",
"min_assertions" => "testing",
other => other,
}
}
fn all_violation_events(
report: &CheckReport,
run_id: &str,
timestamp: &str,
mode: &str,
) -> Vec<history::ViolationEvent> {
let mut events = Vec::new();
for file in &report.files {
let path = file
.path
.strip_prefix("./")
.unwrap_or(&file.path)
.to_string();
for unit in &file.units {
for violation in &unit.violations {
let rule_key = metric_to_rule_key(&violation.metric);
let metric_family = metric_to_family(&violation.metric);
events.push(history::ViolationEvent {
schema_version: 1,
timestamp: timestamp.to_string(),
run_id: run_id.to_string(),
mode: mode.to_string(),
path: path.clone(),
unit_name: Some(unit.name.clone()),
role: file.role.clone(),
rule_key: rule_key.to_string(),
metric_family: metric_family.to_string(),
scope: "unit".to_string(),
severity: "red".to_string(),
actual: violation.actual,
limit: violation.limit,
delta: violation.actual - violation.limit,
fingerprint: history::finding_id(&path, &unit.name, rule_key),
});
}
}
}
events
}
fn write_recurrence_hints(
sink: &mut dyn Write,
summary: &history::HistorySummary,
color: bool,
) -> Result<()> {
let cyan = if color { "\u{1b}[36m" } else { "" };
let bold = if color { "\u{1b}[1m" } else { "" };
let reset = if color { "\u{1b}[0m" } else { "" };
writeln!(sink)?;
writeln!(sink, "{bold}Recurring violations{reset}")?;
for h in &summary.top_hotspots {
writeln!(
sink,
" {cyan}hotspot{reset} {} — {} occurrences across {} days",
h.fingerprint, h.count, h.distinct_days
)?;
}
for p in &summary.top_patterns {
writeln!(
sink,
" {cyan}pattern{reset} {} in {} ({}) — {} occurrences across {} files",
p.rule_key, p.area, p.role, p.count, p.distinct_files
)?;
}
Ok(())
}
fn main() -> ExitCode {
if genesis::cli::maybe_print_version_json(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")) {
return ExitCode::SUCCESS;
}
let mut guide = Guide::builder("pretender", env!("CARGO_PKG_VERSION"))
.commands(PRETENDER_COMMANDS)
.build();
let cli = match Cli::try_parse() {
Ok(cli) => {
guide.set_verbosity(cli.verbosity());
cli
}
Err(err) => {
if err.kind() == clap::error::ErrorKind::InvalidSubcommand {
let err_msg = err.to_string();
if let Some(bad_cmd) = extract_bad_subcommand(&err_msg) {
let engine = SuggestionEngine::new();
if let Some(suggestion) = engine.suggest_typo(&bad_cmd, guide.registry()) {
eprintln!("error: unrecognized subcommand '{bad_cmd}'");
eprintln!();
eprintln!("{}", suggestion.message());
if let Some(footer) = suggestion.footer() {
eprintln!("{footer}");
}
std::process::exit(2);
}
}
}
err.exit();
}
};
match cli.command.run() {
Ok(code) => code,
Err(err) => {
let sink = guide.error_sink().with_suggest(false).with_scratch(false);
let mut stderr = io::stderr();
sink.handle(err.as_ref(), &mut stderr);
write_error_scratch();
ExitCode::FAILURE
}
}
}
fn write_error_scratch() {
let record = ErrorRecord {
ts: unix_epoch_seconds(),
argv: std::env::args().collect(),
exit: 1,
footer: None,
kind: "Error".to_string(),
};
scratch::write_scratch_best_effort("pretender", &record);
}
fn unix_epoch_seconds() -> String {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.to_string()
}
fn extract_bad_subcommand(msg: &str) -> Option<String> {
let quote_start = msg.find('\'')?;
let rest = &msg[quote_start + 1..];
let quote_end = rest.find('\'')?;
Some(rest[..quote_end].to_string())
}
fn resolve_doctor_format() -> genesis::guide::OutputFormat {
let args: Vec<String> = std::env::args().collect();
if args.iter().any(|a| a == "--json" || a == "-j") {
genesis::guide::OutputFormat::Json
} else {
genesis::guide::OutputFormat::Human
}
}
pub struct PretenderStatus;
impl StatusContributor for PretenderStatus {
fn name(&self) -> &'static str {
"pretender"
}
fn status(&self, repo_root: &std::path::Path) -> Result<StatusSection, String> {
let config_exists = repo_root.join("pretender.toml").exists();
let git_dir = repo_root.join(".git");
let has_git = git_dir.exists();
let hook_installed = has_git && git_dir.join("hooks/pre-commit").exists();
let mut items = Vec::new();
if config_exists {
items.push(StatusItem::healthy("config", "pretender.toml found"));
} else {
items.push(StatusItem::error("config", "pretender.toml not found"));
}
if has_git {
items.push(StatusItem::healthy("git", "git repository detected"));
} else {
items.push(StatusItem::warning("git", "not in a git repository"));
}
if hook_installed {
items.push(StatusItem::healthy("hook", "pre-commit hook installed"));
}
let summary = if config_exists {
"configured".to_string()
} else {
"not initialized".to_string()
};
Ok(StatusSection::with_items("pretender", summary, items))
}
}