use rto_graph::SourceIdentity;
use crate::guidance::{Guidance, Line};
use crate::ingest::NormalizedReport;
use crate::runner::ExecError;
use crate::snippet::SnippetSource;
pub mod cargo_audit;
pub mod clippy;
pub mod osv_scanner;
pub mod semgrep;
#[derive(Clone)]
pub struct NativeContext<'a> {
pub started_at: String,
pub ended_at: String,
pub analyzer_version: Option<String>,
pub exit_status: i32,
pub source: &'a SourceIdentity,
pub rules_digest: Option<String>,
pub advisory_db: Option<rto_graph::AdvisoryDb>,
pub worktree: Option<&'a std::path::Path>,
pub snippets: &'a dyn SnippetSource,
}
impl std::fmt::Debug for NativeContext<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NativeContext")
.field("started_at", &self.started_at)
.field("ended_at", &self.ended_at)
.field("analyzer_version", &self.analyzer_version)
.field("exit_status", &self.exit_status)
.field("source", self.source)
.field("rules_digest", &self.rules_digest)
.field("advisory_db", &self.advisory_db)
.field("worktree", &self.worktree)
.finish_non_exhaustive()
}
}
impl NativeContext<'_> {
#[must_use]
pub fn version_or(&self, from_report: Option<&str>) -> String {
self.analyzer_version
.as_deref()
.or(from_report)
.map(str::trim)
.filter(|v| !v.is_empty())
.unwrap_or(UNKNOWN_VERSION)
.to_owned()
}
}
pub const UNKNOWN_VERSION: &str = "unknown";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Invocation {
pub program: String,
pub args: Vec<String>,
pub success_statuses: Vec<i32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InstallHint {
pub program: &'static str,
pub guidance: Guidance,
}
pub const RUST_TOOLCHAIN: Guidance = Guidance::new(&[
Line::Note(&[
"Roteiro does not install toolchains. Install Rust — rustup's front page",
"selects the right installer for this host, so there is nothing to paste",
"here.",
]),
Line::Note(&["Upstream: https://rustup.rs"]),
]);
pub const URL_PREFIX: &str = "Upstream: ";
pub trait Adapter: Sync + std::fmt::Debug {
fn analyzer(&self) -> &'static str;
fn summary(&self) -> &'static str;
fn languages(&self) -> &'static [&'static str];
fn asset_ids(&self) -> &'static [&'static str];
fn host_programs(&self) -> &'static [&'static str];
fn install_hints(&self) -> &'static [InstallHint];
fn command(&self, assets: &AssetPaths<'_>) -> Invocation;
fn normalize(
&self,
native: &[u8],
ctx: &NativeContext<'_>,
) -> Result<NormalizedReport, ExecError>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct AssetPaths<'a> {
entries: &'a [(&'a str, std::path::PathBuf)],
}
impl<'a> AssetPaths<'a> {
#[must_use]
pub fn new(entries: &'a [(&'a str, std::path::PathBuf)]) -> Self {
Self { entries }
}
#[must_use]
pub fn get(&self, id: &str) -> Option<&std::path::Path> {
self.entries
.iter()
.find(|(key, _)| *key == id)
.map(|(_, path)| path.as_path())
}
#[must_use]
pub fn arg(&self, id: &str) -> String {
self.get(id)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default()
}
}
pub static ADAPTERS: &[&dyn Adapter] = &[
&semgrep::Semgrep,
&cargo_audit::CargoAudit,
&osv_scanner::OsvScanner,
];
pub const LINT_ANALYZERS: &[&str] = &[clippy::ANALYZER];
static LINT_ADAPTERS: &[&dyn Adapter] = &[&clippy::Clippy];
#[must_use]
pub fn adapter_for(analyzer: &str) -> Option<&'static dyn Adapter> {
ADAPTERS.iter().copied().find(|a| a.analyzer() == analyzer)
}
pub fn every_adapter() -> impl Iterator<Item = &'static dyn Adapter> {
ADAPTERS.iter().chain(LINT_ADAPTERS).copied()
}
#[must_use]
pub fn install_hint(program: &str) -> Option<Guidance> {
every_adapter()
.flat_map(Adapter::install_hints)
.find(|hint| hint.program == program)
.map(|hint| hint.guidance)
}
#[must_use]
pub fn known_analyzers() -> Vec<&'static str> {
let mut ids: Vec<&'static str> = ADAPTERS.iter().map(|a| a.analyzer()).collect();
ids.sort_unstable();
ids
}
pub const NO_SNIPPET: &str = "no-snippet";
#[must_use]
pub fn snippet_hash(snippet: &str) -> String {
crate::sha256_hex(snippet.trim().as_bytes())[..16].to_owned()
}
#[must_use]
pub fn snippet_hash_at(snippets: &dyn SnippetSource, path: &str, start: u32, end: u32) -> String {
snippets
.snippet(path, start, end)
.map_or_else(|| NO_SNIPPET.to_owned(), |text| snippet_hash(&text))
}
#[cfg(test)]
mod tests {
use super::{
Adapter as _, AssetPaths, Guidance, LINT_ANALYZERS, Line, NO_SNIPPET, NativeContext,
UNKNOWN_VERSION, URL_PREFIX, adapter_for, every_adapter, install_hint, known_analyzers,
snippet_hash, snippet_hash_at,
};
use rto_graph::SourceIdentity;
fn ctx(version: Option<&str>) -> NativeContext<'static> {
static SOURCE: std::sync::LazyLock<SourceIdentity> =
std::sync::LazyLock::new(SourceIdentity::default);
NativeContext {
started_at: "2026-08-15T09:00:00Z".to_owned(),
ended_at: "2026-08-15T09:00:04Z".to_owned(),
analyzer_version: version.map(str::to_owned),
exit_status: 0,
source: &SOURCE,
rules_digest: None,
advisory_db: None,
worktree: None,
snippets: &crate::snippet::NoSnippets,
}
}
#[test]
fn the_registry_answers_for_every_analyzer_it_lists() {
for id in known_analyzers() {
assert_eq!(adapter_for(id).expect("registered").analyzer(), id);
}
assert!(adapter_for("no-such-analyzer").is_none());
}
#[test]
fn every_host_program_has_an_install_hint() {
for adapter in every_adapter() {
for program in adapter.host_programs() {
let hint = adapter
.install_hints()
.iter()
.find(|hint| hint.program == *program);
assert!(
hint.is_some(),
"{} needs `{program}` on PATH and says nothing about how to get it — \
see `Adapter::install_hints`",
adapter.analyzer()
);
}
for hint in adapter.install_hints() {
assert!(
adapter.host_programs().contains(&hint.program),
"{} hints at installing `{}`, which it does not need on PATH — a hint \
for a program no refusal names is one nobody reads",
adapter.analyzer(),
hint.program
);
}
}
}
#[test]
fn every_install_hint_carries_upstream_and_guesses_no_package_manager() {
for adapter in every_adapter() {
for hint in adapter.install_hints() {
let rendered = hint.guidance.to_string();
assert!(
rendered.contains("https://"),
"the hint for `{}` names no upstream page",
hint.program
);
for guess in ["brew ", "apt ", "apt-get ", "yum ", "dnf ", "choco "] {
assert!(
!rendered.contains(guess),
"the hint for `{}` reaches for `{guess}`, which guesses the \
reader's platform",
hint.program
);
}
}
}
}
#[test]
fn every_hint_renders_its_upstream_page_the_same_way() {
for adapter in every_adapter() {
for hint in adapter.install_hints() {
let pages: Vec<&str> = hint
.guidance
.lines()
.iter()
.filter_map(|line| match line {
Line::Note(fragments) => fragments
.iter()
.copied()
.find(|f| f.starts_with(URL_PREFIX)),
Line::Command(_) => None,
})
.collect();
assert_eq!(
pages.len(),
1,
"the hint for `{}` must introduce its upstream page exactly once, as a \
note beginning {URL_PREFIX:?} — found {pages:?}",
hint.program
);
for line in hint.guidance.lines() {
if let Line::Command(command) = line {
assert!(
!command.contains("://"),
"the hint for `{}` puts a URL in the command slot ({command:?}) — \
a page is read, not run; introduce it with {URL_PREFIX:?}",
hint.program
);
}
}
}
}
}
#[test]
fn a_program_two_adapters_need_is_obtained_one_way() {
let mut seen: Vec<(&str, Guidance)> = Vec::new();
for adapter in every_adapter() {
for hint in adapter.install_hints() {
if let Some((_, first)) = seen.iter().find(|(name, _)| *name == hint.program) {
assert_eq!(
*first, hint.guidance,
"`{}` is obtained two different ways depending on which adapter \
asked — `install_hint` resolves by program, so one of them would \
never be printed",
hint.program
);
} else {
seen.push((hint.program, hint.guidance));
}
}
}
}
#[test]
fn every_invoked_program_is_declared_on_path() {
let empty = AssetPaths::default();
for adapter in every_adapter() {
let program = adapter.command(&empty).program;
assert!(
adapter.host_programs().contains(&program.as_str()),
"{} invokes `{program}`, which it does not declare in `host_programs` — \
a refusal naming it would find no install hint",
adapter.analyzer()
);
assert!(
install_hint(&program).is_some(),
"`{program}` is invoked and has no install hint"
);
}
}
#[test]
fn the_lint_tables_name_the_same_analyzers() {
let from_adapters: Vec<&str> = super::LINT_ADAPTERS.iter().map(|a| a.analyzer()).collect();
assert_eq!(from_adapters, LINT_ANALYZERS.to_vec());
}
#[test]
fn the_lookup_answers_for_a_known_program_and_not_an_unknown_one() {
for program in [
"semgrep",
"cargo-audit",
"osv-scanner",
"cargo",
"cargo-clippy",
] {
assert!(install_hint(program).is_some(), "no hint for `{program}`");
}
assert!(install_hint("no-such-binary").is_none());
}
#[test]
fn the_toolchain_and_the_subcommand_are_obtained_differently() {
let toolchain = install_hint("cargo").expect("cargo").to_string();
let subcommand = install_hint("cargo-audit")
.expect("cargo-audit")
.to_string();
assert!(toolchain.contains("https://rustup.rs"), "{toolchain}");
assert!(
!toolchain.contains("cargo install cargo-audit"),
"{toolchain}"
);
assert!(
subcommand.contains("cargo install cargo-audit"),
"{subcommand}"
);
assert!(!subcommand.contains("https://rustup.rs"), "{subcommand}");
assert!(!subcommand.contains("--locked"), "{subcommand}");
}
#[test]
fn an_analyzer_without_one_canonical_command_says_so() {
let hint = install_hint("osv-scanner")
.expect("osv-scanner")
.to_string();
assert!(
hint.contains("https://google.github.io/osv-scanner/installation/"),
"{hint}"
);
assert!(hint.contains("no single install command"), "{hint}");
}
#[test]
fn every_storable_analyzer_pins_what_decides_its_answer() {
for id in known_analyzers() {
let adapter = adapter_for(id).expect("registered");
assert!(
!adapter.asset_ids().is_empty(),
"{id} is stored but pins nothing that decides its findings"
);
}
assert!(
super::clippy::Clippy.asset_ids().is_empty(),
"a linter has no pinned rule set — that is why it is not stored"
);
}
#[test]
fn every_adapter_states_its_coverage() {
for id in known_analyzers() {
let adapter = adapter_for(id).expect("registered");
assert!(!adapter.languages().is_empty(), "{id} claims no language");
assert!(!adapter.summary().is_empty(), "{id} has no summary");
}
}
#[test]
fn a_version_is_taken_from_the_caller_then_the_report_then_unknown() {
assert_eq!(ctx(Some("1.2.3")).version_or(Some("0.0.1")), "1.2.3");
assert_eq!(ctx(None).version_or(Some("0.0.1")), "0.0.1");
assert_eq!(ctx(None).version_or(None), UNKNOWN_VERSION);
assert_eq!(ctx(Some(" ")).version_or(None), UNKNOWN_VERSION);
}
#[test]
fn snippet_hashes_are_short_stable_and_whitespace_insensitive() {
let hash = snippet_hash("eval(user_input)");
assert_eq!(hash.len(), 16);
assert_eq!(hash, snippet_hash(" eval(user_input)\n"));
assert_ne!(hash, snippet_hash("eval(other_input)"));
}
#[test]
fn an_unavailable_snippet_is_named_not_hashed_as_empty() {
let hash = snippet_hash_at(&crate::snippet::NoSnippets, "a.py", 0, 4);
assert_eq!(hash, NO_SNIPPET);
assert_ne!(hash, snippet_hash(""));
}
#[test]
fn asset_paths_resolve_only_what_was_provisioned() {
let entries = [("semgrep-rules", std::path::PathBuf::from("/cache/r.yaml"))];
let paths = AssetPaths::new(&entries);
assert_eq!(paths.arg("semgrep-rules"), "/cache/r.yaml");
assert!(paths.get("advisory-db").is_none());
assert!(paths.arg("advisory-db").is_empty());
}
}