use crate::analysis;
use crate::app::agent_brief::{
AgentBriefChangedOwner, AgentBriefLine, AgentBriefPolicy, AgentBriefResolvedWorkingSet,
select_agent_brief_seams,
};
use crate::app::{self, CheckInput, Mode, OutputFormat};
use crate::cli::agent::{
AgentBriefOptions, AgentBriefWorkingSet, AgentCommand, AgentPacketOptions, AgentReceiptOptions,
AgentVerifyOptions, parse_agent_args,
};
use crate::cli::help;
use crate::cli::parse::{expect_value, parse_format, parse_mode};
use crate::config::{
CONFIG_FILE_NAME, CheckInputExplicit, DEFAULT_LSP_SEAM_DIAGNOSTICS, apply_to_check_input,
generated_init_config, load_for_root,
};
use crate::output;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::Duration;
const DEFAULT_PILOT_TIMEOUT_MS: u64 = 30_000;
#[derive(Debug, PartialEq, Eq)]
struct InitOptions {
root: PathBuf,
dry_run: bool,
force: bool,
ci: Option<InitCi>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum InitCi {
Github,
}
#[derive(Debug, PartialEq, Eq)]
struct PilotOptions {
root: PathBuf,
out_dir: PathBuf,
mode: Mode,
explicit: CheckInputExplicit,
max_seams: usize,
timeout_ms: u64,
}
#[derive(Debug, PartialEq, Eq)]
struct OutcomeOptions {
before: PathBuf,
after: PathBuf,
format: OutcomeFormat,
out: Option<PathBuf>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum OutcomeFormat {
Markdown,
Json,
}
#[derive(Debug, PartialEq, Eq)]
struct CalibrateOptions {
mutants_json: PathBuf,
repo_exposure_json: PathBuf,
format: CalibrateFormat,
out: Option<PathBuf>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum CalibrateFormat {
Markdown,
Json,
}
pub(super) fn agent(args: &[String]) -> Result<(), String> {
match parse_agent_args(args)? {
AgentCommand::Help => {
help::print_agent_help();
Ok(())
}
AgentCommand::BriefHelp => {
help::print_agent_brief_help();
Ok(())
}
AgentCommand::PacketHelp => {
help::print_agent_packet_help();
Ok(())
}
AgentCommand::VerifyHelp => {
help::print_agent_verify_help();
Ok(())
}
AgentCommand::ReceiptHelp => {
help::print_agent_receipt_help();
Ok(())
}
AgentCommand::Brief(options) => run_agent_brief(options),
AgentCommand::Packet(options) => run_agent_packet(options),
AgentCommand::Verify(options) => run_agent_verify(options),
AgentCommand::Receipt(options) => run_agent_receipt(options),
}
}
fn run_agent_brief(options: AgentBriefOptions) -> Result<(), String> {
if !options.root.is_dir() {
return Err(format!(
"agent brief root {} is not a directory",
options.root.display()
));
}
let config = load_for_root(&options.root)?;
let mut input = CheckInput {
root: options.root.clone(),
..CheckInput::default()
};
apply_to_check_input(&mut input, &config, CheckInputExplicit::default());
let working_set = resolve_agent_brief_working_set(&input.root, &options.working_set)?;
let classified = analysis::inventory_classified_seams_at_with_config(&input.root, &config)?;
let selection = select_agent_brief_seams(
&classified,
&working_set,
options.max_seams,
AgentBriefPolicy::from_config(&config),
);
let rendered = output::agent_brief::render_agent_brief_json(
&input.root,
&input.mode,
&config,
&working_set,
&selection,
)?;
println!("{rendered}");
Ok(())
}
fn run_agent_packet(options: AgentPacketOptions) -> Result<(), String> {
if !options.root.is_dir() {
return Err(format!(
"agent packet root {} is not a directory",
options.root.display()
));
}
let config = load_for_root(&options.root)?;
let classified = analysis::inventory_classified_seams_at_with_config(&options.root, &config)?;
let entry = classified
.iter()
.find(|entry| entry.seam.id().as_str() == options.seam_id)
.ok_or_else(|| format!("agent packet seam_id {} was not found", options.seam_id))?;
let policy = AgentBriefPolicy::from_config(&config);
if let Some(reason) = policy.omission_reason_for_class(entry.class) {
return Err(format!("agent packet seam_id {} {reason}", options.seam_id));
}
let rendered = output::agent_seam_packets::render_agent_seam_packet_json(entry);
println!("{rendered}");
Ok(())
}
fn run_agent_verify(options: AgentVerifyOptions) -> Result<(), String> {
let before_path =
validate_agent_verify_snapshot_path(&options.root, &options.before, "--before")?;
let after_path = validate_agent_verify_snapshot_path(&options.root, &options.after, "--after")?;
let before_json = read_agent_verify_snapshot(&before_path, "before")?;
let after_json = read_agent_verify_snapshot(&after_path, "after")?;
let report = output::outcome::targeted_test_outcome_report_from_json(
&before_json,
&after_json,
output::outcome::display_path(&options.before),
output::outcome::display_path(&options.after),
)?;
let rendered = output::outcome::render_agent_verify_json(&report)?;
println!("{rendered}");
Ok(())
}
fn run_agent_receipt(options: AgentReceiptOptions) -> Result<(), String> {
if !options.root.is_dir() {
return Err(format!(
"agent receipt root {} is not a directory",
options.root.display()
));
}
let verify_path = validate_agent_receipt_verify_path(&options.root, &options.verify_json)?;
let verify_json = std::fs::read_to_string(&verify_path).map_err(|err| {
format!(
"read agent receipt verify JSON {} failed: {err}",
output::outcome::display_path(&verify_path)
)
})?;
let rendered = output::agent_receipt::render_agent_receipt_json(
&verify_json,
output::outcome::display_path(&options.verify_json),
&options.seam_id,
options.test_changed.as_deref(),
&options.commands_run,
)?;
match options.out {
Some(path) => {
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
std::fs::create_dir_all(parent)
.map_err(|err| format!("create {} failed: {err}", parent.display()))?;
}
std::fs::write(&path, rendered).map_err(|err| {
format!(
"write {} failed: {err}",
output::outcome::display_path(&path)
)
})
}
None => {
print!("{rendered}");
Ok(())
}
}
}
fn validate_agent_receipt_verify_path(root: &Path, path: &Path) -> Result<PathBuf, String> {
let root = root.canonicalize().map_err(|err| {
format!(
"canonicalize agent receipt root {} failed: {err}",
root.display()
)
})?;
let candidate = if path.is_absolute() {
path.to_path_buf()
} else {
root.join(path)
};
let candidate = candidate.canonicalize().map_err(|err| {
format!(
"canonicalize agent receipt --verify-json {} failed: {err}",
path.display()
)
})?;
if !candidate.starts_with(&root) {
return Err(format!(
"agent receipt --verify-json {} must stay under root {}",
path.display(),
root.display()
));
}
Ok(candidate)
}
fn validate_agent_verify_snapshot_path(
root: &Path,
path: &Path,
flag: &str,
) -> Result<PathBuf, String> {
let root = root.canonicalize().map_err(|err| {
format!(
"canonicalize agent verify root {} failed: {err}",
root.display()
)
})?;
let candidate = if path.is_absolute() {
path.to_path_buf()
} else {
root.join(path)
};
let candidate = candidate.canonicalize().map_err(|err| {
format!(
"canonicalize agent verify {flag} {} failed: {err}",
path.display()
)
})?;
if !candidate.starts_with(&root) {
return Err(format!(
"agent verify {flag} {} must stay under root {}",
path.display(),
root.display()
));
}
Ok(candidate)
}
fn read_agent_verify_snapshot(path: &Path, label: &str) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|err| {
format!(
"read agent verify {label} snapshot {} failed: {err}",
output::outcome::display_path(path)
)
})
}
fn resolve_agent_brief_working_set(
root: &Path,
working_set: &AgentBriefWorkingSet,
) -> Result<AgentBriefResolvedWorkingSet, String> {
match working_set {
AgentBriefWorkingSet::Diff(path) => {
let diff_path = validate_agent_brief_diff_path(root, path)?;
let diff_text = analysis::load_diff(root, None, Some(&diff_path))?;
let changed_lines = agent_brief_lines_from_diff(root, &diff_text);
let changed_owners = agent_brief_owners_for_lines(root, &changed_lines);
Ok(AgentBriefResolvedWorkingSet::diff(
path.clone(),
changed_lines,
))
.map(|working_set| working_set.with_changed_owners(changed_owners))
}
AgentBriefWorkingSet::Base(base) => {
let diff_text = analysis::load_diff(root, Some(base.as_str()), None)?;
let changed_lines = agent_brief_lines_from_diff(root, &diff_text);
let changed_owners = agent_brief_owners_for_lines(root, &changed_lines);
Ok(AgentBriefResolvedWorkingSet::base(
base.clone(),
changed_lines,
))
.map(|working_set| working_set.with_changed_owners(changed_owners))
}
AgentBriefWorkingSet::Files(files) => Ok(AgentBriefResolvedWorkingSet::files(
files
.iter()
.map(|file| normalize_agent_brief_path(root, file))
.collect(),
)),
AgentBriefWorkingSet::SeamId(seam_id) => {
Ok(AgentBriefResolvedWorkingSet::seam_id(seam_id.clone()))
}
}
}
fn validate_agent_brief_diff_path(root: &Path, path: &Path) -> Result<PathBuf, String> {
let root = root.canonicalize().map_err(|err| {
format!(
"canonicalize agent brief root {} failed: {err}",
root.display()
)
})?;
let candidate = if path.is_absolute() || path.exists() {
path.to_path_buf()
} else {
root.join(path)
};
let candidate = candidate.canonicalize().map_err(|err| {
format!(
"canonicalize agent brief diff {} failed: {err}",
path.display()
)
})?;
if !candidate.starts_with(&root) {
return Err(format!(
"agent brief --diff {} must stay under root {}",
path.display(),
root.display()
));
}
Ok(candidate)
}
fn agent_brief_lines_from_diff(root: &Path, diff_text: &str) -> Vec<AgentBriefLine> {
analysis::parse_unified_diff(diff_text)
.into_iter()
.flat_map(|file| {
let path = normalize_agent_brief_path(root, &file.path);
file.added_lines
.into_iter()
.map(move |line| AgentBriefLine::new(path.clone(), line.line))
})
.collect()
}
fn agent_brief_owners_for_lines(
root: &Path,
lines: &[AgentBriefLine],
) -> Vec<AgentBriefChangedOwner> {
let owner_inputs = lines
.iter()
.map(|line| (line.file.clone(), line.line))
.collect::<Vec<_>>();
let Ok(owners) = analysis::owner_symbols_for_lines(root, &owner_inputs) else {
return Vec::new();
};
owners
.into_iter()
.map(|owner| AgentBriefChangedOwner::new(owner.file, owner.line, owner.owner))
.collect()
}
fn normalize_agent_brief_path(root: &Path, path: &Path) -> PathBuf {
let path_text = normalized_path_text(path);
for root_text in normalized_root_prefixes(root) {
let prefix = format!("{root_text}/");
if let Some(stripped) = path_text.strip_prefix(&prefix) {
return PathBuf::from(stripped);
}
}
PathBuf::from(path_text)
}
fn normalized_root_prefixes(root: &Path) -> Vec<String> {
let mut prefixes = Vec::new();
push_unique_normalized_path(&mut prefixes, root);
if let Ok(root) = std::path::absolute(root) {
push_unique_normalized_path(&mut prefixes, &root);
}
if let Ok(root) = root.canonicalize() {
push_unique_normalized_path(&mut prefixes, &root);
}
prefixes
}
fn push_unique_normalized_path(prefixes: &mut Vec<String>, path: &Path) {
let text = normalized_path_text(path);
if !text.is_empty() && !prefixes.iter().any(|existing| existing == &text) {
prefixes.push(text);
}
}
fn normalized_path_text(path: &Path) -> String {
let text = path.to_string_lossy().replace('\\', "/");
text.strip_prefix("./").unwrap_or(&text).to_string()
}
pub(super) fn init(args: &[String]) -> Result<(), String> {
if args.iter().any(|arg| arg == "--help" || arg == "-h") {
help::print_init_help();
return Ok(());
}
let options = parse_init_options(args)?;
if options.dry_run {
print_init_dry_run(&options);
return Ok(());
}
if !options.root.is_dir() {
return Err(format!(
"init root {} is not a directory",
options.root.display()
));
}
let config_path = options.root.join(CONFIG_FILE_NAME);
let workflow_path = options
.ci
.as_ref()
.map(|ci| init_ci_workflow_path(&options.root, ci));
if config_path.exists() && !options.force && options.ci.is_none() {
return Err(format!(
"{} already exists; rerun `ripr init --force` to overwrite it",
config_path.display()
));
}
if let Some(path) = workflow_path
.as_ref()
.filter(|path| path.exists())
.filter(|_| !options.force)
{
return Err(format!(
"{} already exists; rerun `ripr init --ci github --force` to overwrite it",
path.display()
));
}
if config_path.exists() && !options.force {
println!("Left existing {} unchanged", config_path.display());
} else {
std::fs::write(&config_path, generated_init_config())
.map_err(|err| format!("write {} failed: {err}", config_path.display()))?;
println!("Wrote {}", config_path.display());
}
if let Some(ci) = options.ci.as_ref() {
write_init_ci_workflow(&options.root, ci)?;
}
Ok(())
}
fn parse_init_options(args: &[String]) -> Result<InitOptions, String> {
let mut options = InitOptions {
root: PathBuf::from("."),
dry_run: false,
force: false,
ci: None,
};
let mut i = 0usize;
while i < args.len() {
match args[i].as_str() {
"--root" => {
i += 1;
options.root = PathBuf::from(expect_value(args, i, "--root")?);
}
"--ci" => {
i += 1;
options.ci = Some(parse_init_ci(expect_value(args, i, "--ci")?)?);
}
"--dry-run" => options.dry_run = true,
"--force" => options.force = true,
other => return Err(format!("unknown init argument {other:?}")),
}
i += 1;
}
Ok(options)
}
fn parse_init_ci(value: &str) -> Result<InitCi, String> {
match value {
"github" => Ok(InitCi::Github),
_ => Err(format!("unknown init --ci provider {value:?}")),
}
}
fn print_init_dry_run(options: &InitOptions) {
if let Some(ci) = options.ci.as_ref() {
println!("# {}", CONFIG_FILE_NAME);
print!("{}", generated_init_config());
println!();
println!("# {}", init_ci_workflow_path(&options.root, ci).display());
print!("{}", generated_github_actions_workflow());
} else {
print!("{}", generated_init_config());
}
}
fn init_ci_workflow_path(root: &Path, ci: &InitCi) -> PathBuf {
match ci {
InitCi::Github => root.join(".github/workflows/ripr.yml"),
}
}
fn write_init_ci_workflow(root: &Path, ci: &InitCi) -> Result<(), String> {
match ci {
InitCi::Github => {
let path = init_ci_workflow_path(root, ci);
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
std::fs::create_dir_all(parent)
.map_err(|err| format!("create {} failed: {err}", parent.display()))?;
}
std::fs::write(&path, generated_github_actions_workflow())
.map_err(|err| format!("write {} failed: {err}", path.display()))?;
println!("Wrote {}", path.display());
Ok(())
}
}
}
fn generated_github_actions_workflow() -> &'static str {
r#"name: RIPR
on:
pull_request:
workflow_dispatch:
permissions:
contents: read
security-events: write
env:
RIPR_UPLOAD_SARIF: "true"
jobs:
ripr:
name: RIPR advisory reports
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: dtolnay/rust-toolchain@stable
- name: Install ripr
run: cargo install ripr --locked
- name: Generate RIPR pilot packet
continue-on-error: true
run: |
ripr pilot \
--root . \
--out target/ripr/pilot \
--mode ready \
--max-seams 5
- name: Prepare RIPR editor-agent artifacts
if: always()
continue-on-error: true
run: |
mkdir -p target/ripr/reports target/ripr/agent
if [ -f target/ripr/pilot/repo-exposure.json ]; then
cp target/ripr/pilot/repo-exposure.json target/ripr/reports/repo-exposure.json
fi
if [ -f target/ripr/pilot/pilot-summary.json ]; then
top_seam_id="$(jq -r '.top_actionable_seams[0].seam_id // empty' target/ripr/pilot/pilot-summary.json 2>/dev/null || true)"
if [ -n "$top_seam_id" ] && [ "$top_seam_id" != "null" ]; then
echo "RIPR_TOP_SEAM_ID=$top_seam_id" >> "$GITHUB_ENV"
fi
fi
- name: Generate RIPR agent loop artifacts
if: always() && env.RIPR_TOP_SEAM_ID != ''
continue-on-error: true
run: |
ripr agent packet \
--root . \
--seam-id "$RIPR_TOP_SEAM_ID" \
--json \
> target/ripr/agent/agent-packet.json
ripr agent brief \
--root . \
--seam-id "$RIPR_TOP_SEAM_ID" \
--json \
> target/ripr/agent/agent-brief.json
ripr check \
--root . \
--mode ready \
--format repo-exposure-json \
> target/ripr/pilot/after.repo-exposure.json
ripr agent verify \
--root . \
--before target/ripr/pilot/repo-exposure.json \
--after target/ripr/pilot/after.repo-exposure.json \
--json \
> target/ripr/agent/agent-verify.json
ripr agent receipt \
--root . \
--verify-json target/ripr/agent/agent-verify.json \
--seam-id "$RIPR_TOP_SEAM_ID" \
--json \
--out target/ripr/agent/agent-receipt.json
ripr outcome \
--before target/ripr/pilot/repo-exposure.json \
--after target/ripr/pilot/after.repo-exposure.json \
--format json \
--out target/ripr/reports/targeted-test-outcome.json
- name: Capture pull request diff
if: github.event_name == 'pull_request'
run: |
mkdir -p target/ripr/reports
git diff --binary "origin/${{ github.base_ref }}...HEAD" > target/ripr/reports/pr.diff
- name: Render RIPR diff SARIF
if: env.RIPR_UPLOAD_SARIF == 'true' && github.event_name == 'pull_request'
continue-on-error: true
run: |
ripr check \
--root . \
--diff target/ripr/reports/pr.diff \
--format sarif \
> target/ripr/reports/ripr-findings.sarif
- name: Render RIPR repo seam SARIF
if: env.RIPR_UPLOAD_SARIF == 'true'
continue-on-error: true
run: |
mkdir -p target/ripr/reports
ripr check \
--root . \
--mode ready \
--format repo-sarif \
> target/ripr/reports/ripr-seams.sarif
- name: Render RIPR repo badge artifacts
continue-on-error: true
run: |
mkdir -p target/ripr/reports
ripr check \
--root . \
--mode ready \
--format repo-badge-json \
> target/ripr/reports/repo-ripr-badge.json
ripr check \
--root . \
--mode ready \
--format repo-badge-shields \
> target/ripr/reports/repo-ripr-badge-shields.json
- name: Render RIPR operator cockpit
if: always() && hashFiles('crates/ripr/Cargo.toml') != '' && hashFiles('xtask/src/reports/operator.rs') != ''
continue-on-error: true
run: cargo xtask operator-cockpit
- name: Add RIPR pilot summary
if: always() && hashFiles('target/ripr/pilot/pilot-summary.md') != ''
continue-on-error: true
run: cat target/ripr/pilot/pilot-summary.md >> "$GITHUB_STEP_SUMMARY"
- name: Upload RIPR report artifacts
if: always()
continue-on-error: true
uses: actions/upload-artifact@v7
with:
name: ripr-reports
path: |
target/ripr/pilot
target/ripr/agent
target/ripr/reports
if-no-files-found: ignore
retention-days: 14
- name: Upload RIPR diff findings
if: env.RIPR_UPLOAD_SARIF == 'true' && github.event_name == 'pull_request' && hashFiles('target/ripr/reports/ripr-findings.sarif') != ''
continue-on-error: true
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: target/ripr/reports/ripr-findings.sarif
category: ripr-findings
- name: Upload RIPR repo seams
if: env.RIPR_UPLOAD_SARIF == 'true' && hashFiles('target/ripr/reports/ripr-seams.sarif') != ''
continue-on-error: true
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: target/ripr/reports/ripr-seams.sarif
category: ripr-seams
"#
}
pub(super) fn pilot(args: &[String]) -> Result<(), String> {
if args.iter().any(|arg| arg == "--help" || arg == "-h") {
help::print_pilot_help();
return Ok(());
}
let options = parse_pilot_options(args)?;
if !options.root.is_dir() {
return Err(format!(
"pilot root {} is not a directory",
options.root.display()
));
}
let config = load_for_root(&options.root)?;
let mut input = CheckInput {
root: options.root.clone(),
mode: options.mode.clone(),
..CheckInput::default()
};
apply_to_check_input(&mut input, &config, options.explicit);
let artifacts = pilot_artifacts(&options.out_dir);
std::fs::create_dir_all(&options.out_dir)
.map_err(|err| format!("create {} failed: {err}", options.out_dir.display()))?;
let context = output::pilot::PilotSummaryContext {
root: &input.root,
mode: &input.mode,
config_path: config.source_path(),
max_seams: options.max_seams,
timeout_ms: options.timeout_ms,
artifacts: &artifacts,
};
let analysis_root = input.root.clone();
let analysis_config = config.clone();
let analysis_result = run_pilot_analysis_with_timeout(options.timeout_ms, move || {
analysis::inventory_classified_seams_at_with_config(&analysis_root, &analysis_config)
})?;
let PilotAnalysisResult::Complete(classified) = analysis_result else {
std::fs::write(
&artifacts.pilot_summary_json,
output::pilot::render_pilot_timeout_summary_json(context),
)
.map_err(|err| {
format!(
"write {} failed: {err}",
artifacts.pilot_summary_json.display()
)
})?;
std::fs::write(
&artifacts.pilot_summary_md,
output::pilot::render_pilot_timeout_summary_md(context),
)
.map_err(|err| {
format!(
"write {} failed: {err}",
artifacts.pilot_summary_md.display()
)
})?;
print!("{}", output::pilot::render_pilot_timeout_terminal(context));
return Ok(());
};
std::fs::write(
&artifacts.repo_exposure_json,
output::repo_exposure::render_repo_exposure_json(&classified),
)
.map_err(|err| {
format!(
"write {} failed: {err}",
artifacts.repo_exposure_json.display()
)
})?;
std::fs::write(
&artifacts.repo_exposure_md,
output::repo_exposure::render_repo_exposure_md(&classified),
)
.map_err(|err| {
format!(
"write {} failed: {err}",
artifacts.repo_exposure_md.display()
)
})?;
std::fs::write(
&artifacts.agent_seam_packets_json,
output::agent_seam_packets::render_agent_seam_packets_json(&classified),
)
.map_err(|err| {
format!(
"write {} failed: {err}",
artifacts.agent_seam_packets_json.display()
)
})?;
std::fs::write(
&artifacts.pilot_summary_json,
output::pilot::render_pilot_summary_json(&classified, context),
)
.map_err(|err| {
format!(
"write {} failed: {err}",
artifacts.pilot_summary_json.display()
)
})?;
std::fs::write(
&artifacts.pilot_summary_md,
output::pilot::render_pilot_summary_md(&classified, context),
)
.map_err(|err| {
format!(
"write {} failed: {err}",
artifacts.pilot_summary_md.display()
)
})?;
print!(
"{}",
output::pilot::render_pilot_terminal(&classified, context)
);
Ok(())
}
fn parse_pilot_options(args: &[String]) -> Result<PilotOptions, String> {
let mut options = PilotOptions {
root: PathBuf::from("."),
out_dir: PathBuf::from("target/ripr/pilot"),
mode: Mode::Draft,
explicit: CheckInputExplicit::default(),
max_seams: 5,
timeout_ms: DEFAULT_PILOT_TIMEOUT_MS,
};
let mut i = 0usize;
while i < args.len() {
match args[i].as_str() {
"--root" => {
i += 1;
options.root = PathBuf::from(expect_value(args, i, "--root")?);
}
"--out" => {
i += 1;
options.out_dir = PathBuf::from(expect_value(args, i, "--out")?);
}
"--mode" => {
i += 1;
options.mode = parse_mode(expect_value(args, i, "--mode")?)?;
options.explicit.mode = true;
}
"--max-seams" => {
i += 1;
options.max_seams =
parse_positive_usize(expect_value(args, i, "--max-seams")?, "--max-seams")?;
}
"--timeout-ms" => {
i += 1;
options.timeout_ms =
parse_positive_u64(expect_value(args, i, "--timeout-ms")?, "--timeout-ms")?;
}
other => return Err(format!("unknown pilot argument {other:?}")),
}
i += 1;
}
Ok(options)
}
fn parse_positive_usize(value: &str, flag: &str) -> Result<usize, String> {
let parsed = value
.parse::<usize>()
.map_err(|err| format!("invalid {flag}: {err}"))?;
if parsed == 0 {
return Err(format!("invalid {flag}: expected a positive integer"));
}
Ok(parsed)
}
fn parse_positive_u64(value: &str, flag: &str) -> Result<u64, String> {
let parsed = value
.parse::<u64>()
.map_err(|err| format!("invalid {flag}: {err}"))?;
if parsed == 0 {
return Err(format!("invalid {flag}: expected a positive integer"));
}
Ok(parsed)
}
enum PilotAnalysisResult {
Complete(Vec<analysis::ClassifiedSeam>),
TimedOut,
}
fn run_pilot_analysis_with_timeout<F>(
timeout_ms: u64,
runner: F,
) -> Result<PilotAnalysisResult, String>
where
F: FnOnce() -> Result<Vec<analysis::ClassifiedSeam>, String> + Send + 'static,
{
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let result = runner();
let _ignored = tx.send(result);
});
match rx.recv_timeout(Duration::from_millis(timeout_ms)) {
Ok(result) => result.map(PilotAnalysisResult::Complete),
Err(mpsc::RecvTimeoutError::Timeout) => Ok(PilotAnalysisResult::TimedOut),
Err(mpsc::RecvTimeoutError::Disconnected) => {
Err("pilot analysis stopped before producing a result".to_string())
}
}
}
fn pilot_artifacts(out_dir: &Path) -> output::pilot::PilotArtifacts {
output::pilot::PilotArtifacts {
repo_exposure_json: out_dir.join("repo-exposure.json"),
repo_exposure_md: out_dir.join("repo-exposure.md"),
agent_seam_packets_json: out_dir.join("agent-seam-packets.json"),
pilot_summary_json: out_dir.join("pilot-summary.json"),
pilot_summary_md: out_dir.join("pilot-summary.md"),
}
}
pub(super) fn outcome(args: &[String]) -> Result<(), String> {
if args.iter().any(|arg| arg == "--help" || arg == "-h") {
help::print_outcome_help();
return Ok(());
}
let options = parse_outcome_options(args)?;
let before_json = std::fs::read_to_string(&options.before).map_err(|err| {
format!(
"read {} failed: {err}",
output::outcome::display_path(&options.before)
)
})?;
let after_json = std::fs::read_to_string(&options.after).map_err(|err| {
format!(
"read {} failed: {err}",
output::outcome::display_path(&options.after)
)
})?;
let report = output::outcome::targeted_test_outcome_report_from_json(
&before_json,
&after_json,
output::outcome::display_path(&options.before),
output::outcome::display_path(&options.after),
)?;
let rendered = match options.format {
OutcomeFormat::Markdown => output::outcome::render_targeted_test_outcome_md(&report),
OutcomeFormat::Json => output::outcome::render_targeted_test_outcome_json(&report)?,
};
match options.out {
Some(path) => {
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
std::fs::create_dir_all(parent)
.map_err(|err| format!("create {} failed: {err}", parent.display()))?;
}
std::fs::write(&path, rendered).map_err(|err| {
format!(
"write {} failed: {err}",
output::outcome::display_path(&path)
)
})
}
None => {
print!("{rendered}");
Ok(())
}
}
}
pub(super) fn calibrate(args: &[String]) -> Result<(), String> {
if args.iter().any(|arg| arg == "--help" || arg == "-h") {
help::print_calibrate_help();
return Ok(());
}
let Some((subcommand, rest)) = args.split_first() else {
return Err("calibrate requires subcommand `cargo-mutants`".to_string());
};
if subcommand != "cargo-mutants" {
return Err(format!(
"unknown calibrate subcommand {subcommand:?}; expected `cargo-mutants`"
));
}
let options = parse_calibrate_cargo_mutants_options(rest)?;
let repo_exposure_json =
std::fs::read_to_string(&options.repo_exposure_json).map_err(|err| {
format!(
"read {} failed: {err}",
output::outcome::display_path(&options.repo_exposure_json)
)
})?;
let mutants_json = read_calibration_mutants_json(&options.mutants_json)?;
let report = output::mutation_calibration::mutation_calibration_report_from_json(
&repo_exposure_json,
&mutants_json,
)?;
let rendered = match options.format {
CalibrateFormat::Markdown => {
output::mutation_calibration::render_mutation_calibration_md(&report)
}
CalibrateFormat::Json => {
output::mutation_calibration::render_mutation_calibration_json(&report)?
}
};
match options.out {
Some(path) => {
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
std::fs::create_dir_all(parent)
.map_err(|err| format!("create {} failed: {err}", parent.display()))?;
}
std::fs::write(&path, rendered).map_err(|err| {
format!(
"write {} failed: {err}",
output::outcome::display_path(&path)
)
})
}
None => {
print!("{rendered}");
Ok(())
}
}
}
fn parse_calibrate_cargo_mutants_options(args: &[String]) -> Result<CalibrateOptions, String> {
let mut mutants_json: Option<PathBuf> = None;
let mut repo_exposure_json: Option<PathBuf> = None;
let mut format = CalibrateFormat::Markdown;
let mut out: Option<PathBuf> = None;
let mut i = 0usize;
while i < args.len() {
match args[i].as_str() {
"--mutants-json" | "--cargo-mutants-json" | "--input" => {
i += 1;
mutants_json = Some(PathBuf::from(expect_value(args, i, "--mutants-json")?));
}
"--repo-exposure-json" | "--static-json" => {
i += 1;
repo_exposure_json = Some(PathBuf::from(expect_value(
args,
i,
"--repo-exposure-json",
)?));
}
"--format" => {
i += 1;
format = parse_calibrate_format(expect_value(args, i, "--format")?)?;
}
"--out" => {
i += 1;
out = Some(PathBuf::from(expect_value(args, i, "--out")?));
}
other => {
return Err(format!(
"unknown calibrate cargo-mutants argument {other:?}"
));
}
}
i += 1;
}
let mutants_json = mutants_json
.ok_or_else(|| "calibrate cargo-mutants requires --mutants-json <path>".to_string())?;
let repo_exposure_json = repo_exposure_json.ok_or_else(|| {
"calibrate cargo-mutants requires --repo-exposure-json <path>".to_string()
})?;
Ok(CalibrateOptions {
mutants_json,
repo_exposure_json,
format,
out,
})
}
fn parse_calibrate_format(value: &str) -> Result<CalibrateFormat, String> {
match value {
"md" | "markdown" | "text" => Ok(CalibrateFormat::Markdown),
"json" => Ok(CalibrateFormat::Json),
_ => Err(format!("unknown calibrate format {value:?}")),
}
}
fn read_calibration_mutants_json(path: &Path) -> Result<String, String> {
if path.is_dir() {
let outcomes_path = path.join("outcomes.json");
let mutants_path = path.join("mutants.json");
let outcomes_exists = outcomes_path.exists();
let mutants_exists = mutants_path.exists();
if outcomes_exists && mutants_exists {
let outcomes = read_json_value(&outcomes_path)?;
let mutants = read_json_value(&mutants_path)?;
return serde_json::to_string(&serde_json::Value::Array(vec![outcomes, mutants]))
.map_err(|err| format!("failed to combine cargo-mutants directory JSON: {err}"));
}
if outcomes_exists {
return read_calibration_text(&outcomes_path);
}
if mutants_exists {
return read_calibration_text(&mutants_path);
}
return Err(format!(
"{} is a directory but contains neither outcomes.json nor mutants.json",
output::outcome::display_path(path)
));
}
read_calibration_text(path)
}
fn read_json_value(path: &Path) -> Result<serde_json::Value, String> {
let text = read_calibration_text(path)?;
serde_json::from_str(&text).map_err(|err| {
format!(
"failed to parse JSON from {}: {err}",
output::outcome::display_path(path)
)
})
}
fn read_calibration_text(path: &Path) -> Result<String, String> {
std::fs::read_to_string(path)
.map_err(|err| format!("read {} failed: {err}", output::outcome::display_path(path)))
}
fn parse_outcome_options(args: &[String]) -> Result<OutcomeOptions, String> {
let mut before: Option<PathBuf> = None;
let mut after: Option<PathBuf> = None;
let mut format = OutcomeFormat::Markdown;
let mut out: Option<PathBuf> = None;
let mut i = 0usize;
while i < args.len() {
match args[i].as_str() {
"--before" => {
i += 1;
before = Some(PathBuf::from(expect_value(args, i, "--before")?));
}
"--after" => {
i += 1;
after = Some(PathBuf::from(expect_value(args, i, "--after")?));
}
"--format" => {
i += 1;
format = parse_outcome_format(expect_value(args, i, "--format")?)?;
}
"--out" => {
i += 1;
out = Some(PathBuf::from(expect_value(args, i, "--out")?));
}
other => return Err(format!("unknown outcome argument {other:?}")),
}
i += 1;
}
let before = before.ok_or_else(|| "outcome requires --before <path>".to_string())?;
let after = after.ok_or_else(|| "outcome requires --after <path>".to_string())?;
Ok(OutcomeOptions {
before,
after,
format,
out,
})
}
fn parse_outcome_format(value: &str) -> Result<OutcomeFormat, String> {
match value {
"md" | "markdown" | "text" => Ok(OutcomeFormat::Markdown),
"json" => Ok(OutcomeFormat::Json),
_ => Err(format!("unknown outcome format {value:?}")),
}
}
pub(super) fn check(args: &[String]) -> Result<(), String> {
let mut input = CheckInput::default();
let mut explicit = CheckInputExplicit::default();
let mut i = 0usize;
while i < args.len() {
match args[i].as_str() {
"--root" => {
i += 1;
input.root = PathBuf::from(expect_value(args, i, "--root")?);
}
"--base" => {
i += 1;
input.base = Some(expect_value(args, i, "--base")?.to_string());
}
"--diff" => {
i += 1;
input.diff_file = Some(PathBuf::from(expect_value(args, i, "--diff")?));
}
"--mode" => {
i += 1;
input.mode = parse_mode(expect_value(args, i, "--mode")?)?;
explicit.mode = true;
}
"--json" => input.format = OutputFormat::Json,
"--format" => {
i += 1;
input.format = parse_format(expect_value(args, i, "--format")?)?;
}
"--no-unchanged-tests" => {
input.include_unchanged_tests = false;
explicit.include_unchanged_tests = true;
}
"--help" | "-h" => {
help::print_check_help();
return Ok(());
}
other => return Err(format!("unknown check argument {other:?}")),
}
i += 1;
}
let config = load_for_root(&input.root)?;
apply_to_check_input(&mut input, &config, explicit);
let format = input.format.clone();
let output = if format.is_repo_seam_inventory() {
app::repo_seam_inventory_input(input)
} else if format.is_repo_scope() {
app::check_workspace_repo_with_config(input, &config)?
} else {
app::check_workspace_with_config(input, &config)?
};
print!(
"{}",
app::render_check_with_config(&output, &format, &config)?
);
Ok(())
}
pub(super) fn explain(args: &[String]) -> Result<(), String> {
let mut input = CheckInput::default();
let mut selector: Option<String> = None;
let mut i = 0usize;
while i < args.len() {
match args[i].as_str() {
"--root" => {
i += 1;
input.root = PathBuf::from(expect_value(args, i, "--root")?);
}
"--base" => {
i += 1;
input.base = Some(expect_value(args, i, "--base")?.to_string());
}
"--diff" => {
i += 1;
input.diff_file = Some(PathBuf::from(expect_value(args, i, "--diff")?));
}
"--help" | "-h" => {
help::print_explain_help();
return Ok(());
}
value if selector.is_none() => selector = Some(value.to_string()),
other => return Err(format!("unexpected explain argument {other:?}")),
}
i += 1;
}
let selector = selector.ok_or_else(|| "missing finding selector".to_string())?;
let config = load_for_root(&input.root)?;
apply_to_check_input(&mut input, &config, CheckInputExplicit::default());
println!(
"{}",
app::explain_finding_with_config(input, &selector, &config)?
);
Ok(())
}
pub(super) fn context(args: &[String]) -> Result<(), String> {
let mut input = CheckInput {
format: OutputFormat::Json,
..CheckInput::default()
};
let mut selector: Option<String> = None;
let mut max_tests = crate::config::DEFAULT_CONTEXT_RELATED_TESTS;
let mut explicit_max_tests = false;
let mut i = 0usize;
while i < args.len() {
match args[i].as_str() {
"--root" => {
i += 1;
input.root = PathBuf::from(expect_value(args, i, "--root")?);
}
"--base" => {
i += 1;
input.base = Some(expect_value(args, i, "--base")?.to_string());
}
"--diff" => {
i += 1;
input.diff_file = Some(PathBuf::from(expect_value(args, i, "--diff")?));
}
"--at" => {
i += 1;
selector = Some(expect_value(args, i, "--at")?.to_string());
}
"--finding" => {
i += 1;
selector = Some(expect_value(args, i, "--finding")?.to_string());
}
"--max-related-tests" => {
i += 1;
max_tests = expect_value(args, i, "--max-related-tests")?
.parse::<usize>()
.map_err(|err| format!("invalid --max-related-tests: {err}"))?;
explicit_max_tests = true;
}
"--json" => input.format = OutputFormat::Json,
"--help" | "-h" => {
help::print_context_help();
return Ok(());
}
other => return Err(format!("unexpected context argument {other:?}")),
}
i += 1;
}
let selector = selector.ok_or_else(|| "missing --at or --finding selector".to_string())?;
let config = load_for_root(&input.root)?;
apply_to_check_input(&mut input, &config, CheckInputExplicit::default());
if !explicit_max_tests {
max_tests = config.reports().max_related_tests();
}
println!(
"{}",
app::collect_context_with_config(input, &selector, max_tests, &config)?
);
Ok(())
}
pub(super) fn doctor(args: &[String]) -> Result<(), String> {
let root = match args {
[] => PathBuf::from("."),
[flag] if flag == "--help" || flag == "-h" => {
help::print_doctor_help();
return Ok(());
}
[flag] if flag == "--root" => return Err("missing value for --root".to_string()),
[flag, value] if flag == "--root" => PathBuf::from(value),
[other, ..] => return Err(format!("unknown doctor argument {other:?}")),
};
let mut ok = true;
println!("ripr doctor");
println!("- root: {}", root.display());
if root.is_dir() {
println!("✓ root directory exists");
} else {
println!("! root directory does not exist");
ok = false;
}
if root.join("Cargo.toml").exists() {
println!(
"✓ Cargo.toml found at {}",
root.join("Cargo.toml").display()
);
} else {
println!("! no Cargo.toml found at {}", root.display());
ok = false;
}
report_config_status(&root, &mut ok);
for (tool, args) in [
("git", vec!["--version"]),
("cargo", vec!["--version"]),
("rustc", vec!["--version"]),
] {
match std::process::Command::new(tool).args(&args).output() {
Ok(output) if output.status.success() => {
println!("✓ {}", String::from_utf8_lossy(&output.stdout).trim())
}
_ => {
println!("! {tool} not available");
ok = false;
}
}
}
if ok {
println!("✓ doctor checks passed");
Ok(())
} else {
println!("! doctor checks failed; run `ripr doctor --help` for usage");
Err("doctor found issues".to_string())
}
}
fn report_config_status(root: &Path, ok: &mut bool) {
match load_for_root(root) {
Ok(config) => {
match config.source_path() {
Some(path) => {
println!("✓ Config: loaded {CONFIG_FILE_NAME}");
println!("- Config path: {}", path.display());
}
None => println!("✓ Config: not found; using built-in defaults"),
}
let analysis_mode = config
.analysis()
.mode()
.map(Mode::as_str)
.unwrap_or_else(|| Mode::Draft.as_str());
println!("- Analysis mode default: {analysis_mode}");
println!(
"- LSP seam diagnostics default: {}",
config
.lsp()
.seam_diagnostics()
.unwrap_or(DEFAULT_LSP_SEAM_DIAGNOSTICS)
);
println!(
"- Suppressions path: {}",
config.suppressions().display_path()
);
}
Err(err) => {
println!("! Config: invalid {CONFIG_FILE_NAME}");
println!("- Config path: {}", root.join(CONFIG_FILE_NAME).display());
println!(" error: {err}");
*ok = false;
}
}
}
pub(super) fn lsp(args: &[String]) -> Result<(), String> {
for arg in args {
match arg.as_str() {
"--stdio" => {}
"--version" | "-V" => {
println!("ripr-lsp {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
"--help" | "-h" => {
help::print_lsp_help();
return Ok(());
}
other => return Err(format!("unknown lsp argument {other:?}")),
}
}
crate::lsp::serve()
}
#[cfg(test)]
mod tests {
use super::*;
fn args(values: &[&str]) -> Vec<String> {
values.iter().map(|value| value.to_string()).collect()
}
fn unique_command_test_dir(label: &str) -> PathBuf {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
std::env::temp_dir().join(format!(
"ripr-command-{label}-{}-{stamp}",
std::process::id()
))
}
fn unique_repo_relative_test_dir(label: &str) -> PathBuf {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
PathBuf::from("target/ripr").join(format!(
"ripr-command-{label}-{}-{stamp}",
std::process::id()
))
}
#[test]
fn check_requires_values_for_value_flags() {
assert_eq!(
check(&args(&["--diff"])),
Err("missing value for --diff".to_string())
);
assert_eq!(
check(&args(&["--mode"])),
Err("missing value for --mode".to_string())
);
}
#[test]
fn command_help_branches_return_ok() {
assert_eq!(init(&args(&["--help"])), Ok(()));
assert_eq!(pilot(&args(&["--help"])), Ok(()));
assert_eq!(calibrate(&args(&["--help"])), Ok(()));
assert_eq!(agent(&args(&["--help"])), Ok(()));
assert_eq!(agent(&args(&["brief", "--help"])), Ok(()));
assert_eq!(check(&args(&["--help"])), Ok(()));
assert_eq!(explain(&args(&["--help"])), Ok(()));
assert_eq!(context(&args(&["--help"])), Ok(()));
assert_eq!(doctor(&args(&["--help"])), Ok(()));
assert_eq!(lsp(&args(&["--help"])), Ok(()));
}
#[test]
fn pilot_requires_values_for_value_flags() {
assert_eq!(
pilot(&args(&["--root"])),
Err("missing value for --root".to_string())
);
assert_eq!(
pilot(&args(&["--out"])),
Err("missing value for --out".to_string())
);
assert_eq!(
pilot(&args(&["--mode"])),
Err("missing value for --mode".to_string())
);
assert_eq!(
pilot(&args(&["--max-seams"])),
Err("missing value for --max-seams".to_string())
);
assert_eq!(
pilot(&args(&["--timeout-ms"])),
Err("missing value for --timeout-ms".to_string())
);
}
#[test]
fn pilot_rejects_unknown_arguments() {
assert_eq!(
pilot(&args(&["--wat"])),
Err("unknown pilot argument \"--wat\"".to_string())
);
}
#[test]
fn pilot_rejects_non_positive_max_seams() {
assert_eq!(
parse_pilot_options(&args(&["--max-seams", "0"])),
Err("invalid --max-seams: expected a positive integer".to_string())
);
}
#[test]
fn pilot_rejects_non_positive_timeout() {
assert_eq!(
parse_pilot_options(&args(&["--timeout-ms", "0"])),
Err("invalid --timeout-ms: expected a positive integer".to_string())
);
}
#[test]
fn pilot_parses_root_out_mode_max_seams_and_timeout() {
let options = parse_pilot_options(&args(&[
"--root",
"repo",
"--out",
"target/pilot",
"--mode",
"ready",
"--max-seams",
"3",
"--timeout-ms",
"120000",
]));
assert_eq!(
options,
Ok(PilotOptions {
root: PathBuf::from("repo"),
out_dir: PathBuf::from("target/pilot"),
mode: Mode::Ready,
explicit: CheckInputExplicit {
mode: true,
include_unchanged_tests: false,
},
max_seams: 3,
timeout_ms: 120_000,
})
);
}
#[test]
fn pilot_analysis_timeout_returns_partial_result() {
let result = run_pilot_analysis_with_timeout(1, || {
std::thread::sleep(std::time::Duration::from_millis(20));
Ok(Vec::new())
});
assert!(matches!(result, Ok(PilotAnalysisResult::TimedOut)));
}
#[test]
fn outcome_parses_required_paths_format_and_out() {
assert_eq!(
parse_outcome_options(&args(&[
"--before",
"before.json",
"--after",
"after.json",
"--format",
"json",
"--out",
"target/ripr/outcome/targeted-test-outcome.json",
])),
Ok(OutcomeOptions {
before: PathBuf::from("before.json"),
after: PathBuf::from("after.json"),
format: OutcomeFormat::Json,
out: Some(PathBuf::from(
"target/ripr/outcome/targeted-test-outcome.json"
)),
})
);
}
#[test]
fn outcome_defaults_to_markdown_stdout_shape() {
assert_eq!(
parse_outcome_options(&args(
&["--before", "before.json", "--after", "after.json",]
)),
Ok(OutcomeOptions {
before: PathBuf::from("before.json"),
after: PathBuf::from("after.json"),
format: OutcomeFormat::Markdown,
out: None,
})
);
}
#[test]
fn outcome_requires_before_and_after() {
assert_eq!(
parse_outcome_options(&args(&["--after", "after.json"])),
Err("outcome requires --before <path>".to_string())
);
assert_eq!(
parse_outcome_options(&args(&["--before", "before.json"])),
Err("outcome requires --after <path>".to_string())
);
}
#[test]
fn outcome_help_returns_ok() {
assert_eq!(outcome(&args(&["--help"])), Ok(()));
}
#[test]
fn outcome_command_writes_json_file() -> Result<(), String> {
let dir = unique_command_test_dir("outcome");
std::fs::create_dir_all(&dir).map_err(|err| format!("create temp dir: {err}"))?;
let before = dir.join("before.json");
let after = dir.join("after.json");
let out = dir.join("nested/targeted-test-outcome.json");
std::fs::write(&before, outcome_before_json())
.map_err(|err| format!("write before snapshot: {err}"))?;
std::fs::write(&after, outcome_after_json())
.map_err(|err| format!("write after snapshot: {err}"))?;
outcome(&args(&[
"--before",
&before.display().to_string(),
"--after",
&after.display().to_string(),
"--format",
"json",
"--out",
&out.display().to_string(),
]))?;
let rendered =
std::fs::read_to_string(&out).map_err(|err| format!("read outcome output: {err}"))?;
assert!(rendered.contains(r#""schema_version": "0.1""#));
assert!(rendered.contains(r#""moved": 1"#));
let _ = std::fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn outcome_command_reports_read_failures() -> Result<(), String> {
let dir = unique_command_test_dir("outcome-read");
std::fs::create_dir_all(&dir).map_err(|err| format!("create temp dir: {err}"))?;
let before = dir.join("before.json");
std::fs::write(&before, outcome_before_json())
.map_err(|err| format!("write before snapshot: {err}"))?;
let missing_before = outcome(&args(&[
"--before",
&dir.join("missing-before.json").display().to_string(),
"--after",
&dir.join("missing-after.json").display().to_string(),
]));
assert!(matches!(missing_before, Err(message) if message.contains("read")));
let missing_after = outcome(&args(&[
"--before",
&before.display().to_string(),
"--after",
&dir.join("missing-after.json").display().to_string(),
]));
assert!(matches!(missing_after, Err(message) if message.contains("read")));
let _ = std::fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn calibrate_parses_required_inputs_format_and_out() {
assert_eq!(
parse_calibrate_cargo_mutants_options(&args(&[
"--mutants-json",
"target/mutants/outcomes.json",
"--repo-exposure-json",
"target/ripr/after.repo-exposure.json",
"--format",
"json",
"--out",
"target/ripr/calibration/mutation-calibration.json",
])),
Ok(CalibrateOptions {
mutants_json: PathBuf::from("target/mutants/outcomes.json"),
repo_exposure_json: PathBuf::from("target/ripr/after.repo-exposure.json"),
format: CalibrateFormat::Json,
out: Some(PathBuf::from(
"target/ripr/calibration/mutation-calibration.json"
)),
})
);
}
#[test]
fn calibrate_requires_subcommand_and_inputs() {
assert_eq!(
calibrate(&args(&[])),
Err("calibrate requires subcommand `cargo-mutants`".to_string())
);
assert_eq!(
calibrate(&args(&["runtime"])),
Err("unknown calibrate subcommand \"runtime\"; expected `cargo-mutants`".to_string())
);
assert_eq!(
parse_calibrate_cargo_mutants_options(&args(&["--repo-exposure-json", "repo.json"])),
Err("calibrate cargo-mutants requires --mutants-json <path>".to_string())
);
assert_eq!(
parse_calibrate_cargo_mutants_options(&args(&["--mutants-json", "mutants.json"])),
Err("calibrate cargo-mutants requires --repo-exposure-json <path>".to_string())
);
}
#[test]
fn calibrate_help_returns_ok() {
assert_eq!(calibrate(&args(&["--help"])), Ok(()));
assert_eq!(calibrate(&args(&["cargo-mutants", "--help"])), Ok(()));
}
#[test]
fn agent_rejects_unknown_subcommands() {
assert_eq!(
agent(&args(&["unknown"])),
Err(
"unknown agent subcommand \"unknown\"; expected `brief`, `packet`, `verify`, or `receipt`"
.to_string()
)
);
}
#[test]
fn agent_packet_rejects_missing_root_before_analysis() {
assert_eq!(
agent(&args(&[
"packet",
"--root",
"target/ripr/missing-agent-packet-root",
"--seam-id",
"f3c9e4d21a0b7c88",
"--json",
])),
Err(
"agent packet root target/ripr/missing-agent-packet-root is not a directory"
.to_string()
)
);
}
#[test]
fn agent_verify_reports_read_failures() -> Result<(), String> {
let dir = unique_command_test_dir("agent-verify-read");
std::fs::create_dir_all(&dir).map_err(|err| format!("create temp dir: {err}"))?;
let before = dir.join("before.json");
std::fs::write(&before, outcome_before_json())
.map_err(|err| format!("write before snapshot: {err}"))?;
let missing_before = agent(&args(&[
"verify",
"--root",
&dir.display().to_string(),
"--before",
&dir.join("missing-before.json").display().to_string(),
"--after",
&dir.join("missing-after.json").display().to_string(),
"--json",
]));
assert!(
matches!(missing_before, Err(message) if message.contains("canonicalize agent verify --before"))
);
let missing_after = agent(&args(&[
"verify",
"--root",
&dir.display().to_string(),
"--before",
&before.display().to_string(),
"--after",
&dir.join("missing-after.json").display().to_string(),
"--json",
]));
assert!(
matches!(missing_after, Err(message) if message.contains("canonicalize agent verify --after"))
);
let _ = std::fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn agent_verify_rejects_snapshots_outside_root() -> Result<(), String> {
let root = unique_command_test_dir("agent-verify-root");
let outside = unique_command_test_dir("agent-verify-outside");
std::fs::create_dir_all(&root).map_err(|err| format!("create root dir: {err}"))?;
std::fs::create_dir_all(&outside).map_err(|err| format!("create outside dir: {err}"))?;
let before = outside.join("before.json");
let after = root.join("after.json");
std::fs::write(&before, outcome_before_json())
.map_err(|err| format!("write before snapshot: {err}"))?;
std::fs::write(&after, outcome_after_json())
.map_err(|err| format!("write after snapshot: {err}"))?;
let result = agent(&args(&[
"verify",
"--root",
&root.display().to_string(),
"--before",
&before.display().to_string(),
"--after",
&after.display().to_string(),
"--json",
]));
assert!(matches!(result, Err(message) if message.contains("must stay under root")));
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&outside);
Ok(())
}
#[test]
fn agent_receipt_reports_read_failures() -> Result<(), String> {
let dir = unique_command_test_dir("agent-receipt-read");
std::fs::create_dir_all(&dir).map_err(|err| format!("create temp dir: {err}"))?;
let missing = agent(&args(&[
"receipt",
"--root",
&dir.display().to_string(),
"--verify-json",
&dir.join("missing-agent-verify.json").display().to_string(),
"--seam-id",
"seam-a",
"--json",
]));
assert!(
matches!(missing, Err(message) if message.contains("canonicalize agent receipt --verify-json"))
);
let _ = std::fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn agent_receipt_rejects_verify_json_outside_root() -> Result<(), String> {
let root = unique_command_test_dir("agent-receipt-root");
let outside = unique_command_test_dir("agent-receipt-outside");
std::fs::create_dir_all(&root).map_err(|err| format!("create root dir: {err}"))?;
std::fs::create_dir_all(&outside).map_err(|err| format!("create outside dir: {err}"))?;
let verify = outside.join("agent-verify.json");
std::fs::write(&verify, "{}").map_err(|err| format!("write verify JSON: {err}"))?;
let result = agent(&args(&[
"receipt",
"--root",
&root.display().to_string(),
"--verify-json",
&verify.display().to_string(),
"--seam-id",
"seam-a",
"--json",
]));
assert!(matches!(result, Err(message) if message.contains("must stay under root")));
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&outside);
Ok(())
}
#[test]
fn agent_brief_rejects_missing_root_before_analysis() {
assert_eq!(
agent(&args(&[
"brief",
"--root",
"target/ripr/missing-agent-brief-root",
"--diff",
"change.diff",
"--json",
])),
Err(
"agent brief root target/ripr/missing-agent-brief-root is not a directory"
.to_string()
)
);
}
#[test]
fn agent_brief_diff_lines_are_normalized_to_requested_root() {
let diff = "diff --git a/crates/ripr/examples/sample/src/lib.rs b/crates/ripr/examples/sample/src/lib.rs\n--- a/crates/ripr/examples/sample/src/lib.rs\n+++ b/crates/ripr/examples/sample/src/lib.rs\n@@ -8,1 +8,1 @@\n-old\n+new\n";
let lines = agent_brief_lines_from_diff(Path::new("crates/ripr/examples/sample"), diff);
assert_eq!(
lines,
vec![AgentBriefLine::new(PathBuf::from("src/lib.rs"), 8)]
);
}
#[test]
fn agent_brief_owner_lines_are_resolved_from_changed_lines() -> Result<(), String> {
let root = unique_command_test_dir("agent-brief-owner-lines");
std::fs::create_dir_all(root.join("src")).map_err(|err| format!("create src: {err}"))?;
std::fs::write(
root.join("src/lib.rs"),
"pub fn discounted_total(amount: i32) -> i32 {\n let discount = 10;\n amount - discount\n}\n",
)
.map_err(|err| format!("write src/lib.rs: {err}"))?;
let lines = vec![AgentBriefLine::new(PathBuf::from("src/lib.rs"), 3)];
let owners = agent_brief_owners_for_lines(&root, &lines);
assert_eq!(owners.len(), 1);
assert_eq!(owners[0].line, 3);
assert!(owners[0].owner.ends_with("discounted_total"));
std::fs::remove_dir_all(&root).map_err(|err| format!("remove temp root: {err}"))?;
Ok(())
}
#[test]
fn agent_brief_owner_lines_are_best_effort_for_missing_files() -> Result<(), String> {
let root = unique_command_test_dir("agent-brief-owner-missing");
std::fs::create_dir_all(&root).map_err(|err| format!("create root: {err}"))?;
let lines = vec![AgentBriefLine::new(PathBuf::from("src/missing.rs"), 3)];
let owners = agent_brief_owners_for_lines(&root, &lines);
assert!(owners.is_empty());
std::fs::remove_dir_all(&root).map_err(|err| format!("remove temp root: {err}"))?;
Ok(())
}
#[test]
fn agent_brief_normalizes_absolute_diff_paths_against_relative_root() -> Result<(), String> {
let root = unique_repo_relative_test_dir("agent-brief-normalize");
let src = root.join("src");
std::fs::create_dir_all(&src).map_err(|err| format!("create src dir: {err}"))?;
let absolute_file = std::env::current_dir()
.map_err(|err| format!("read current dir: {err}"))?
.join(&root)
.join("src/lib.rs");
assert_eq!(
normalize_agent_brief_path(&root, &absolute_file),
PathBuf::from("src/lib.rs")
);
std::fs::remove_dir_all(&root).map_err(|err| format!("remove temp root: {err}"))?;
Ok(())
}
#[test]
fn agent_brief_diff_path_must_stay_under_root() -> Result<(), String> {
let root = unique_command_test_dir("agent-brief-root");
let outside = unique_command_test_dir("agent-brief-outside");
std::fs::create_dir_all(&root).map_err(|err| format!("create root: {err}"))?;
std::fs::create_dir_all(&outside).map_err(|err| format!("create outside: {err}"))?;
let outside_diff = outside.join("change.diff");
std::fs::write(&outside_diff, "diff --git a/src/lib.rs b/src/lib.rs\n")
.map_err(|err| format!("write outside diff: {err}"))?;
let result = resolve_agent_brief_working_set(
&root,
&AgentBriefWorkingSet::Diff(outside_diff.clone()),
);
let err = match result {
Ok(_) => return Err("outside diff path should be rejected".to_string()),
Err(err) => err,
};
assert!(
err.contains("must stay under root"),
"unexpected error: {err}"
);
std::fs::remove_dir_all(&root).map_err(|err| format!("remove root: {err}"))?;
std::fs::remove_dir_all(&outside).map_err(|err| format!("remove outside: {err}"))?;
Ok(())
}
#[test]
fn calibrate_command_writes_json_file() -> Result<(), String> {
let dir = unique_command_test_dir("calibrate");
std::fs::create_dir_all(&dir).map_err(|err| format!("create temp dir: {err}"))?;
let repo = dir.join("repo-exposure.json");
let mutants = dir.join("mutants.json");
let out = dir.join("nested/mutation-calibration.json");
std::fs::write(&repo, calibration_repo_json())
.map_err(|err| format!("write repo exposure: {err}"))?;
std::fs::write(&mutants, calibration_mutants_json())
.map_err(|err| format!("write mutants: {err}"))?;
calibrate(&args(&[
"cargo-mutants",
"--mutants-json",
&mutants.display().to_string(),
"--repo-exposure-json",
&repo.display().to_string(),
"--format",
"json",
"--out",
&out.display().to_string(),
]))?;
let rendered = std::fs::read_to_string(&out)
.map_err(|err| format!("read calibration output: {err}"))?;
assert!(rendered.contains(r#""schema_version": "0.1""#));
assert!(rendered.contains(r#""static_gap_and_runtime_signal": 1"#));
let _ = std::fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn calibrate_reads_cargo_mutants_directory() -> Result<(), String> {
let dir = unique_command_test_dir("calibrate-dir");
let mutants_dir = dir.join("cargo-mutants");
std::fs::create_dir_all(&mutants_dir)
.map_err(|err| format!("create mutants dir: {err}"))?;
std::fs::write(
mutants_dir.join("mutants.json"),
r#"{"mutants":[{"id":"m1","seam_id":"seam-a","operator":"replace"}]}"#,
)
.map_err(|err| format!("write mutants.json: {err}"))?;
std::fs::write(
mutants_dir.join("outcomes.json"),
r#"{"outcomes":[{"id":"m1","outcome":"missed"}]}"#,
)
.map_err(|err| format!("write outcomes.json: {err}"))?;
let combined = read_calibration_mutants_json(&mutants_dir)?;
assert!(combined.contains("mutants"));
assert!(combined.contains("outcomes"));
let _ = std::fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn context_rejects_invalid_max_related_tests() {
let result = context(&args(&[
"--at",
"probe:file.rs:1:predicate",
"--max-related-tests",
"many",
]));
assert!(
matches!(result, Err(message) if message.starts_with("invalid --max-related-tests:"))
);
}
#[test]
fn doctor_requires_root_value() {
assert_eq!(
doctor(&args(&["--root"])),
Err("missing value for --root".to_string())
);
}
#[test]
fn init_requires_root_value() {
assert_eq!(
init(&args(&["--root"])),
Err("missing value for --root".to_string())
);
assert_eq!(
init(&args(&["--ci"])),
Err("missing value for --ci".to_string())
);
}
#[test]
fn init_rejects_unknown_arguments() {
assert_eq!(
init(&args(&["--wat"])),
Err("unknown init argument \"--wat\"".to_string())
);
assert_eq!(
init(&args(&["--ci", "gitlab"])),
Err("unknown init --ci provider \"gitlab\"".to_string())
);
}
#[test]
fn init_parses_root_dry_run_and_force() {
assert_eq!(
parse_init_options(&args(&[
"--root",
"repo",
"--dry-run",
"--force",
"--ci",
"github",
])),
Ok(InitOptions {
root: PathBuf::from("repo"),
dry_run: true,
force: true,
ci: Some(InitCi::Github),
})
);
}
#[test]
fn init_generated_github_workflow_is_advisory() {
let workflow = generated_github_actions_workflow();
assert!(workflow.contains("continue-on-error: true"));
assert!(workflow.contains("github/codeql-action/upload-sarif@v4"));
assert!(workflow.contains("actions/upload-artifact@v7"));
assert!(workflow.contains("RIPR_UPLOAD_SARIF"));
assert!(workflow.contains("--format sarif"));
assert!(workflow.contains("--format repo-sarif"));
assert!(workflow.contains("--format repo-badge-json"));
assert!(workflow.contains("ripr pilot"));
assert!(workflow.contains("ripr agent packet"));
assert!(workflow.contains("ripr agent brief"));
assert!(workflow.contains("ripr agent verify"));
assert!(workflow.contains("ripr agent receipt"));
assert!(workflow.contains("ripr outcome"));
assert!(workflow.contains("target/ripr/agent/agent-packet.json"));
assert!(workflow.contains("target/ripr/agent/agent-brief.json"));
assert!(workflow.contains("target/ripr/agent/agent-verify.json"));
assert!(workflow.contains("target/ripr/agent/agent-receipt.json"));
assert!(workflow.contains("target/ripr/reports/targeted-test-outcome.json"));
assert!(!workflow.contains("fail-on-new-warning"));
assert!(!workflow.contains("sarif-policy"));
}
#[test]
fn init_generated_github_workflow_uploads_reports_and_makes_sarif_optional() {
let workflow = generated_github_actions_workflow();
assert!(workflow.contains("name: RIPR advisory reports"));
assert!(workflow.contains("target/ripr/pilot"));
assert!(workflow.contains("target/ripr/agent"));
assert!(workflow.contains("target/ripr/reports"));
assert!(workflow.contains("name: ripr-reports"));
assert!(workflow.contains("RIPR_TOP_SEAM_ID"));
assert!(workflow.contains(".top_actionable_seams[0].seam_id"));
assert!(!workflow.contains(".top_seams[0].seam_id"));
assert!(workflow.contains("cargo xtask operator-cockpit"));
assert!(workflow.contains("hashFiles('crates/ripr/Cargo.toml')"));
assert!(workflow.contains("hashFiles('xtask/src/reports/operator.rs')"));
assert!(workflow.contains("if: env.RIPR_UPLOAD_SARIF == 'true'"));
assert!(workflow.contains(
"if: env.RIPR_UPLOAD_SARIF == 'true' && github.event_name == 'pull_request'"
));
assert!(workflow.contains("cat target/ripr/pilot/pilot-summary.md"));
}
#[test]
fn init_ci_github_writes_workflow_and_preserves_existing_config() -> Result<(), String> {
let dir = unique_command_test_dir("init-ci");
std::fs::create_dir_all(&dir).map_err(|err| format!("create temp dir: {err}"))?;
let config = dir.join(CONFIG_FILE_NAME);
std::fs::write(&config, "# existing policy\n")
.map_err(|err| format!("write existing config: {err}"))?;
init(&args(&[
"--root",
&dir.display().to_string(),
"--ci",
"github",
]))?;
let config_text =
std::fs::read_to_string(&config).map_err(|err| format!("read config: {err}"))?;
let workflow_path = dir.join(".github/workflows/ripr.yml");
let workflow = std::fs::read_to_string(&workflow_path)
.map_err(|err| format!("read workflow: {err}"))?;
assert_eq!(config_text, "# existing policy\n");
assert!(workflow.contains("RIPR advisory reports"));
assert!(workflow.contains("continue-on-error: true"));
assert!(workflow.contains("actions/upload-artifact@v7"));
let _ = std::fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn init_ci_github_refuses_existing_workflow_without_force() -> Result<(), String> {
let dir = unique_command_test_dir("init-ci-existing");
let workflow_dir = dir.join(".github/workflows");
std::fs::create_dir_all(&workflow_dir)
.map_err(|err| format!("create workflow dir: {err}"))?;
let workflow = workflow_dir.join("ripr.yml");
std::fs::write(&workflow, "name: Existing\n")
.map_err(|err| format!("write existing workflow: {err}"))?;
let result = init(&args(&[
"--root",
&dir.display().to_string(),
"--ci",
"github",
]));
assert!(matches!(result, Err(message) if message.contains("already exists")));
assert!(!dir.join(CONFIG_FILE_NAME).exists());
let _ = std::fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn doctor_rejects_unknown_arguments() {
assert_eq!(
doctor(&args(&["--verbose"])),
Err("unknown doctor argument \"--verbose\"".to_string())
);
}
#[test]
fn doctor_accepts_default_root() {
assert_eq!(doctor(&args(&[])), Ok(()));
}
#[test]
fn lsp_version_returns_ok() {
assert_eq!(lsp(&args(&["--version"])), Ok(()));
}
#[test]
fn lsp_rejects_unknown_arguments() {
assert_eq!(
lsp(&args(&["--bad"])),
Err("unknown lsp argument \"--bad\"".to_string())
);
}
#[test]
fn check_rejects_unknown_argument() {
assert_eq!(
check(&args(&["--wat"])),
Err("unknown check argument \"--wat\"".to_string())
);
}
#[test]
fn check_requires_values_for_all_value_flags() {
assert_eq!(
check(&args(&["--root"])),
Err("missing value for --root".to_string())
);
assert_eq!(
check(&args(&["--base"])),
Err("missing value for --base".to_string())
);
assert_eq!(
check(&args(&["--format"])),
Err("missing value for --format".to_string())
);
}
#[test]
fn explain_requires_selector() {
assert_eq!(
explain(&args(&[])),
Err("missing finding selector".to_string())
);
}
#[test]
fn explain_rejects_unexpected_argument_after_selector() {
assert_eq!(
explain(&args(&["probe:src_lib_rs:10:return_value", "extra"])),
Err("unexpected explain argument \"extra\"".to_string())
);
}
#[test]
fn explain_requires_values_for_value_flags() {
assert_eq!(
explain(&args(&["--root"])),
Err("missing value for --root".to_string())
);
assert_eq!(
explain(&args(&["--base"])),
Err("missing value for --base".to_string())
);
assert_eq!(
explain(&args(&["--diff"])),
Err("missing value for --diff".to_string())
);
}
#[test]
fn context_requires_selector() {
assert_eq!(
context(&args(&[])),
Err("missing --at or --finding selector".to_string())
);
}
#[test]
fn context_rejects_unknown_argument() {
assert_eq!(
context(&args(&["--unknown", "value"])),
Err("unexpected context argument \"--unknown\"".to_string())
);
}
#[test]
fn context_requires_values_for_value_flags() {
assert_eq!(
context(&args(&["--at"])),
Err("missing value for --at".to_string())
);
assert_eq!(
context(&args(&["--finding"])),
Err("missing value for --finding".to_string())
);
assert_eq!(
context(&args(&["--root"])),
Err("missing value for --root".to_string())
);
}
#[test]
fn lsp_accepts_stdio_flag() {
assert_eq!(lsp(&args(&["--stdio"])), Ok(()));
}
#[test]
fn lsp_version_returns_ok_with_short_flag() {
assert_eq!(lsp(&args(&["-V"])), Ok(()));
}
fn outcome_before_json() -> &'static str {
r#"{
"schema_version": "0.2",
"scope": "repo",
"seams": [
{
"seam_id": "seam-a",
"kind": "predicate_boundary",
"file": "src/pricing.rs",
"line": 42,
"grip_class": "weakly_gripped",
"related_tests": [
{"oracle_kind": "exact_value", "oracle_strength": "weak"}
],
"observed_values": ["50"],
"missing_discriminators": [
{"value": "threshold equality", "reason": "not observed"}
]
}
]
}"#
}
fn outcome_after_json() -> &'static str {
r#"{
"schema_version": "0.2",
"scope": "repo",
"seams": [
{
"seam_id": "seam-a",
"kind": "predicate_boundary",
"file": "src/pricing.rs",
"line": 42,
"grip_class": "strongly_gripped",
"related_tests": [
{"oracle_kind": "exact_value", "oracle_strength": "strong"}
],
"observed_values": ["50", "100"],
"missing_discriminators": []
}
]
}"#
}
fn calibration_repo_json() -> &'static str {
r#"{
"schema_version": "0.2",
"scope": "repo",
"seams": [
{
"seam_id": "seam-a",
"kind": "predicate_boundary",
"file": "src/pricing.rs",
"line": 42,
"grip_class": "weakly_gripped",
"related_tests": [],
"observed_values": [],
"missing_discriminators": []
}
]
}"#
}
fn calibration_mutants_json() -> &'static str {
r#"[{"id":"m1","seam_id":"seam-a","outcome":"missed","operator":"replace"}]"#
}
}