use std::collections::{btree_map, BTreeMap};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use crate::error::{self, Result};
use crate::event::EventKind;
use crate::landed::Landed;
use crate::registry::Registry;
use crate::releases::{
Acknowledgement, Adoption, Baseline, BaselineRecord, Probe, ReleaseAnswer, ReleaseDefault,
ReleaseRule, ReleaseStatus, ReleaseTarget, ReleasesFile, RepositoryReleases, SupersededRelease,
TargetName, VERSION,
};
use crate::remainder::Remainder;
use crate::stream::Stream;
use crate::{git, home, ids, lock, policy, probe, status, store};
pub const ACTOR_ENV: &str = "ONEVCS_ACTOR";
pub const UNKNOWN_ACTOR: &str = "unknown";
pub const RECORD_VERSION: u32 = 1;
pub fn default_path() -> Result<PathBuf> {
Ok(home::root()?.join("releases.yml"))
}
fn nothing() -> ReleasesFile {
ReleasesFile {
version: VERSION,
repositories: Vec::new(),
default: ReleaseDefault {
adoption: Adoption::Fast,
},
}
}
pub fn load() -> Result<ReleasesFile> {
let path = default_path()?;
if !path.is_file() {
return Ok(nothing());
}
let raw = std::fs::read_to_string(&path).map_err(|failure| {
error::invalid(format!(
"cannot read the release-targets file at {}: {failure}",
path.display()
))
})?;
let malformed = |failure: serde_yaml_ng::Error| {
error::invalid(format!(
"the release-targets file at {} is malformed: {failure}",
path.display()
))
};
let document: serde_yaml_ng::Value = serde_yaml_ng::from_str(&raw).map_err(malformed)?;
if let Some(declared) = document
.get("version")
.and_then(serde_yaml_ng::Value::as_u64)
{
if declared < u64::from(VERSION) {
return Err(error::invalid(format!(
"the release-targets file at {} declares version {declared}; this build reads \
version {VERSION} and newer",
path.display()
)));
}
}
let file: ReleasesFile = serde_yaml_ng::from_value(document).map_err(malformed)?;
validate(&path, &file)?;
Ok(file)
}
fn validate(path: &Path, file: &ReleasesFile) -> Result<()> {
for (index, rule) in file.repositories.iter().enumerate() {
let named = format!("repository rule {}", index + 1);
let mut seen: Vec<&TargetName> = Vec::new();
for target in &rule.targets {
if seen.contains(&&target.name) {
return Err(error::invalid(format!(
"the release-targets file at {} declares the target {name:?} twice in \
{named}; a target's name is what every record and every command names it \
by, so two of them are two answers to one question",
path.display(),
name = target.name,
)));
}
seen.push(&target.name);
}
if let Some(default) = rule.default_target.as_ref() {
if !rule.targets.iter().any(|target| target.name == *default) {
return Err(error::invalid(format!(
"the release-targets file at {} has {named} naming default_target \
{default:?}, which it does not declare as a target",
path.display(),
)));
}
}
}
Ok(())
}
pub struct Located {
pub releases: RepositoryReleases,
checkout: probe::Checkout,
}
pub fn for_repository(registry: &Registry, repo: &str) -> Result<Located> {
let (key, publication) = locate(registry, repo)?;
let file = load()?;
let normalized = store::normalize(&key);
let matched = publication
.as_deref()
.map(|checkout| first_match(&file, &normalized, checkout))
.unwrap_or_else(|| first_match(&file, &normalized, Path::new("")));
let releases = RepositoryReleases {
identity: key,
adoption: matched
.and_then(|rule| rule.adoption)
.unwrap_or(file.default.adoption),
default_target: matched.and_then(|rule| rule.default_target.clone()),
targets: matched.map(|rule| rule.targets.clone()).unwrap_or_default(),
};
Ok(Located {
releases,
checkout: probe_checkout(publication),
})
}
fn first_match<'a>(
file: &'a ReleasesFile,
identity: &store::Normalized,
checkout: &Path,
) -> Option<&'a ReleaseRule> {
file.repositories
.iter()
.find(|rule| policy::matches(&rule.r#match, identity, checkout))
}
fn locate(registry: &Registry, repo: &str) -> Result<(String, Option<PathBuf>)> {
match store::resolve(registry, repo) {
Ok(resolution) => Ok((resolution.key, Some(resolution.publication))),
Err(refusal) => {
let key = if registry.identities.contains_key(repo) {
repo.to_owned()
} else {
store::normalize(repo).key
};
match registry.identities.contains_key(&key) {
true => Ok((key, None)),
false => Err(refusal),
}
}
}
}
fn probe_checkout(publication: Option<PathBuf>) -> probe::Checkout {
let Some(checkout) = publication else {
return probe::Checkout::None(
"this identity has no registered checkout, so there is nowhere to run a script probe \
from; register one with `onevcs register PATH`"
.to_owned(),
);
};
let standing = git::default_branch(&checkout, "origin")
.and_then(|base| git::current_branch(&checkout).map(|on| (base, on)));
match standing {
Ok((base, on)) if on == base => probe::Checkout::At(checkout),
Ok((base, on)) => probe::Checkout::None(format!(
"the publication checkout {} has {on:?} checked out rather than the base {base:?}, \
and a script probe runs at the base; `onevcs sync` puts it back",
checkout.display()
)),
Err(failure) => probe::Checkout::None(format!(
"the publication checkout {} could not be asked which branch it is on ({failure}), \
and a script probe runs at the base",
checkout.display()
)),
}
}
pub fn targets(registry: &Registry, repo: &str) -> Result<RepositoryReleases> {
Ok(for_repository(registry, repo)?.releases)
}
pub fn adoption(registry: &Registry, repo: &str) -> Result<Adoption> {
Ok(for_repository(registry, repo)?.releases.adoption)
}
pub fn latest(
registry: &Registry,
repo: &str,
named: Option<&TargetName>,
) -> Result<ReleaseAnswer> {
let located = for_repository(registry, repo)?;
let target = located.releases.select(named)?;
match target.probe() {
Some(configured) => {
let mut stream = Stream::releases(&located.releases.identity)?;
Ok(ask(&located, target, configured, &mut stream).answer)
}
None => Ok(
newest_acknowledgement(&located.releases.identity, &target.name)?
.map(|version| ReleaseAnswer::Released { version })
.unwrap_or(ReleaseAnswer::NoRelease),
),
}
}
fn ask(
located: &Located,
target: &ReleaseTarget,
configured: &Probe,
stream: &mut Stream,
) -> probe::Probed {
let probed = probe::run(configured, &located.checkout);
let mut payload = json_object(json!({
"identity": located.releases.identity,
"target": target.name.to_string(),
"form": configured.form(),
"outcome": outcome_of(&probed.answer),
"elapsed_ms": probed.elapsed_ms as u64,
}));
if let ReleaseAnswer::Released { version } = &probed.answer {
payload.insert("version".to_owned(), Value::String(version.clone()));
}
stream.emit(EventKind::ReleaseProbed, payload);
probed
}
fn outcome_of(answer: &ReleaseAnswer) -> &'static str {
match answer {
ReleaseAnswer::Released { .. } => "released",
ReleaseAnswer::NoRelease => "no-release",
ReleaseAnswer::NotAnswered { .. } => "not-answered",
}
}
pub fn status(
registry: &Registry,
reference: &str,
named: Option<&TargetName>,
) -> Result<ReleaseStatus> {
let landing = status::landing_of(registry, reference)?;
let located = for_repository(registry, &landing.identity)?;
let target = located.releases.select(named)?;
let commit = match &landing.landed {
Landed::Yes { evidence } => evidence.commit().to_owned(),
Landed::No => return Ok(ReleaseStatus::NotLanded),
Landed::Unknown => {
return Ok(ReleaseStatus::NotAnswered {
reason: format!(
"nothing records that {branch} reached its base — no landing, no change \
request's number in the base's history, and no landing trailer — and the \
base already carries what it changed, so there is no landing commit to \
compare a release against. `onevcs status {branch}` reports what history \
does say",
branch = landing.branch,
),
})
}
};
let mut stream = Stream::releases(&located.releases.identity)?;
match target.probe() {
Some(configured) => automated_status(&located, target, configured, &commit, &mut stream),
None => human_step_status(&located, target, &commit, &landing),
}
}
fn automated_status(
located: &Located,
target: &ReleaseTarget,
configured: &Probe,
commit: &str,
stream: &mut Stream,
) -> Result<ReleaseStatus> {
let identity = &located.releases.identity;
let baseline = read(identity)?.baseline(&target.name, commit);
let answer = ask(located, target, configured, stream).answer;
let established = match baseline {
Some(BaselineRecord::Established(baseline)) => baseline,
other => match &answer {
ReleaseAnswer::NoRelease => {
establish(identity, &target.name, commit, Baseline::NoRelease)?;
Baseline::NoRelease
}
_ => {
return Ok(ReleaseStatus::NotAnswered {
reason: unsound(target, commit, other.as_ref()),
})
}
},
};
let now = match answer {
ReleaseAnswer::NotAnswered { reason } => return Ok(ReleaseStatus::NotAnswered { reason }),
ReleaseAnswer::NoRelease => {
return Ok(ReleaseStatus::NotReleased {
at_landing: established,
now: String::new(),
})
}
ReleaseAnswer::Released { version } => version,
};
match &established {
Baseline::NoRelease => released(located, target, commit, now, stream),
Baseline::At { version } => match carries(version, &now) {
Comparison::Unparseable { which, value } => Ok(ReleaseStatus::NotAnswered {
reason: format!(
"the {which} version {value:?} of the release target {name:?} is not a \
semantic version, so this build cannot say whether one carries the other; a \
string comparison would report a yank or a re-tag as a release",
name = target.name,
),
}),
Comparison::Greater => released(located, target, commit, now, stream),
Comparison::NotGreater => Ok(ReleaseStatus::NotReleased {
at_landing: established.clone(),
now,
}),
},
}
}
fn unsound(target: &ReleaseTarget, commit: &str, record: Option<&BaselineRecord>) -> String {
let then = match record {
Some(BaselineRecord::Unestablished {
reason,
attempted_at,
}) => format!("the probe did not answer at {attempted_at}: {reason}"),
_ => "no probe was run for this target at that landing".to_owned(),
};
format!(
"no baseline was captured for the release target {name:?} at landing {commit}, so a \
comparison would be unsound — the release carrying this very change may already be \
included in whatever the probe answers now. {then}. Fix the probe and land again, or \
adopt fast for this dependency",
name = target.name,
)
}
fn released(
located: &Located,
target: &ReleaseTarget,
commit: &str,
version: String,
stream: &mut Stream,
) -> Result<ReleaseStatus> {
observe(&located.releases.identity, target, commit, &version, stream)?;
Ok(ReleaseStatus::Released {
target: target.name.clone(),
style: target.style(),
version,
})
}
fn human_step_status(
located: &Located,
target: &ReleaseTarget,
commit: &str,
landing: &status::LandingOf,
) -> Result<ReleaseStatus> {
let identity = &located.releases.identity;
if let Some(recorded) = read(identity)?.acknowledgement(&target.name, commit) {
return Ok(ReleaseStatus::Released {
target: target.name.clone(),
style: target.style(),
version: recorded.version,
});
}
let action = target
.action()
.expect("a human-step target names what a person has to do")
.to_owned();
let Some(since) = landing
.carrier
.as_deref()
.map(|repo| git::Asked::borrowing(repo, landing.lent.as_deref()))
.and_then(|repo| git::committer_date(repo, commit))
else {
return Ok(ReleaseStatus::NotAnswered {
reason: format!(
"the landing {commit} is not readable in any repository this host holds, so how \
long {name:?} has waited cannot be said",
name = target.name,
),
});
};
Ok(ReleaseStatus::AwaitingHumanStep {
target: target.name.clone(),
action,
since,
})
}
enum Comparison {
Greater,
NotGreater,
Unparseable {
which: &'static str,
value: String,
},
}
fn carries(at_landing: &str, now: &str) -> Comparison {
let Ok(landing) = semver::Version::parse(at_landing) else {
return Comparison::Unparseable {
which: "landing",
value: at_landing.to_owned(),
};
};
let Ok(current) = semver::Version::parse(now) else {
return Comparison::Unparseable {
which: "current",
value: now.to_owned(),
};
};
match current > landing {
true => Comparison::Greater,
false => Comparison::NotGreater,
}
}
pub fn record_baselines(registry: &Registry, identity: &str, commit: &str, stream: &mut Stream) {
if let Err(failure) = capture(registry, identity, commit, stream) {
eprintln!(
"onevcs: warning: the release baselines for {identity} at landing {commit} were not \
captured: {failure}"
);
}
}
fn capture(registry: &Registry, identity: &str, commit: &str, stream: &mut Stream) -> Result<()> {
let located = for_repository(registry, identity)?;
for target in &located.releases.targets {
let Some(configured) = target.probe() else {
continue;
};
if read(identity)?.baseline(&target.name, commit).is_some() {
continue;
}
let answer = ask(&located, target, configured, stream).answer;
let record = match answer {
ReleaseAnswer::Released { version } => {
BaselineRecord::Established(Baseline::At { version })
}
ReleaseAnswer::NoRelease => BaselineRecord::Established(Baseline::NoRelease),
ReleaseAnswer::NotAnswered { reason } => BaselineRecord::Unestablished {
reason,
attempted_at: ids::timestamp(),
},
};
write_baseline(identity, &target.name, commit, record)?;
}
Ok(())
}
pub fn acknowledge(
registry: &Registry,
reference: &str,
named: &TargetName,
version: &str,
supersede: bool,
) -> Result<Acknowledgement> {
let landing = status::landing_of(registry, reference)?;
let located = for_repository(registry, &landing.identity)?;
let target = located.releases.select(Some(named))?;
if target.probe().is_some() {
return Err(error::invalid(format!(
"the release target {named:?} is automated, so its version comes from its probe and \
not from a hand-written second answer; ask it with `onevcs release latest {identity} \
--target {named}`",
identity = located.releases.identity,
)));
}
let commit = match &landing.landed {
Landed::Yes { evidence } => evidence.commit().to_owned(),
Landed::No => {
return Err(error::invalid(format!(
"{reference:?} has not landed, so there is no release to acknowledge for it yet; \
`onevcs status {reference}` says what it is waiting on"
)))
}
Landed::Unknown => {
return Err(error::invalid(format!(
"nothing records that {reference:?} reached its base, so there is no landing \
commit to record a release against; `onevcs status {reference}` reports what \
history does say"
)))
}
};
if semver::Version::parse(version).is_err() {
return Err(error::invalid(format!(
"{version:?} is not a semantic version, and a release is compared against others by \
semantic-version ordering; record the version the release actually carries, e.g. \
2026.8.23 rather than 2026.08.23"
)));
}
let identity = located.releases.identity.clone();
let actor = actor()?;
let recorded = write_acknowledgement(&Recording {
identity: &identity,
target: named,
commit: &commit,
version,
actor: &actor,
supersede,
reference,
})?;
if let Some(written) = recorded.written {
let mut payload = json_object(json!({
"identity": identity,
"target": named.to_string(),
"version": version,
"landing_commit": commit,
"actor": actor,
}));
if let Some(replaced) = written.replaced {
payload.insert("superseded".to_owned(), Value::String(replaced));
}
let mut stream = Stream::releases(&identity)?;
stream.emit(EventKind::ReleaseAcknowledged, payload);
if !written.was_a_correction {
observe(&identity, target, &commit, version, &mut stream)?;
}
}
Ok(recorded.acknowledgement)
}
const MAX_ACTOR: usize = 128;
fn actor() -> Result<String> {
if let Some(named) = std::env::var_os(ACTOR_ENV) {
let named = named.to_string_lossy().trim().to_owned();
return match usable_actor(&named) {
true => Ok(named),
false => Err(error::invalid(format!(
"{ACTOR_ENV} is set to {named:?}, which cannot name whoever performed a release: \
it must be one line of at most {MAX_ACTOR} characters, and not blank"
))),
};
}
for name in ["USER", "LOGNAME"] {
if let Some(value) = std::env::var_os(name) {
let value = value.to_string_lossy().trim().to_owned();
if usable_actor(&value) {
return Ok(value);
}
}
}
Ok(UNKNOWN_ACTOR.to_owned())
}
fn usable_actor(value: &str) -> bool {
!value.is_empty() && value.chars().count() <= MAX_ACTOR && !value.chars().any(char::is_control)
}
fn observe(
identity: &str,
target: &ReleaseTarget,
commit: &str,
version: &str,
stream: &mut Stream,
) -> Result<()> {
let inserted = update(identity, |record| {
let landings = record.observed.entry(target.name.to_string()).or_default();
match landings.entry(commit.to_owned()) {
btree_map::Entry::Occupied(_) => Ok(false),
btree_map::Entry::Vacant(slot) => {
slot.insert(version.to_owned());
Ok(true)
}
}
})?;
if !inserted {
return Ok(());
}
stream.emit(
EventKind::ReleaseObserved,
json_object(json!({
"identity": identity,
"target": target.name.to_string(),
"style": target.style().as_str(),
"version": version,
"landing_commit": commit,
})),
);
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Record {
version: u32,
identity: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
baselines: BTreeMap<String, BTreeMap<String, BaselineRecord>>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
acknowledgements: BTreeMap<String, BTreeMap<String, StoredAcknowledgement>>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
observed: BTreeMap<String, BTreeMap<String, String>>,
#[serde(skip)]
carried: Remainder,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct StoredAcknowledgement {
version: String,
recorded_at: String,
actor: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
superseded: Vec<SupersededRelease>,
}
impl Record {
fn empty(identity: &str) -> Self {
Record {
version: RECORD_VERSION,
identity: identity.to_owned(),
baselines: BTreeMap::new(),
acknowledgements: BTreeMap::new(),
observed: BTreeMap::new(),
carried: Remainder::default(),
}
}
fn baseline(&self, target: &TargetName, commit: &str) -> Option<BaselineRecord> {
self.baselines.get(&**target)?.get(commit).cloned()
}
fn acknowledgement(&self, target: &TargetName, commit: &str) -> Option<StoredAcknowledgement> {
self.acknowledgements.get(&**target)?.get(commit).cloned()
}
}
fn record_path(identity: &str) -> Result<PathBuf> {
let flattened: String = identity
.chars()
.map(|c| if c == '/' { '-' } else { c })
.collect();
let name = match ids::is_safe_name(&flattened) {
true => flattened,
false => ids::short_digest(identity),
};
Ok(home::releases_dir()?.join(format!("{name}.json")))
}
fn record_lock(identity: &str) -> String {
format!("releases:{identity}")
}
fn read(identity: &str) -> Result<Record> {
let path = record_path(identity)?;
read_at(&path, identity)
}
fn read_at(path: &Path, identity: &str) -> Result<Record> {
let raw = match std::fs::read_to_string(path) {
Ok(raw) => raw,
Err(failure) if failure.kind() == std::io::ErrorKind::NotFound => {
return Ok(Record::empty(identity))
}
Err(failure) => {
return Err(error::invalid(format!(
"cannot read the release record at {}: {failure}",
path.display()
)))
}
};
let document: serde_json::Value = serde_json::from_str(&raw).map_err(|failure| {
error::invalid(format!(
"the release record at {} is not one this build reads: {failure}",
path.display()
))
})?;
let mut record: Record = serde_json::from_value(document.clone()).map_err(|failure| {
error::invalid(format!(
"the release record at {} is not one this build reads: {failure}",
path.display()
))
})?;
if record.version < RECORD_VERSION {
return Err(error::invalid(format!(
"the release record at {} declares version {}; this build reads version \
{RECORD_VERSION} and newer",
path.display(),
record.version
)));
}
record.carried = Remainder::between(
&document,
&serde_json::to_value(&record).map_err(|failure| {
error::invalid(format!(
"the release record at {} is not one this build reads: {failure}",
path.display()
))
})?,
);
if record.identity != identity {
return Err(error::invalid(format!(
"the release record at {} is about {other:?} rather than {identity:?}; two identities \
cannot share one record",
path.display(),
other = record.identity,
)));
}
usable(path, &record)?;
Ok(record)
}
fn usable(path: &Path, record: &Record) -> Result<()> {
let refuse = |what: String| {
Err(error::invalid(format!(
"the release record at {} {what}; it is a record this build cannot answer from",
path.display()
)))
};
for (target, landings) in &record.acknowledgements {
for (commit, stored) in landings {
let named = format!("has, for {target:?} at {commit:?},");
let releases = std::iter::once(&stored.version)
.zip(std::iter::once(&stored.recorded_at))
.zip(std::iter::once(&stored.actor))
.chain(
stored
.superseded
.iter()
.map(|it| ((&it.version, &it.recorded_at), &it.actor)),
);
for ((version, recorded_at), actor) in releases {
if semver::Version::parse(version).is_err() {
return refuse(format!(
"{named} an acknowledged version that is not a semantic version \
({version:?})"
));
}
if !ids::is_timestamp(recorded_at) {
return refuse(format!(
"{named} a recorded_at that is not a timestamp this build can order by \
({recorded_at:?})"
));
}
if !usable_actor(actor) {
return refuse(format!(
"{named} an actor that cannot name whoever performed the release \
({actor:?})"
));
}
}
}
}
for (target, landings) in &record.baselines {
for (commit, baseline) in landings {
let named = format!("has, for {target:?} at {commit:?},");
match baseline {
BaselineRecord::Established(Baseline::At { version }) => {
if version.trim().is_empty() || !one_line(version) {
return refuse(format!(
"{named} a baseline version that is not one printable line \
({version:?})"
));
}
}
BaselineRecord::Established(Baseline::NoRelease) => {}
BaselineRecord::Unestablished {
reason,
attempted_at,
} => {
if reason.trim().is_empty() || !one_line(reason) {
return refuse(format!(
"{named} an unestablished baseline whose reason is not one printable \
line ({reason:?})"
));
}
if !ids::is_timestamp(attempted_at) {
return refuse(format!(
"{named} an unestablished baseline whose attempted_at is not a \
timestamp ({attempted_at:?})"
));
}
}
}
}
}
Ok(())
}
fn one_line(value: &str) -> bool {
!value.chars().any(char::is_control)
}
fn update<T>(identity: &str, change: impl FnOnce(&mut Record) -> Result<T>) -> Result<T> {
let _guard = lock::exclusive(&record_lock(identity))?;
let path = record_path(identity)?;
let mut record = read_at(&path, identity)?;
let outcome = change(&mut record)?;
let cannot = |failure: serde_json::Error| {
error::invalid(format!("cannot serialize the release record: {failure}"))
};
let mut document = serde_json::to_value(&record).map_err(cannot)?;
record.carried.restore(&mut document);
let mut json = serde_json::to_string_pretty(&document).map_err(cannot)?;
json.push('\n');
home::atomic_write(&path, &json)?;
Ok(outcome)
}
fn write_baseline(
identity: &str,
target: &TargetName,
commit: &str,
baseline: BaselineRecord,
) -> Result<()> {
update(identity, |record| {
let landings = record.baselines.entry(target.to_string()).or_default();
landings.entry(commit.to_owned()).or_insert(baseline);
Ok(())
})
}
fn establish(identity: &str, target: &TargetName, commit: &str, baseline: Baseline) -> Result<()> {
update(identity, |record| {
record
.baselines
.entry(target.to_string())
.or_default()
.insert(commit.to_owned(), BaselineRecord::Established(baseline));
Ok(())
})
}
struct Recorded {
acknowledgement: Acknowledgement,
written: Option<Written>,
}
struct Written {
replaced: Option<String>,
was_a_correction: bool,
}
struct Recording<'a> {
identity: &'a str,
target: &'a TargetName,
commit: &'a str,
version: &'a str,
actor: &'a str,
supersede: bool,
reference: &'a str,
}
fn write_acknowledgement(asked: &Recording<'_>) -> Result<Recorded> {
let Recording {
identity,
target,
commit,
version,
actor,
supersede,
reference,
} = *asked;
update(identity, |record| {
let existing = record
.acknowledgements
.get(&**target)
.and_then(|landings| landings.get(commit))
.cloned();
if let Some(existing) = existing {
if existing.version == version {
return Ok(Recorded {
acknowledgement: reconstitute(identity, target, commit, existing),
written: None,
});
}
if !supersede {
return Err(error::invalid(format!(
"the release target {target:?} is already acknowledged as {recorded:?} for \
landing {commit}, and a consumer may have read that answer and started work \
on it; recording {version:?} over it would change a fact somebody has acted \
on. Replace it explicitly with `onevcs release acknowledge {reference} \
--target {target} --version {version} --supersede`",
recorded = existing.version,
)));
}
let mut superseded = existing.superseded.clone();
superseded.push(SupersededRelease {
version: existing.version.clone(),
recorded_at: existing.recorded_at.clone(),
actor: existing.actor.clone(),
});
let stored = StoredAcknowledgement {
version: version.to_owned(),
recorded_at: ids::timestamp(),
actor: actor.to_owned(),
superseded,
};
record
.acknowledgements
.entry(target.to_string())
.or_default()
.insert(commit.to_owned(), stored.clone());
return Ok(Recorded {
acknowledgement: reconstitute(identity, target, commit, stored),
written: Some(Written {
replaced: Some(existing.version),
was_a_correction: true,
}),
});
}
let stored = StoredAcknowledgement {
version: version.to_owned(),
recorded_at: ids::timestamp(),
actor: actor.to_owned(),
superseded: Vec::new(),
};
record
.acknowledgements
.entry(target.to_string())
.or_default()
.insert(commit.to_owned(), stored.clone());
Ok(Recorded {
acknowledgement: reconstitute(identity, target, commit, stored),
written: Some(Written {
replaced: None,
was_a_correction: false,
}),
})
})
}
fn reconstitute(
identity: &str,
target: &TargetName,
commit: &str,
stored: StoredAcknowledgement,
) -> Acknowledgement {
Acknowledgement {
identity: identity.to_owned(),
target: target.clone(),
landing_commit: commit.to_owned(),
version: stored.version,
recorded_at: stored.recorded_at,
actor: stored.actor,
superseded: stored.superseded,
}
}
fn newest_acknowledgement(identity: &str, target: &TargetName) -> Result<Option<String>> {
Ok(read(identity)?
.acknowledgements
.get(&**target)
.and_then(|landings| {
landings
.values()
.max_by(|left, right| left.recorded_at.cmp(&right.recorded_at))
.map(|stored| stored.version.clone())
}))
}
fn json_object(value: Value) -> Map<String, Value> {
match value {
Value::Object(object) => object,
_ => Map::new(),
}
}