atproto-devtool 0.1.0

A multitool for the atproto developer ecosystem
Documentation
//! `atproto-devtool test labeler <target>` command.

pub mod crypto;
pub mod http;
pub mod identity;
pub mod pipeline;
pub mod report;
pub mod subscription;

use std::io;
use std::process::ExitCode;
use std::time::Duration;

use clap::Args;
use miette::Report;

use crate::common::{
    APP_USER_AGENT,
    identity::{RealDnsResolver, RealHttpClient},
};
use pipeline::{LabelerOptions, parse_target, run_pipeline};
use report::RenderConfig;

/// Run the labeler conformance suite against a handle, DID, or endpoint URL.
#[derive(Debug, Args)]
pub struct LabelerCmd {
    /// Handle (`alice.example`), DID (`did:plc:...` / `did:web:...`), or labeler endpoint URL.
    pub target: String,

    /// Explicit DID override. Required (and combined with the target URL) when
    /// `target` is a raw endpoint URL and you want identity/crypto checks to run.
    #[arg(long)]
    pub did: Option<String>,

    /// Per-connection time budget for the subscription-layer checks.
    ///
    /// Minimum 1 second; values below 1 second are rejected at parse time.
    #[arg(
        long,
        default_value = "5s",
        value_parser = parse_subscribe_timeout,
    )]
    pub subscribe_timeout: Duration,

    /// Whether to suppress colored output.
    #[arg(long)]
    pub no_color: bool,

    /// Whether to emit verbose diagnostics.
    #[arg(long)]
    pub verbose: bool,
}

impl LabelerCmd {
    pub async fn run(self, no_color: bool) -> Result<ExitCode, Report> {
        // Parse the target.
        let target =
            parse_target(&self.target, self.did.as_deref()).map_err(|e| miette::miette!("{e}"))?;

        // Build a single shared HTTP client.
        let reqwest_client = reqwest::Client::builder()
            .use_rustls_tls()
            .user_agent(APP_USER_AGENT)
            .timeout(std::time::Duration::from_secs(10))
            .build()
            .map_err(|e| miette::miette!("Failed to initialize HTTP client: {}", e))?;

        // Build HTTP and DNS clients using the shared client.
        let http = RealHttpClient::from_client(reqwest_client.clone());
        let dns = RealDnsResolver::new();

        // Run the pipeline.
        let opts = LabelerOptions {
            http: &http,
            dns: &dns,
            http_tee: pipeline::HttpTee::Real(&reqwest_client),
            ws_client: None,
            subscribe_timeout: self.subscribe_timeout,
            verbose: self.verbose,
        };

        let report = run_pipeline(target, opts).await;

        // Render the report to stdout.
        let mut stdout = io::stdout().lock();
        report
            .render(&mut stdout, &RenderConfig { no_color })
            .map_err(|e| miette::miette!("Failed to render report: {}", e))?;

        // Return appropriate exit code.
        let exit_code = report.exit_code();
        Ok(ExitCode::from(exit_code as u8))
    }
}

pub fn parse_subscribe_timeout(raw: &str) -> Result<Duration, String> {
    let parsed = humantime::parse_duration(raw)
        .map_err(|e| format!("invalid --subscribe-timeout value `{raw}`: {e}"))?;

    if parsed < Duration::from_secs(1) {
        return Err(format!(
            "--subscribe-timeout must be at least 1 second (got {parsed:?})"
        ));
    }

    Ok(parsed)
}