use std::{fmt, hash::Hash, path::PathBuf, time::SystemTime};
pub use semver;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum GitTarget {
#[serde(untagged)]
Revision {
#[serde(rename = "revision")]
hash: String,
},
#[serde(untagged)]
Tag {
#[serde(rename = "tag")]
name: String,
},
#[serde(untagged)]
Branch {
#[serde(rename = "branch")]
name: String,
latest_revision: Option<String>,
},
}
impl Default for GitTarget {
fn default() -> Self {
GitTarget::Branch {
name: String::from("main"),
latest_revision: None,
}
}
}
impl fmt::Display for GitTarget {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self {
GitTarget::Branch { name, .. } => write!(f, "branch = \"{name}\""),
GitTarget::Revision { hash } => write!(f, "rev = \"{hash}\""),
GitTarget::Tag { name: tag } => write!(f, "tag = \"{tag}\""),
}
}
}
impl GitTarget {
pub fn to_cargo_flag(&self) -> [String; 2] {
match &self {
GitTarget::Branch { name, .. } => [String::from("--branch"), String::from(name)],
GitTarget::Revision { hash } => [String::from("--rev"), String::from(hash)],
GitTarget::Tag { name: tag } => [String::from("--tag"), String::from(tag)],
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Hash, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Authority {
Path {
path: PathBuf,
#[serde(skip_serializing_if = "Option::is_none")]
last_modification: Option<SystemTime>,
},
Git {
repository_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
subpath: Option<String>,
#[serde(default)]
#[serde(flatten)]
target: GitTarget,
},
Registry {
version: semver::Version,
},
}
#[derive(Clone, Copy, Debug)]
pub(crate) enum SourcePin<'a> {
Path(Option<SystemTime>),
Git(Option<&'a str>),
Immutable,
}
impl SourcePin<'_> {
pub(crate) fn changed_to(self, next: SourcePin<'_>) -> bool {
match (self, next) {
(SourcePin::Path(old), SourcePin::Path(new)) => {
old.is_none() || new.is_none() || old != new
},
(SourcePin::Git(old), SourcePin::Git(new)) => {
old.is_none() || new.is_none() || old != new
},
_ => false,
}
}
}
impl Authority {
pub(crate) fn source_pin(&self) -> SourcePin<'_> {
match self {
Self::Path { last_modification, .. } => SourcePin::Path(*last_modification),
Self::Git {
target: GitTarget::Branch { latest_revision, .. },
..
} => SourcePin::Git(latest_revision.as_deref()),
Self::Git { .. } | Self::Registry { .. } => SourcePin::Immutable,
}
}
}
impl core::str::FromStr for Authority {
type Err = serde_json::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
serde_json::from_str(s)
}
}
impl fmt::Display for Authority {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self {
Authority::Registry { version, .. } => write!(f, "{version}"),
Authority::Git { repository_url, target, .. } => {
write!(f, "{repository_url}:{target}")
},
Authority::Path { path, .. } => write!(f, "{}", path.display()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plan::ResolvedAuthority;
fn path_pin(mtime: Option<SystemTime>) -> Authority {
Authority::Path {
path: "/source".into(),
last_modification: mtime,
}
}
fn branch_pin(revision: Option<&str>) -> Authority {
Authority::Git {
repository_url: "https://example.invalid/source".into(),
subpath: None,
target: GitTarget::Branch {
name: "main".into(),
latest_revision: revision.map(str::to_owned),
},
}
}
#[test]
fn path_source_pin_comparison_agrees_before_and_after_planning() {
let first = SystemTime::UNIX_EPOCH;
let second = first + std::time::Duration::from_secs(1);
for (old, new, changed) in [
(None, None, true),
(None, Some(first), true),
(Some(first), None, true),
(Some(first), Some(first), false),
(Some(first), Some(second), true),
] {
let installed = path_pin(old);
let upstream = path_pin(new);
let planned = ResolvedAuthority::Path { canonical: "/source".into(), mtime: new };
assert_eq!(installed.source_pin().changed_to(upstream.source_pin()), changed);
assert_eq!(installed.source_pin().changed_to(planned.source_pin()), changed);
}
}
#[test]
fn branch_source_pin_comparison_agrees_before_and_after_planning() {
for (old, new, changed) in [
(None, None, true),
(None, Some("first"), true),
(Some("first"), None, true),
(Some("first"), Some("first"), false),
(Some("first"), Some("second"), true),
] {
let installed = branch_pin(old);
let upstream = branch_pin(new);
assert_eq!(installed.source_pin().changed_to(upstream.source_pin()), changed);
if let Some(revision) = new {
let planned = ResolvedAuthority::Git {
url: "https://example.invalid/source".into(),
revision: revision.into(),
subpath: None,
};
assert_eq!(installed.source_pin().changed_to(planned.source_pin()), changed);
}
}
}
#[test]
fn immutable_sources_and_changes_of_source_kind_use_structural_comparison() {
let registry = Authority::Registry { version: semver::Version::new(1, 0, 0) };
let git = branch_pin(Some("first"));
let path = path_pin(Some(SystemTime::UNIX_EPOCH));
for (old, new) in [(®istry, ®istry), (&git, &path), (&path, &git)] {
assert!(!old.source_pin().changed_to(new.source_pin()));
}
for target in [
GitTarget::Revision { hash: "first".into() },
GitTarget::Tag { name: "v1".into() },
] {
let fixed = Authority::Git {
repository_url: "https://example.invalid/source".into(),
subpath: None,
target,
};
assert!(!fixed.source_pin().changed_to(git.source_pin()));
}
}
}