#![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},
};
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 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() {
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)
);
}
}