haematite-cli 0.21.8

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
Documentation
//! `haematite branch-rename`: give a branch a new name, offline, through the
//! service's own fork and discard.
//!
//! A branch written before the fork refused unplaceable names (0353ee4) can
//! hold a `/` that Lys never places, so no grant reaches it. This command
//! forks `--to` from the exact head of `--from`, checks that every shard of
//! the new branch was forked from that head root, then discards `--from` through the
//! ordinary discard, which writes its history rows and receipt first: the
//! data is the new branch's, and the old versions stay under the store's
//! retention for discarded branches like any other discard.
//!
//! Both steps are mutations with request ids derived from the two names, so a
//! run cut short is finished by running it again: a step already applied
//! answers its stored receipt and writes nothing. The store is opened as its
//! one writer, so a running service holds it off.

use std::path::PathBuf;
use std::time::Duration;

use haematite::store::NodeCacheBudget;

use super::{DEFAULT_LONG_WAIT_INTERVAL, parse_long_wait_interval, read_config};
use crate::serve::contracts::placeable;
use crate::serve::protocol::{BranchDiscard, Change, Fork, Mutation, Selection, Version};
use crate::serve::storage::{Captured, Storage};

/// The command's usage line, also printed on a usage error.
pub const BRANCH_RENAME_USAGE: &str = "usage: haematite branch-rename --config <file.json> \
    --from <name> --to <name> (--check | --apply) [--long-wait-interval <duration>]  \
    (Unix; offline; example: --long-wait-interval 30s)";

#[derive(Debug, PartialEq, Eq)]
struct Request {
    config: PathBuf,
    from: String,
    to: String,
    apply: bool,
    long_wait_interval: Duration,
}

/// Run `haematite branch-rename`.
///
/// # Errors
/// A usage error, the configuration's, the store's open refusal, or a refusal
/// by name: `branch_name_unplaceable` for `--to`, `branch_absent` for
/// `--from`, `branch_exists` for a `--to` that names another branch,
/// `rename_roots_differ` when the fork's anchor is not the source's head, or the
/// fork's or the discard's own refusal.
pub fn branch_rename(args: impl Iterator<Item = String>) -> Result<(), String> {
    let request = parse(args)?;
    if request.from == "main" {
        return Err(
            "main_protected: main cannot be renamed because it cannot be discarded".to_owned(),
        );
    }
    placeable(&request.to).map_err(|failure| failure.to_string())?;
    let config = read_config(&request.config)?;
    let mut storage = Storage::open_kept(
        &config.data_dir,
        NodeCacheBudget::Bytes(config.branch_cache_bytes_per_shard),
        config.kept_limit()?,
        request.long_wait_interval,
    )
    .map_err(|error| error.to_string())?;
    let source = capture(&storage, &request.from)?
        .ok_or_else(|| format!("branch_absent: no branch is named {:?}", request.from))?;
    let renamed = capture(&storage, &request.to)?;
    let fork_id = request_id("fork", &request);
    if let Some(renamed) = &renamed
        && !forked_from(&source, renamed)
    {
        return Err(format!(
            "branch_exists: {:?} already names a branch not forked from {:?}'s head",
            request.to, request.from
        ));
    }
    if !request.apply {
        println!(
            "would fork {:?} from {:?} at {}.{}, check its roots on {} shards, then discard {:?}",
            request.to,
            request.from,
            source.version.generation,
            source.version.sequence,
            source.shards.len(),
            request.from
        );
        return Ok(());
    }
    storage
        .mutate(&Mutation {
            request_id: fork_id,
            branch: request.from.clone(),
            expected: source.version,
            change: Change::Fork(Fork {
                name: request.to.clone(),
                from: source.version,
            }),
        })
        .map_err(|failure| failure.to_string())?;
    let forked = capture(&storage, &request.to)?.ok_or_else(|| {
        format!(
            "rename_roots_differ: {:?} is absent after its fork",
            request.to
        )
    })?;
    if !forked_from(&source, &forked) {
        return Err(format!(
            "rename_roots_differ: {:?} was not forked from {:?}'s head; {:?} is kept",
            request.to, request.from, request.from
        ));
    }
    storage
        .mutate(&Mutation {
            request_id: request_id("discard", &request),
            branch: request.from.clone(),
            expected: source.version,
            change: Change::Discard(BranchDiscard {
                name: request.from.clone(),
            }),
        })
        .map_err(|failure| failure.to_string())?;
    println!(
        "renamed {:?} to {:?} at {}.{}",
        request.from, request.to, forked.version.generation, forked.version.sequence
    );
    Ok(())
}

/// The branch's head as the service captures it, or `None` when no branch
/// has the name.
fn capture(storage: &Storage, branch: &str) -> Result<Option<Captured>, String> {
    let head: Option<Version> = storage
        .catalogue()
        .head(branch)
        .map_err(|failure| failure.to_string())?;
    head.map(|version| {
        storage
            .capture(&Selection {
                branch: branch.to_owned(),
                version: Some(version),
            })
            .map_err(|failure| failure.to_string())
    })
    .transpose()
}

/// Whether `forked` was forked from `source`'s head: on every shard its fork
/// anchor, the root it diverged from, is the source's head root.
fn forked_from(source: &Captured, forked: &Captured) -> bool {
    source.shards.len() == forked.shards.len()
        && source
            .shards
            .iter()
            .zip(&forked.shards)
            .all(|(source, forked)| {
                (source.shard_id, source.head) == (forked.shard_id, forked.fork_anchor)
            })
}

/// The same request id on every run of one rename, so a second run is
/// answered from the receipt of a step already applied.
fn request_id(step: &str, request: &Request) -> String {
    format!(
        "branch-rename {step} {:?} to {:?}",
        request.from, request.to
    )
}

fn parse(mut args: impl Iterator<Item = String>) -> Result<Request, String> {
    let (mut config, mut from, mut to, mut apply, mut long_wait) = (None, None, None, None, None);
    while let Some(arg) = args.next() {
        match arg.as_str() {
            "--check" if apply.is_none() => apply = Some(false),
            "--apply" if apply.is_none() => apply = Some(true),
            "--config" | "--from" | "--to" | "--long-wait-interval" => {
                let value = args.next().ok_or(BRANCH_RENAME_USAGE)?;
                let slot = match arg.as_str() {
                    "--config" => &mut config,
                    "--from" => &mut from,
                    "--to" => &mut to,
                    _ => &mut long_wait,
                };
                if slot.replace(value).is_some() {
                    return Err(format!("{arg} is repeated\n{BRANCH_RENAME_USAGE}"));
                }
            }
            _ => {
                return Err(format!(
                    "unknown or repeated argument {arg:?}\n{BRANCH_RENAME_USAGE}"
                ));
            }
        }
    }
    let from = from.ok_or(BRANCH_RENAME_USAGE)?;
    let to = to.ok_or(BRANCH_RENAME_USAGE)?;
    if from == to {
        return Err(format!(
            "--from and --to name the same branch\n{BRANCH_RENAME_USAGE}"
        ));
    }
    Ok(Request {
        config: PathBuf::from(config.ok_or(BRANCH_RENAME_USAGE)?),
        from,
        to,
        apply: apply.ok_or(BRANCH_RENAME_USAGE)?,
        long_wait_interval: long_wait
            .as_deref()
            .map(parse_long_wait_interval)
            .transpose()?
            .unwrap_or(DEFAULT_LONG_WAIT_INTERVAL),
    })
}

#[cfg(test)]
#[path = "branch_rename_tests.rs"]
mod tests;