use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{self, Result};
use crate::releases::TargetName;
pub const FILE: &str = "release-targets.toml";
pub const SCHEMA_VERSION: u32 = 1;
const MAX_PROSE: usize = 400;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Declaration {
pub schema_version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub probe: Option<RepositoryPath>,
#[serde(rename = "target", default, skip_serializing_if = "Vec::is_empty")]
pub targets: Vec<DeclaredTarget>,
#[serde(rename = "retired", default, skip_serializing_if = "Vec::is_empty")]
pub retired: Vec<RetiredArtifact>,
}
impl Declaration {
pub fn target(&self, name: &TargetName) -> Option<&DeclaredTarget> {
self.targets.iter().find(|target| target.name == *name)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeclaredTarget {
pub id: RegistryId,
pub name: TargetName,
pub what: Prose,
pub published_by: Prose,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manifest: Option<RepositoryPath>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub covers: Vec<RegistryId>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetiredArtifact {
pub id: RegistryId,
pub why: Prose,
}
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct RegistryId {
registry: String,
name: String,
}
const MAX_IDENTIFIER: usize = 128;
impl RegistryId {
pub fn registry(&self) -> &str {
&self.registry
}
pub fn name(&self) -> &str {
&self.name
}
}
impl TryFrom<String> for RegistryId {
type Error = String;
fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
if value.len() > MAX_IDENTIFIER {
return Err(format!(
"the release-target identifier {value:?} is longer than {MAX_IDENTIFIER} characters"
));
}
let Some((registry, name)) = value.split_once(':') else {
return Err(format!(
"the release-target identifier {value:?} names no registry; spell every \
identifier as <registry>:<name>, e.g. crate:onevcs, because one name published \
to two registries is two artifacts"
));
};
if registry.is_empty() {
return Err(format!(
"the release-target identifier {value:?} has nothing before its colon; spell \
every identifier as <registry>:<name>, e.g. pypi:onevcs-cli"
));
}
if !registry
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(format!(
"the release-target identifier {value:?} names the registry {registry:?}, which \
is not one word of lowercase letters, digits, and '-'"
));
}
if name.is_empty()
|| !name
.chars()
.next()
.is_some_and(|first| first.is_ascii_alphanumeric())
|| !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '@' | '/'))
{
return Err(format!(
"the release-target identifier {value:?} names {name:?}, which is not a name a \
registry serves; spell the name exactly as its registry does"
));
}
Ok(RegistryId {
registry: registry.to_owned(),
name: name.to_owned(),
})
}
}
impl std::str::FromStr for RegistryId {
type Err = String;
fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
RegistryId::try_from(value.to_owned())
}
}
impl From<RegistryId> for String {
fn from(id: RegistryId) -> Self {
id.to_string()
}
}
impl std::fmt::Debug for RegistryId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Debug::fmt(&self.to_string(), f)
}
}
impl std::fmt::Display for RegistryId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}", self.registry, self.name)
}
}
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct Prose(String);
impl TryFrom<String> for Prose {
type Error = String;
fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
if value.trim().is_empty() {
return Err(
"a release declaration's `what`, `published_by`, and `why` are each what a \
reader learns from the entry they describe, so none of them may be blank"
.to_owned(),
);
}
if value.len() > MAX_PROSE {
return Err(format!(
"a release declaration's prose is longer than {MAX_PROSE} characters; it is \
rendered on one line beside the entry it describes, and the reasoning behind \
it belongs in a comment"
));
}
if value.chars().any(char::is_control) {
return Err(format!(
"the release declaration prose {value:?} carries a control character; it is \
rendered on one line, so it must be one"
));
}
Ok(Prose(value))
}
}
impl std::str::FromStr for Prose {
type Err = String;
fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
Prose::try_from(value.to_owned())
}
}
impl From<Prose> for String {
fn from(prose: Prose) -> Self {
prose.0
}
}
impl std::fmt::Debug for Prose {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Debug::fmt(&self.0, f)
}
}
impl std::fmt::Display for Prose {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::ops::Deref for Prose {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "PathBuf", into = "PathBuf")]
pub struct RepositoryPath(PathBuf);
impl RepositoryPath {
pub fn as_path(&self) -> &Path {
&self.0
}
}
impl TryFrom<PathBuf> for RepositoryPath {
type Error = String;
fn try_from(value: PathBuf) -> std::result::Result<Self, Self::Error> {
let spelling = value.to_string_lossy();
if spelling.is_empty() {
return Err("a release declaration names an empty path".to_owned());
}
if spelling.starts_with(SEPARATORS) {
return Err(format!(
"the release declaration path {path} is absolute; it is a path relative to the \
repository root, because it names something the repository being released \
carries",
path = value.display()
));
}
if drive_qualified(&spelling) {
return Err(format!(
"the release declaration path {path} names a drive on the reader's own machine; \
it is a path relative to the repository root, because it names something the \
repository being released carries",
path = value.display()
));
}
if spelling
.split(SEPARATORS)
.any(|component| component == "..")
{
return Err(format!(
"the release declaration path {path} leaves the repository root; it names \
something the repository being released carries",
path = value.display()
));
}
Ok(RepositoryPath(value))
}
}
const SEPARATORS: [char; 2] = ['/', '\\'];
fn drive_qualified(spelling: &str) -> bool {
let mut characters = spelling.chars();
matches!(
(characters.next(), characters.next()),
(Some(drive), Some(':')) if drive.is_ascii_alphabetic()
)
}
impl std::str::FromStr for RepositoryPath {
type Err = String;
fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
RepositoryPath::try_from(PathBuf::from(value))
}
}
impl From<RepositoryPath> for PathBuf {
fn from(path: RepositoryPath) -> Self {
path.0
}
}
impl std::fmt::Debug for RepositoryPath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Debug::fmt(&self.0, f)
}
}
impl std::fmt::Display for RepositoryPath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.display().fmt(f)
}
}
pub(crate) fn read(path: &Path) -> Result<Declaration> {
let document = locate(path)?;
let raw = std::fs::read_to_string(&document).map_err(|failure| {
error::invalid(format!(
"cannot read the release declaration at {}: {failure}",
document.display()
))
})?;
parse(&raw, &document.display().to_string())
}
fn locate(path: &Path) -> Result<PathBuf> {
let document = match path.is_dir() {
true => path.join(FILE),
false => path.to_path_buf(),
};
if !document.is_file() {
return Err(error::invalid(format!(
"{} declares no release targets: there is no {FILE} there, so nothing says what this \
repository publishes",
document.display()
)));
}
Ok(document)
}
pub(crate) fn parse(raw: &str, origin: &str) -> Result<Declaration> {
let document: toml::Value = toml::from_str(raw).map_err(|failure| {
error::invalid(format!(
"the release declaration at {origin} is not TOML: {failure}"
))
})?;
let Some(declared) = document
.get("schema_version")
.and_then(toml::Value::as_integer)
else {
return Err(error::invalid(format!(
"the release declaration at {origin} declares no schema_version; every declaration \
opens with `schema_version = {SCHEMA_VERSION}`, before any table"
)));
};
if declared < i64::from(SCHEMA_VERSION) {
return Err(error::invalid(format!(
"the release declaration at {origin} declares schema_version {declared}; this build \
reads schema_version {SCHEMA_VERSION} and newer"
)));
}
if declared == i64::from(SCHEMA_VERSION) {
refuse_unknown_keys(&document, origin)?;
}
let declaration: Declaration = toml::from_str(raw).map_err(|failure| {
error::invalid(format!(
"the release declaration at {origin} is not the shape schema_version \
{SCHEMA_VERSION} declares: {failure}"
))
})?;
validate(&declaration, origin)?;
Ok(declaration)
}
const TOP_LEVEL_KEYS: [&str; 4] = ["schema_version", "probe", "target", "retired"];
const TARGET_KEYS: [&str; 6] = ["id", "name", "what", "published_by", "manifest", "covers"];
const RETIRED_KEYS: [&str; 2] = ["id", "why"];
fn refuse_unknown_keys(document: &toml::Value, origin: &str) -> Result<()> {
let unknown = |table: &str, key: &str| {
error::invalid(format!(
"the release declaration at {origin} names {key:?} in {table}, which schema_version \
{SCHEMA_VERSION} does not declare; a misspelled key would otherwise be read as an \
absent one"
))
};
let Some(top) = document.as_table() else {
return Err(error::invalid(format!(
"the release declaration at {origin} is not a table of keys; every declaration opens \
with `schema_version = {SCHEMA_VERSION}`, before any table"
)));
};
for key in top.keys() {
if !TOP_LEVEL_KEYS.contains(&key.as_str()) {
return Err(unknown("the document", key));
}
}
for (array, keys) in [("target", &TARGET_KEYS[..]), ("retired", &RETIRED_KEYS[..])] {
let Some(entries) = top.get(array).and_then(toml::Value::as_array) else {
continue;
};
for (index, entry) in entries.iter().enumerate() {
let Some(table) = entry.as_table() else {
continue;
};
for key in table.keys() {
if !keys.contains(&key.as_str()) {
return Err(unknown(&format!("[[{array}]] {}", index + 1), key));
}
}
}
}
Ok(())
}
fn validate(declaration: &Declaration, origin: &str) -> Result<()> {
if declaration.targets.is_empty() {
return Err(error::invalid(format!(
"the release declaration at {origin} declares no [[target]]; a declaration that names \
nothing says less than no declaration at all, because a consumer reading it cannot \
tell whether this repository publishes nothing or nobody has said what it publishes"
)));
}
for (index, target) in declaration.targets.iter().enumerate() {
let at = format!("[[target]] {} ({:?})", index + 1, target.id);
if let Some(earlier) = declaration.targets[..index]
.iter()
.position(|other| other.name == target.name)
{
return Err(error::invalid(format!(
"the release declaration at {origin} has {at} taking the short name {name:?}, \
which [[target]] {} already takes; the short name is what a host document and a \
consumer's plan name this target by, so two of them are two answers to one \
question",
earlier + 1,
name = target.name,
)));
}
if let Some(earlier) = declaration.targets[..index]
.iter()
.position(|other| other.id == target.id)
{
return Err(error::invalid(format!(
"the release declaration at {origin} has {at} declaring the identifier \
[[target]] {} already declares; one artifact is one target",
earlier + 1,
)));
}
}
covered(declaration, origin)?;
retired(declaration, origin)
}
fn covered(declaration: &Declaration, origin: &str) -> Result<()> {
let mut seen: Vec<(&RegistryId, usize)> = Vec::new();
for (index, target) in declaration.targets.iter().enumerate() {
let at = format!("[[target]] {} ({:?})", index + 1, target.id);
for id in &target.covers {
if *id == target.id {
return Err(error::invalid(format!(
"the release declaration at {origin} has {at} covering its own identifier; \
`covers` names what a target's release also ships and that is not a target \
of its own"
)));
}
if let Some(other) = declaration
.targets
.iter()
.position(|target| target.id == *id)
{
return Err(error::invalid(format!(
"the release declaration at {origin} has {at} covering {id:?}, which \
[[target]] {} declares as a target of its own; an artifact is one or the \
other, because a consumer waits on a target by name and never waits on \
something covered",
other + 1
)));
}
if let Some((_, earlier)) = seen.iter().find(|(covered, _)| *covered == id) {
return Err(error::invalid(format!(
"the release declaration at {origin} has {at} covering {id:?}, which \
[[target]] {} already covers; one artifact is shipped by one release",
earlier + 1
)));
}
seen.push((id, index));
}
}
Ok(())
}
fn retired(declaration: &Declaration, origin: &str) -> Result<()> {
for (index, entry) in declaration.retired.iter().enumerate() {
let at = format!("[[retired]] {} ({:?})", index + 1, entry.id);
if let Some(target) = declaration
.targets
.iter()
.position(|target| target.id == entry.id)
{
return Err(error::invalid(format!(
"the release declaration at {origin} has {at} retiring what [[target]] {} \
publishes; a retired artifact is one this repository does not publish any more",
target + 1
)));
}
if let Some(earlier) = declaration.retired[..index]
.iter()
.position(|other| other.id == entry.id)
{
return Err(error::invalid(format!(
"the release declaration at {origin} has {at} repeating what [[retired]] {} \
already records",
earlier + 1
)));
}
}
Ok(())
}
pub(crate) fn render(declaration: &Declaration) -> Result<String> {
validate(declaration, "the declaration handed to be rendered")?;
toml::to_string_pretty(declaration).map_err(|failure| {
error::invalid(format!(
"the declaration handed to be rendered cannot be written as TOML: {failure}"
))
})
}