#![cfg(feature = "client")]
use anyhow::{Result, anyhow};
use heddle_core::prove_plan::{
ProofStatusKind, format_unix_secs_label, proof_status_label, proof_submit_followup,
require_host_repo, timestamp_secs_u64,
};
use repo::Repository;
use super::RecoveryAdvice;
use crate::{
cli::{Cli, ProveArgs, ProveCommands, ProveListArgs, ProveSubmitArgs},
client::{HostedAuthMode, HostedGrpcClient},
config::UserConfig,
remote::{RemoteTarget, resolve_remote_with_key},
};
pub async fn cmd_prove(cli: &Cli, args: ProveArgs) -> Result<()> {
match args.command.clone() {
Some(ProveCommands::Submit(sub)) => cmd_prove_submit(cli, sub).await,
Some(ProveCommands::List(list)) => cmd_prove_list(cli, list).await,
None => cmd_prove_start(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!(
"prove 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 identity proofs live on the hosted server"
),
"running locally would imply a proof the hosted server never 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)
}
async fn cmd_prove_start(cli: &Cli, args: ProveArgs) -> Result<()> {
let (host, repo_arg) = require_host_repo(args.host.as_deref(), args.repo_spec.as_deref())
.map_err(|e| anyhow!(e))?;
let repo = cli.open_repo()?;
let mut client = open_hosted_client(&repo, &args.remote).await?;
let challenge = client.request_proof_challenge(host, repo_arg).await?;
if let Some(path) = args.write_file.as_deref() {
if let Some(parent) = std::path::Path::new(path).parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
std::fs::write(path, format!("{}\n", challenge.marker_line))?;
println!("Wrote marker to {path} (commit + push it yourself).");
println!();
}
println!("Proof challenge started for {repo_arg} on {host}.");
println!("Challenge id: {}", challenge.challenge_id);
println!();
println!("1. Create this file in your repo:");
println!();
println!(" {}", challenge.well_known_path);
println!();
println!(" with exactly this line:");
println!();
println!(" {}", challenge.marker_line);
println!();
println!("2. Commit and push it to {repo_arg} (you own the repo — the CLI does not push).");
println!();
println!("3. Verify: heddle prove submit {}", challenge.challenge_id);
Ok(())
}
async fn cmd_prove_submit(cli: &Cli, args: ProveSubmitArgs) -> Result<()> {
let repo = cli.open_repo()?;
let mut client = open_hosted_client(&repo, &args.remote).await?;
let response = client.submit_proof(&args.challenge_id).await?;
let status = ProofStatusKind::from_i32(response.status);
println!("Proof status: {}", proof_status_label(status));
if !response.detail.is_empty() {
println!(" {}", response.detail);
}
if let Some(followup) = proof_submit_followup(status, &args.challenge_id) {
println!("{followup}");
}
Ok(())
}
async fn cmd_prove_list(cli: &Cli, args: ProveListArgs) -> Result<()> {
let repo = cli.open_repo()?;
let mut client = open_hosted_client(&repo, &args.remote).await?;
let proofs = client.list_proofs().await?;
if proofs.is_empty() {
println!("You have no identity proofs.");
return Ok(());
}
println!("{} proof(s):", proofs.len());
println!(
" {host:<20} {repo:<30} {status:<10} verified",
host = "HOST",
repo = "REPO",
status = "STATUS",
);
for proof in &proofs {
let status = ProofStatusKind::from_i32(proof.status);
let when = format_unix_secs_label(timestamp_secs_u64(
proof.verified_at.as_ref().map(|t| t.seconds),
));
println!(
" {host:<20} {repo:<30} {status:<10} {when}",
host = proof.host,
repo = proof.repo,
status = proof_status_label(status),
when = when,
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use clap::Parser;
use super::*;
use crate::cli::{Cli, Commands};
fn parse_prove(extra: &[&str]) -> Result<ProveArgs, clap::Error> {
let mut argv: Vec<&str> = vec!["heddle", "prove"];
argv.extend_from_slice(extra);
let cli = Cli::try_parse_from(argv)?;
match cli.command {
Commands::Prove(args) => Ok(args),
_ => panic!("expected Commands::Prove"),
}
}
#[test]
fn start_parses_host_and_repo_positionals() {
let args = parse_prove(&["github.com", "owner/repo"]).expect("host + repo should parse");
assert!(args.command.is_none());
assert_eq!(args.host.as_deref(), Some("github.com"));
assert_eq!(args.repo_spec.as_deref(), Some("owner/repo"));
assert_eq!(args.remote, "origin");
assert!(args.write_file.is_none());
}
#[test]
fn start_accepts_write_file_and_remote() {
let args = parse_prove(&[
"github.com",
"owner/repo",
"--write-file",
".well-known/heddle",
"--remote",
"upstream",
])
.expect("flags should parse");
assert_eq!(args.write_file.as_deref(), Some(".well-known/heddle"));
assert_eq!(args.remote, "upstream");
}
#[test]
fn start_requires_both_positionals() {
let bare = parse_prove(&[]).expect("bare prove parses");
assert!(require_host_repo(bare.host.as_deref(), bare.repo_spec.as_deref()).is_err());
let host_only = parse_prove(&["github.com"]).expect("host-only parses");
assert!(
require_host_repo(host_only.host.as_deref(), host_only.repo_spec.as_deref()).is_err()
);
let full = parse_prove(&["github.com", "owner/repo"]).expect("full form parses");
let (h, r) = require_host_repo(full.host.as_deref(), full.repo_spec.as_deref())
.expect("full form passes the guard");
assert_eq!((h, r), ("github.com", "owner/repo"));
}
#[test]
fn submit_parses_challenge_id() {
let args = parse_prove(&["submit", "chal-123"]).expect("submit should parse");
match args.command {
Some(ProveCommands::Submit(sub)) => {
assert_eq!(sub.challenge_id, "chal-123");
assert_eq!(sub.remote, "origin");
}
other => panic!("expected Submit, got {other:?}"),
}
}
#[test]
fn submit_requires_challenge_id() {
parse_prove(&["submit"]).expect_err("submit without id should fail");
}
#[test]
fn list_parses_with_default_remote() {
let args = parse_prove(&["list"]).expect("list should parse");
match args.command {
Some(ProveCommands::List(list)) => assert_eq!(list.remote, "origin"),
other => panic!("expected List, got {other:?}"),
}
}
#[test]
fn status_label_covers_every_variant() {
assert_eq!(proof_status_label(ProofStatusKind::Verified), "verified");
assert_eq!(proof_status_label(ProofStatusKind::Pending), "pending");
assert_eq!(proof_status_label(ProofStatusKind::Failed), "failed");
assert_eq!(
proof_status_label(ProofStatusKind::Unspecified),
"unspecified"
);
}
}