#[cfg(not(unix))]
compile_error!(
"opi is Unix-only: it runs a script by replacing its own process with exec, \
and its interactive list drives termios directly."
);
mod actions;
mod audit;
mod cargo;
mod check;
mod check_config;
mod clean;
mod cli;
mod manifest;
mod osv;
mod outdated;
mod project;
mod run;
mod sbom;
mod scripts;
mod secrets;
mod sources;
mod staged;
mod supply;
mod task;
mod workflow;
mod workspace;
use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use runemark::{
ColorMode, Console, DetailLevel, ErrorBlock, Finding, FindingGroup, Group, Hint, Item, Layout,
Menu, Metric, NextStep, Outcome, Picked, Report, SelectMode, Tone, Verdict,
};
use crate::cli::Invocation;
use crate::manifest::{Manifest, ManifestError};
use crate::project::Project;
use crate::task::{Task, by_group, find, suggestions};
const VERSION: &str = env!("CARGO_PKG_VERSION");
const OUTPUT_LINES: usize = 20;
const TABS_ABOVE_TASKS: usize = 15;
const TABS_ABOVE_GROUPS: usize = 5;
const PACKAGES_TAB: &str = "Packages";
fn main() -> ExitCode {
let invocation = cli::parse(std::env::args().skip(1));
match &invocation {
Invocation::Help => {
println!("{}", cli::help(VERSION));
return ExitCode::SUCCESS;
}
Invocation::Version => {
println!("opi {VERSION}");
return ExitCode::SUCCESS;
}
_ => {}
}
let directory = match std::env::current_dir() {
Ok(directory) => directory,
Err(error) => {
let console = Console::stderr(ColorMode::Auto);
eprintln!(
"{}",
console.paint(
Tone::Error,
format!("Cannot read the current directory: {error}")
)
);
return ExitCode::FAILURE;
}
};
let npm = Manifest::discover(&directory);
let rust = cargo::discover(&directory);
if let (Err(error), None) = (&npm, &rust) {
report_error(error);
return ExitCode::FAILURE;
}
let has_npm = npm.is_ok();
let (manifest, root) = match npm {
Ok((manifest, root)) => (manifest, root),
Err(_) => (
Manifest::default(),
rust.as_ref()
.map_or_else(|| directory.clone(), |(_, root)| root.clone()),
),
};
let project = Project::detect(&manifest, &root)
.or_named(rust.as_ref().and_then(|(rust, _)| rust.name.clone()))
.with_npm(has_npm);
let scripted_members = workspace::members(&root, &manifest);
let members = workspace::all(&root, &manifest);
let mut tasks = Task::from_workspace(&manifest, &scripted_members);
if let Some((rust, _)) = &rust {
tasks.extend(task::from_cargo(rust));
}
task::arrange(&mut tasks);
let rust_root = rust.as_ref().map(|(_, root)| root.clone());
match invocation {
Invocation::List => list(
&manifest,
&members,
rust_root.as_deref(),
&project,
&tasks,
&root,
),
Invocation::Health => health(&manifest, &members, rust_root.as_deref(), &project, &root),
Invocation::Clean => clean(&manifest, &members, rust_root.as_deref(), &project, &root),
Invocation::Security => {
security(&manifest, &members, rust_root.as_deref(), &project, &root)
}
Invocation::Updates => updates(&manifest, &members, &project, rust_root.as_deref(), &root),
Invocation::Workflow(name) => run_workflow(
&name,
&manifest,
&members,
rust_root.as_deref(),
&project,
&root,
),
Invocation::Hooks => hooks(&root),
Invocation::Checks(name) => configure_checks(
name.as_deref(),
&manifest,
&members,
rust_root.as_deref(),
&project,
&root,
),
Invocation::Sbom(out) => sbom(&project, rust_root.as_deref(), &root, out.as_deref()),
Invocation::Run {
name,
args,
confirmed,
} => start(&project, &tasks, &name, &args, confirmed),
Invocation::UnknownFlag(flag) => {
let console = Console::stderr(ColorMode::Auto);
let block = ErrorBlock::new(format!("Unknown option {flag}"))
.with_remedy("Run opi --help to see the available options.");
write_block(&block, console);
ExitCode::FAILURE
}
Invocation::Help | Invocation::Version => unreachable!("handled above"),
}
}
fn list(
manifest: &Manifest,
members: &[workspace::Member],
rust_root: Option<&Path>,
project: &Project,
tasks: &[Task],
directory: &Path,
) -> ExitCode {
let manager = project.package_manager;
if tasks.is_empty() {
let console = Console::stdout(ColorMode::Auto);
println!(
"{} {}",
console.paint(Tone::Title, project.display_name(directory)),
console.paint(Tone::Muted, manager.manager)
);
println!(
"{}",
console.paint(Tone::Muted, "This project defines no scripts.")
);
let others = task::other_runners(directory);
if !others.is_empty() {
println!(
"{}",
console.paint(
Tone::Muted,
format!("Its tasks may live in {}.", others.join(", "))
)
);
}
return ExitCode::SUCCESS;
}
let runs_scripts = tasks
.iter()
.any(|task| matches!(task.exec, task::Exec::Script));
if !manager.is_certain() && runs_scripts {
let console = Console::stderr(ColorMode::Auto);
eprintln!(
"{}",
console.paint(
Tone::Warning,
format!(
"No lockfile and no packageManager field — assuming {}.",
manager.manager
),
)
);
}
let mut menu = build_menu(project, tasks, directory);
let checks = check::Check::detect_all(
manifest,
members,
rust_root,
directory,
project.package_manager.manager,
);
if !checks.is_empty() {
menu = menu.add_hint(Hint::new('H', "Health"));
}
menu = menu
.add_hint(Hint::new('C', "Clean"))
.add_hint(Hint::new('S', "Security"))
.add_hint(Hint::new('U', "Updates"));
let interactive = io::stdout().is_terminal() && io::stderr().is_terminal();
let outcome = match menu.run(
Console::stderr(ColorMode::Auto),
SelectMode::Auto,
interactive,
) {
Ok(outcome) => outcome,
Err(error) => {
let block = ErrorBlock::new("Cannot open the terminal")
.with_explanation(format!("{error}"))
.with_remedy("Run opi <script> to start a script without the list.");
write_block(&block, Console::stderr(ColorMode::Auto));
return ExitCode::FAILURE;
}
};
match outcome {
Outcome::Selected(id) => start(project, tasks, &id, &[], false),
Outcome::Cancelled => ExitCode::SUCCESS,
Outcome::Hotkey('H') => health(manifest, members, rust_root, project, directory),
Outcome::Hotkey('C') => clean(manifest, members, rust_root, project, directory),
Outcome::Hotkey('S') => security(manifest, members, rust_root, project, directory),
Outcome::Hotkey('U') => updates(manifest, members, project, rust_root, directory),
Outcome::Hotkey(_) => ExitCode::SUCCESS,
Outcome::Unavailable => {
print!("{}", menu.render(Console::stdout(ColorMode::Auto)));
ExitCode::SUCCESS
}
}
}
fn toolchains(tasks: &[Task], project: &Project) -> String {
let mut names = Vec::new();
if tasks
.iter()
.any(|task| matches!(task.exec, task::Exec::Script))
{
names.push(project.package_manager.manager.to_string());
}
if tasks
.iter()
.any(|task| matches!(&task.exec, task::Exec::Direct { program, .. } if program == "cargo"))
{
names.push("cargo".to_owned());
}
if names.is_empty() {
names.push(project.package_manager.manager.to_string());
}
names.join(" · ")
}
fn layout(entries: usize, groups: usize) -> Layout {
if entries > TABS_ABOVE_TASKS || groups > TABS_ABOVE_GROUPS {
Layout::Tabs
} else {
Layout::Flat
}
}
fn summary(sections: &[(&task::Group, &[Task])], entries: usize, others: &[String]) -> String {
let mut parts = vec![
plural(entries, "entry", "entries"),
plural(sections.len(), "group", "groups"),
];
let packages = sections
.iter()
.filter(|(group, _)| matches!(group, task::Group::Workspace(_)))
.count();
if packages > 0 {
parts.push(plural(packages, "package", "packages"));
}
if !others.is_empty() {
parts.push(format!("also {}", others.join(", ")));
}
parts.join(" · ")
}
fn plural(count: usize, one: &str, many: &str) -> String {
format!("{count} {}", if count == 1 { one } else { many })
}
fn build_menu(project: &Project, tasks: &[Task], directory: &Path) -> Menu {
let sections = by_group(tasks);
let entries: usize = sections.iter().map(|(_, section)| section.len()).sum();
let mut menu = Menu::new()
.with_heading(project.display_name(directory))
.with_note(toolchains(tasks, project))
.with_summary(summary(§ions, entries, &task::other_runners(directory)))
.with_layout(layout(entries, sections.len()));
let mut packaged = false;
for (group, section) in sections {
let mut rendered = Group::new(group.label());
if matches!(group, task::Group::Workspace(_)) {
rendered = rendered.in_tab(PACKAGES_TAB);
if !packaged {
rendered = rendered.with_divider();
packaged = true;
}
}
for task in section {
let id = match &task.workspace {
Some(member) => format!("{member}/{}", task.name),
None => task.name.clone(),
};
let mut item = Item::new(id, &task.name);
if let Some(description) = &task.description {
item = item.with_description(description);
}
rendered = rendered.add_item(item);
}
menu = menu.add_group(rendered);
}
menu
}
fn start(
project: &Project,
tasks: &[Task],
name: &str,
args: &[String],
confirmed: bool,
) -> ExitCode {
let Some(task) = find(tasks, name) else {
let console = Console::stderr(ColorMode::Auto);
let mut block = ErrorBlock::new(format!("No script named {name}"));
let close = suggestions(tasks, name);
block = if close.is_empty() {
block.with_remedy("Run opi to see the project's scripts.")
} else {
block.with_explanation(format!("Did you mean {}?", close.join(", ")))
};
for candidate in close {
block = block.add_command(format!("opi {candidate}"));
}
write_block(&block, console);
return ExitCode::FAILURE;
};
if task.confirm && !confirmed && !confirm(task) {
return ExitCode::FAILURE;
}
let manager = project.package_manager;
let error = run::execute(task, manager.manager, args);
let console = Console::stderr(ColorMode::Auto);
let block = ErrorBlock::new(format!("Cannot run {}", manager.manager))
.with_explanation(format!("{error}"))
.with_remedy(if manager.is_certain() {
format!(
"Check that {} is installed and on your PATH.",
manager.manager
)
} else {
format!(
"No lockfile or packageManager field names a package manager, so {} was assumed.",
manager.manager
)
});
write_block(&block, console);
ExitCode::FAILURE
}
fn confirm(task: &Task) -> bool {
let console = Console::stderr(ColorMode::Auto);
let interactive = io::stdout().is_terminal() && io::stderr().is_terminal();
if !interactive {
let block = ErrorBlock::new(format!("{} needs confirming", task.name))
.with_explanation("This project marked it as needing a confirmation, and there is no terminal to ask in.")
.with_remedy("Run it again with --yes if that is what you mean.")
.add_command(format!("opi --yes {}", task.name));
write_block(&block, console);
return false;
}
let menu = Menu::new()
.with_heading(format!("Run {}?", task.name))
.with_note(&task.command)
.add_group(
Group::new("Confirm")
.add_item(Item::new("no", "Cancel"))
.add_item(Item::new("yes", format!("Run {}", task.name))),
);
matches!(
menu.run(console, SelectMode::Auto, true),
Ok(Outcome::Selected(choice)) if choice == "yes"
)
}
fn write_block(block: &ErrorBlock, console: Console) {
let mut stderr = io::stderr().lock();
let _ = block.write_to(console, &mut stderr);
let _ = stderr.flush();
}
fn report_error(error: &ManifestError) {
let console = Console::stderr(ColorMode::Auto);
let block = match error {
ManifestError::Missing { directory } => ErrorBlock::new("No package.json found")
.with_explanation(format!(
"Searched {} and every directory above it.",
directory.display()
))
.with_remedy("Change into a project directory and run opi again."),
ManifestError::Unreadable { path, error } => ErrorBlock::new("package.json is unreadable")
.with_explanation(format!("{}: {error}", path.display()))
.with_remedy("Check the file permissions."),
ManifestError::Malformed { path, error } => {
ErrorBlock::new("package.json is not valid JSON")
.with_explanation(format!("{}: {error}", path.display()))
.with_remedy("Fix the syntax error and run opi again.")
}
};
write_block(&block, console);
}
fn health(
manifest: &Manifest,
members: &[workspace::Member],
rust_root: Option<&Path>,
project: &Project,
root: &Path,
) -> ExitCode {
let console = Console::stdout(ColorMode::Auto);
let checks = check::Check::detect_all(
manifest,
members,
rust_root,
root,
project.package_manager.manager,
);
println!(
"{} {}",
console.paint(Tone::Title, project.display_name(root)),
console.paint(Tone::Muted, "health")
);
println!();
if checks.is_empty() {
println!(
"{}",
console.paint(
Tone::Muted,
"No checks apply: this project depends on none of the tools opi knows."
)
);
return ExitCode::SUCCESS;
}
let width = checks
.iter()
.map(|check| check.label().chars().count())
.max()
.unwrap_or(0);
let reports = check::run_all(checks, |report| {
let (tone, mark) = match report {
report if report.warned() => (Tone::Warning, "!"),
report if report.passed() => (Tone::Success, "✓"),
report if report.unusable() => (Tone::Warning, "!"),
_ => (Tone::Error, "✗"),
};
println!(
"{} {} {} {}",
console.paint(tone, mark),
console.paint(tone, format!("{:width$}", report.check.label())),
console.paint(
Tone::Muted,
format!("{:>6.1}s", report.duration.as_secs_f64())
),
console.paint(Tone::Muted, report.check.tool),
);
});
let failed: Vec<&check::Report> = reports.iter().filter(|report| !report.passed()).collect();
let unusable = failed.iter().filter(|report| report.unusable()).count();
let warned = reports.iter().filter(|report| report.warned()).count();
for report in reports
.iter()
.filter(|report| !report.passed() || report.warned())
{
println!();
let tone = if report.unusable() || report.warned() {
Tone::Warning
} else {
Tone::Error
};
println!(
"{}",
console.paint(
tone,
format!("{} — {}", report.check.label(), report.check.tool)
)
);
let lines: Vec<&str> = report.output.lines().collect();
for line in lines.iter().take(OUTPUT_LINES) {
println!(" {line}");
}
if let Some(hidden) = lines.len().checked_sub(OUTPUT_LINES).filter(|n| *n > 0) {
println!(
" {}",
console.paint(
Tone::Muted,
format!(
"… {hidden} more lines — run `{}` in {} to see them all",
report.check.command_line(),
report.check.scope.as_deref().unwrap_or("the project root"),
)
)
);
}
}
println!();
if failed.is_empty() {
let note = if warned > 0 {
format!("{} checks passed, {warned} with warnings.", reports.len())
} else {
format!("{} checks passed.", reports.len())
};
println!("{}", console.paint(Tone::Success, note));
ExitCode::SUCCESS
} else {
let note = if unusable > 0 {
format!(
"{} of {} checks failed, {unusable} could not run.",
failed.len(),
reports.len()
)
} else {
format!("{} of {} checks failed.", failed.len(), reports.len())
};
println!("{}", console.paint(Tone::Error, note));
ExitCode::FAILURE
}
}
fn clean(
manifest: &Manifest,
members: &[workspace::Member],
rust_root: Option<&Path>,
project: &Project,
root: &Path,
) -> ExitCode {
let console = Console::stdout(ColorMode::Auto);
let candidates = clean::candidates(manifest, members, rust_root, root);
println!(
"{} {}",
console.paint(Tone::Title, project.display_name(root)),
console.paint(Tone::Muted, "clean")
);
println!();
if candidates.is_empty() {
println!(
"{}",
console.paint(Tone::Muted, "Nothing to remove; the project is clean.")
);
return ExitCode::SUCCESS;
}
let width = candidates
.iter()
.map(|candidate| candidate.display.chars().count())
.max()
.unwrap_or(0);
for candidate in &candidates {
println!(
" {} {}",
console.paint(
if candidate.heavy {
Tone::Warning
} else {
Tone::Info
},
format!("{:width$}", candidate.display)
),
console.paint(Tone::Muted, clean::human(candidate.bytes)),
);
}
println!();
let artefacts: Vec<&clean::Candidate> =
candidates.iter().filter(|entry| !entry.heavy).collect();
let everything: Vec<&clean::Candidate> = candidates.iter().collect();
let artefact_bytes: u64 = artefacts.iter().map(|entry| entry.bytes).sum();
let total_bytes: u64 = everything.iter().map(|entry| entry.bytes).sum();
let mut menu = Menu::new().add_group({
let mut group = Group::new("Remove");
if !artefacts.is_empty() {
group = group.add_item(
Item::new("artefacts", "Build artefacts")
.with_description(clean::human(artefact_bytes)),
);
}
if artefacts.len() != everything.len() {
group = group.add_item(
Item::new("everything", "Everything, including node_modules")
.with_description(clean::human(total_bytes)),
);
}
group.add_item(Item::new("cancel", "Cancel"))
});
menu = menu.with_note(format!("{} removable", clean::human(total_bytes)));
let interactive = io::stdout().is_terminal() && io::stderr().is_terminal();
let chosen = match menu.run(
Console::stderr(ColorMode::Auto),
SelectMode::Auto,
interactive,
) {
Ok(Outcome::Selected(id)) => id,
Ok(Outcome::Unavailable) => {
println!(
"{}",
console.paint(
Tone::Muted,
"Run opi --clean in a terminal to remove any of it."
)
);
return ExitCode::SUCCESS;
}
Ok(_) => return ExitCode::SUCCESS,
Err(error) => {
let block =
ErrorBlock::new("Cannot open the terminal").with_explanation(format!("{error}"));
write_block(&block, Console::stderr(ColorMode::Auto));
return ExitCode::FAILURE;
}
};
let selected: &[&clean::Candidate] = match chosen.as_str() {
"artefacts" => &artefacts,
"everything" => &everything,
_ => return ExitCode::SUCCESS,
};
let started = std::time::Instant::now();
let (freed, failures) = clean::remove(selected);
println!(
"{}",
console.paint(
Tone::Success,
format!(
"Removed {} in {:.1}s",
clean::human(freed),
started.elapsed().as_secs_f64()
)
)
);
for (path, error) in &failures {
println!(
"{}",
console.paint(Tone::Error, format!("Could not remove {path}: {error}"))
);
}
if failures.is_empty() {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
fn security(
manifest: &Manifest,
members: &[workspace::Member],
rust_root: Option<&Path>,
project: &Project,
root: &Path,
) -> ExitCode {
let console = Console::stdout(ColorMode::Auto);
println!(
"{} {}",
console.paint(Tone::Title, project.display_name(root)),
console.paint(Tone::Muted, "security")
);
println!();
let mut clean = true;
let mut known: Vec<(&str, String)> = Vec::new();
let scans: Vec<check::Check> = check::Check::detect_all(
manifest,
members,
None,
root,
project.package_manager.manager,
)
.into_iter()
.filter(|check| check.name == "Secrets")
.collect();
if scans.is_empty() {
println!(
"{}",
console.paint(
Tone::Muted,
"No secret scanner: this project depends on none that opi knows."
)
);
} else {
let reports = check::run_all(scans, |_| {});
for report in &reports {
if report.passed() && !report.warned() {
println!(
"{} {}",
console.paint(Tone::Success, "✓"),
console.paint(Tone::Success, format!("Secrets — {}", report.check.tool))
);
} else {
let (tone, mark) = if report.warned() {
(Tone::Warning, "!")
} else {
clean = false;
(Tone::Error, "✗")
};
println!(
"{} {}",
console.paint(tone, mark),
console.paint(tone, format!("Secrets — {}", report.check.tool))
);
let lines: Vec<&str> = report.output.lines().collect();
for line in lines.iter().take(OUTPUT_LINES) {
println!(" {line}");
}
if let Some(rest) = lines.len().checked_sub(OUTPUT_LINES).filter(|n| *n > 0) {
println!(
" {}",
console.paint(
Tone::Muted,
format!(
"… {rest} more lines — run `{}`",
report.check.command_line()
)
)
);
}
}
}
}
if project.npm {
println!();
match audit::run(project.package_manager.manager, root) {
Ok(found) if found.is_empty() => println!(
"{} {}",
console.paint(Tone::Success, "✓"),
console.paint(Tone::Success, "Dependencies — no known vulnerabilities")
),
Ok(found) => {
known.extend(found.advisories.iter().map(|a| ("npm", a.module.clone())));
let serious = found
.advisories
.iter()
.any(|advisory| advisory.severity.serious());
clean = clean && !serious;
let mut report = Report::new(
"Dependencies",
if serious {
Verdict::Failed
} else {
Verdict::Warning
},
)
.with_detail_level(DetailLevel::Detailed);
for (severity, count) in found.counts() {
report = report.add_metric(
Metric::new(severity.label(), count.to_string()).with_verdict(
if severity.serious() {
Verdict::Failed
} else {
Verdict::Warning
},
),
);
}
let mut group = FindingGroup::new("Vulnerable dependencies");
for advisory in &found.advisories {
let mut finding = Finding::new(
if advisory.severity.serious() {
Tone::Error
} else {
Tone::Warning
},
&advisory.module,
)
.with_rule_id(advisory.severity.label());
if let Some(patched) = &advisory.patched {
finding = finding.with_remedy(format!("update to {patched}"));
}
group = group.add_finding(finding);
}
report = report.add_group(group);
print!("{}", report.render(console));
}
Err(error) => {
clean &= !audit_failed(&error);
println!(
"{} {}",
console.paint(Tone::Muted, "–"),
console.paint(Tone::Muted, format!("Dependencies — {error}"))
);
}
}
println!();
clean &= supply_chain(console, project.package_manager.manager, root);
}
if let Some(rust_root) = rust_root {
println!();
match audit::cargo(rust_root) {
Ok(found) if found.is_empty() => println!(
"{} {}",
console.paint(Tone::Success, "✓"),
console.paint(
Tone::Success,
"Dependencies (rust) — no known vulnerabilities"
)
),
Ok(found) => {
clean = false;
known.extend(found.iter().map(|a| ("crates.io", a.package.clone())));
let mut report = Report::new("Dependencies (rust)", Verdict::Failed)
.with_detail_level(DetailLevel::Detailed)
.add_metric(
Metric::new("advisories", found.len().to_string())
.with_verdict(Verdict::Failed),
);
let mut group = FindingGroup::new("Vulnerable crates");
for advisory in &found {
let mut finding = Finding::new(
Tone::Error,
format!("{} {}", advisory.package, advisory.version),
)
.with_rule_id(&advisory.id);
if let Some(patched) = &advisory.patched {
finding = finding.with_remedy(format!("update to {patched}"));
}
group = group.add_finding(finding);
}
report = report.add_group(group);
report = report.add_next_step(
NextStep::new("Severities and dependency paths").with_command("cargo audit"),
);
print!("{}", report.render(console));
}
Err(error) => {
clean &= !audit_failed(&error);
println!(
"{} {}",
console.paint(Tone::Muted, "–"),
console.paint(Tone::Muted, format!("Dependencies (rust) — {error}"))
);
}
}
license_report(console, supply::cargo_licenses(rust_root));
}
if project.npm || rust_root.is_some() {
println!();
clean &= osv_audit(console, root, &known);
}
unpinned_actions(console, root);
println!();
non_registry_sources(console, &[Some(root), rust_root]);
if clean {
println!("{}", console.paint(Tone::Success, "Nothing to act on."));
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
fn osv_audit(console: Console, root: &Path, known: &[(&str, String)]) -> bool {
let name = "Dependencies (osv)";
match osv::run(root) {
Ok(found) => {
let found = osv::without_known(found, known);
if found.is_empty() {
println!(
"{} {}",
console.paint(Tone::Success, "✓"),
console.paint(
Tone::Success,
format!("{name} — nothing beyond the audits above")
)
);
return true;
}
let serious = found.iter().any(|f| f.advisory.severity.serious());
let verdict = |serious: bool| {
if serious {
Verdict::Failed
} else {
Verdict::Warning
}
};
let mut report =
Report::new(name, verdict(serious)).with_detail_level(DetailLevel::Detailed);
let audit = audit::Audit {
advisories: found.iter().map(|f| f.advisory.clone()).collect(),
};
for (severity, count) in audit.counts() {
report = report.add_metric(
Metric::new(severity.label(), count.to_string())
.with_verdict(verdict(severity.serious())),
);
}
let mut group = FindingGroup::new("Vulnerable packages in the lockfiles");
for advisory in &audit.advisories {
let mut finding = Finding::new(
if advisory.severity.serious() {
Tone::Error
} else {
Tone::Warning
},
&advisory.module,
)
.with_rule_id(advisory.severity.label());
if let Some(patched) = &advisory.patched {
finding = finding.with_remedy(format!("update to {patched}"));
}
group = group.add_finding(finding);
}
print!("{}", report.add_group(group).render(console));
!serious
}
Err(error) => {
let reason = match &error {
audit::AuditError::NotInstalled => {
"osv-scanner is not installed; see google.github.io/osv-scanner".to_owned()
}
other => other.to_string(),
};
println!(
"{} {}",
console.paint(Tone::Muted, "–"),
console.paint(Tone::Muted, format!("{name} — {reason}"))
);
!audit_failed(&error)
}
}
}
fn non_registry_sources(console: Console, dirs: &[Option<&Path>]) {
let mut found: Vec<sources::Foreign> = dirs
.iter()
.flatten()
.flat_map(|dir| sources::scan(dir))
.collect();
found.dedup();
if found.is_empty() {
return;
}
println!(
"{} {}",
console.paint(Tone::Warning, "!"),
console.paint(
Tone::Warning,
format!(
"Sources — {} from outside the registry",
plural(found.len(), "dependency", "dependencies")
)
)
);
for item in found.iter().take(OUTPUT_LINES) {
println!(
" {} {}",
item.name,
console.paint(Tone::Muted, &item.source)
);
}
if let Some(rest) = found.len().checked_sub(OUTPUT_LINES).filter(|n| *n > 0) {
println!(" {}", console.paint(Tone::Muted, format!("… {rest} more")));
}
println!();
}
fn audit_failed(error: &audit::AuditError) -> bool {
matches!(error, audit::AuditError::Failed(_))
}
fn unpinned_actions(console: Console, root: &Path) {
let found = actions::unpinned(root);
if found.is_empty() {
return;
}
println!();
println!(
"{} {}",
console.paint(Tone::Warning, "!"),
console.paint(
Tone::Warning,
format!(
"GitHub Actions — {} not pinned to a commit SHA",
plural(found.len(), "reference", "references")
)
)
);
for item in found.iter().take(OUTPUT_LINES) {
println!(" {}:{} {}", item.file, item.line, item.reference);
}
if let Some(rest) = found.len().checked_sub(OUTPUT_LINES).filter(|n| *n > 0) {
println!(" {}", console.paint(Tone::Muted, format!("… {rest} more")));
}
}
fn supply_chain(console: Console, manager: project::PackageManager, root: &Path) -> bool {
let line = |tone: Tone, mark: &str, text: String| {
println!(
"{} {}",
console.paint(tone, mark),
console.paint(tone, text)
);
};
let list = |names: &[String]| {
for name in names.iter().take(OUTPUT_LINES) {
println!(" {name}");
}
if let Some(rest) = names.len().checked_sub(OUTPUT_LINES).filter(|n| *n > 0) {
println!(" {}", console.paint(Tone::Muted, format!("… {rest} more")));
}
};
let mut clean = true;
match supply::signatures(root) {
Ok(found) if found.invalid.is_empty() && found.missing.is_empty() => line(
Tone::Success,
"✓",
"Signatures — every installed package verified".to_owned(),
),
Ok(found) => {
if !found.invalid.is_empty() {
clean = false;
line(
Tone::Error,
"✗",
format!(
"Signatures — {} do not match what the registry published",
plural(found.invalid.len(), "package", "packages")
),
);
list(&found.invalid);
}
if !found.missing.is_empty() {
line(
Tone::Warning,
"!",
format!(
"Signatures — {} without a registry signature",
plural(found.missing.len(), "package", "packages")
),
);
list(&found.missing);
}
}
Err(reason) => line(Tone::Muted, "–", format!("Signatures — {reason}")),
}
match supply::release_age(manager, root) {
Some(supply::ReleaseAge::Set(age)) => line(
Tone::Success,
"✓",
format!("Release age — versions younger than {age} are not installed"),
),
Some(supply::ReleaseAge::Unset) => {
line(
Tone::Warning,
"!",
"Release age — not set: a version installs the moment it is published".to_owned(),
);
let remedy = match manager {
project::PackageManager::Npm => "min-release-age=<days> in .npmrc",
_ => "minimumReleaseAge: <minutes> in pnpm-workspace.yaml",
};
println!(" {}", console.paint(Tone::Muted, remedy));
}
None => {}
}
install_scripts(console, manager, root);
if let Some(licenses) = supply::licenses(manager, root) {
license_report(console, licenses);
}
clean
}
fn license_report(console: Console, found: Result<supply::Licenses, String>) {
let found = match found {
Ok(found) => found,
Err(reason) => {
println!(
"{} {}",
console.paint(Tone::Muted, "–"),
console.paint(Tone::Muted, format!("Licenses — {reason}"))
);
return;
}
};
const SHOWN: usize = 4;
let mut parts: Vec<String> = found
.counts
.iter()
.take(SHOWN)
.map(|(license, count)| format!("{count} {license}"))
.collect();
let others: usize = found.counts.iter().skip(SHOWN).map(|(_, n)| n).sum();
if others > 0 {
parts.push(format!("{others} other"));
}
let (tone, mark) = if found.notable.is_empty() {
(Tone::Success, "✓")
} else {
(Tone::Warning, "!")
};
println!(
"{} {}",
console.paint(tone, mark),
console.paint(tone, format!("Licenses — {}", parts.join(" · ")))
);
for (license, name) in found.notable.iter().take(OUTPUT_LINES) {
println!(" {name} {license}");
}
if let Some(rest) = found
.notable
.len()
.checked_sub(OUTPUT_LINES)
.filter(|n| *n > 0)
{
println!(" {}", console.paint(Tone::Muted, format!("… {rest} more")));
}
}
fn install_scripts(console: Console, manager: project::PackageManager, root: &Path) {
let Some(found) = scripts::install_scripts(manager, root) else {
return;
};
let count = |status| found.packages.iter().filter(|(_, s)| *s == status).count();
let (allowed, ignored) = (
count(scripts::Status::Allowed),
count(scripts::Status::Ignored),
);
let unreviewed: Vec<&str> = found
.packages
.iter()
.filter(|(_, status)| *status == scripts::Status::Unreviewed)
.map(|(name, _)| name.as_str())
.collect();
let summary = format!(
"{} with install scripts: {allowed} allowed · {ignored} ignored · {} not reviewed",
plural(found.packages.len(), "dependency", "dependencies"),
unreviewed.len()
);
if unreviewed.is_empty() {
println!(
"{} {}",
console.paint(Tone::Success, "✓"),
console.paint(Tone::Success, format!("Install scripts — {summary}"))
);
return;
}
let tone = Tone::Warning;
println!(
"{} {}",
console.paint(tone, "!"),
console.paint(tone, format!("Install scripts — {summary}"))
);
for name in unreviewed.iter().take(OUTPUT_LINES) {
println!(" {name}");
}
if let Some(rest) = unreviewed
.len()
.checked_sub(OUTPUT_LINES)
.filter(|n| *n > 0)
{
println!(" {}", console.paint(Tone::Muted, format!("… {rest} more")));
}
let remedy = match manager {
project::PackageManager::Pnpm => {
"list them in onlyBuiltDependencies or ignoredBuiltDependencies"
}
project::PackageManager::Bun => "trust them in trustedDependencies, or leave them blocked",
_ => "they run on install; ignore-scripts=true in .npmrc turns that off",
};
println!(" {}", console.paint(Tone::Muted, remedy));
}
fn configure_checks(
name: Option<&str>,
manifest: &Manifest,
members: &[workspace::Member],
rust_root: Option<&Path>,
project: &Project,
root: &Path,
) -> ExitCode {
let mut config = match check_config::Config::load(root) {
Ok(config) => config,
Err(error) => return check_config_error(error),
};
if !io::stdin().is_terminal() || !io::stderr().is_terminal() {
write_block(
&ErrorBlock::new("Selecting checks needs a terminal")
.with_remedy("Run opi --checks in a terminal, or edit .opi/checks.json."),
Console::stderr(ColorMode::Auto),
);
return ExitCode::FAILURE;
}
let name = match name {
Some(name) => name.to_owned(),
None => {
let group = ["commit", "push", "release", "deploy"]
.into_iter()
.fold(Group::new("Workflow"), |group, name| {
group.add_item(Item::new(name, name))
});
match Menu::new()
.with_heading("Configure checks")
.add_group(group)
.run(Console::stderr(ColorMode::Auto), SelectMode::Auto, true)
{
Ok(Outcome::Selected(name)) => name,
Ok(_) => return ExitCode::SUCCESS,
Err(error) => return check_config_error(error.to_string()),
}
}
};
let Some(workflow) = workflow::Workflow::parse(&name) else {
return check_config_error(format!(
"Unknown workflow {name:?}; use commit, push, release or deploy."
));
};
let mut checks = check::Check::detect_all(
manifest,
members,
rust_root,
root,
project.package_manager.manager,
);
if workflow == workflow::Workflow::Commit {
checks = check::Check::for_commit(checks, root);
}
let available: std::collections::BTreeSet<&str> =
checks.iter().map(|check| check.name).collect();
let previous = config.workflows.get(&name);
let mut ticked: Vec<String> = match previous {
Some(previous) => previous.checks.clone(),
None => available
.iter()
.filter(|name| workflow.includes(name))
.map(|name| (*name).to_owned())
.collect(),
};
let has_build = !check::Check::builds(
manifest,
members,
rust_root,
root,
project.package_manager.manager,
)
.is_empty();
let build = previous.map_or_else(
|| workflow.builds() && has_build,
|selection| selection.build,
);
if build {
ticked.push("@build".to_owned());
}
let mut group = Group::new("Checks — all detected tools and workspace members");
for name in &available {
group = group.add_item(Item::new(*name, *name));
}
if let Some(previous) = previous {
for name in previous
.checks
.iter()
.filter(|name| !available.contains(name.as_str()))
{
group = group.add_item(
Item::new(name, name).with_description("Unavailable here; deselect to remove"),
);
}
}
if has_build || build {
group =
group.add_item(Item::new("@build", "Build").with_description("Run after the checks"));
}
let menu = Menu::new()
.with_heading(format!("Checks before {name}"))
.with_ticked(ticked)
.add_group(group);
let chosen = match menu.run_multi(Console::stderr(ColorMode::Auto), SelectMode::Auto, true) {
Ok(Picked::Chosen(chosen)) => chosen,
Ok(_) => return ExitCode::SUCCESS,
Err(error) => return check_config_error(error.to_string()),
};
let selection = check_config::Selection {
build: chosen.iter().any(|name| name == "@build"),
checks: chosen.into_iter().filter(|name| name != "@build").collect(),
};
config.workflows.insert(name.clone(), selection);
match config.save(root) {
Ok(_) => {
println!(
"{}",
Console::stdout(ColorMode::Auto).paint(
Tone::Success,
format!(
"Saved {name} checks in {}. Run opi --check {name} to use them.",
check_config::FILE
)
)
);
ExitCode::SUCCESS
}
Err(error) => check_config_error(error),
}
}
fn check_config_error(error: impl Into<String>) -> ExitCode {
write_block(
&ErrorBlock::new("Cannot use the workflow check selection")
.with_explanation(error.into())
.with_remedy("Review .opi/checks.json or run opi --checks in a terminal."),
Console::stderr(ColorMode::Auto),
);
ExitCode::FAILURE
}
fn run_workflow(
name: &str,
manifest: &Manifest,
members: &[workspace::Member],
rust_root: Option<&Path>,
project: &Project,
root: &Path,
) -> ExitCode {
let console = Console::stdout(ColorMode::Auto);
let Some(workflow) = workflow::Workflow::parse(name) else {
let block = ErrorBlock::new(format!("No workflow named {name}"))
.with_remedy("Known workflows: commit, push, release, deploy.");
write_block(&block, Console::stderr(ColorMode::Auto));
return ExitCode::FAILURE;
};
let config = match check_config::Config::load(root) {
Ok(config) => config,
Err(error) => return check_config_error(error),
};
let selection = config.workflows.get(name);
println!(
"{} {}",
console.paint(Tone::Title, project.display_name(root)),
console.paint(Tone::Muted, workflow.label())
);
println!();
let mut blocked = false;
for gate in workflow.gates() {
let result = gate.check(root, manifest.version.as_deref());
let (tone, mark) = match result.verdict {
runemark::Verdict::Passed => (Tone::Success, "✓"),
runemark::Verdict::Skipped => (Tone::Muted, "–"),
_ => {
blocked = true;
(Tone::Error, "✗")
}
};
let detail = result
.detail
.map_or_else(String::new, |detail| format!(" {detail}"));
println!(
"{} {}{}",
console.paint(tone, mark),
console.paint(tone, gate.name()),
console.paint(Tone::Muted, detail)
);
}
let checks: Vec<check::Check> = check::Check::detect_all(
manifest,
members,
rust_root,
root,
project.package_manager.manager,
)
.into_iter()
.filter(|check| {
selection.map_or_else(
|| workflow.includes(check.name),
|selected| selected.includes(check.name),
)
})
.collect();
let checks = if workflow == workflow::Workflow::Commit {
check::Check::for_commit(checks, root)
.into_iter()
.filter(|check| selection.is_none_or(|selected| selected.includes(check.name)))
.collect()
} else {
checks
};
let builds = if selection.map_or_else(|| workflow.builds(), |selected| selected.build) {
check::Check::builds(
manifest,
members,
rust_root,
root,
project.package_manager.manager,
)
} else {
Vec::new()
};
if let Some(selection) = selection {
let mut missing: Vec<_> = selection
.checks
.iter()
.filter(|name| !checks.iter().any(|check| check.name == name.as_str()))
.map(String::as_str)
.collect();
if selection.build && builds.is_empty() {
missing.push("Build");
}
if !missing.is_empty() {
return check_config_error(format!(
"Selected checks unavailable in this project: {}",
missing.join(", "),
));
}
}
if checks.is_empty() && builds.is_empty() {
println!(
"{}",
console.paint(Tone::Muted, "No checks apply to this project.")
);
}
let width = checks
.iter()
.chain(&builds)
.map(|check| check.label().chars().count())
.max()
.unwrap_or(0);
let print = |report: &check::Report| {
let (tone, mark) = match report {
report if report.warned() => (Tone::Warning, "!"),
report if report.passed() => (Tone::Success, "✓"),
report if report.unusable() => (Tone::Warning, "!"),
_ => (Tone::Error, "✗"),
};
println!(
"{} {} {}",
console.paint(tone, mark),
console.paint(tone, format!("{:width$}", report.check.label())),
console.paint(Tone::Muted, report.check.tool),
);
};
let mut reports = check::run_all(checks, print);
for build in builds {
let report = build.run();
print(&report);
reports.push(report);
}
let failed = reports.iter().filter(|report| !report.passed()).count();
println!();
if blocked || failed > 0 {
for report in reports.iter().filter(|report| !report.passed()) {
println!(
"{}",
console.paint(
Tone::Error,
format!("{} — {}", report.check.label(), report.check.tool)
)
);
for line in report.output.lines().take(OUTPUT_LINES) {
println!(" {line}");
}
}
println!();
println!(
"{}",
console.paint(Tone::Error, format!("Not ready to {name}."))
);
ExitCode::FAILURE
} else {
println!(
"{}",
console.paint(Tone::Success, format!("Ready to {name}."))
);
ExitCode::SUCCESS
}
}
fn hooks(root: &Path) -> ExitCode {
let console = Console::stdout(ColorMode::Auto);
let (message, hook) = match workflow::install_hook(root) {
Ok(workflow::HookInstall::Created(hook)) => ("Installed the pre-push hook", hook),
Ok(workflow::HookInstall::Appended(hook)) => ("Added opi to the pre-push hook", hook),
Ok(workflow::HookInstall::Present(hook)) => ("The pre-push hook already runs opi", hook),
Err(error) => {
let block = match error {
workflow::HookError::NotARepository => ErrorBlock::new("Not a git repository")
.with_remedy("Hooks belong to a repository; run git init first."),
workflow::HookError::Io(error) => {
ErrorBlock::new("Cannot write the hook").with_explanation(format!("{error}"))
}
};
write_block(&block, Console::stderr(ColorMode::Auto));
return ExitCode::FAILURE;
}
};
let shown = hook.strip_prefix(root).map_or_else(
|_| hook.display().to_string(),
|path| path.display().to_string(),
);
println!(
"{} {} {}",
console.paint(Tone::Success, "✓"),
console.paint(Tone::Success, message),
console.paint(Tone::Muted, shown)
);
println!(
"{}",
console.paint(
Tone::Muted,
"Every push now runs `opi --check push`; `git push --no-verify` skips it."
)
);
ExitCode::SUCCESS
}
fn sbom(project: &Project, rust_root: Option<&Path>, root: &Path, out: Option<&str>) -> ExitCode {
let console = Console::stdout(ColorMode::Auto);
let out = match out {
Some(dir) => PathBuf::from(dir),
None => match sbom::default_dir(root) {
Ok(dir) => dir,
Err(error) => {
let block =
ErrorBlock::new("No place for the SBOM").with_explanation(format!("{error:?}"));
write_block(&block, Console::stderr(ColorMode::Auto));
return ExitCode::FAILURE;
}
},
};
let mut results = Vec::new();
if project.npm {
results.push((
"npm",
sbom::npm(project.package_manager.manager, root, &out),
));
}
if let Some(rust_root) = rust_root {
results.push(("Cargo", sbom::cargo(rust_root, &out)));
}
if results.is_empty() {
let block = ErrorBlock::new("Nothing to describe")
.with_remedy("An SBOM needs a package.json or a Cargo.toml.");
write_block(&block, Console::stderr(ColorMode::Auto));
return ExitCode::FAILURE;
}
let mut ok = true;
for (ecosystem, result) in results {
match result {
Ok(path) => println!(
"{} {} {}",
console.paint(Tone::Success, "✓"),
ecosystem,
console.paint(Tone::Muted, path.display().to_string())
),
Err(error) => {
ok = false;
let block = match error {
sbom::SbomError::Missing { tool, install } => {
ErrorBlock::new(format!("{ecosystem}: {tool} is not installed"))
.with_remedy(format!("Install it with {install}."))
}
sbom::SbomError::Failed(reason) => {
ErrorBlock::new(format!("{ecosystem}: no SBOM written"))
.with_explanation(reason)
}
};
write_block(&block, Console::stderr(ColorMode::Auto));
}
}
}
if ok {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
fn updates(
manifest: &Manifest,
members: &[workspace::Member],
project: &Project,
rust_root: Option<&Path>,
root: &Path,
) -> ExitCode {
let console = Console::stdout(ColorMode::Auto);
let manager = project.package_manager.manager;
println!(
"{} {}",
console.paint(Tone::Title, project.display_name(root)),
console.paint(Tone::Muted, "updates")
);
println!();
let workspace = workspace::declared(root, manifest);
let listed = if project.npm {
outdated::run(manager, root, workspace)
} else {
Err(outdated::OutdatedError::NoManifest)
};
let npm_updates = match &listed {
Ok(found) => found.clone(),
Err(_) => Vec::new(),
};
let mut failed = section(console, "Dependencies", listed);
if let Some(rust_root) = rust_root {
println!();
failed |= section(console, "Dependencies (rust)", outdated::cargo(rust_root));
}
if failed {
return ExitCode::FAILURE;
}
apply_updates(manifest, members, project, rust_root, root, &npm_updates)
}
fn apply_updates(
manifest: &Manifest,
members: &[workspace::Member],
project: &Project,
rust_root: Option<&Path>,
root: &Path,
found: &[outdated::Update],
) -> ExitCode {
let console = Console::stdout(ColorMode::Auto);
let manager = project.package_manager.manager;
let workspace = workspace::declared(root, manifest);
let in_range = found.iter().filter(|update| update.in_range).count();
let safe: Vec<String> = found
.iter()
.filter(|update| !update.jump.breaking())
.map(|update| update.name.clone())
.collect();
let raises = outdated::can_raise(manager) && !safe.is_empty();
let decides = outdated::can_raise(manager) && !found.is_empty();
let chooses = project.npm && outdated::can_choose(manager);
if in_range == 0 && !raises && !decides && !chooses {
return ExitCode::SUCCESS;
}
if let Some(conflict) = project::conflict(root, project.package_manager) {
println!();
println!(
"{}",
console.paint(
Tone::Muted,
format!(
"Not offering to apply these: {} both present, and no packageManager field says which. Adding one answers it.",
conflict.lockfiles.join(" and ")
)
)
);
return ExitCode::SUCCESS;
}
println!();
let mut group = Group::new("Update");
if in_range > 0 {
group = group.add_item(
Item::new("in-range", "Within the declared ranges")
.with_description(format!("{in_range} packages, no package.json touched")),
);
}
if raises {
group = group.add_item(
Item::new("latest", "Raise the ranges").with_description(format!(
"{} safe, majors left out",
plural(safe.len(), "package", "packages")
)),
);
}
if decides {
group = group.add_item(
Item::new("decide", "Decide per package")
.with_description("tick what to raise, majors included"),
);
}
if chooses {
group = group.add_item(
Item::new("choose", format!("Choose in {manager}'s own list"))
.with_description("its interactive update, then the commit checks"),
);
}
let menu = Menu::new().add_group(group.add_item(Item::new("cancel", "Cancel")));
let interactive = io::stdout().is_terminal() && io::stderr().is_terminal();
let chosen = match menu.run(
Console::stderr(ColorMode::Auto),
SelectMode::Auto,
interactive,
) {
Ok(Outcome::Selected(id)) => id,
Ok(Outcome::Unavailable) => {
println!(
"{}",
console.paint(
Tone::Muted,
"Run opi --updates in a terminal to take any of them."
)
);
return ExitCode::SUCCESS;
}
Ok(_) => return ExitCode::SUCCESS,
Err(error) => {
let block =
ErrorBlock::new("Cannot open the terminal").with_explanation(format!("{error}"));
write_block(&block, Console::stderr(ColorMode::Auto));
return ExitCode::FAILURE;
}
};
let (mode, names) = match chosen.as_str() {
"in-range" => (outdated::Apply::InRange, safe),
"latest" => (outdated::Apply::Latest, safe),
"decide" => match pick_updates(found, &safe) {
Some(picked) => (outdated::Apply::Latest, picked),
None => return ExitCode::SUCCESS,
},
"choose" => (outdated::Apply::Choose, Vec::new()),
_ => return ExitCode::SUCCESS,
};
if names.is_empty() && mode == outdated::Apply::Latest {
return ExitCode::SUCCESS;
}
println!();
if let Err(error) = outdated::apply(manager, root, workspace, mode, &names) {
let block = ErrorBlock::new("The update did not finish")
.with_explanation(format!("{error}"))
.with_remedy("The package manager's own output above says why.");
write_block(&block, Console::stderr(ColorMode::Auto));
return ExitCode::FAILURE;
}
println!();
run_workflow("commit", manifest, members, rust_root, project, root)
}
fn pick_updates(found: &[outdated::Update], safe: &[String]) -> Option<Vec<String>> {
let mut menu = Menu::new()
.with_heading("Raise the ranges")
.with_ticked(safe.iter().cloned());
for (title, breaking) in [("Safe to take", false), ("A decision each", true)] {
let mut group = Group::new(title);
for update in found
.iter()
.filter(|update| update.jump.breaking() == breaking)
{
group = group.add_item(Item::new(&update.name, &update.name).with_description(
format!(
"{} → {} {}",
update.current,
update.latest,
update.jump.label()
),
));
}
if !group.items.is_empty() {
menu = menu.add_group(group);
}
}
match menu.run_multi(Console::stderr(ColorMode::Auto), SelectMode::Auto, true) {
Ok(Picked::Chosen(names)) => Some(names),
_ => None,
}
}
fn section(
console: Console,
title: &str,
listed: Result<Vec<outdated::Update>, outdated::OutdatedError>,
) -> bool {
let found = match listed {
Ok(found) if found.is_empty() => {
println!(
"{} {}",
console.paint(Tone::Success, "✓"),
console.paint(Tone::Success, format!("{title} — everything is current"))
);
return false;
}
Ok(found) => found,
Err(error) => {
let fatal = matches!(error, outdated::OutdatedError::Failed(_));
println!(
"{} {}",
console.paint(Tone::Muted, "–"),
console.paint(Tone::Muted, format!("{title} — {error}"))
);
return fatal;
}
};
let (breaking, safe): (Vec<_>, Vec<_>) =
found.iter().partition(|update| update.jump.breaking());
let mut report = Report::new(
title,
if breaking.is_empty() {
Verdict::Info
} else {
Verdict::Warning
},
)
.with_detail_level(DetailLevel::Detailed);
if !safe.is_empty() {
report = report.add_metric(Metric::new("safe", safe.len().to_string()));
}
if !breaking.is_empty() {
report = report
.add_metric(Metric::new("major", breaking.len().to_string()).with_tone(Tone::Warning));
}
for (group_title, tone, group) in [
("Safe to take", Tone::Muted, &safe),
("A decision each", Tone::Warning, &breaking),
] {
if group.is_empty() {
continue;
}
let mut findings = FindingGroup::new(group_title);
for update in group.iter() {
findings = findings.add_finding(
Finding::new(
tone,
format!("{} {} → {}", update.name, update.current, update.latest),
)
.with_rule_id(update.jump.label()),
);
}
report = report.add_group(findings);
}
print!("{}", report.render(console));
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::task::Group as TaskGroup;
fn task(name: &str, group: TaskGroup) -> Task {
Task {
name: name.to_owned(),
description: None,
command: name.to_owned(),
group,
exec: task::Exec::Script,
confirm: false,
workspace: None,
hidden: false,
}
}
fn sections(counts: &[(TaskGroup, usize)]) -> Vec<Task> {
counts
.iter()
.flat_map(|(group, count)| {
(0..*count).map(move |n| task(&format!("s{n}"), group.clone()))
})
.collect()
}
#[test]
fn a_short_list_stays_flat() {
assert_eq!(layout(12, 4), Layout::Flat);
assert_eq!(layout(15, 5), Layout::Flat, "the thresholds are inclusive");
}
#[test]
fn a_long_list_becomes_tabs() {
assert_eq!(layout(27, 7), Layout::Tabs);
}
#[test]
fn either_count_is_enough_on_its_own() {
assert_eq!(layout(8, 8), Layout::Tabs);
assert_eq!(layout(30, 2), Layout::Tabs);
}
#[test]
fn the_summary_counts_entries_and_groups() {
let tasks = sections(&[
(TaskGroup::Development, 4),
(TaskGroup::Build, 7),
(TaskGroup::Quality, 4),
]);
let sections = by_group(&tasks);
assert_eq!(summary(§ions, 15, &[]), "15 entries · 3 groups");
}
#[test]
fn a_project_with_no_workspace_says_nothing_about_packages() {
let tasks = sections(&[(TaskGroup::Development, 1)]);
let sections = by_group(&tasks);
assert!(!summary(§ions, 1, &[]).contains("package"));
assert_eq!(summary(§ions, 1, &[]), "1 entry · 1 group");
}
#[test]
fn the_summary_counts_workspace_packages() {
let tasks = sections(&[
(TaskGroup::Development, 2),
(TaskGroup::Workspace("@casoon/blog".to_owned()), 1),
(TaskGroup::Workspace("@casoon/starter".to_owned()), 1),
]);
let sections = by_group(&tasks);
assert_eq!(
summary(§ions, 4, &[]),
"4 entries · 3 groups · 2 packages"
);
}
#[test]
fn the_summary_names_other_task_runners() {
let tasks = sections(&[(TaskGroup::Development, 2)]);
let sections = by_group(&tasks);
assert_eq!(
summary(§ions, 2, &["Makefile".to_owned()]),
"2 entries · 1 group · also Makefile"
);
}
}