use anyhow::{bail, Context, Result};
use camino::Utf8Path;
use doiget_core::orchestrator::resolve_only;
use doiget_core::refs::{parse_input, Format, ParseError};
use doiget_core::verify_config::{self, OnMissingId};
use doiget_core::CapabilityProfile;
use super::fetch::CliExit;
use super::output::OutputMode;
fn load_verify_config() -> verify_config::VerifyConfig {
let path = match crate::commands::fetch::config_dir_utf8() {
Ok(dir) => dir.join("doiget").join("config.toml"),
Err(_) => return verify_config::VerifyConfig::default(),
};
match verify_config::load(&path) {
Ok(cfg) => cfg,
Err(e) => {
#[allow(clippy::print_stderr)]
{
eprintln!("warning: ignoring [verify] config: {e}");
}
verify_config::VerifyConfig::default()
}
}
}
fn parse_format(s: &str) -> Result<Format> {
match s {
"auto" => Ok(Format::Auto),
"refs" => Ok(Format::Refs),
"csl-json" => Ok(Format::CslJson),
"bibtex" => Ok(Format::Bibtex),
other => bail!("unknown --format {other:?} (expected auto|refs|csl-json|bibtex)"),
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum VerifyStatus {
Valid,
Illegal,
Absent,
Unreachable,
Unverifiable,
}
impl VerifyStatus {
const ALL: [VerifyStatus; 5] = [
VerifyStatus::Valid,
VerifyStatus::Illegal,
VerifyStatus::Absent,
VerifyStatus::Unreachable,
VerifyStatus::Unverifiable,
];
fn as_wire(self) -> &'static str {
match self {
VerifyStatus::Valid => "valid",
VerifyStatus::Illegal => "illegal",
VerifyStatus::Absent => "absent",
VerifyStatus::Unreachable => "unreachable",
VerifyStatus::Unverifiable => "unverifiable",
}
}
fn index(self) -> usize {
match self {
VerifyStatus::Valid => 0,
VerifyStatus::Illegal => 1,
VerifyStatus::Absent => 2,
VerifyStatus::Unreachable => 3,
VerifyStatus::Unverifiable => 4,
}
}
fn is_failing(self, strict: bool, on_missing: OnMissingId) -> bool {
match self {
VerifyStatus::Valid => false,
VerifyStatus::Illegal | VerifyStatus::Absent => true,
VerifyStatus::Unreachable => strict,
VerifyStatus::Unverifiable => on_missing == OnMissingId::Error,
}
}
}
pub async fn run(path: String, format: String, cli_strict: bool, mode: OutputMode) -> Result<()> {
let fmt = parse_format(&format)?;
let text = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read reference file {path}"))?;
let entries = parse_input(&text, fmt, Some(Utf8Path::new(&path)));
let config = load_verify_config();
let strict = cli_strict || config.strict;
let on_missing = if cli_strict {
OnMissingId::Error
} else if strict {
match config.on_missing_id {
OnMissingId::Skip => OnMissingId::Warn,
other => other,
}
} else {
config.on_missing_id
};
let ctx = crate::commands::fetch::build_resolve_context()?;
let profile = CapabilityProfile::from_env().context("resolving capability profile")?;
let mut counts = [0u32; VerifyStatus::ALL.len()];
for entry in entries {
if matches!(&entry, Err(ParseError::NoIdentifier { .. })) && on_missing == OnMissingId::Skip
{
continue;
}
let (status, record) = match entry {
Ok(parsed) => {
let ref_ = parsed.ref_;
let entry_key = parsed.entry_key;
match resolve_only(&ref_, &profile, &ctx).await {
Ok(_) => (
VerifyStatus::Valid,
serde_json::json!({
"ok": true,
"ref": ref_.as_input_str(),
"status": VerifyStatus::Valid.as_wire(),
"entry_key": entry_key,
}),
),
Err(e) => {
let code: doiget_core::ErrorCode = (&e).into();
if code == doiget_core::ErrorCode::LogError {
return Err(anyhow::anyhow!(
"provenance log error during verify (aborting): {e}"
));
}
if code == doiget_core::ErrorCode::InternalError {
return Err(anyhow::anyhow!(
"internal error during verify (aborting; please report): {e}"
));
}
let status = if code == doiget_core::ErrorCode::NotFound {
VerifyStatus::Absent
} else {
VerifyStatus::Unreachable
};
(
status,
serde_json::json!({
"ok": false,
"ref": ref_.as_input_str(),
"status": status.as_wire(),
"entry_key": entry_key,
"error": { "code": code.as_wire(), "message": e.to_string() },
}),
)
}
}
}
Err(ParseError::InvalidRef {
raw,
entry_key,
source,
}) => (
VerifyStatus::Illegal,
serde_json::json!({
"ok": false,
"ref": raw,
"status": VerifyStatus::Illegal.as_wire(),
"entry_key": entry_key,
"error": { "code": "INVALID_REF", "message": source.to_string() },
}),
),
Err(ParseError::NoIdentifier { entry_key }) => (
VerifyStatus::Unverifiable,
serde_json::json!({
"ok": false,
"ref": serde_json::Value::Null,
"status": VerifyStatus::Unverifiable.as_wire(),
"entry_key": entry_key,
"error": { "code": "INVALID_REF", "message": "entry has no DOI / arXiv id" },
}),
),
Err(ParseError::Decode { format, message }) => (
VerifyStatus::Illegal,
serde_json::json!({
"ok": false,
"status": VerifyStatus::Illegal.as_wire(),
"error": {
"code": "INVALID_REF",
"message": format!("input did not parse as {format}: {message}"),
},
}),
),
Err(ParseError::UnsupportedFormat { format }) => {
bail!("{format} parsing is not supported for verification");
}
Err(_) => {
bail!("reference file could not be parsed");
}
};
counts[status.index()] += 1;
#[allow(clippy::print_stdout)]
{
println!("{record}");
}
}
let total = counts.iter().copied().fold(0u32, u32::saturating_add);
if mode != OutputMode::Quiet {
#[allow(clippy::print_stderr)]
{
eprintln!(
"verify: {total} entries — {} valid, {} illegal, {} absent, \
{} unreachable, {} unverifiable{}",
counts[VerifyStatus::Valid.index()],
counts[VerifyStatus::Illegal.index()],
counts[VerifyStatus::Absent.index()],
counts[VerifyStatus::Unreachable.index()],
counts[VerifyStatus::Unverifiable.index()],
if strict { " (strict)" } else { "" }
);
}
}
let failing = VerifyStatus::ALL
.iter()
.filter(|s| s.is_failing(strict, on_missing))
.map(|s| counts[s.index()])
.fold(0u32, u32::saturating_add);
if failing == 0 {
Ok(())
} else {
Err(anyhow::Error::new(CliExit(failing.min(255) as i32)))
}
}