use std::path::Path;
use loftspec::check::Placed;
use loftspec::codec::ReadError;
use loftspec::publish::{Operation, Publication, PublishedAt, ReadTree, Refusal, Relabel, publish};
use loftspec_store::clock::{Clock as _, SystemClock};
use loftspec_store::repository::{GitCommand, Repository as _};
use loftspec_store::{FileStore, StoreError};
use serde_json::{Value, json};
use crate::finish::Finished;
use crate::json;
use crate::out::outln;
use crate::stop::Stop;
const SCHEMA: &str = "loft-publish/1";
enum Failure {
Read(Vec<ReadError>),
Invalid(Vec<Placed>),
Refused(String),
}
impl<E: ToString> From<E> for Failure {
fn from(error: E) -> Self {
Self::Refused(error.to_string())
}
}
pub fn run(root: &Path, as_json: bool) -> Finished {
match attempt(root) {
Ok(publication) => {
if as_json {
json::print(&published(&publication));
} else {
print(&publication);
}
Finished::success(outcome(&publication))
}
Err(failure) => stop(failure).exit(SCHEMA, as_json),
}
}
fn attempt(root: &Path) -> Result<Publication, Failure> {
let store = FileStore::open(root)?;
let files = store.read_files()?;
let tree = ReadTree::read(&files).map_err(Failure::Read)?;
let repo = GitCommand::default().state(root)?;
let previous = match store.lock() {
Err(StoreError::Lock(..)) if repo.stopped.is_some() => None,
lock => lock?,
};
let at = PublishedAt::from_unix_seconds(SystemClock::from_env().now()?)
.map_err(|error| Failure::Refused(format!("SOURCE_DATE_EPOCH: {error}")))?;
let publication = publish(tree, &repo, previous.as_ref(), at).map_err(refusal)?;
if let Publication::Published { lock, files, .. } = &publication {
store.publish(files.clone(), lock)?;
}
Ok(publication)
}
fn refusal(refusal: Refusal) -> Failure {
match refusal {
Refusal::Invalid(errors) => Failure::Invalid(errors),
Refusal::Modified(paths) => Failure::Refused(format!(
"uncommitted changes under spec/: {}; commit them before publishing",
paths.join(", ")
)),
Refusal::Branch {
current: Some(current),
publish,
} => Failure::Refused(format!(
"on branch `{current}`, but the publish branch is `{publish}`"
)),
Refusal::Branch {
current: None,
publish,
} => Failure::Refused(format!(
"HEAD is detached, not on the publish branch `{publish}`"
)),
Refusal::Unmerged(paths) => Failure::Refused(format!(
"unmerged paths other than spec/labels.yaml and loft.lock.json: {}; resolve them \
before publishing",
paths.join(", ")
)),
Refusal::UnmergedBindings => Failure::Refused(
"spec/labels.yaml is unmerged but holds no conflict record, so `loft merge-driver` \
did not merge it: git runs the driver only when `loft` is on git's search path, \
and the driver writes nothing when it reports an error. Fix what stopped it, run \
`git checkout -m spec/labels.yaml` to merge the file again, and publish again"
.to_owned(),
),
Refusal::Lock { revision, message } => Failure::Refused(format!(
"the lock at {revision} is not a lock record: {message}"
)),
}
}
fn print(publication: &Publication) {
match publication {
Publication::Published {
lock,
allocated,
rewritten,
withdrawn,
finish,
..
} => {
for allocated in allocated {
outln!("{} {}", allocated.label, allocated.element.key());
}
for withdrawn in withdrawn {
outln!(
"withdrew {} from {}, which now holds {}",
withdrawn.withdrawn,
withdrawn.element,
withdrawn.label
);
}
for rewritten in rewritten {
outln!(
"rewrote {} as {} in {}",
rewritten.withdrawn,
rewritten.label,
rewritten.element
);
}
outln!(
"published sequence {}, state root {}",
lock.publish_sequence,
lock.state_root
);
if let Some(operation) = finish {
outln!("to finish: {}", finishing(*operation));
}
}
Publication::Unchanged(lock) => outln!(
"nothing changed since publish sequence {}, state root {}",
lock.publish_sequence,
lock.state_root
),
}
}
fn stop(failure: Failure) -> Stop {
match failure {
Failure::Read(errors) => errors.into(),
Failure::Invalid(errors) => {
let mut stop = Stop::invalid(&errors);
let count = errors.len();
stop.lines.push(format!(
"publish refused: the validator reports {count} errors"
));
stop
}
Failure::Refused(reason) => Stop {
errors: vec![json::error("refused", &reason, &[])],
lines: vec![format!("publish refused: {reason}")],
},
}
}
const fn finishing(operation: Operation) -> &'static str {
match operation {
Operation::Merge => "git add -A && git commit --no-edit",
Operation::Rebase => "git add -A && git rebase --continue",
}
}
const fn outcome(publication: &Publication) -> &'static str {
match publication {
Publication::Published { .. } => "published",
Publication::Unchanged(_) => "unchanged",
}
}
fn published(publication: &Publication) -> Value {
let outcome = outcome(publication);
let (lock, allocated, rewritten, withdrawn, finish) = match publication {
Publication::Published {
lock,
allocated,
rewritten,
withdrawn,
finish,
..
} => (
lock,
allocated.as_slice(),
rewritten.as_slice(),
withdrawn.as_slice(),
*finish,
),
Publication::Unchanged(lock) => (lock, &[][..], &[][..], &[][..], None),
};
let allocated: Vec<Value> = allocated
.iter()
.map(|allocated| {
json!({
"label": allocated.label.to_string(),
"key": allocated.element.key().to_string(),
})
})
.collect();
let mut fields = json!({
"allocated": allocated,
"rewritten": relabels(rewritten),
"withdrawn": relabels(withdrawn),
"publishSequence": lock.publish_sequence.get(),
"stateRoot": lock.state_root.to_string(),
});
if let Some(operation) = finish {
fields["finish"] = json!(finishing(operation));
}
json::result(SCHEMA, outcome, fields, Vec::new())
}
fn relabels(relabels: &[Relabel]) -> Vec<Value> {
relabels
.iter()
.map(|relabel| {
json!({
"key": relabel.element.to_string(),
"withdrawn": relabel.withdrawn.to_string(),
"label": relabel.label.to_string(),
})
})
.collect()
}