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    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
62/// Required source providers from the current fixture contract.
63pub 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
73/// Optional source providers available to richer status/evidence projections.
74pub const OPTIONAL_SWARM_SOURCE_PROVIDERS: &[SwarmProviderName] =
75    &[SwarmProviderName::DependencyDrift];
76
77/// Every fixtureable provider named by the swarm status contract.
78pub 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/// Provider availability normalized for robot output.
90#[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/// Where a diagnostic belongs. Provider stderr is kept out of stdout payloads.
100#[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/// One provider snapshot, including typed status and raw provider payload.
114#[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
219/// Common interface for live and fixture-backed swarm status providers.
220pub 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}