use std::path::PathBuf;
use url::Url;
use onevcs::{
ArtifactId, ChangeId, ChangeRequest, ChangeSpec, Check, Error, Hosting, MergeOutcome,
MergePolicy, RemoteHost, Result, Sha,
};
use crate::events;
use crate::state::HostState;
use crate::store::{FileStore, MemoryStore, Store};
pub const DEFAULT_HOST: &str = "github.com";
pub const DEFAULT_SLUG: &str = "onevcs/testing";
#[derive(Debug)]
pub struct Host<T> {
store: T,
slug: String,
}
pub type MemoryHost = Host<MemoryStore<HostState>>;
pub type FileHost = Host<FileStore<HostState>>;
impl MemoryHost {
pub fn new() -> Self {
Self::seeded(HostState::default())
}
pub fn seeded(state: HostState) -> Self {
Self {
store: MemoryStore::new(state),
slug: DEFAULT_SLUG.to_owned(),
}
}
pub fn state(&self) -> HostState {
self.store
.snapshot()
.expect("an in-memory store always answers")
}
}
impl Default for MemoryHost {
fn default() -> Self {
Self::new()
}
}
impl FileHost {
pub fn create(path: impl Into<PathBuf>) -> Result<Self> {
Ok(Self {
store: FileStore::attach(path, &HostState::default())?,
slug: DEFAULT_SLUG.to_owned(),
})
}
pub fn seeded(path: impl Into<PathBuf>, state: HostState) -> Result<Self> {
Ok(Self {
store: FileStore::replace(path, &state)?,
slug: DEFAULT_SLUG.to_owned(),
})
}
pub fn state(&self) -> Result<HostState> {
self.store.snapshot()
}
}
impl<T: Store<HostState> + Clone + std::fmt::Debug + Send + Sync + 'static> Hosting for Host<T> {
fn for_repo(&self, slug: &str) -> Result<Box<dyn RemoteHost>> {
Ok(Box::new(Host {
store: self.store.clone(),
slug: named_repository(slug)?,
}))
}
}
impl<T: Store<HostState>> RemoteHost for Host<T> {
fn authenticated_user(&self) -> Result<String> {
let login = self.store.snapshot()?.authenticated_user;
if login.trim().is_empty() {
return Err(Error::Invalid {
reason: "the host reported no authenticated user".to_owned(),
});
}
Ok(login)
}
fn open_change(&self, req: ChangeSpec) -> Result<ChangeRequest> {
addressable(&req.head, "the head branch")?;
addressable(&req.base, "the base branch")?;
crate::state::titled(&req.title)?;
let slug = self.slug.clone();
self.store.with(|state| {
let id = ChangeId((state.changes.len() + 1).to_string());
let url = format!("https://{DEFAULT_HOST}/{slug}/pull/{}", id.0);
let change = ChangeRequest {
head_sha: Sha(events::stable_sha(&[&slug, &req.head, &id.0])),
url: Url::parse(&url).map_err(|e| Error::Invalid {
reason: format!("{url:?} is not a URL: {e}"),
})?,
base: req.base.clone(),
id: id.clone(),
};
state.heads.insert(id.clone(), req.head.clone());
state.titles.insert(id, req.title.clone());
state.changes.push(change.clone());
Ok(change)
})
}
fn find_changes(&self, head: &str, base: &str) -> Result<Vec<ChangeRequest>> {
addressable(head, "the head branch")?;
addressable(base, "the base branch")?;
let state = self.store.snapshot()?;
Ok(state
.changes
.iter()
.filter(|change| {
change.base == base
&& state.heads.get(&change.id).is_some_and(|from| from == head)
&& !matches!(state.merges.get(&change.id), Some(MergeOutcome::Merged(_)))
})
.cloned()
.collect())
}
fn change_checks(&self, cr: &ChangeRequest) -> Result<Vec<Check>> {
Ok(self
.store
.snapshot()?
.checks
.get(&cr.id)
.cloned()
.unwrap_or_default())
}
fn check_log(&self, cr: &ChangeRequest, check: &Check) -> Result<ArtifactId> {
let log = self
.store
.snapshot()?
.check_logs
.get(&cr.id)
.and_then(|logs| logs.get(&check.name))
.cloned()
.unwrap_or_else(|| format!("the host log for check {}\n", check.name));
events::store_artifact(&artifact_id(&cr.id, &check.name), &log)
}
fn merge(&self, cr: &ChangeRequest, policy: MergePolicy) -> Result<MergeOutcome> {
self.store.with(|state| {
if let Some(decided) = state.merges.get(&cr.id) {
return Ok(decided.clone());
}
let landed = |state: &mut HostState| {
let sha = Sha(events::stable_sha(&["merge", &cr.id.0, cr.url.as_str()]));
state
.merges
.insert(cr.id.clone(), MergeOutcome::Merged(sha.clone()));
MergeOutcome::Merged(sha)
};
Ok(match policy {
MergePolicy::LocalDirect | MergePolicy::ChangeOpen => MergeOutcome::Open,
MergePolicy::ChangeAuto => {
if required_checks_green(state, &cr.id) {
landed(state)
} else {
state.merges.insert(cr.id.clone(), MergeOutcome::Queued);
MergeOutcome::Queued
}
}
MergePolicy::ChangeDirect => landed(state),
})
})
}
}
fn required_checks_green(state: &HostState, id: &ChangeId) -> bool {
let checks = match state.checks.get(id) {
Some(checks) => checks,
None => return false,
};
let required: Vec<&Check> = checks.iter().filter(|check| check.required).collect();
!required.is_empty() && required.iter().all(|check| check.green())
}
fn artifact_id(change: &ChangeId, check: &str) -> String {
let safe: String = check
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'-'
}
})
.collect();
let number: String = change
.0
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect();
format!("a-testing-{number}-{safe}")
}
fn addressable(value: &str, what: &str) -> Result<()> {
if value.is_empty() || value.starts_with('-') || value.contains(char::is_whitespace) {
return Err(Error::Invalid {
reason: format!(
"{what} {value:?} cannot address anything on the host: it must be non-empty, \
must not begin with '-', and must carry no whitespace"
),
});
}
Ok(())
}
fn named_repository(slug: &str) -> Result<String> {
let mut parts = slug.split('/');
let named = matches!(
(parts.next(), parts.next(), parts.next()),
(Some(owner), Some(name), None)
if !owner.is_empty()
&& !name.is_empty()
&& !slug.starts_with('-')
&& !slug.contains(char::is_whitespace)
);
if !named {
return Err(Error::Invalid {
reason: format!("{slug:?} does not name one repository as owner/name"),
});
}
Ok(slug.to_owned())
}