heddle-cli 0.10.1

An AI-native version control system
Documentation
// SPDX-License-Identifier: Apache-2.0
//! `heddle spool …` — hosted spool child-edge management + facet-history
//! inspection (Spool epic P9, weft#358).
//!
//! Thin wrappers over the `HostedUserService` Spool RPCs. Each subcommand
//! resolves the hosted server from a named remote (mirroring `heddle thread
//! approve/…`) and renders the raw proto reply as text. The monorepo *clone*
//! lives in `clone.rs`; this file is the edge/history control surface.
//!
//! These are text-output management verbs (like `heddle presence`/`support`);
//! they do not advertise a JSON schema.

#![cfg(feature = "client")]

use anyhow::{Result, anyhow};
use heddle_core::spool_plan::{
    default_mount_name, edge_status_label_from_i32, short_id, spool_attach_message,
    spool_children_empty_message, spool_children_header, spool_detach_message,
};
use repo::Repository;

use super::RecoveryAdvice;
use crate::{
    cli::{
        Cli, SpoolAttachArgs, SpoolChildrenArgs, SpoolCommands, SpoolDetachArgs, SpoolHistoryArgs,
    },
    client::{HostedAuthMode, HostedGrpcClient},
    config::UserConfig,
    remote::{RemoteTarget, resolve_remote_with_key},
};

/// Dispatch `heddle spool <subcommand>`.
pub async fn cmd_spool(cli: &Cli, command: SpoolCommands) -> Result<()> {
    match command {
        SpoolCommands::Attach(args) => cmd_spool_attach(cli, args).await,
        SpoolCommands::Detach(args) => cmd_spool_detach(cli, args).await,
        SpoolCommands::Children(args) => cmd_spool_children(cli, args).await,
        SpoolCommands::Governance(args) => cmd_spool_governance(cli, args).await,
        SpoolCommands::Membership(args) => cmd_spool_membership(cli, args).await,
    }
}

/// Resolve the named remote to a hosted client. Spool edges are a hosted-server
/// concept, so a local remote is a hard error (mirrors thread approvals).
async fn open_hosted_client(repo: &Repository, remote_name: &str) -> Result<HostedGrpcClient> {
    let (target, server_key) = resolve_remote_with_key(repo, Some(remote_name))?;
    let addr = match target {
        RemoteTarget::Network { addr, .. } => addr,
        RemoteTarget::Local(_) => {
            return Err(anyhow!(RecoveryAdvice::safety_refusal(
                "hosted_remote_required",
                format!(
                    "spool operations require a hosted remote; remote '{remote_name}' is local"
                ),
                "Configure a hosted remote or retry against one that resolves to a network target.",
                format!(
                    "remote '{remote_name}' is local, but spool child edges live on the hosted server"
                ),
                "running locally would imply a hosted spool change that no server recorded",
                "no hosted request was sent and local repository state was left unchanged",
                "heddle remote list",
                vec!["heddle remote list".to_string()],
            )));
        }
    };

    let user_config = UserConfig::load_default()?;
    // Attach/detach are pop-tier mutations, so use CredentialFallback (resolves
    // the credential store's proof key) rather than a token-only session, and
    // register the terminal human-signature callback for any human-tier
    // escalation.
    let client = HostedGrpcClient::open_session(
        addr,
        &user_config,
        server_key,
        HostedAuthMode::CredentialFallback,
    )
    .await?
    .with_human_signature_callback(crate::client::cli_human_signature_callback());
    Ok(client)
}

fn change_id_string(bytes: &[u8]) -> String {
    if bytes.is_empty() {
        return String::new();
    }
    objects::object::ChangeId::try_from_slice(bytes)
        .map(|id| id.to_string_full())
        .unwrap_or_default()
}

async fn cmd_spool_attach(cli: &Cli, args: SpoolAttachArgs) -> Result<()> {
    let repo = cli.open_repo()?;
    let mount = args
        .mount_name
        .clone()
        .unwrap_or_else(|| default_mount_name(&args.child).to_string());
    let mut client = open_hosted_client(&repo, &args.remote).await?;
    let edge = client
        .attach_child(&args.parent, &args.child, &mount)
        .await?;

    println!(
        "{}",
        spool_attach_message(&args.child, &args.parent, &mount)
    );
    let anchored = change_id_string(&edge.anchored_state_id);
    if !anchored.is_empty() {
        println!("  anchored state: {}", short_id(&anchored));
    }
    println!(
        "  status:         {}",
        edge_status_label_from_i32(edge.status)
    );
    Ok(())
}

async fn cmd_spool_detach(cli: &Cli, args: SpoolDetachArgs) -> Result<()> {
    let repo = cli.open_repo()?;
    let mut client = open_hosted_client(&repo, &args.remote).await?;
    let removed = client.detach_child(&args.parent, &args.mount_name).await?;

    println!(
        "{}",
        spool_detach_message(&args.mount_name, &args.parent, removed)
    );
    Ok(())
}

async fn cmd_spool_children(cli: &Cli, args: SpoolChildrenArgs) -> Result<()> {
    let repo = cli.open_repo()?;
    let mut client = open_hosted_client(&repo, &args.remote).await?;
    let children = client.list_children(&args.parent).await?;

    if children.is_empty() {
        println!("{}", spool_children_empty_message(&args.parent));
    } else {
        println!("{}", spool_children_header(children.len(), &args.parent));
        for edge in &children {
            let anchored = change_id_string(&edge.anchored_state_id);
            println!(
                "  {mount:<20} {child}  [{status}] @ {state}",
                mount = edge.mount_name,
                child = edge.child_spool_id,
                status = edge_status_label_from_i32(edge.status),
                state = short_id(&anchored),
            );
        }
    }
    Ok(())
}

async fn cmd_spool_governance(cli: &Cli, args: SpoolHistoryArgs) -> Result<()> {
    let repo = cli.open_repo()?;
    let mut client = open_hosted_client(&repo, &args.remote).await?;
    let entries = client
        .get_governance_history(&args.spool, args.limit)
        .await?;

    if entries.is_empty() {
        println!("No governance history for {}.", args.spool);
    } else {
        println!(
            "{} governance entr{} for {} (newest first):",
            entries.len(),
            if entries.len() == 1 { "y" } else { "ies" },
            args.spool
        );
        for entry in &entries {
            let cid = change_id_string(&entry.change_id);
            println!(
                "  {cid}  visibility={vis}  {when}",
                cid = short_id(&cid),
                vis = entry.visibility,
                when = ts_label(&entry.committed_at),
            );
        }
    }
    Ok(())
}

async fn cmd_spool_membership(cli: &Cli, args: SpoolHistoryArgs) -> Result<()> {
    let repo = cli.open_repo()?;
    let mut client = open_hosted_client(&repo, &args.remote).await?;
    let entries = client
        .get_membership_history(&args.spool, args.limit)
        .await?;

    if entries.is_empty() {
        println!("No membership history for {}.", args.spool);
    } else {
        println!(
            "{} membership entr{} for {} (newest first):",
            entries.len(),
            if entries.len() == 1 { "y" } else { "ies" },
            args.spool
        );
        for entry in &entries {
            let cid = change_id_string(&entry.change_id);
            println!(
                "  {cid}  {n} grant(s)  {when}",
                cid = short_id(&cid),
                n = entry.grants.len(),
                when = ts_label(&entry.committed_at),
            );
            for grant in &entry.grants {
                println!("    {} = {}", grant.subject, grant.role);
            }
        }
    }
    Ok(())
}

fn ts_secs(ts: &Option<prost_types::Timestamp>) -> u64 {
    ts.as_ref().map(|t| t.seconds.max(0) as u64).unwrap_or(0)
}

fn ts_label(ts: &Option<prost_types::Timestamp>) -> String {
    let secs = ts_secs(ts);
    if secs == 0 {
        return String::new();
    }
    chrono::DateTime::from_timestamp(secs as i64, 0)
        .map(|d| d.to_rfc3339())
        .unwrap_or_else(|| secs.to_string())
}

#[cfg(test)]
mod tests {
    use heddle_core::spool_plan::{
        ChildEdgeStatusKind, default_mount_name, edge_status_from_i32, edge_status_label,
        edge_status_label_from_i32,
    };

    #[test]
    fn default_mount_name_uses_last_path_segment() {
        assert_eq!(default_mount_name("acme/lib"), "lib");
        assert_eq!(default_mount_name("acme/team/lib"), "lib");
        assert_eq!(default_mount_name("acme/lib/"), "lib");
        assert_eq!(default_mount_name("solo"), "solo");
    }

    #[test]
    fn edge_status_label_covers_every_variant() {
        // Wire discriminants match hosted.proto ChildEdgeStatus.
        assert_eq!(edge_status_label_from_i32(1), "up-to-date");
        assert_eq!(edge_status_label_from_i32(2), "fast-forwardable");
        assert_eq!(edge_status_label_from_i32(3), "diverged");
        assert_eq!(edge_status_label_from_i32(4), "no-child-head");
        assert_eq!(edge_status_label_from_i32(0), "unspecified");
        assert_eq!(
            edge_status_label(edge_status_from_i32(1)),
            edge_status_label(ChildEdgeStatusKind::UpToDate)
        );
    }
}