#![cfg(feature = "client")]
use anyhow::{Result, anyhow};
use grpc::heddle::v1::ChildEdgeStatus;
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},
};
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,
}
}
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()?;
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 default_mount_name(child_path: &str) -> &str {
child_path
.trim_end_matches('/')
.rsplit('/')
.find(|s| !s.is_empty())
.unwrap_or(child_path)
}
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()
}
fn short(id: &str) -> &str {
&id[..id.len().min(12)]
}
fn edge_status_label(status: i32) -> &'static str {
match ChildEdgeStatus::try_from(status).unwrap_or(ChildEdgeStatus::Unspecified) {
ChildEdgeStatus::UpToDate => "up-to-date",
ChildEdgeStatus::FastForwardable => "fast-forwardable",
ChildEdgeStatus::Diverged => "diverged",
ChildEdgeStatus::NoChildHead => "no-child-head",
ChildEdgeStatus::Unspecified => "unspecified",
}
}
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!(
"Attached {child} under {parent} at '{mount}'",
child = args.child,
parent = args.parent,
);
let anchored = change_id_string(&edge.anchored_state_id);
if !anchored.is_empty() {
println!(" anchored state: {}", short(&anchored));
}
println!(" status: {}", edge_status_label(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?;
if removed {
println!("Detached '{}' from {}", args.mount_name, args.parent);
} else {
println!(
"No child mounted at '{}' under {} (nothing to detach).",
args.mount_name, args.parent
);
}
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!("{} has no child spools.", args.parent);
} else {
println!("{} child spool(s) of {}:", 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(edge.status),
state = short(&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(&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(&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 super::*;
#[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() {
assert_eq!(
edge_status_label(ChildEdgeStatus::UpToDate as i32),
"up-to-date"
);
assert_eq!(
edge_status_label(ChildEdgeStatus::FastForwardable as i32),
"fast-forwardable"
);
assert_eq!(
edge_status_label(ChildEdgeStatus::Diverged as i32),
"diverged"
);
assert_eq!(
edge_status_label(ChildEdgeStatus::NoChildHead as i32),
"no-child-head"
);
assert_eq!(
edge_status_label(ChildEdgeStatus::Unspecified as i32),
"unspecified"
);
}
}