use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use onevcs::rules::{Approvals, Drafts};
use onevcs::{
ChangeId, ChangeRequest, Check, CheckSource, CommentId, CommentKind, DraftReason, Error,
Identity, Landed, MergeOutcome, ReadCost, Recoverable, Result, Sha,
};
use onevcs::{MergePolicy, Publication, RequiredChecks, Session, SessionRequest, SessionToken};
use crate::events;
use crate::store::Checked;
pub const STATE_VERSION: u32 = 24;
pub const OLDEST_READABLE_VERSION: u32 = 2;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct VcsState {
pub version: u32,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub identities: Vec<Identity>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub sessions: Vec<Session>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub session_identities: BTreeMap<SessionToken, String>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub session_labels: BTreeMap<SessionToken, BTreeMap<String, String>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub preserved: Vec<Recoverable>,
#[serde(skip_serializing_if = "BTreeSet::is_empty")]
pub closed_sessions: BTreeSet<SessionToken>,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy: Option<MergePolicy>,
#[serde(skip_serializing_if = "Option::is_none")]
pub approvals: Option<Approvals>,
#[serde(skip_serializing_if = "Option::is_none")]
pub drafts: Option<Drafts>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub publications: Vec<Publication>,
}
impl Default for VcsState {
fn default() -> Self {
Self {
version: STATE_VERSION,
identities: Vec::new(),
sessions: Vec::new(),
session_identities: BTreeMap::new(),
session_labels: BTreeMap::new(),
preserved: Vec::new(),
closed_sessions: BTreeSet::new(),
policy: None,
approvals: None,
drafts: None,
publications: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct HostState {
pub version: u32,
pub authenticated_user: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub changes: Vec<ChangeRequest>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub heads: BTreeMap<ChangeId, String>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub titles: BTreeMap<ChangeId, String>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub bodies: BTreeMap<ChangeId, String>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub checks: BTreeMap<ChangeId, Vec<Check>>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub check_logs: BTreeMap<ChangeId, BTreeMap<String, String>>,
#[serde(
skip_serializing_if = "BTreeMap::is_empty",
deserialize_with = "drafts_of_any_version"
)]
pub drafts: BTreeMap<ChangeId, DraftReason>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub made_ready: Vec<ChangeId>,
#[serde(skip_serializing_if = "BTreeSet::is_empty")]
pub awaiting_checks: BTreeSet<ChangeId>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub checks_after_lift: BTreeMap<ChangeId, Vec<Check>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub required_checks: Option<RequiredChecks>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub described: Vec<Described>,
#[serde(skip_serializing_if = "Option::is_none")]
pub check_sources: Option<BTreeSet<CheckSource>>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub merges: BTreeMap<ChangeId, MergeOutcome>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub merge_times: BTreeMap<ChangeId, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub review_comments: BTreeMap<ChangeId, Vec<HostComment>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub replies: Vec<Replied>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub review_charges: Vec<ReadCost>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub closed: BTreeSet<ChangeId>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub deleted_branches: BTreeSet<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub head_checks: BTreeMap<Sha, Vec<Check>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ledger: Vec<HostCall>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub up_to_date_required: bool,
#[serde(default = "allowed", skip_serializing_if = "is_allowed")]
pub allow_auto_merge: bool,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub armed: BTreeSet<ChangeId>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub check_readings: BTreeMap<ChangeId, Vec<Vec<Check>>>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub check_readings_after_lift: BTreeMap<ChangeId, Vec<Vec<Check>>>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub head_activity: BTreeMap<ChangeId, Vec<ActivityAnswer>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", tag = "answer")]
pub enum ActivityAnswer {
Head {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
in_progress: Vec<String>,
#[serde(default = "allowed", skip_serializing_if = "is_allowed")]
complete: bool,
},
Other {
head: Sha,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
in_progress: Vec<String>,
},
Refused,
}
pub(crate) const LEDGER_METHODS: &[&str] = &[
"allows_auto_merge",
"authenticated_user",
"change_checks",
"change_description",
"change_standings",
"check_log",
"describe_change",
"find_changes",
"head_activity",
"is_draft",
"merge",
"merge_time",
"merged_at",
"merged_head",
"mergeability",
"open_change",
"ready_for_review",
"reopen_change",
"reply_to_comment",
"required_checks_on",
"requires_up_to_date",
"retarget_change",
"review_comments",
"review_standings",
"visibility",
];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostCall {
pub method: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub changes: Vec<ChangeId>,
pub charge: ReadCost,
}
impl HostCall {
pub(crate) fn checked(&self) -> Result<()> {
if !LEDGER_METHODS.contains(&self.method.as_str()) {
return Err(Error::Invalid {
reason: format!(
"the call ledger records a call to {:?}, which is no method this host \
answers",
self.method
),
});
}
if self.changes.iter().any(|id| id.0.trim().is_empty()) {
return Err(Error::Invalid {
reason: format!(
"the call ledger records a call to {:?} naming a change request with no \
identifier",
self.method
),
});
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostComment {
pub id: CommentId,
pub kind: CommentKind,
pub author: String,
pub body: String,
pub url: String,
pub created_at: String,
pub updated_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub in_reply_to: Option<CommentId>,
#[serde(default)]
pub revision: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Replied {
pub change: ChangeId,
pub comment: CommentId,
pub key: String,
pub reply: CommentId,
pub threaded: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Described {
pub id: ChangeId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub body: String,
}
fn drafts_of_any_version<'de, D>(
deserializer: D,
) -> std::result::Result<BTreeMap<ChangeId, DraftReason>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw: BTreeMap<ChangeId, serde_json::Map<String, serde_json::Value>> =
Deserialize::deserialize(deserializer)?;
raw.into_iter()
.map(|(id, mut fields)| {
fields
.entry("kind")
.or_insert_with(|| serde_json::Value::from("awaiting-release"));
serde_json::from_value::<DraftReason>(serde_json::Value::Object(fields))
.map(|reason| (id, reason))
.map_err(serde::de::Error::custom)
})
.collect()
}
pub const DEFAULT_AUTHENTICATED_USER: &str = "onevcs-testing";
impl Default for HostState {
fn default() -> Self {
Self {
version: STATE_VERSION,
authenticated_user: DEFAULT_AUTHENTICATED_USER.to_owned(),
changes: Vec::new(),
heads: BTreeMap::new(),
titles: BTreeMap::new(),
bodies: BTreeMap::new(),
drafts: BTreeMap::new(),
made_ready: Vec::new(),
awaiting_checks: BTreeSet::new(),
checks_after_lift: BTreeMap::new(),
required_checks: None,
described: Vec::new(),
checks: BTreeMap::new(),
check_logs: BTreeMap::new(),
check_sources: None,
merges: BTreeMap::new(),
merge_times: BTreeMap::new(),
review_comments: BTreeMap::new(),
replies: Vec::new(),
review_charges: Vec::new(),
closed: BTreeSet::new(),
deleted_branches: BTreeSet::new(),
head_checks: BTreeMap::new(),
ledger: Vec::new(),
up_to_date_required: false,
allow_auto_merge: true,
armed: BTreeSet::new(),
check_readings: BTreeMap::new(),
check_readings_after_lift: BTreeMap::new(),
head_activity: BTreeMap::new(),
}
}
}
fn allowed() -> bool {
true
}
#[expect(
clippy::trivially_copy_pass_by_ref,
reason = "serde's skip_serializing_if passes a reference"
)]
fn is_allowed(allow: &bool) -> bool {
*allow
}
pub(crate) fn identity_of<'a>(state: &'a VcsState, origin_or_path: &str) -> Option<&'a Identity> {
let wanted = origin_or_path.trim_end_matches('/');
state
.identities
.iter()
.find(|identity| identity.origin == wanted)
.or_else(|| {
state.identities.iter().find(|identity| {
identity
.origin
.rsplit('/')
.next()
.is_some_and(|name| name == wanted)
|| identity.origin.ends_with(&format!("/{wanted}"))
})
})
}
pub(crate) fn known(state: &VcsState) -> String {
if state.identities.is_empty() {
return "this provider was seeded with no identities".to_owned();
}
let names: Vec<&str> = state
.identities
.iter()
.map(|identity| identity.origin.as_str())
.collect();
format!("it knows {}", names.join(", "))
}
pub(crate) fn identity_for(state: &VcsState, token: &SessionToken) -> Result<String> {
state
.session_identities
.get(token)
.cloned()
.ok_or_else(|| Error::Invalid {
reason: format!(
"this provider has no record of session {:?}, so it cannot say which identity \
its work belongs to",
token.0
),
})
}
pub(crate) fn session_of<'a>(state: &'a VcsState, token: &SessionToken) -> Option<&'a Session> {
state
.sessions
.iter()
.find(|session| session.token == *token)
}
pub(crate) fn requested_branch(req: &SessionRequest, token: &SessionToken) -> Result<String> {
if let (Some(pinned), Some(proposed)) = (&req.branch, &req.branch_name) {
return Err(Error::Invalid {
reason: format!(
"this request names both a branch to continue ({pinned:?}) and a name to cut \
({proposed:?}), which are two answers to one question"
),
});
}
let name = req
.branch
.clone()
.or_else(|| req.branch_name.clone())
.unwrap_or_else(|| format!("onevcs/{}", token.0));
named_branch(&name, "the branch")?;
Ok(name)
}
pub(crate) fn named_branch(value: &str, what: &str) -> Result<()> {
let components_usable = !value.is_empty()
&& value.split('/').all(|component| {
!component.is_empty() && !component.starts_with('.') && !component.ends_with(".lock")
});
let usable = components_usable
&& !value.starts_with('-')
&& !value.contains("..")
&& !value.contains("@{")
&& !value.ends_with('.')
&& !value.ends_with('/')
&& !value.chars().any(|c| {
c.is_whitespace() || c.is_ascii_control() || c == '\u{7f}' || "~^:?*[\\".contains(c)
});
if !usable {
return Err(Error::Invalid {
reason: format!("{what} {value:?} is a name git would not accept"),
});
}
Ok(())
}
pub(crate) fn label_pair(key: &str, value: &str) -> Result<()> {
let key_usable = !key.is_empty()
&& key
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
if !key_usable {
return Err(Error::Invalid {
reason: format!(
"{key:?} is not a label key; a key is one or more of A-Z, a-z, 0-9, `_` and `-`"
),
});
}
if value.contains('\n') {
return Err(Error::Invalid {
reason: format!(
"the value of label {key:?} holds a newline; a label's value is one line"
),
});
}
Ok(())
}
impl Checked for VcsState {
fn check(&self) -> Result<()> {
readable_version(self.version)?;
for session in &self.sessions {
if !events::is_safe_name(&session.token.0) {
return Err(Error::Invalid {
reason: format!("{:?} is not a session token", session.token.0),
});
}
named_branch(&session.branch, "the branch")?;
named_branch(&session.base, "the base")?;
}
for row in &self.preserved {
known_identity(self, &row.identity, "preserved work")?;
named_branch(&row.branch.branch, "the preserved branch")?;
named_branch(&row.branch.base, "the preserved branch's base")?;
if let Some(token) = row.held_by.as_ref().and_then(|held| held.token.as_ref()) {
opened(self, token, "holding preserved work")?;
}
}
for token in &self.closed_sessions {
opened(self, token, "closed")?;
}
for (token, labels) in &self.session_labels {
opened(self, token, "labelled")?;
for (key, value) in labels {
label_pair(key, value)?;
}
}
for (token, origin) in &self.session_identities {
opened(self, token, "given an identity")?;
known_identity(self, origin, &format!("session {:?}", token.0))?;
}
for publication in &self.publications {
let session = opened(self, &publication.session, "published")?;
named_branch(&publication.branch, "the published branch")?;
if publication.branch != session.branch {
return Err(Error::Invalid {
reason: format!(
"the publication of session {:?} names branch {:?}, but that session is \
on {:?}",
publication.session.0, publication.branch, session.branch
),
});
}
}
Ok(())
}
fn carry_forward(&mut self) {
if self.version < 5 {
for row in &mut self.preserved {
row.landed = Landed::No;
}
}
self.version = STATE_VERSION;
}
}
fn opened_change(state: &HostState, id: &ChangeId, what: &str) -> Result<()> {
if state.changes.iter().any(|change| change.id == *id) {
return Ok(());
}
Err(Error::Invalid {
reason: format!(
"{what} is recorded for change request {:?}, but no change request by that \
identifier was opened",
id.0
),
})
}
fn known_identity(state: &VcsState, origin: &str, what: &str) -> Result<()> {
if state
.identities
.iter()
.any(|identity| identity.origin == origin)
{
return Ok(());
}
Err(Error::Invalid {
reason: format!(
"{what} belongs to identity {origin:?}, which this provider does not know; {}",
known(state)
),
})
}
pub(crate) fn titled(title: &str) -> Result<()> {
if title.trim().is_empty() {
return Err(Error::Invalid {
reason: "a change request's title is blank, so it names no change".to_owned(),
});
}
Ok(())
}
fn opened<'a>(state: &'a VcsState, token: &SessionToken, what: &str) -> Result<&'a Session> {
if !events::is_safe_name(&token.0) {
return Err(Error::Invalid {
reason: format!("{:?} is not a session token", token.0),
});
}
session_of(state, token).ok_or_else(|| Error::Invalid {
reason: format!(
"session {:?} is {what} here, but no session by that token was opened",
token.0
),
})
}
impl Checked for HostState {
fn check(&self) -> Result<()> {
readable_version(self.version)?;
for change in &self.changes {
named_branch(&change.base, "the base of a seeded change request")?;
if change.id.0.is_empty() {
return Err(Error::Invalid {
reason: "a seeded change request carries no identifier".to_owned(),
});
}
if change.head_sha.0.trim().is_empty() {
return Err(Error::Invalid {
reason: format!(
"the seeded change request {:?} names no commit its checks are \
reported against",
change.id.0
),
});
}
}
for (id, head) in &self.heads {
opened_change(self, id, "a head")?;
named_branch(head, "the head of a seeded change request")?;
}
for (id, title) in &self.titles {
opened_change(self, id, "a title")?;
titled(title)?;
}
for id in self.bodies.keys() {
opened_change(self, id, "a body")?;
}
for (id, reason) in &self.drafts {
opened_change(self, id, "a draft reason")?;
reason.checked()?;
}
for id in &self.made_ready {
opened_change(self, id, "a lifted draft")?;
}
for id in &self.awaiting_checks {
opened_change(self, id, "a draft awaiting its checks")?;
}
for described in &self.described {
opened_change(self, &described.id, "a description")?;
if let Some(title) = &described.title {
titled(title)?;
}
}
for (id, comments) in &self.review_comments {
opened_change(self, id, "review feedback")?;
let mut held = BTreeSet::new();
for comment in comments {
if comment.id.0.trim().is_empty() || !held.insert(&comment.id) {
return Err(Error::Invalid {
reason: format!(
"the review feedback seeded for change request {:?} holds the \
comment id {:?} more than once, or an empty one",
id.0, comment.id.0
),
});
}
}
for comment in comments {
for (field, at) in [
("created_at", &comment.created_at),
("updated_at", &comment.updated_at),
] {
if events::moment(at).is_none() {
return Err(Error::Invalid {
reason: format!(
"comment {:?} on change request {:?} has {field} {at:?}, which \
is not an RFC 3339 moment",
comment.id.0, id.0
),
});
}
}
if let Some(answered) = &comment.in_reply_to {
if !held.contains(answered) {
return Err(Error::Invalid {
reason: format!(
"comment {:?} on change request {:?} replies to {:?}, which \
that change request does not carry",
comment.id.0, id.0, answered.0
),
});
}
}
}
}
for id in &self.closed {
opened_change(self, id, "a closing")?;
}
for id in &self.armed {
opened_change(self, id, "an armed merge")?;
}
for (id, readings) in self
.check_readings
.iter()
.chain(&self.check_readings_after_lift)
{
if readings.is_empty() {
return Err(Error::Invalid {
reason: format!(
"the checks scripted for change request {:?} hold no reading",
id.0
),
});
}
}
for (id, answers) in &self.head_activity {
if answers.is_empty() {
return Err(Error::Invalid {
reason: format!(
"the head activity scripted for change request {:?} holds no answer",
id.0
),
});
}
for answer in answers {
if let ActivityAnswer::Other { head, .. } = answer {
if head.0.trim().is_empty() {
return Err(Error::Invalid {
reason: format!(
"the head activity scripted for change request {:?} answers \
about a commit it does not name",
id.0
),
});
}
}
}
}
for branch in &self.deleted_branches {
named_branch(branch, "a deleted branch")?;
}
for call in &self.ledger {
call.checked()?;
}
for (id, at) in &self.merge_times {
if events::moment(at).is_none() {
return Err(Error::Invalid {
reason: format!(
"the merge time seeded for change request {:?} is {at:?}, which is \
not an RFC3339 moment",
id.0
),
});
}
}
Ok(())
}
fn carry_forward(&mut self) {
self.version = STATE_VERSION;
}
}
fn readable_version(declared: u32) -> Result<()> {
if !(OLDEST_READABLE_VERSION..=STATE_VERSION).contains(&declared) {
return Err(Error::Invalid {
reason: format!(
"the document declares version {declared}; this build reads versions \
{OLDEST_READABLE_VERSION} to {STATE_VERSION}"
),
});
}
Ok(())
}