1use crate::pages::redact::{redact_swarm_json_value, redact_swarm_text};
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use std::collections::BTreeMap;
12use std::error::Error;
13use std::fmt;
14use std::fs;
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum SwarmProviderName {
22 AgentMail,
23 Beads,
24 CassHealth,
25 CassStatus,
26 Evidence,
27 Git,
28 Process,
29}
30
31impl SwarmProviderName {
32 #[must_use]
33 pub const fn as_str(self) -> &'static str {
34 match self {
35 Self::AgentMail => "agent_mail",
36 Self::Beads => "beads",
37 Self::CassHealth => "cass_health",
38 Self::CassStatus => "cass_status",
39 Self::Evidence => "evidence",
40 Self::Git => "git",
41 Self::Process => "process",
42 }
43 }
44
45 #[must_use]
46 pub const fn fixture_key(self) -> &'static str {
47 match self {
48 Self::Process => "processes",
49 _ => self.as_str(),
50 }
51 }
52}
53
54impl fmt::Display for SwarmProviderName {
55 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56 f.write_str(self.as_str())
57 }
58}
59
60pub const REQUIRED_SWARM_SOURCE_PROVIDERS: &[SwarmProviderName] = &[
62 SwarmProviderName::AgentMail,
63 SwarmProviderName::Beads,
64 SwarmProviderName::CassHealth,
65 SwarmProviderName::CassStatus,
66 SwarmProviderName::Evidence,
67 SwarmProviderName::Git,
68 SwarmProviderName::Process,
69];
70
71pub const OPTIONAL_SWARM_SOURCE_PROVIDERS: &[SwarmProviderName] = &[];
73
74pub const ALL_SWARM_SOURCE_PROVIDERS: &[SwarmProviderName] = &[
76 SwarmProviderName::AgentMail,
77 SwarmProviderName::Beads,
78 SwarmProviderName::CassHealth,
79 SwarmProviderName::CassStatus,
80 SwarmProviderName::Evidence,
81 SwarmProviderName::Git,
82 SwarmProviderName::Process,
83];
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "kebab-case")]
88pub enum SwarmProviderStatus {
89 Ok,
90 Partial,
91 Unavailable,
92 Skipped,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case")]
98pub enum SwarmDiagnosticStream {
99 Stderr,
100 Internal,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub struct SwarmProviderDiagnostic {
105 pub stream: SwarmDiagnosticStream,
106 pub message: String,
107}
108
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111pub struct SwarmSourceSnapshot {
112 pub name: SwarmProviderName,
113 pub source: String,
114 pub status: SwarmProviderStatus,
115 pub freshness_ms: Option<u64>,
116 pub elapsed_ms: u64,
117 pub error_kind: Option<String>,
118 pub warning: Option<String>,
119 #[serde(default, skip_serializing_if = "Vec::is_empty")]
120 pub diagnostics: Vec<SwarmProviderDiagnostic>,
121 pub payload: Value,
122}
123
124impl SwarmSourceSnapshot {
125 #[must_use]
126 pub fn ok(name: SwarmProviderName, source: impl Into<String>, payload: Value) -> Self {
127 Self {
128 name,
129 source: source.into(),
130 status: SwarmProviderStatus::Ok,
131 freshness_ms: Some(0),
132 elapsed_ms: 0,
133 error_kind: None,
134 warning: None,
135 diagnostics: Vec::new(),
136 payload: redact_swarm_json_value(&payload),
137 }
138 }
139
140 #[must_use]
141 pub fn partial(
142 name: SwarmProviderName,
143 source: impl Into<String>,
144 warning: impl Into<String>,
145 payload: Value,
146 ) -> Self {
147 let warning = warning.into();
148 let warning = redact_swarm_text(&warning);
149 Self {
150 name,
151 source: source.into(),
152 status: SwarmProviderStatus::Partial,
153 freshness_ms: Some(0),
154 elapsed_ms: 0,
155 error_kind: None,
156 warning: Some(warning.clone()),
157 diagnostics: vec![SwarmProviderDiagnostic {
158 stream: SwarmDiagnosticStream::Internal,
159 message: warning,
160 }],
161 payload: redact_swarm_json_value(&payload),
162 }
163 }
164
165 #[must_use]
166 pub fn unavailable(
167 name: SwarmProviderName,
168 source: impl Into<String>,
169 error_kind: impl Into<String>,
170 warning: impl Into<String>,
171 ) -> Self {
172 let warning = warning.into();
173 let warning = redact_swarm_text(&warning);
174 Self {
175 name,
176 source: source.into(),
177 status: SwarmProviderStatus::Unavailable,
178 freshness_ms: None,
179 elapsed_ms: 0,
180 error_kind: Some(error_kind.into()),
181 warning: Some(warning.clone()),
182 diagnostics: vec![SwarmProviderDiagnostic {
183 stream: SwarmDiagnosticStream::Stderr,
184 message: warning,
185 }],
186 payload: Value::Null,
187 }
188 }
189
190 #[must_use]
191 pub fn skipped(
192 name: SwarmProviderName,
193 source: impl Into<String>,
194 warning: impl Into<String>,
195 ) -> Self {
196 let warning = warning.into();
197 let warning = redact_swarm_text(&warning);
198 Self {
199 name,
200 source: source.into(),
201 status: SwarmProviderStatus::Skipped,
202 freshness_ms: None,
203 elapsed_ms: 0,
204 error_kind: None,
205 warning: Some(warning.clone()),
206 diagnostics: vec![SwarmProviderDiagnostic {
207 stream: SwarmDiagnosticStream::Internal,
208 message: warning,
209 }],
210 payload: Value::Null,
211 }
212 }
213}
214
215pub trait SwarmSourceAdapter: Send + Sync {
217 fn provider(&self) -> SwarmProviderName;
218 fn collect(&self) -> SwarmSourceSnapshot;
219}
220
221#[derive(Debug, Clone, PartialEq)]
222pub struct SwarmSourceCollection {
223 pub snapshots: Vec<SwarmSourceSnapshot>,
224}
225
226impl SwarmSourceCollection {
227 #[must_use]
228 pub fn partial(&self) -> bool {
229 self.snapshots
230 .iter()
231 .any(|snapshot| snapshot.status != SwarmProviderStatus::Ok)
232 }
233
234 #[must_use]
235 pub fn snapshot(&self, provider: SwarmProviderName) -> Option<&SwarmSourceSnapshot> {
236 self.snapshots
237 .iter()
238 .find(|snapshot| snapshot.name == provider)
239 }
240}
241
242#[must_use]
243pub fn collect_swarm_sources<'a, I>(adapters: I) -> SwarmSourceCollection
244where
245 I: IntoIterator<Item = &'a dyn SwarmSourceAdapter>,
246{
247 SwarmSourceCollection {
248 snapshots: adapters
249 .into_iter()
250 .map(SwarmSourceAdapter::collect)
251 .collect(),
252 }
253}
254
255#[derive(Debug, Clone)]
256pub struct SwarmFixtureInput {
257 path: PathBuf,
258 fixture_id: String,
259 description: Option<String>,
260 sources: BTreeMap<String, Value>,
261}
262
263#[derive(Debug, Deserialize)]
264struct RawSwarmFixtureInput {
265 fixture_id: String,
266 #[serde(default)]
267 description: Option<String>,
268 sources: BTreeMap<String, Value>,
269}
270
271impl SwarmFixtureInput {
272 pub fn from_path(path: impl AsRef<Path>) -> Result<Self, SwarmSourceError> {
273 let path = path.as_ref();
274 let body = fs::read_to_string(path).map_err(|source| SwarmSourceError::Io {
275 path: path.to_path_buf(),
276 source,
277 })?;
278 let raw = serde_json::from_str::<RawSwarmFixtureInput>(&body).map_err(|source| {
279 SwarmSourceError::Json {
280 path: path.to_path_buf(),
281 source,
282 }
283 })?;
284 Self::from_raw(path.to_path_buf(), raw)
285 }
286
287 pub fn from_value(path: impl Into<PathBuf>, value: Value) -> Result<Self, SwarmSourceError> {
288 let path = path.into();
289 let raw = serde_json::from_value::<RawSwarmFixtureInput>(value).map_err(|source| {
290 SwarmSourceError::Json {
291 path: path.clone(),
292 source,
293 }
294 })?;
295 Self::from_raw(path, raw)
296 }
297
298 fn from_raw(path: PathBuf, raw: RawSwarmFixtureInput) -> Result<Self, SwarmSourceError> {
299 if raw.fixture_id.trim().is_empty() {
300 return Err(SwarmSourceError::InvalidFixture {
301 path,
302 reason: "fixture_id cannot be empty",
303 });
304 }
305 Ok(Self {
306 path,
307 fixture_id: raw.fixture_id,
308 description: raw.description,
309 sources: raw.sources,
310 })
311 }
312
313 #[must_use]
314 pub fn fixture_id(&self) -> &str {
315 &self.fixture_id
316 }
317
318 #[must_use]
319 pub fn description(&self) -> Option<&str> {
320 self.description.as_deref()
321 }
322
323 #[must_use]
324 pub fn path(&self) -> &Path {
325 &self.path
326 }
327
328 #[must_use]
329 pub fn source_value(&self, provider: SwarmProviderName) -> Option<&Value> {
330 self.sources.get(provider.fixture_key())
331 }
332}
333
334#[derive(Debug, Clone)]
335pub struct FixtureSwarmSourceAdapter {
336 input: Arc<SwarmFixtureInput>,
337 provider: SwarmProviderName,
338}
339
340impl FixtureSwarmSourceAdapter {
341 #[must_use]
342 pub fn new(input: Arc<SwarmFixtureInput>, provider: SwarmProviderName) -> Self {
343 Self { input, provider }
344 }
345}
346
347impl SwarmSourceAdapter for FixtureSwarmSourceAdapter {
348 fn provider(&self) -> SwarmProviderName {
349 self.provider
350 }
351
352 fn collect(&self) -> SwarmSourceSnapshot {
353 let source = format!("fixture:{}", self.provider.fixture_key());
354 match self.input.source_value(self.provider) {
355 Some(value) => SwarmSourceSnapshot::ok(self.provider, source, value.clone()),
356 None => SwarmSourceSnapshot::unavailable(
357 self.provider,
358 source,
359 "missing-fixture-provider",
360 format!(
361 "fixture {} at {} is missing provider source {}",
362 self.input.fixture_id(),
363 self.input.path().display(),
364 self.provider.fixture_key()
365 ),
366 ),
367 }
368 }
369}
370
371#[derive(Debug, Clone)]
372pub struct FixtureSwarmAdapterSet {
373 input: Arc<SwarmFixtureInput>,
374}
375
376impl FixtureSwarmAdapterSet {
377 pub fn from_fixture_path(path: impl AsRef<Path>) -> Result<Self, SwarmSourceError> {
378 Ok(Self {
379 input: Arc::new(SwarmFixtureInput::from_path(path)?),
380 })
381 }
382
383 #[must_use]
384 pub fn from_input(input: SwarmFixtureInput) -> Self {
385 Self {
386 input: Arc::new(input),
387 }
388 }
389
390 #[must_use]
391 pub fn input(&self) -> &SwarmFixtureInput {
392 &self.input
393 }
394
395 #[must_use]
396 pub fn required_adapters(&self) -> Vec<FixtureSwarmSourceAdapter> {
397 REQUIRED_SWARM_SOURCE_PROVIDERS
398 .iter()
399 .copied()
400 .map(|provider| FixtureSwarmSourceAdapter::new(Arc::clone(&self.input), provider))
401 .collect()
402 }
403
404 #[must_use]
405 pub fn all_adapters(&self) -> Vec<FixtureSwarmSourceAdapter> {
406 ALL_SWARM_SOURCE_PROVIDERS
407 .iter()
408 .copied()
409 .map(|provider| FixtureSwarmSourceAdapter::new(Arc::clone(&self.input), provider))
410 .collect()
411 }
412
413 #[must_use]
414 pub fn collect_required(&self) -> SwarmSourceCollection {
415 let adapters = self.required_adapters();
416 collect_swarm_sources(
417 adapters
418 .iter()
419 .map(|adapter| adapter as &dyn SwarmSourceAdapter),
420 )
421 }
422
423 #[must_use]
424 pub fn collect_all(&self) -> SwarmSourceCollection {
425 let adapters = self.all_adapters();
426 collect_swarm_sources(
427 adapters
428 .iter()
429 .map(|adapter| adapter as &dyn SwarmSourceAdapter),
430 )
431 }
432}
433
434#[derive(Debug)]
435pub enum SwarmSourceError {
436 Io {
437 path: PathBuf,
438 source: std::io::Error,
439 },
440 Json {
441 path: PathBuf,
442 source: serde_json::Error,
443 },
444 InvalidFixture {
445 path: PathBuf,
446 reason: &'static str,
447 },
448}
449
450impl fmt::Display for SwarmSourceError {
451 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
452 match self {
453 Self::Io { path, source } => {
454 write!(
455 f,
456 "failed to read swarm fixture {}: {source}",
457 path.display()
458 )
459 }
460 Self::Json { path, source } => {
461 write!(
462 f,
463 "failed to parse swarm fixture {}: {source}",
464 path.display()
465 )
466 }
467 Self::InvalidFixture { path, reason } => {
468 write!(f, "invalid swarm fixture {}: {reason}", path.display())
469 }
470 }
471 }
472}
473
474impl Error for SwarmSourceError {
475 fn source(&self) -> Option<&(dyn Error + 'static)> {
476 match self {
477 Self::Io { source, .. } => Some(source),
478 Self::Json { source, .. } => Some(source),
479 Self::InvalidFixture { .. } => None,
480 }
481 }
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487 use serde_json::json;
488
489 fn repo_path(relative: &str) -> PathBuf {
490 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative)
491 }
492
493 #[test]
494 fn fixture_adapter_collects_every_required_provider_from_healthy_fixture() {
495 let adapters = FixtureSwarmAdapterSet::from_fixture_path(repo_path(
496 "tests/fixtures/swarm_status/healthy.inputs.json",
497 ))
498 .expect("healthy fixture should parse");
499
500 let collection = adapters.collect_required();
501
502 assert!(!collection.partial());
503 assert_eq!(
504 collection
505 .snapshots
506 .iter()
507 .map(|snapshot| snapshot.name.as_str())
508 .collect::<Vec<_>>(),
509 vec![
510 "agent_mail",
511 "beads",
512 "cass_health",
513 "cass_status",
514 "evidence",
515 "git",
516 "process"
517 ]
518 );
519 assert_eq!(
520 collection
521 .snapshot(SwarmProviderName::Beads)
522 .and_then(|snapshot| snapshot.payload["ready"].as_array())
523 .map(Vec::len),
524 Some(1)
525 );
526 }
527
528 #[test]
529 fn missing_fixture_provider_becomes_unavailable_snapshot() {
530 let input = SwarmFixtureInput::from_value(
531 "inline-missing.json",
532 json!({
533 "fixture_id": "missing-provider",
534 "sources": {
535 "beads": {"ready": []}
536 }
537 }),
538 )
539 .expect("inline fixture should parse");
540 let set = FixtureSwarmAdapterSet::from_input(input);
541
542 let collection = set.collect_required();
543 let missing = collection
544 .snapshot(SwarmProviderName::AgentMail)
545 .expect("agent_mail snapshot should exist");
546
547 assert!(collection.partial());
548 assert_eq!(missing.status, SwarmProviderStatus::Unavailable);
549 assert_eq!(
550 missing.error_kind.as_deref(),
551 Some("missing-fixture-provider")
552 );
553 assert_eq!(missing.payload, Value::Null);
554 assert_eq!(
555 missing
556 .diagnostics
557 .first()
558 .map(|diagnostic| diagnostic.stream),
559 Some(SwarmDiagnosticStream::Stderr)
560 );
561 }
562
563 #[test]
564 fn process_provider_uses_contract_name_and_fixture_key() {
565 let input = SwarmFixtureInput::from_value(
566 "inline-process.json",
567 json!({
568 "fixture_id": "process-provider",
569 "sources": {
570 "processes": {"active_rch_jobs": 2}
571 }
572 }),
573 )
574 .expect("inline fixture should parse");
575 let adapter = FixtureSwarmSourceAdapter::new(Arc::new(input), SwarmProviderName::Process);
576 let snapshot = adapter.collect();
577
578 assert_eq!(SwarmProviderName::Process.as_str(), "process");
579 assert_eq!(SwarmProviderName::Process.fixture_key(), "processes");
580 assert_eq!(snapshot.name, SwarmProviderName::Process);
581 assert_eq!(snapshot.source, "fixture:processes");
582 assert_eq!(snapshot.status, SwarmProviderStatus::Ok);
583 assert_eq!(snapshot.payload["active_rch_jobs"], 2);
584 }
585
586 #[test]
587 fn status_variants_serialize_to_contract_values() {
588 assert_eq!(
589 serde_json::to_string(&SwarmProviderStatus::Ok).unwrap(),
590 r#""ok""#
591 );
592 assert_eq!(
593 serde_json::to_string(&SwarmProviderStatus::Partial).unwrap(),
594 r#""partial""#
595 );
596 assert_eq!(
597 serde_json::to_string(&SwarmProviderStatus::Unavailable).unwrap(),
598 r#""unavailable""#
599 );
600 assert_eq!(
601 serde_json::to_string(&SwarmProviderStatus::Skipped).unwrap(),
602 r#""skipped""#
603 );
604 }
605
606 #[test]
607 fn partial_and_skipped_snapshots_are_degraded_and_redacted() {
608 let partial = SwarmSourceSnapshot::partial(
609 SwarmProviderName::Git,
610 "fixture:git",
611 "partial fixture read at /home/alice/private-client with TOKEN=SECRET_VALUE",
612 json!({
613 "path": "/home/alice/private-client/src/lib.rs",
614 "dirty_by_path": {
615 "/home/alice/private-client/src/lib.rs": "modified"
616 },
617 "command": "env TOKEN=SECRET_VALUE cargo test",
618 "evidence_ref": "pack:///data/projects/private-client/session.jsonl#L44"
619 }),
620 );
621 let skipped = SwarmSourceSnapshot::skipped(
622 SwarmProviderName::Evidence,
623 "fixture:evidence",
624 "skipped optional evidence probe for /home/alice/private-client",
625 );
626 let collection = SwarmSourceCollection {
627 snapshots: vec![partial, skipped],
628 };
629
630 assert!(collection.partial());
631 let git = collection
632 .snapshot(SwarmProviderName::Git)
633 .expect("git snapshot should exist");
634 let evidence = collection
635 .snapshot(SwarmProviderName::Evidence)
636 .expect("evidence snapshot should exist");
637
638 assert_eq!(git.status, SwarmProviderStatus::Partial);
639 assert_eq!(evidence.status, SwarmProviderStatus::Skipped);
640 assert_eq!(git.diagnostics[0].stream, SwarmDiagnosticStream::Internal);
641 assert_eq!(
642 evidence.diagnostics[0].stream,
643 SwarmDiagnosticStream::Internal
644 );
645 assert_eq!(git.payload["evidence_ref"], "pack://[REDACTED_PATH]#L44");
646 assert!(
647 git.payload["dirty_by_path"]
648 .as_object()
649 .is_some_and(|paths| paths.contains_key("[REDACTED_PATH]"))
650 );
651
652 let serialized =
653 serde_json::to_string(&collection.snapshots).expect("snapshots should serialize");
654 assert!(!serialized.contains("/home/alice"));
655 assert!(!serialized.contains("/data/projects/private-client"));
656 assert!(!serialized.contains("SECRET_VALUE"));
657 assert!(serialized.contains("[REDACTED_PATH]"));
658 assert!(serialized.contains("[SECRET_ENV_REDACTED]"));
659 }
660
661 #[test]
662 fn required_adapters_collects_evidence_provider() {
663 let input = SwarmFixtureInput::from_value(
664 "inline-evidence.json",
665 json!({
666 "fixture_id": "evidence-provider",
667 "sources": {
668 "agent_mail": {"messages": []},
669 "beads": {"ready": []},
670 "cass_health": {"healthy": true},
671 "cass_status": {"search_ready": true},
672 "git": {"dirty": false},
673 "processes": {"active_rch_jobs": 0},
674 "evidence": {
675 "recent_proofs": [
676 {
677 "ref": "pack:///data/projects/private-client/session.jsonl#L44",
678 "status": "redacted"
679 }
680 ]
681 }
682 }
683 }),
684 )
685 .expect("inline fixture should parse");
686 let set = FixtureSwarmAdapterSet::from_input(input);
687
688 let collection = set.collect_required();
689 let evidence = collection
690 .snapshot(SwarmProviderName::Evidence)
691 .expect("evidence snapshot should exist");
692
693 assert_eq!(
694 collection.snapshots.len(),
695 REQUIRED_SWARM_SOURCE_PROVIDERS.len()
696 );
697 assert!(!collection.partial());
698 assert_eq!(evidence.status, SwarmProviderStatus::Ok);
699 assert_eq!(evidence.source, "fixture:evidence");
700 assert_eq!(
701 evidence.payload["recent_proofs"][0]["ref"],
702 "pack://[REDACTED_PATH]#L44"
703 );
704 }
705
706 #[test]
707 fn fixture_payload_strings_pass_through_redaction_layer() {
708 let input = SwarmFixtureInput::from_value(
709 "inline-redaction.json",
710 json!({
711 "fixture_id": "redaction-provider",
712 "sources": {
713 "git": {
714 "dirty_paths": [
715 {"path": "/home/alice/private-client/src/lib.rs"}
716 ],
717 "dirty_by_path": {
718 "/home/alice/private-client/src/lib.rs": "modified"
719 },
720 "last_author": "alice@example.com",
721 "command": "env TOKEN=SECRET_VALUE CARGO_TARGET_DIR=/home/alice/cass-target cargo test",
722 "evidence_ref": "pack:///data/projects/private-client/session.jsonl#L44"
723 }
724 }
725 }),
726 )
727 .expect("inline fixture should parse");
728 let adapter = FixtureSwarmSourceAdapter::new(Arc::new(input), SwarmProviderName::Git);
729 let snapshot = adapter.collect();
730 let serialized = serde_json::to_string(&snapshot.payload).expect("payload serializes");
731
732 assert!(!serialized.contains("/home/alice"));
733 assert!(!serialized.contains("/data/projects/private-client"));
734 assert!(!serialized.contains("alice@example.com"));
735 assert!(!serialized.contains("SECRET_VALUE"));
736 assert_eq!(
737 snapshot.payload["evidence_ref"],
738 "pack://[REDACTED_PATH]#L44"
739 );
740 assert!(
741 snapshot.payload["dirty_by_path"]
742 .as_object()
743 .is_some_and(|paths| paths.contains_key("[REDACTED_PATH]"))
744 );
745 assert!(serialized.contains("[REDACTED_PATH]"));
746 assert!(serialized.contains("[EMAIL_REDACTED]"));
747 assert!(serialized.contains("[SECRET_ENV_REDACTED]"));
748 }
749
750 #[test]
751 fn collector_consumes_only_the_adapter_trait() {
752 let input = Arc::new(
753 SwarmFixtureInput::from_value(
754 "inline-trait.json",
755 json!({
756 "fixture_id": "trait-collector",
757 "sources": {
758 "beads": {"ready": []},
759 "git": {"dirty": false}
760 }
761 }),
762 )
763 .expect("inline fixture should parse"),
764 );
765 let adapters = [
766 FixtureSwarmSourceAdapter::new(Arc::clone(&input), SwarmProviderName::Beads),
767 FixtureSwarmSourceAdapter::new(Arc::clone(&input), SwarmProviderName::Git),
768 ];
769 let trait_refs = adapters
770 .iter()
771 .map(|adapter| adapter as &dyn SwarmSourceAdapter);
772
773 let collection = collect_swarm_sources(trait_refs);
774
775 assert_eq!(collection.snapshots.len(), 2);
776 assert_eq!(
777 collection.snapshot(SwarmProviderName::Git).unwrap().status,
778 SwarmProviderStatus::Ok
779 );
780 }
781
782 #[test]
783 fn checked_in_swarm_fixtures_provide_all_required_sources() {
784 for name in [
785 "healthy",
786 "busy",
787 "stale_advisory",
788 "reservation_conflict",
789 "build_pressure",
790 "no_ready_work",
791 "privacy_guardrails",
792 ] {
793 let path = repo_path(&format!("tests/fixtures/swarm_status/{name}.inputs.json"));
794 let adapters = FixtureSwarmAdapterSet::from_fixture_path(path)
795 .unwrap_or_else(|err| panic!("{name} fixture should parse: {err}"));
796 let collection = adapters.collect_required();
797
798 assert!(
799 !collection.partial(),
800 "{name} fixture should provide every required provider: {collection:#?}"
801 );
802 }
803 }
804}