shepherd-cli 6.7.0

The canonical shepherd command-line interface over the per-project registry, run artifacts, and sprint pipeline.
//! Explicit registry inspection, repair, and rollback commands.

use std::path::PathBuf;

use clap::Args;
use shepherd::registry::{Registry, RegistryRepairRequest, RegistryRollbackRequest};

use crate::{
    ContextInputs, ExecutionContext,
    interface::{CliError, CliGlobals},
};

#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Args)]
#[command(disable_help_subcommand = true)]
pub struct RegistryCmd {
    #[command(subcommand)]
    action: Option<RegistryAction>,
}

#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, clap::Subcommand)]
enum RegistryAction {
    /// Repair one fully absent applied migration after creating a verified snapshot.
    Repair {
        /// Authorize the snapshot-backed registry mutation.
        #[arg(long)]
        confirm: bool,
        /// Write immutable snapshot and receipt evidence below this directory.
        #[arg(long, value_name = "PATH")]
        snapshot_dir: Option<PathBuf>,
        /// Emit the complete immutable receipt as JSON.
        #[arg(long)]
        json: bool,
    },
    /// Restore the exact pre-repair database from an immutable receipt snapshot.
    Rollback {
        /// Immutable receipt returned by a successful repair.
        #[arg(long, value_name = "PATH")]
        receipt: PathBuf,
        /// SHA-256 witness of the immutable repair plan.
        #[arg(long, value_name = "SHA256")]
        witness_sha256: String,
        /// SHA-256 witness of the exact successful repair receipt bytes.
        #[arg(long, value_name = "SHA256")]
        receipt_sha256: String,
        /// Authorize the snapshot-backed registry restore.
        #[arg(long)]
        confirm: bool,
        /// Emit the complete rollback receipt as JSON.
        #[arg(long)]
        json: bool,
    },
}

impl RegistryCmd {
    pub(crate) fn run(self, globals: CliGlobals) -> Result<(), CliError> {
        let mut context = context(globals)?;
        match self.action {
            Some(RegistryAction::Repair {
                confirm,
                snapshot_dir,
                json,
            }) => {
                if !confirm {
                    return Err(CliError::message_with_code(
                        "registry repair is mutating; re-run with --confirm",
                        2,
                    ));
                }
                let report = Registry::repair(&RegistryRepairRequest {
                    registry_path: context.registry_path.clone(),
                    project_root: context.primary_root.clone(),
                    snapshot_dir,
                })
                .map_err(|error| CliError::message_with_code(error.to_string(), 5))?;
                if json {
                    write_report_json(&mut context, &report)?;
                } else {
                    write(
                        &mut context,
                        format!(
                            "registry repaired: {}\nsnapshot: {}\nreceipt: {}\nplan witness: {}\nreceipt witness: {}\nrollback: {}",
                            report.receipt.project_id,
                            report.snapshot_path.display(),
                            report.receipt_path.display(),
                            report.receipt.plan_sha256,
                            report.receipt_sha256,
                            report.rollback_command
                        ),
                    )?;
                }
                Ok(())
            }
            Some(RegistryAction::Rollback {
                receipt,
                witness_sha256,
                receipt_sha256,
                confirm,
                json,
            }) => {
                if !confirm {
                    return Err(CliError::message_with_code(
                        "registry rollback is mutating; re-run with --confirm",
                        2,
                    ));
                }
                let report = Registry::rollback(&RegistryRollbackRequest {
                    receipt_path: receipt,
                    project_root: context.primary_root.clone(),
                    witness_sha256,
                    receipt_sha256,
                })
                .map_err(|error| CliError::message_with_code(error.to_string(), 5))?;
                if json {
                    write_report_json(&mut context, &report)?;
                } else {
                    write(
                        &mut context,
                        format!(
                            "registry rolled back: {}\nsnapshot: {}\nreceipt: {}\nplan witness: {}\nreceipt witness: {}\nrollback: {}",
                            report.receipt.project_id,
                            report.snapshot_path.display(),
                            report.receipt_path.display(),
                            report.receipt.plan_sha256,
                            report.receipt_sha256,
                            report.rollback_command
                        ),
                    )?;
                }
                Ok(())
            }
            None => write(&mut context, "shepherd registry <repair|rollback>".into()),
        }
    }
}

fn context(globals: CliGlobals) -> Result<ExecutionContext, CliError> {
    let cwd = std::env::current_dir().map_err(|error| CliError::message(error.to_string()))?;
    let mut inputs = ContextInputs::from_environment(cwd)
        .map_err(|error| CliError::message(error.to_string()))?;
    inputs.explicit_config = globals.config;
    inputs.verbosity = globals.verbosity;
    ExecutionContext::discover(inputs).map_err(|error| CliError::message(error.to_string()))
}

fn write(context: &mut ExecutionContext, output: String) -> Result<(), CliError> {
    context
        .write_stdout(format!("{output}\n").as_bytes())
        .map_err(|error| CliError::message(format!("cannot write stdout: {error}")))
}

fn write_json<T: serde::Serialize>(
    context: &mut ExecutionContext,
    value: &T,
) -> Result<(), CliError> {
    let output = serde_json::to_string_pretty(value)
        .map_err(|error| CliError::message(format!("cannot encode registry evidence: {error}")))?;
    write(context, output)
}

fn write_report_json(
    context: &mut ExecutionContext,
    report: &shepherd::registry::RegistryRepairReport,
) -> Result<(), CliError> {
    let mut value = serde_json::to_value(&report.receipt)
        .map_err(|error| CliError::message(format!("cannot encode registry evidence: {error}")))?;
    value["receipt_sha256"] = serde_json::Value::String(report.receipt_sha256.clone());
    value["rollback_command"] = serde_json::Value::String(report.rollback_command.clone());
    write_json(context, &value)
}