use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use crate::declaration::Declaration;
use crate::rules::RuleMatch;
pub const VERSION: u32 = 1;
pub const DEFAULT_PROBE_TIMEOUT_SECONDS: u64 = 60;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReleasesFile {
pub version: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub repositories: Vec<ReleaseRule>,
pub default: ReleaseDefault,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReleaseDefault {
pub adoption: Adoption,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReleaseRule {
#[serde(rename = "match")]
pub r#match: RuleMatch,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub adoption: Option<Adoption>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_target: Option<TargetName>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub declaration: Option<DeclarationPolicy>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub targets: Vec<ReleaseTarget>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DeclarationPolicy {
#[default]
Merge,
Ignore,
}
impl DeclarationPolicy {
pub fn as_str(&self) -> &'static str {
match self {
DeclarationPolicy::Merge => "merge",
DeclarationPolicy::Ignore => "ignore",
}
}
}
impl std::fmt::Display for DeclarationPolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Adoption {
Fast,
Published,
}
impl Adoption {
pub fn as_str(&self) -> &'static str {
match self {
Adoption::Fast => "fast",
Adoption::Published => "published",
}
}
}
impl std::fmt::Display for Adoption {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReleaseTarget {
pub name: TargetName,
pub release: ReleaseMethod,
}
impl ReleaseTarget {
pub fn style(&self) -> ReleaseStyle {
match self.release {
ReleaseMethod::Automated { .. } => ReleaseStyle::Automated,
ReleaseMethod::HumanStep { .. } => ReleaseStyle::HumanStep,
}
}
pub fn probe(&self) -> Option<&Probe> {
match &self.release {
ReleaseMethod::Automated { probe } => Some(probe),
ReleaseMethod::HumanStep { .. } => None,
}
}
pub fn action(&self) -> Option<&str> {
match &self.release {
ReleaseMethod::HumanStep { action } => Some(action),
ReleaseMethod::Automated { .. } => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReleaseMethod {
Automated {
probe: Probe,
},
HumanStep {
action: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ReleaseStyle {
Automated,
HumanStep,
}
impl ReleaseStyle {
pub fn as_str(&self) -> &'static str {
match self {
ReleaseStyle::Automated => "automated",
ReleaseStyle::HumanStep => "human-step",
}
}
}
impl std::fmt::Display for ReleaseStyle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Probe {
Script {
script: PathBuf,
args: Vec<String>,
timeout_seconds: u64,
},
Shell {
shell: String,
timeout_seconds: u64,
},
}
impl Probe {
pub fn form(&self) -> &'static str {
match self {
Probe::Script { .. } => "script",
Probe::Shell { .. } => "shell",
}
}
}
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct TargetName(String);
const MAX_TARGET_NAME: usize = 64;
const MAX_ACTION: usize = 200;
fn renders_on_one_line(value: &str) -> bool {
!value.chars().any(char::is_control)
}
impl TryFrom<String> for TargetName {
type Error = String;
fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
if value.is_empty() {
return Err("a release target's name cannot be empty".to_owned());
}
if value.len() > MAX_TARGET_NAME {
return Err(format!(
"the release target name {value:?} is longer than {MAX_TARGET_NAME} characters"
));
}
if !value
.chars()
.next()
.is_some_and(|first| first.is_ascii_alphanumeric())
{
return Err(format!(
"the release target name {value:?} must start with a letter or a digit"
));
}
if !value
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
{
return Err(format!(
"the release target name {value:?} may hold only letters, digits, '-', '_', \
and '.'"
));
}
Ok(TargetName(value))
}
}
impl std::str::FromStr for TargetName {
type Err = String;
fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
TargetName::try_from(value.to_owned())
}
}
impl From<TargetName> for String {
fn from(name: TargetName) -> Self {
name.0
}
}
impl std::fmt::Debug for TargetName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Debug::fmt(&self.0, f)
}
}
impl std::fmt::Display for TargetName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::ops::Deref for TargetName {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "kebab-case")]
pub enum ReleaseAnswer {
Released {
version: String,
},
NoRelease,
NotAnswered {
reason: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "kebab-case")]
pub enum Baseline {
At {
version: String,
},
NoRelease,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BaselineRecord {
Established(Baseline),
Unestablished {
reason: String,
attempted_at: String,
},
}
#[derive(Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "kebab-case")]
enum StoredBaseline {
At {
version: String,
},
NoRelease,
Unestablished {
reason: String,
attempted_at: String,
},
}
impl Serialize for BaselineRecord {
fn serialize<S: serde::Serializer>(
&self,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
let stored = match self {
BaselineRecord::Established(Baseline::At { version }) => StoredBaseline::At {
version: version.clone(),
},
BaselineRecord::Established(Baseline::NoRelease) => StoredBaseline::NoRelease,
BaselineRecord::Unestablished {
reason,
attempted_at,
} => StoredBaseline::Unestablished {
reason: reason.clone(),
attempted_at: attempted_at.clone(),
},
};
stored.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for BaselineRecord {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
Ok(match StoredBaseline::deserialize(deserializer)? {
StoredBaseline::At { version } => BaselineRecord::Established(Baseline::At { version }),
StoredBaseline::NoRelease => BaselineRecord::Established(Baseline::NoRelease),
StoredBaseline::Unestablished {
reason,
attempted_at,
} => BaselineRecord::Unestablished {
reason,
attempted_at,
},
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "kebab-case")]
pub enum ReleaseStatus {
Released {
target: TargetName,
style: ReleaseStyle,
version: String,
},
NotReleased {
at_landing: Baseline,
now: String,
},
AwaitingHumanStep {
target: TargetName,
action: String,
since: String,
},
NotAnswered {
reason: String,
},
NotLanded,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Acknowledgement {
pub identity: String,
pub target: TargetName,
pub landing_commit: String,
pub version: String,
pub recorded_at: String,
pub actor: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub superseded: Vec<SupersededRelease>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SupersededRelease {
pub version: String,
pub recorded_at: String,
pub actor: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "kebab-case")]
pub enum DeclarationSource {
Declared {
document: PathBuf,
declared: Declaration,
},
Undeclared {
looked_in: PathBuf,
},
Unreadable {
reason: String,
},
}
impl DeclarationSource {
pub fn as_str(&self) -> &'static str {
match self {
DeclarationSource::Declared { .. } => "declared",
DeclarationSource::Undeclared { .. } => "undeclared",
DeclarationSource::Unreadable { .. } => "unreadable",
}
}
pub fn declared(&self) -> Option<&Declaration> {
match self {
DeclarationSource::Declared { declared, .. } => Some(declared),
_ => None,
}
}
pub fn unreadable(&self) -> Option<&str> {
match self {
DeclarationSource::Unreadable { reason } => Some(reason),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TargetSource {
Declared,
Host,
Override,
}
impl TargetSource {
pub fn as_str(&self) -> &'static str {
match self {
TargetSource::Declared => "declared",
TargetSource::Host => "host",
TargetSource::Override => "override",
}
}
}
impl std::fmt::Display for TargetSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositoryReleases {
pub identity: String,
pub adoption: Adoption,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_target: Option<TargetName>,
pub targets: Vec<ReleaseTarget>,
pub declaration: DeclarationSource,
pub sources: BTreeMap<TargetName, TargetSource>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Discovery {
pub releases: RepositoryReleases,
pub released: Vec<TargetRelease>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TargetRelease {
pub target: TargetName,
pub style: ReleaseStyle,
pub answer: ReleaseAnswer,
}
impl RepositoryReleases {
pub fn select(&self, named: Option<&TargetName>) -> crate::Result<&ReleaseTarget> {
if self.targets.is_empty() {
return Err(crate::error::invalid(format!(
"the repository {} declares no release targets, so there is nothing to ask \
about; declare some in its own {file}, or under `repositories:` in the \
release-targets file{unknown}",
self.identity,
file = crate::declaration::FILE,
unknown = self.unknown(),
)));
}
let wanted = match named.or(self.default_target.as_ref()) {
Some(wanted) => wanted,
None => {
return Err(crate::error::invalid(format!(
"the repository {} declares no default_target, so a target has to be named: \
--target {}",
self.identity,
self.declared()
)))
}
};
self.targets
.iter()
.find(|target| target.name == *wanted)
.ok_or_else(|| {
crate::error::invalid(format!(
"the repository {} declares no release target {wanted:?}; it declares \
{declared}. `onevcs release targets {identity}` lists them{unknown}",
self.identity,
declared = self.declared(),
identity = self.identity,
unknown = self.unknown(),
))
})
}
fn declared(&self) -> String {
self.targets
.iter()
.map(|target| target.name.to_string())
.collect::<Vec<String>>()
.join(", ")
}
fn unknown(&self) -> String {
self.declaration
.unreadable()
.map_or_else(String::new, |reason| {
format!(
". This repository's own declaration could not be read, so it may declare \
targets this does not name: {reason}"
)
})
}
}
#[derive(Deserialize)]
struct StoredTarget {
name: TargetName,
style: ReleaseStyle,
#[serde(default)]
probe: Option<StoredProbe>,
#[serde(default)]
action: Option<String>,
}
#[derive(Deserialize)]
struct StoredProbe {
#[serde(default)]
script: Option<PathBuf>,
#[serde(default)]
args: Option<Vec<String>>,
#[serde(default)]
shell: Option<String>,
#[serde(default)]
timeout_seconds: Option<u64>,
}
#[derive(Serialize)]
struct WrittenTarget<'a> {
name: &'a TargetName,
style: ReleaseStyle,
#[serde(skip_serializing_if = "Option::is_none")]
probe: Option<WrittenProbe<'a>>,
#[serde(skip_serializing_if = "Option::is_none")]
action: Option<&'a str>,
}
#[derive(Serialize)]
struct WrittenProbe<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
script: Option<&'a Path>,
#[serde(skip_serializing_if = "Option::is_none")]
args: Option<&'a [String]>,
#[serde(skip_serializing_if = "Option::is_none")]
shell: Option<&'a str>,
timeout_seconds: u64,
}
impl Serialize for ReleaseTarget {
fn serialize<S: serde::Serializer>(
&self,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
let written = WrittenTarget {
name: &self.name,
style: self.style(),
probe: self.probe().map(|probe| match probe {
Probe::Script {
script,
args,
timeout_seconds,
} => WrittenProbe {
script: Some(script),
args: Some(args),
shell: None,
timeout_seconds: *timeout_seconds,
},
Probe::Shell {
shell,
timeout_seconds,
} => WrittenProbe {
script: None,
args: None,
shell: Some(shell),
timeout_seconds: *timeout_seconds,
},
}),
action: self.action(),
};
written.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for ReleaseTarget {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
let stored = StoredTarget::deserialize(deserializer)?;
ReleaseTarget::try_from(stored).map_err(serde::de::Error::custom)
}
}
impl TryFrom<StoredTarget> for ReleaseTarget {
type Error = String;
fn try_from(stored: StoredTarget) -> std::result::Result<Self, Self::Error> {
let name = stored.name;
let release = match stored.style {
ReleaseStyle::Automated => {
if stored.action.is_some() {
return Err(format!(
"the release target {name:?} declares style: automated and names an \
action, which only a human-step target has — a machine releases an \
automated target and its probe says what is out. Remove the action, or \
declare style: human-step"
));
}
let probe = stored.probe.ok_or_else(|| {
format!(
"the release target {name:?} declares style: automated and names no \
probe; an automated target is answered by running one. Give it a \
`probe:` with either `script:` or `shell:`"
)
})?;
ReleaseMethod::Automated {
probe: probe.into_probe(&name)?,
}
}
ReleaseStyle::HumanStep => {
if stored.probe.is_some() {
return Err(format!(
"the release target {name:?} declares style: human-step and names a \
probe, which only an automated target has — a human-step target has \
nothing to ask, because the release happens when a person does \
something. Remove the probe, or declare style: automated"
));
}
let action = stored.action.ok_or_else(|| {
format!(
"the release target {name:?} declares style: human-step and names no \
action; the action is what a person has to do, and it is what the wait \
renders. Give it an `action:`"
)
})?;
if action.trim().is_empty() {
return Err(format!(
"the release target {name:?} has a blank action; it is what a person \
reads to know what to do, so a blank one is a wait nobody can act on"
));
}
if !renders_on_one_line(&action) || action.chars().count() > MAX_ACTION {
return Err(format!(
"the release target {name:?} has an action that is not one printable \
line of at most {MAX_ACTION} characters; it is rendered beside the wait \
it explains, so it says what to do rather than how"
));
}
ReleaseMethod::HumanStep { action }
}
};
Ok(ReleaseTarget { name, release })
}
}
impl StoredProbe {
fn into_probe(self, name: &TargetName) -> std::result::Result<Probe, String> {
let timeout_seconds = match self.timeout_seconds {
None => DEFAULT_PROBE_TIMEOUT_SECONDS,
Some(seconds)
if seconds > 0
&& Instant::now()
.checked_add(Duration::from_secs(seconds))
.is_some() =>
{
seconds
}
Some(seconds) => {
return Err(format!(
"the release target {name:?} names timeout_seconds: {seconds}; a probe's \
bound must be above zero and short enough to be waited out from now"
))
}
};
match (self.script, self.shell) {
(Some(_), Some(_)) => Err(format!(
"the release target {name:?} names both a script and a shell probe; a probe is \
one or the other. Keep the form the repository actually carries"
)),
(None, None) => Err(format!(
"the release target {name:?} names neither a script nor a shell probe; an \
automated target is answered by running one of the two"
)),
(Some(script), None) => {
relative_to_the_repository(&script, name)?;
let args = self.args.unwrap_or_default();
if let Some(argument) = args.iter().find(|it| !renders_on_one_line(it)) {
return Err(format!(
"the release target {name:?} names a probe argument carrying a control \
character ({argument:?}); an argument is printed wherever the probe is \
named, so it has to be the one line it renders as"
));
}
Ok(Probe::Script {
script,
args,
timeout_seconds,
})
}
(None, Some(shell)) => {
if self.args.is_some() {
return Err(format!(
"the release target {name:?} names args beside a shell probe; a shell \
probe is one line that `sh` reads, so its arguments are written into \
it. Move them into the `shell:` line, or name a `script:` instead"
));
}
if shell.trim().is_empty() {
return Err(format!(
"the release target {name:?} names a blank shell probe; there is nothing \
there to run"
));
}
if !renders_on_one_line(&shell) {
return Err(format!(
"the release target {name:?} names a shell probe carrying a control \
character; this form is the one line `sh` reads, and it is printed \
wherever the probe is named. Put a script the repository carries behind \
a `script:` instead"
));
}
Ok(Probe::Shell {
shell,
timeout_seconds,
})
}
}
}
}
fn relative_to_the_repository(script: &Path, name: &TargetName) -> std::result::Result<(), String> {
if script.as_os_str().is_empty() {
return Err(format!(
"the release target {name:?} names an empty script path"
));
}
if script.is_absolute() {
return Err(format!(
"the release target {name:?} names the absolute script path {script}; a script probe \
is a path relative to the repository root, because it runs what the repository being \
released carries",
script = script.display()
));
}
if script
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return Err(format!(
"the release target {name:?} names the script path {script}, which leaves the \
repository root; a script probe runs what the repository being released carries",
script = script.display()
));
}
Ok(())
}