use serde_json::Value;
use super::semver::{SemVer, SemVerError};
pub(crate) const INDEX_CRATE_NAME: &str = "aion-cli";
#[derive(Debug, thiserror::Error)]
pub(crate) enum IndexError {
#[error("the crate index body is empty — not one version line to read")]
Empty,
#[error("crate index line {line_number} is not JSON: {reason}")]
Syntax {
line_number: usize,
reason: String,
},
#[error("crate index line {line_number} is {found}, not an object")]
NotAnObject {
line_number: usize,
found: &'static str,
},
#[error("crate index line {line_number} has no `{field}` field")]
MissingField {
line_number: usize,
field: &'static str,
},
#[error("crate index line {line_number}: `{field}` is {found}, not {expected}")]
WrongFieldType {
line_number: usize,
field: &'static str,
expected: &'static str,
found: &'static str,
},
#[error(
"crate index line {line_number} names a different crate than `{INDEX_CRATE_NAME}` — \
this is not the `{INDEX_CRATE_NAME}` index; the fetched body is on the run's transcript"
)]
WrongCrate {
line_number: usize,
},
#[error("crate index line {line_number} carries an unparseable version: {source}")]
MalformedVersion {
line_number: usize,
source: SemVerError,
},
#[error(
"the crate index holds no installable version: {yanked} yanked and {prereleases} \
prerelease lines, nothing a plain `cargo install` would select"
)]
NoInstallableVersion {
yanked: usize,
prereleases: usize,
},
}
pub(crate) fn latest_published(body: &str) -> Result<SemVer, IndexError> {
let mut latest: Option<SemVer> = None;
let mut yanked_lines: usize = 0;
let mut prerelease_lines: usize = 0;
let mut parsed_lines: usize = 0;
for (position, line) in body.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let line_number = position + 1;
let entry = classify_line(line, line_number)?;
parsed_lines += 1;
if entry.yanked {
yanked_lines += 1;
continue;
}
let version = SemVer::parse(&entry.vers).map_err(|error| IndexError::MalformedVersion {
line_number,
source: error,
})?;
if !version.prerelease.is_empty() {
prerelease_lines += 1;
continue;
}
latest = Some(match latest.take() {
Some(current) if current >= version => current,
_ => version,
});
}
match latest {
Some(version) => Ok(version),
None if parsed_lines == 0 => Err(IndexError::Empty),
None => Err(IndexError::NoInstallableVersion {
yanked: yanked_lines,
prereleases: prerelease_lines,
}),
}
}
struct IndexLine {
vers: String,
yanked: bool,
}
fn classify_line(line: &str, line_number: usize) -> Result<IndexLine, IndexError> {
let value: Value = serde_json::from_str(line).map_err(|error| IndexError::Syntax {
line_number,
reason: error.to_string(),
})?;
let Some(object) = value.as_object() else {
return Err(IndexError::NotAnObject {
line_number,
found: json_kind(&value),
});
};
let name = required_str(object, "name", line_number)?;
if name != INDEX_CRATE_NAME {
return Err(IndexError::WrongCrate { line_number });
}
let vers = required_str(object, "vers", line_number)?;
let yanked_value = object.get("yanked").ok_or(IndexError::MissingField {
line_number,
field: "yanked",
})?;
let Some(yanked) = yanked_value.as_bool() else {
return Err(IndexError::WrongFieldType {
line_number,
field: "yanked",
expected: "a boolean",
found: json_kind(yanked_value),
});
};
Ok(IndexLine {
vers: vers.to_owned(),
yanked,
})
}
fn required_str<'body>(
object: &'body serde_json::Map<String, Value>,
field: &'static str,
line_number: usize,
) -> Result<&'body str, IndexError> {
let value = object
.get(field)
.ok_or(IndexError::MissingField { line_number, field })?;
value.as_str().ok_or_else(|| IndexError::WrongFieldType {
line_number,
field,
expected: "a string",
found: json_kind(value),
})
}
const fn json_kind(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "a boolean",
Value::Number(_) => "a number",
Value::String(_) => "a string",
Value::Array(_) => "an array",
Value::Object(_) => "an object",
}
}
#[cfg(test)]
mod tests {
use super::{INDEX_CRATE_NAME, IndexError, latest_published};
type TestResult = Result<(), Box<dyn std::error::Error>>;
const CAPTURED_INDEX: &str = include_str!("fixtures/aion-cli-index.jsonl");
fn line(vers: &str, yanked: bool) -> String {
format!("{{\"name\":\"{INDEX_CRATE_NAME}\",\"vers\":\"{vers}\",\"yanked\":{yanked}}}\n")
}
#[test]
fn the_captured_real_index_answers_its_greatest_version() -> TestResult {
assert_eq!(latest_published(CAPTURED_INDEX)?.to_string(), "0.13.7");
Ok(())
}
#[test]
fn the_greatest_version_need_not_be_the_last_line() -> TestResult {
let body = [
line("0.9.0", false),
line("0.10.1", false),
line("0.2.0", false),
]
.concat();
assert_eq!(latest_published(&body)?.to_string(), "0.10.1");
Ok(())
}
#[test]
fn a_yanked_version_never_becomes_the_answer() -> TestResult {
let body = [line("0.13.7", false), line("9.9.9", true)].concat();
assert_eq!(latest_published(&body)?.to_string(), "0.13.7");
Ok(())
}
#[test]
fn a_higher_core_prerelease_never_becomes_the_answer() -> TestResult {
let body = [line("0.13.7", false), line("0.14.0-rc.1", false)].concat();
assert_eq!(latest_published(&body)?.to_string(), "0.13.7");
Ok(())
}
#[test]
fn a_prerelease_does_not_outrank_its_release() -> TestResult {
let body = [line("1.0.0", false), line("1.0.0-rc.1", false)].concat();
assert_eq!(latest_published(&body)?.to_string(), "1.0.0");
Ok(())
}
#[test]
fn an_index_with_nothing_installable_refuses_with_the_counts() {
let body = [line("1.0.0", true), line("2.0.0-beta.1", false)].concat();
assert!(matches!(
latest_published(&body),
Err(IndexError::NoInstallableVersion {
yanked: 1,
prereleases: 1
})
));
}
#[test]
fn an_empty_body_refuses() {
assert!(matches!(latest_published(""), Err(IndexError::Empty)));
assert!(matches!(latest_published("\n\n"), Err(IndexError::Empty)));
}
#[test]
fn a_malformed_line_fails_the_whole_read_by_line_number() {
let body = format!("{}an HTML error page, say\n", line("1.0.0", false));
assert!(matches!(
latest_published(&body),
Err(IndexError::Syntax { line_number: 2, .. })
));
}
#[test]
fn the_sequence_form_is_refused_by_shape() {
let body = "[\"1.0.0\", false]\n";
match latest_published(body) {
Err(IndexError::NotAnObject {
line_number: 1,
found,
}) => assert_eq!(found, "an array"),
other => assert!(
matches!(other, Err(IndexError::NotAnObject { .. })),
"the sequence form must refuse as NotAnObject, got {other:?}"
),
}
}
#[test]
fn a_body_for_a_different_crate_refuses() {
let body = "{\"name\":\"serde\",\"vers\":\"1.0.999\",\"yanked\":false}\n";
assert!(matches!(
latest_published(body),
Err(IndexError::WrongCrate { line_number: 1 })
));
}
#[test]
fn missing_and_mistyped_fields_refuse_by_name() {
let missing_yanked = format!("{{\"name\":\"{INDEX_CRATE_NAME}\",\"vers\":\"1.0.0\"}}\n");
assert!(matches!(
latest_published(&missing_yanked),
Err(IndexError::MissingField {
line_number: 1,
field: "yanked"
})
));
let numeric_vers =
format!("{{\"name\":\"{INDEX_CRATE_NAME}\",\"vers\":5,\"yanked\":false}}\n");
assert!(matches!(
latest_published(&numeric_vers),
Err(IndexError::WrongFieldType {
line_number: 1,
field: "vers",
..
})
));
}
#[test]
fn a_malformed_version_refuses_without_echoing_the_remote_value() -> TestResult {
let hostile = "9".repeat(4096);
let body = line(&format!("not-a-version-{hostile}"), false);
match latest_published(&body) {
Err(error @ IndexError::MalformedVersion { line_number: 1, .. }) => {
let rendered = error.to_string();
assert!(
!rendered.contains(&hostile),
"the refusal must not echo remote input: {} bytes rendered",
rendered.len()
);
assert!(
rendered.len() < 512,
"the refusal must stay bounded; got {} bytes",
rendered.len()
);
Ok(())
}
other => Err(format!("expected a MalformedVersion refusal, got {other:?}").into()),
}
}
}