loft-cli 2.0.4

The loft command line: author, check, format, render and publish a specification in a git repository
//! `loft publish` (F-41): the publisher run over the tree inside a git repository. It prints
//! each label it allocated with its key, each label it withdrew, each citation of a withdrawn
//! label it rewrote, the new publish sequence and state root, and in a stopped merge or rebase
//! the git command that finishes it; that nothing changed; or why it refused (RQ-145). A
//! refusal writes nothing and exits 1. It runs no git command that changes the repository.

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;

/// The schema `--format json` writes to, by name and version (RQ-156).
const SCHEMA: &str = "loft-publish/1";

/// Why a publish did not happen.
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),
    }
}

/// Reads the tree, then the repository, the previous lock and the time, runs the publisher and
/// writes what it gives in one save. In a stopped merge or rebase a lock that is not a lock
/// record, such as one holding conflict markers, is read as none.
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
        ),
    }
}

/// A failed publish as the errors that stop the command.
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}")],
        },
    }
}

/// The git command that finishes a stopped merge or rebase once a publish settled it.
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",
    }
}

/// The outcome a publication names: `published`, or `unchanged` when nothing changed.
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())
}

/// Labels a publish replaced, each with the element's key, the label it withdrew and the label
/// that took its place.
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()
}