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