CLI app for developers prototyping atproto functionality
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add LabelerTarget parsing and pipeline driver skeleton

Implements Task 3 from Phase 3 of the test-labeler feature. Adds:

- LabelerTarget enum (Identified and Endpoint variants) for representing
labeler targets (handles, DIDs, or raw URLs).
- AtIdentifier enum for handles and DIDs.
- LabelerOptions struct with HTTP client, DNS resolver, subscription
timeout, and verbose flag.
- parse_target() function with full target parsing logic supporting
handles, DIDs (did:plc and did:web), HTTPS URLs, and explicit --did
flag handling. Rejects HTTP URLs with helpful messages.
- TargetParseError diagnostic for user-facing error messages.
- run_pipeline() async driver skeleton that builds a LabelerReport,
records stub check results for all four stages (identity, HTTP,
subscription, crypto), and returns the finished report. Identity stage
is stubbed with 'not yet implemented (phase 3 task 4)' reason; later
stages skip with appropriate phase references.
- 8 unit tests covering all target parsing cases (handles, DIDs,
HTTPS endpoints, explicit DIDs, HTTP rejection, ambiguous specs,
and unrecognized targets).

All tests pass. Code passes cargo fmt and cargo clippy --all-targets.

Verifies AC1.1-AC1.5 partially (full pipeline verification deferred to
integration tests in Task 6).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

+363
+1
src/commands/test/labeler.rs
··· 1 1 //! `atproto-devtool test labeler <target>` command. 2 2 3 + pub mod pipeline; 3 4 pub mod report; 4 5 5 6 use std::time::Duration;
+362
src/commands/test/labeler/pipeline.rs
··· 1 + //! Target parsing and pipeline driver skeleton for labeler conformance checks. 2 + 3 + use std::borrow::Cow; 4 + use std::time::Duration; 5 + 6 + use miette::Diagnostic; 7 + use thiserror::Error; 8 + use url::Url; 9 + 10 + use crate::commands::test::labeler::report::{CheckResult, LabelerReport, ReportHeader, Stage}; 11 + use crate::common::identity::{Did, DnsResolver, HttpClient}; 12 + 13 + /// A labeler target: either a resolvable identifier (handle or DID) or a raw endpoint URL. 14 + #[derive(Debug, Clone)] 15 + pub enum LabelerTarget { 16 + /// A handle or DID that can be resolved. 17 + Identified { 18 + /// The handle or DID to resolve. 19 + identifier: AtIdentifier, 20 + /// An optional explicit DID override (for cross-checking). 21 + explicit_did: Option<Did>, 22 + }, 23 + /// A raw HTTP endpoint, optionally with a DID for identity checks. 24 + Endpoint { 25 + /// The endpoint URL. 26 + url: Url, 27 + /// An optional DID to cross-check against the endpoint. 28 + did: Option<Did>, 29 + }, 30 + } 31 + 32 + /// An ATProto identifier: a handle or a DID. 33 + #[derive(Debug, Clone)] 34 + pub enum AtIdentifier { 35 + /// An ATProto handle (e.g., `alice.bsky.social`). 36 + Handle(String), 37 + /// A decentralized identifier (e.g., `did:plc:...` or `did:web:...`). 38 + Did(Did), 39 + } 40 + 41 + /// Options for running the labeler pipeline. 42 + pub struct LabelerOptions<'a> { 43 + /// HTTP client for network requests. 44 + pub http: &'a dyn HttpClient, 45 + /// DNS resolver for handle lookups. 46 + pub dns: &'a dyn DnsResolver, 47 + /// Per-connection time budget for subscription checks. 48 + pub subscribe_timeout: Duration, 49 + /// Whether to emit verbose diagnostics. 50 + pub verbose: bool, 51 + } 52 + 53 + /// Error from target parsing. 54 + #[derive(Debug, Error, Diagnostic)] 55 + #[error("{message}")] 56 + pub struct TargetParseError { 57 + /// The error message. 58 + message: String, 59 + } 60 + 61 + impl TargetParseError { 62 + fn new(message: impl Into<String>) -> Self { 63 + Self { 64 + message: message.into(), 65 + } 66 + } 67 + 68 + fn unrecognized_target(raw: &str) -> Self { 69 + Self::new(format!( 70 + "Unrecognized target '{}'. Expected one of:\n - ATProto handle (e.g., alice.bsky.social)\n - DID (e.g., did:plc:abc123 or did:web:example.com)\n - HTTPS endpoint URL (e.g., https://labeler.example.com)", 71 + raw 72 + )) 73 + } 74 + 75 + fn http_not_supported(raw: &str) -> Self { 76 + Self::new(format!( 77 + "HTTP endpoint '{}' is not supported. Please use an HTTPS endpoint instead.", 78 + raw 79 + )) 80 + } 81 + 82 + fn ambiguous_did(raw: &str, explicit: &str) -> Self { 83 + Self::new(format!( 84 + "Ambiguous target specification: target '{}' is already a DID, but --did {} was also provided. Please use only one.", 85 + raw, explicit 86 + )) 87 + } 88 + } 89 + 90 + /// Check if a string is a valid ATProto handle. 91 + /// 92 + /// A valid handle: 93 + /// - Contains at least one dot. 94 + /// - Contains only alphanumeric characters, hyphens, and dots. 95 + /// - Does not start or end with a hyphen or dot. 96 + /// - Has no empty segments (no consecutive dots or leading/trailing dots). 97 + fn is_valid_handle(s: &str) -> bool { 98 + if !s.contains('.') { 99 + return false; 100 + } 101 + 102 + // Check for empty string or leading/trailing special chars. 103 + if s.is_empty() 104 + || s.starts_with('-') 105 + || s.starts_with('.') 106 + || s.ends_with('-') 107 + || s.ends_with('.') 108 + { 109 + return false; 110 + } 111 + 112 + // Check all characters are alphanumeric, hyphen, or dot. 113 + for c in s.chars() { 114 + if !c.is_ascii_alphanumeric() && c != '-' && c != '.' { 115 + return false; 116 + } 117 + } 118 + 119 + // Check no empty segments (no consecutive dots). 120 + if s.contains("..") { 121 + return false; 122 + } 123 + 124 + true 125 + } 126 + 127 + /// Parse a labeler target from a string and optional explicit DID. 128 + /// 129 + /// Returns a `LabelerTarget` on success, or a `TargetParseError` on failure. 130 + /// 131 + /// Rules: 132 + /// - If `raw` starts with `did:`, parse as a DID. If `explicit_did` is also provided, return an error. 133 + /// - If `raw` starts with `https://`, parse as a URL. `explicit_did` is carried as an optional DID. 134 + /// - If `raw` starts with `http://`, return an error pointing the user to HTTPS. 135 + /// - If `raw` contains a dot and matches handle grammar, treat as a handle. `explicit_did` is carried. 136 + /// - Otherwise, return an unrecognized target error. 137 + pub fn parse_target( 138 + raw: &str, 139 + explicit_did: Option<&str>, 140 + ) -> Result<LabelerTarget, TargetParseError> { 141 + // Check for DID. 142 + if raw.starts_with("did:") { 143 + if let Some(ed) = explicit_did { 144 + return Err(TargetParseError::ambiguous_did(raw, ed)); 145 + } 146 + return Ok(LabelerTarget::Identified { 147 + identifier: AtIdentifier::Did(Did(raw.to_string())), 148 + explicit_did: None, 149 + }); 150 + } 151 + 152 + // Check for HTTPS URL. 153 + if raw.starts_with("https://") { 154 + let url = Url::parse(raw) 155 + .map_err(|e| TargetParseError::new(format!("Invalid URL '{}': {}", raw, e)))?; 156 + return Ok(LabelerTarget::Endpoint { 157 + url, 158 + did: explicit_did.map(|d| Did(d.to_string())), 159 + }); 160 + } 161 + 162 + // Check for HTTP URL (reject with helpful message). 163 + if raw.starts_with("http://") { 164 + return Err(TargetParseError::http_not_supported(raw)); 165 + } 166 + 167 + // Check for handle. 168 + if is_valid_handle(raw) { 169 + return Ok(LabelerTarget::Identified { 170 + identifier: AtIdentifier::Handle(raw.to_string()), 171 + explicit_did: explicit_did.map(|d| Did(d.to_string())), 172 + }); 173 + } 174 + 175 + // Unrecognized target. 176 + Err(TargetParseError::unrecognized_target(raw)) 177 + } 178 + 179 + /// Run the full labeler conformance pipeline. 180 + /// 181 + /// This is the main driver that orchestrates all validation stages. 182 + /// Currently only identity checks are stubbed; later stages return `Skipped` results. 183 + pub async fn run_pipeline(target: LabelerTarget, _opts: LabelerOptions<'_>) -> LabelerReport { 184 + // Build initial header from target. 185 + let header = ReportHeader { 186 + target: format_target(&target), 187 + resolved_did: None, 188 + pds_endpoint: None, 189 + labeler_endpoint: None, 190 + }; 191 + 192 + let mut report = LabelerReport::new(header); 193 + 194 + // TODO: Implement identity stage (Task 4). 195 + // For now, stub it out so the pipeline compiles. 196 + report.record(CheckResult { 197 + id: "identity::stub", 198 + stage: Stage::Identity, 199 + status: crate::commands::test::labeler::report::CheckStatus::Skipped, 200 + summary: Cow::Borrowed("identity stage (stub)"), 201 + diagnostic: None, 202 + skipped_reason: Some(Cow::Borrowed("not yet implemented (phase 3 task 4)")), 203 + }); 204 + 205 + // Stub HTTP stage. 206 + report.record(CheckResult { 207 + id: "http::stub", 208 + stage: Stage::Http, 209 + status: crate::commands::test::labeler::report::CheckStatus::Skipped, 210 + summary: Cow::Borrowed("HTTP stage (stub)"), 211 + diagnostic: None, 212 + skipped_reason: Some(Cow::Borrowed("not yet implemented (phase 4)")), 213 + }); 214 + 215 + // Stub subscription stage. 216 + report.record(CheckResult { 217 + id: "subscription::stub", 218 + stage: Stage::Subscription, 219 + status: crate::commands::test::labeler::report::CheckStatus::Skipped, 220 + summary: Cow::Borrowed("Subscription stage (stub)"), 221 + diagnostic: None, 222 + skipped_reason: Some(Cow::Borrowed("not yet implemented (phase 5)")), 223 + }); 224 + 225 + // Stub crypto stage. 226 + report.record(CheckResult { 227 + id: "crypto::stub", 228 + stage: Stage::Crypto, 229 + status: crate::commands::test::labeler::report::CheckStatus::Skipped, 230 + summary: Cow::Borrowed("Crypto stage (stub)"), 231 + diagnostic: None, 232 + skipped_reason: Some(Cow::Borrowed("not yet implemented (phase 6)")), 233 + }); 234 + 235 + report.finish(); 236 + report 237 + } 238 + 239 + /// Format a target for display in the report header. 240 + fn format_target(target: &LabelerTarget) -> String { 241 + match target { 242 + LabelerTarget::Identified { 243 + identifier, 244 + explicit_did, 245 + } => { 246 + let id_str = match identifier { 247 + AtIdentifier::Handle(h) => h.clone(), 248 + AtIdentifier::Did(d) => d.0.clone(), 249 + }; 250 + if explicit_did.is_some() { 251 + format!("{} (with explicit DID)", id_str) 252 + } else { 253 + id_str 254 + } 255 + } 256 + LabelerTarget::Endpoint { url, did } => { 257 + if did.is_some() { 258 + format!("{} (with explicit DID)", url) 259 + } else { 260 + url.to_string() 261 + } 262 + } 263 + } 264 + } 265 + 266 + #[cfg(test)] 267 + mod tests { 268 + use super::*; 269 + 270 + #[test] 271 + fn parse_target_handle() { 272 + let target = parse_target("alice.bsky.social", None).expect("should parse"); 273 + match target { 274 + LabelerTarget::Identified { 275 + identifier, 276 + explicit_did, 277 + } => { 278 + assert!( 279 + matches!(identifier, AtIdentifier::Handle(ref h) if h == "alice.bsky.social") 280 + ); 281 + assert!(explicit_did.is_none()); 282 + } 283 + _ => panic!("expected Identified variant"), 284 + } 285 + } 286 + 287 + #[test] 288 + fn parse_target_did_plc() { 289 + let target = parse_target("did:plc:abc123", None).expect("should parse"); 290 + match target { 291 + LabelerTarget::Identified { 292 + identifier, 293 + explicit_did, 294 + } => { 295 + assert!(matches!(identifier, AtIdentifier::Did(ref d) if d.0 == "did:plc:abc123")); 296 + assert!(explicit_did.is_none()); 297 + } 298 + _ => panic!("expected Identified variant"), 299 + } 300 + } 301 + 302 + #[test] 303 + fn parse_target_did_web() { 304 + let target = parse_target("did:web:example.com", None).expect("should parse"); 305 + match target { 306 + LabelerTarget::Identified { 307 + identifier, 308 + explicit_did, 309 + } => { 310 + assert!( 311 + matches!(identifier, AtIdentifier::Did(ref d) if d.0 == "did:web:example.com") 312 + ); 313 + assert!(explicit_did.is_none()); 314 + } 315 + _ => panic!("expected Identified variant"), 316 + } 317 + } 318 + 319 + #[test] 320 + fn parse_target_endpoint_https() { 321 + let target = parse_target("https://example.com/labeler", None).expect("should parse"); 322 + match target { 323 + LabelerTarget::Endpoint { url, did } => { 324 + assert_eq!(url.as_str(), "https://example.com/labeler"); 325 + assert!(did.is_none()); 326 + } 327 + _ => panic!("expected Endpoint variant"), 328 + } 329 + } 330 + 331 + #[test] 332 + fn parse_target_endpoint_with_explicit_did() { 333 + let target = 334 + parse_target("https://example.com/labeler", Some("did:plc:xyz")).expect("should parse"); 335 + match target { 336 + LabelerTarget::Endpoint { url, did } => { 337 + assert_eq!(url.as_str(), "https://example.com/labeler"); 338 + assert_eq!(did.map(|d| d.0.clone()), Some("did:plc:xyz".to_string())); 339 + } 340 + _ => panic!("expected Endpoint variant"), 341 + } 342 + } 343 + 344 + #[test] 345 + fn parse_target_endpoint_http_rejected() { 346 + let err = parse_target("http://evil.example", None).expect_err("should reject http"); 347 + assert!(err.message.contains("HTTPS")); 348 + } 349 + 350 + #[test] 351 + fn parse_target_unrecognised() { 352 + let err = parse_target("not a handle or did", None).expect_err("should fail"); 353 + assert!(err.message.contains("Unrecognized target")); 354 + } 355 + 356 + #[test] 357 + fn parse_target_did_with_conflicting_flag() { 358 + let err = parse_target("did:plc:abc", Some("did:web:example.com")) 359 + .expect_err("should reject ambiguous target"); 360 + assert!(err.message.contains("Ambiguous")); 361 + } 362 + }