capability.rs (16091B)
1 //! Side-effect-free client capability reporting. 2 3 use std::collections::{BTreeMap, BTreeSet}; 4 5 /// Stable runtime identity for an SDK capability. 6 /// 7 /// These values intentionally describe behavior rather than Cargo features. 8 /// Construction is private so every reported ID comes from the governed 9 /// catalog below. 10 #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] 11 pub struct CapabilityId(&'static str); 12 13 impl CapabilityId { 14 /// Canonical storage operations. 15 pub const CANONICAL_STORAGE: Self = Self("storage.canonical"); 16 /// Persistent storage operations. 17 pub const PERSISTENT_STORAGE: Self = Self("storage.persistent"); 18 /// Storage backup and restore operations. 19 pub const BACKUP_RESTORE: Self = Self("storage.backup-restore"); 20 /// Local signing. 21 pub const LOCAL_SIGNING: Self = Self("signing.local"); 22 /// NIP-46 remote signing. 23 pub const NIP46_SIGNING: Self = Self("signing.nip46"); 24 /// Nostr event fetch. 25 pub const NOSTR_FETCH: Self = Self("transport.nostr.fetch"); 26 /// Nostr event delivery. 27 pub const NOSTR_DELIVERY: Self = Self("transport.nostr.delivery"); 28 /// Reticulum event fetch preview. 29 pub const RETICULUM_FETCH: Self = Self("transport.reticulum.fetch"); 30 /// Reticulum event delivery preview. 31 pub const RETICULUM_DELIVERY: Self = Self("transport.reticulum.delivery"); 32 /// Mesh transport preview. 33 pub const MESH_TRANSPORT: Self = Self("transport.mesh"); 34 /// SimpleX transport experiment. 35 pub const SIMPLEX_TRANSPORT: Self = Self("transport.simplex"); 36 /// Daemon-mediated event delivery. 37 pub const DAEMON_DELIVERY: Self = Self("transport.daemon.delivery"); 38 /// Inbound synchronization. 39 pub const SYNC_PULL: Self = Self("sync.pull"); 40 /// Outbound synchronization. 41 pub const SYNC_PUSH: Self = Self("sync.push"); 42 /// Farm event publication. 43 pub const FARM_PUBLICATION: Self = Self("product.farm.publish"); 44 /// Listing event publication. 45 pub const LISTING_PUBLICATION: Self = Self("product.listing.publish"); 46 /// Trade command execution. 47 pub const TRADE_COMMANDS: Self = Self("product.trade.command"); 48 /// Trade queries. 49 pub const TRADE_QUERIES: Self = Self("product.trade.query"); 50 /// Knowledge event support. 51 pub const KNOWLEDGE_EVENTS: Self = Self("event.knowledge"); 52 53 /// Returns the stable presentation-independent identity. 54 #[must_use] 55 pub const fn as_str(self) -> &'static str { 56 self.0 57 } 58 } 59 60 impl std::fmt::Display for CapabilityId { 61 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 62 formatter.write_str(self.0) 63 } 64 } 65 66 /// Product maturity independent of runtime availability. 67 #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] 68 pub enum Maturity { 69 /// Supported Release V1 behavior. 70 Stable, 71 /// Preserved pre-stable behavior with an explicit compatibility warning. 72 Preview, 73 /// Exploratory behavior without a compatibility commitment. 74 Experimental, 75 } 76 77 /// Current runtime availability independent of compilation and configuration. 78 #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] 79 pub enum Availability { 80 /// The configured capability is ready. 81 Available, 82 /// The configured capability is usable with reduced functionality. 83 Degraded, 84 /// The capability is compiled but not currently usable. 85 Unavailable, 86 /// The capability is not supported by this build. 87 Unsupported, 88 } 89 90 /// One immutable capability observation. 91 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 92 pub struct CapabilityStatus { 93 id: CapabilityId, 94 compiled: bool, 95 configured: bool, 96 availability: Availability, 97 maturity: Maturity, 98 } 99 100 impl CapabilityStatus { 101 /// Returns the stable runtime identity. 102 #[must_use] 103 pub const fn id(self) -> CapabilityId { 104 self.id 105 } 106 107 /// Returns whether support was compiled into this package. 108 #[must_use] 109 pub const fn is_compiled(self) -> bool { 110 self.compiled 111 } 112 113 /// Returns whether the host configured the capability for this client. 114 #[must_use] 115 pub const fn is_configured(self) -> bool { 116 self.configured 117 } 118 119 /// Returns the current side-effect-free availability observation. 120 #[must_use] 121 pub const fn availability(self) -> Availability { 122 self.availability 123 } 124 125 /// Returns the independent product maturity classification. 126 #[must_use] 127 pub const fn maturity(self) -> Maturity { 128 self.maturity 129 } 130 } 131 132 /// Complete deterministic capability report for one client observation. 133 #[derive(Clone, Debug, Eq, PartialEq)] 134 pub struct CapabilityReport { 135 statuses: Vec<CapabilityStatus>, 136 } 137 138 impl CapabilityReport { 139 /// Returns all known capabilities in stable catalog order. 140 pub fn iter(&self) -> impl ExactSizeIterator<Item = &CapabilityStatus> { 141 self.statuses.iter() 142 } 143 144 /// Finds one capability by stable runtime identity. 145 #[must_use] 146 pub fn get(&self, id: CapabilityId) -> Option<CapabilityStatus> { 147 self.statuses.iter().copied().find(|status| status.id == id) 148 } 149 } 150 151 #[derive(Clone, Copy)] 152 struct Definition { 153 id: CapabilityId, 154 maturity: Maturity, 155 compiled: bool, 156 } 157 158 const CATALOG: &[Definition] = &[ 159 Definition::stable(CapabilityId::CANONICAL_STORAGE, true), 160 Definition::stable(CapabilityId::PERSISTENT_STORAGE, cfg!(feature = "sqlite")), 161 Definition::stable(CapabilityId::BACKUP_RESTORE, true), 162 Definition::stable(CapabilityId::LOCAL_SIGNING, cfg!(feature = "local-signing")), 163 Definition::stable(CapabilityId::NIP46_SIGNING, cfg!(feature = "nip46")), 164 Definition::stable(CapabilityId::NOSTR_FETCH, cfg!(feature = "nostr")), 165 Definition::stable(CapabilityId::NOSTR_DELIVERY, cfg!(feature = "nostr")), 166 Definition::preview(CapabilityId::RETICULUM_FETCH), 167 Definition::preview(CapabilityId::RETICULUM_DELIVERY), 168 Definition::experimental(CapabilityId::MESH_TRANSPORT), 169 Definition::experimental(CapabilityId::SIMPLEX_TRANSPORT), 170 Definition::stable(CapabilityId::DAEMON_DELIVERY, cfg!(feature = "radrootsd")), 171 Definition::stable(CapabilityId::SYNC_PULL, cfg!(feature = "sync")), 172 Definition::stable(CapabilityId::SYNC_PUSH, cfg!(feature = "sync")), 173 Definition::stable(CapabilityId::FARM_PUBLICATION, true), 174 Definition::stable(CapabilityId::LISTING_PUBLICATION, true), 175 Definition::stable(CapabilityId::TRADE_COMMANDS, true), 176 Definition::stable(CapabilityId::TRADE_QUERIES, true), 177 Definition::stable(CapabilityId::KNOWLEDGE_EVENTS, cfg!(feature = "knowledge")), 178 ]; 179 180 impl Definition { 181 const fn stable(id: CapabilityId, compiled: bool) -> Self { 182 Self { 183 id, 184 maturity: Maturity::Stable, 185 compiled, 186 } 187 } 188 189 const fn preview(id: CapabilityId) -> Self { 190 Self { 191 id, 192 maturity: Maturity::Preview, 193 compiled: false, 194 } 195 } 196 197 const fn experimental(id: CapabilityId) -> Self { 198 Self { 199 id, 200 maturity: Maturity::Experimental, 201 compiled: false, 202 } 203 } 204 } 205 206 pub(crate) struct Context<'a> { 207 pub(crate) storage: bool, 208 pub(crate) signer: bool, 209 pub(crate) source: bool, 210 pub(crate) sink: bool, 211 pub(crate) sync: bool, 212 pub(crate) source_availability: Availability, 213 pub(crate) sink_availability: Availability, 214 pub(crate) lifecycle_availability: Availability, 215 pub(crate) explicitly_configured: &'a BTreeSet<CapabilityId>, 216 pub(crate) overrides: &'a BTreeMap<CapabilityId, Availability>, 217 } 218 219 pub(crate) fn report(context: Context<'_>) -> CapabilityReport { 220 let statuses = CATALOG 221 .iter() 222 .map(|definition| { 223 let configured = configured(definition.id, &context); 224 let availability = if !definition.compiled { 225 Availability::Unsupported 226 } else if !configured { 227 Availability::Unavailable 228 } else if context.lifecycle_availability != Availability::Available { 229 context.lifecycle_availability 230 } else { 231 context 232 .overrides 233 .get(&definition.id) 234 .copied() 235 .unwrap_or_else(|| directional_availability(definition.id, &context)) 236 }; 237 CapabilityStatus { 238 id: definition.id, 239 compiled: definition.compiled, 240 configured, 241 availability, 242 maturity: definition.maturity, 243 } 244 }) 245 .collect(); 246 CapabilityReport { statuses } 247 } 248 249 fn directional_availability(id: CapabilityId, context: &Context<'_>) -> Availability { 250 match id { 251 CapabilityId::NOSTR_FETCH | CapabilityId::SYNC_PULL => context.source_availability, 252 CapabilityId::NOSTR_DELIVERY 253 | CapabilityId::SYNC_PUSH 254 | CapabilityId::FARM_PUBLICATION 255 | CapabilityId::LISTING_PUBLICATION 256 | CapabilityId::TRADE_COMMANDS => context.sink_availability, 257 _ => context.lifecycle_availability, 258 } 259 } 260 261 fn configured(id: CapabilityId, context: &Context<'_>) -> bool { 262 match id { 263 CapabilityId::CANONICAL_STORAGE 264 | CapabilityId::BACKUP_RESTORE 265 | CapabilityId::TRADE_QUERIES => context.storage, 266 CapabilityId::SYNC_PULL => (context.sync, context.source) == (true, true), 267 CapabilityId::SYNC_PUSH => (context.sync, context.sink) == (true, true), 268 CapabilityId::FARM_PUBLICATION 269 | CapabilityId::LISTING_PUBLICATION 270 | CapabilityId::TRADE_COMMANDS => (context.signer, context.sink) == (true, true), 271 CapabilityId::KNOWLEDGE_EVENTS => cfg!(feature = "knowledge"), 272 CapabilityId::RETICULUM_FETCH 273 | CapabilityId::RETICULUM_DELIVERY 274 | CapabilityId::MESH_TRANSPORT 275 | CapabilityId::SIMPLEX_TRANSPORT => false, 276 _ => context.explicitly_configured.contains(&id), 277 } 278 } 279 280 #[cfg(test)] 281 mod tests { 282 use super::*; 283 284 #[test] 285 fn catalog_ids_are_unique_and_not_feature_names() { 286 for (index, definition) in CATALOG.iter().enumerate() { 287 assert!( 288 !CATALOG[..index] 289 .iter() 290 .any(|candidate| candidate.id == definition.id), 291 "duplicate capability {}", 292 definition.id 293 ); 294 assert!(definition.id.as_str().contains('.')); 295 } 296 } 297 298 #[test] 299 fn maturity_and_unsupported_preview_states_are_independent() { 300 let overrides = BTreeMap::new(); 301 let explicitly_configured = BTreeSet::new(); 302 let report = report(Context { 303 storage: true, 304 signer: false, 305 source: false, 306 sink: false, 307 sync: false, 308 source_availability: Availability::Unavailable, 309 sink_availability: Availability::Unavailable, 310 lifecycle_availability: Availability::Available, 311 explicitly_configured: &explicitly_configured, 312 overrides: &overrides, 313 }); 314 let storage = report 315 .get(CapabilityId::CANONICAL_STORAGE) 316 .expect("storage"); 317 assert!(storage.is_compiled()); 318 assert!(storage.is_configured()); 319 assert_eq!(storage.availability(), Availability::Available); 320 assert_eq!(storage.maturity(), Maturity::Stable); 321 322 let knowledge = report 323 .get(CapabilityId::KNOWLEDGE_EVENTS) 324 .expect("knowledge"); 325 assert_eq!(knowledge.is_compiled(), cfg!(feature = "knowledge")); 326 assert_eq!(knowledge.is_configured(), cfg!(feature = "knowledge")); 327 328 let reticulum = report 329 .get(CapabilityId::RETICULUM_FETCH) 330 .expect("reticulum"); 331 assert!(!reticulum.is_compiled()); 332 assert!(!reticulum.is_configured()); 333 assert_eq!(reticulum.availability(), Availability::Unsupported); 334 assert_eq!(reticulum.maturity(), Maturity::Preview); 335 336 let mesh = report.get(CapabilityId::MESH_TRANSPORT).expect("mesh"); 337 assert_eq!(mesh.maturity(), Maturity::Experimental); 338 } 339 340 #[test] 341 fn configuration_and_availability_matrix_covers_every_decision_path() { 342 let explicitly_configured = BTreeSet::from([ 343 CapabilityId::PERSISTENT_STORAGE, 344 CapabilityId::NIP46_SIGNING, 345 CapabilityId::NOSTR_FETCH, 346 ]); 347 let overrides = BTreeMap::from([ 348 (CapabilityId::PERSISTENT_STORAGE, Availability::Degraded), 349 (CapabilityId::NOSTR_FETCH, Availability::Unavailable), 350 ]); 351 352 let complete = report(Context { 353 storage: false, 354 signer: true, 355 source: true, 356 sink: true, 357 sync: true, 358 source_availability: Availability::Available, 359 sink_availability: Availability::Degraded, 360 lifecycle_availability: Availability::Available, 361 explicitly_configured: &explicitly_configured, 362 overrides: &overrides, 363 }); 364 assert!( 365 complete 366 .get(CapabilityId::SYNC_PULL) 367 .expect("pull") 368 .is_configured() 369 ); 370 assert_eq!( 371 complete 372 .get(CapabilityId::SYNC_PUSH) 373 .expect("push") 374 .availability(), 375 if cfg!(feature = "sync") { 376 Availability::Degraded 377 } else { 378 Availability::Unsupported 379 } 380 ); 381 assert_eq!( 382 complete 383 .get(CapabilityId::SYNC_PULL) 384 .expect("pull") 385 .availability(), 386 if cfg!(feature = "sync") { 387 Availability::Available 388 } else { 389 Availability::Unsupported 390 } 391 ); 392 assert!( 393 complete 394 .get(CapabilityId::SYNC_PUSH) 395 .expect("push") 396 .is_configured() 397 ); 398 assert!( 399 complete 400 .get(CapabilityId::FARM_PUBLICATION) 401 .expect("farm") 402 .is_configured() 403 ); 404 assert_eq!( 405 complete 406 .get(CapabilityId::NOSTR_FETCH) 407 .expect("fetch") 408 .availability(), 409 if cfg!(feature = "nostr") { 410 Availability::Unavailable 411 } else { 412 Availability::Unsupported 413 } 414 ); 415 416 let partial = report(Context { 417 storage: false, 418 signer: true, 419 source: false, 420 sink: false, 421 sync: true, 422 source_availability: Availability::Unavailable, 423 sink_availability: Availability::Unavailable, 424 lifecycle_availability: Availability::Degraded, 425 explicitly_configured: &explicitly_configured, 426 overrides: &overrides, 427 }); 428 assert!( 429 !partial 430 .get(CapabilityId::SYNC_PULL) 431 .expect("pull") 432 .is_configured() 433 ); 434 assert!( 435 !partial 436 .get(CapabilityId::SYNC_PUSH) 437 .expect("push") 438 .is_configured() 439 ); 440 assert!( 441 !partial 442 .get(CapabilityId::TRADE_COMMANDS) 443 .expect("trade") 444 .is_configured() 445 ); 446 if partial 447 .get(CapabilityId::NIP46_SIGNING) 448 .expect("nip46") 449 .is_compiled() 450 { 451 assert_eq!( 452 partial 453 .get(CapabilityId::NIP46_SIGNING) 454 .expect("nip46") 455 .availability(), 456 Availability::Degraded 457 ); 458 } 459 assert_eq!(complete.iter().len(), CATALOG.len()); 460 assert_eq!( 461 CapabilityId::NOSTR_FETCH.to_string(), 462 "transport.nostr.fetch" 463 ); 464 } 465 }