Skip to main content

coding_agent_search/
swarm_status.rs

1//! Fixtureable source adapters for the planned `cass swarm status` surface.
2//!
3//! This module intentionally avoids live provider calls. It defines the adapter
4//! trait and deterministic fixture-backed implementation that the future
5//! aggregator can consume without knowing whether data came from fixtures or a
6//! live source.
7
8use 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/// Providers named by the swarm status contract.
19#[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
60/// Required source providers from the current fixture contract.
61pub 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
71/// Optional source providers available to richer status/evidence projections.
72pub const OPTIONAL_SWARM_SOURCE_PROVIDERS: &[SwarmProviderName] = &[];
73
74/// Every fixtureable provider named by the swarm status contract.
75pub 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/// Provider availability normalized for robot output.
86#[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/// Where a diagnostic belongs. Provider stderr is kept out of stdout payloads.
96#[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/// One provider snapshot, including typed status and raw provider payload.
110#[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
215/// Common interface for live and fixture-backed swarm status providers.
216pub 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}