Skip to main content

zeroclaw_config/
traits.rs

1/// Describes a single secret field discovered via `#[derive(Configurable)]`.
2#[derive(Debug, Clone)]
3pub struct SecretFieldInfo {
4    /// Full dotted name (e.g. `channels.matrix.access-token`)
5    pub name: &'static str,
6    /// Category for grouping in `zeroclaw config list`
7    pub category: &'static str,
8    /// Whether this field currently has a non-empty value
9    pub is_set: bool,
10}
11
12/// Runtime type classification for config property values.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum PropKind {
15    String,
16    Bool,
17    Integer,
18    Float,
19    /// An enum or other serde-serializable type (parsed as TOML string).
20    Enum,
21    /// A `Vec<String>` field; set via comma-separated input.
22    StringArray,
23    /// A `Vec<T>` field where `T` is a serializable struct (e.g. `Vec<McpServerConfig>`,
24    /// `Vec<PeripheralBoardConfig>`). Round-tripped on the wire as a JSON array of
25    /// objects; the dashboard renders a per-row sub-form using the JSON Schema
26    /// from `OPTIONS /api/config` to discover the element type's field shape.
27    /// Schema v3 / #5947 will migrate the load-bearing ones (mcp.servers etc.)
28    /// to `HashMap<String, T>` keyed tables; until then this kind covers them.
29    ObjectArray,
30    /// A struct-shaped scalar field (e.g. `Option<ModelPricing>`). Round-tripped
31    /// on the wire as a JSON object; the dashboard renders a sub-form for the
32    /// inner fields using the JSON Schema from `OPTIONS /api/config`. Distinct
33    /// from `String`, which inserts the raw value as a TOML string and breaks
34    /// the serde round-trip for typed structs.
35    Object,
36}
37
38/// Maps Rust types to PropKind at compile time.
39/// Scalars have explicit impls; the blanket impl catches everything
40/// else as `PropKind::Enum`.
41pub trait HasPropKind {
42    const PROP_KIND: PropKind;
43}
44
45macro_rules! impl_prop_kind {
46    ($kind:expr, $($ty:ty),+) => {
47        $(impl HasPropKind for $ty { const PROP_KIND: PropKind = $kind; })+
48    };
49}
50
51impl_prop_kind!(PropKind::Bool, bool);
52impl_prop_kind!(PropKind::String, String);
53impl_prop_kind!(PropKind::Float, f64, f32);
54impl_prop_kind!(
55    PropKind::Integer,
56    u8,
57    u16,
58    u32,
59    u64,
60    usize,
61    i8,
62    i16,
63    i32,
64    i64,
65    isize
66);
67impl HasPropKind for Vec<String> {
68    const PROP_KIND: PropKind = PropKind::StringArray;
69}
70
71// The per-category provider-ref newtypes (defined in `crate::providers`)
72// serialize as plain strings; the schema-tooling layer treats them as
73// strings too.
74impl HasPropKind for crate::providers::ModelProviderRef {
75    const PROP_KIND: PropKind = PropKind::String;
76}
77impl HasPropKind for crate::providers::TtsProviderRef {
78    const PROP_KIND: PropKind = PropKind::String;
79}
80impl HasPropKind for crate::providers::TranscriptionProviderRef {
81    const PROP_KIND: PropKind = PropKind::String;
82}
83impl HasPropKind for crate::providers::ChannelRef {
84    const PROP_KIND: PropKind = PropKind::String;
85}
86impl HasPropKind for Vec<crate::providers::ChannelRef> {
87    const PROP_KIND: PropKind = PropKind::StringArray;
88}
89
90// Multi-agent typed primitives. AgentAlias / PeerGroupName /
91// PeerUsername round-trip as plain strings; AccessMode and
92// MemoryBackendKind are enums.
93impl HasPropKind for crate::multi_agent::AgentAlias {
94    const PROP_KIND: PropKind = PropKind::String;
95}
96impl HasPropKind for crate::multi_agent::PeerGroupName {
97    const PROP_KIND: PropKind = PropKind::String;
98}
99impl HasPropKind for crate::multi_agent::PeerUsername {
100    const PROP_KIND: PropKind = PropKind::String;
101}
102impl HasPropKind for crate::multi_agent::AccessMode {
103    const PROP_KIND: PropKind = PropKind::Enum;
104}
105impl HasPropKind for crate::multi_agent::MemoryBackendKind {
106    const PROP_KIND: PropKind = PropKind::Enum;
107}
108impl HasPropKind for Vec<crate::multi_agent::AgentAlias> {
109    const PROP_KIND: PropKind = PropKind::StringArray;
110}
111impl HasPropKind for Vec<crate::multi_agent::PeerUsername> {
112    const PROP_KIND: PropKind = PropKind::StringArray;
113}
114impl HasPropKind
115    for std::collections::BTreeMap<crate::multi_agent::AgentAlias, crate::multi_agent::AccessMode>
116{
117    // Serialized as a TOML inline table: `{ beta = "read", gamma = "read_write" }`.
118    const PROP_KIND: PropKind = PropKind::Object;
119}
120
121// Vec<struct> fields are surfaced as PropKind::ObjectArray — each
122// element renders as a per-row sub-form on the dashboard rather than a
123// chip. The Configurable derive routes `<Vec<T> as HasPropKind>::PROP_KIND`
124// for every Vec field, so a missing impl here surfaces as a "trait bound
125// not satisfied" compile error pointing at the field. Add the impl in
126// the same module that defines the type if traits.rs's crate scope is
127// too narrow.
128impl HasPropKind for Vec<crate::schema::ClassificationRule> {
129    const PROP_KIND: PropKind = PropKind::ObjectArray;
130}
131impl HasPropKind for Vec<crate::schema::EmbeddingRouteConfig> {
132    const PROP_KIND: PropKind = PropKind::ObjectArray;
133}
134impl HasPropKind for Vec<crate::schema::GoogleWorkspaceAllowedOperation> {
135    const PROP_KIND: PropKind = PropKind::ObjectArray;
136}
137impl HasPropKind for Vec<crate::schema::McpServerConfig> {
138    const PROP_KIND: PropKind = PropKind::ObjectArray;
139}
140impl HasPropKind for Vec<crate::schema::ModelRouteConfig> {
141    const PROP_KIND: PropKind = PropKind::ObjectArray;
142}
143impl HasPropKind for Vec<crate::schema::NevisRoleMappingConfig> {
144    const PROP_KIND: PropKind = PropKind::ObjectArray;
145}
146impl HasPropKind for Vec<crate::schema::PeripheralBoardConfig> {
147    const PROP_KIND: PropKind = PropKind::ObjectArray;
148}
149impl HasPropKind for Vec<crate::schema::ToolFilterGroup> {
150    const PROP_KIND: PropKind = PropKind::ObjectArray;
151}
152
153/// Describes a single property field discovered via `#[derive(Configurable)]`.
154#[derive(Clone)]
155pub struct PropFieldInfo {
156    /// Full dotted name (e.g. `channels.telegram.draft-update-interval-ms`).
157    /// Owned so the `HashMap<String, T>` branch of the derive can inject the
158    /// runtime map key into the path (`model_providers.anthropic.api-key`)
159    /// — `&'static str` can't carry user-supplied keys.
160    pub name: String,
161    /// Category for grouping in property listings
162    pub category: &'static str,
163    /// Current value formatted for display (secrets show `"****"`)
164    pub display_value: String,
165    /// Raw Rust type string for display (e.g. `"bool"`, `"u64"`, `"Option<StreamMode>"`)
166    pub type_hint: &'static str,
167    /// Runtime type classification
168    pub kind: PropKind,
169    /// Whether this field is marked `#[secret]`
170    pub is_secret: bool,
171    /// Returns valid variant names for enum fields (None for non-enum fields)
172    pub enum_variants: Option<fn() -> Vec<String>>,
173    /// Field's `///` doc comment, flattened to a single line. Empty string
174    /// when the field has no doc comment. Onboard uses this as human-readable
175    /// prompt text instead of the raw kebab-case field name.
176    pub description: &'static str,
177    /// Whether this field's value is derived from a secret (`#[derived_from_secret]`).
178    /// Subject to the same write-only / no-readback rules as `#[secret]`.
179    /// Reserved for future schema additions; currently no fields are derived.
180    pub derived_from_secret: bool,
181}
182
183impl PropFieldInfo {
184    pub fn is_enum(&self) -> bool {
185        self.enum_variants.is_some()
186    }
187}
188
189impl std::fmt::Debug for PropFieldInfo {
190    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191        f.debug_struct("PropFieldInfo")
192            .field("name", &self.name)
193            .field("kind", &self.kind)
194            .field("is_secret", &self.is_secret)
195            .finish_non_exhaustive()
196    }
197}
198
199/// Mask and restore secret fields on config structs.
200///
201/// Automatically implemented by `#[derive(Configurable)]` for any struct that
202/// has fields annotated with `#[secret]` or `#[nested]`. A blanket impl covers
203/// `HashMap<String, T: MaskSecrets>` so the trait propagates through alias maps
204/// without any per-type boilerplate.
205pub trait MaskSecrets {
206    fn mask_secrets(&mut self);
207    fn restore_secrets_from(&mut self, current: &Self);
208}
209
210impl<T: MaskSecrets> MaskSecrets for std::collections::HashMap<String, T> {
211    fn mask_secrets(&mut self) {
212        for v in self.values_mut() {
213            v.mask_secrets();
214        }
215    }
216    fn restore_secrets_from(&mut self, current: &Self) {
217        for (k, v) in self.iter_mut() {
218            if let Some(cur) = current.get(k) {
219                v.restore_secrets_from(cur);
220            }
221        }
222    }
223}
224
225impl<T: MaskSecrets> MaskSecrets for Vec<T> {
226    fn mask_secrets(&mut self) {
227        for v in self.iter_mut() {
228            v.mask_secrets();
229        }
230    }
231    fn restore_secrets_from(&mut self, current: &Self) {
232        for (v, cur) in self.iter_mut().zip(current.iter()) {
233            v.restore_secrets_from(cur);
234        }
235    }
236}
237
238pub const MASKED_SECRET: &str = "***MASKED***";
239
240pub fn is_masked_secret(value: &str) -> bool {
241    value == MASKED_SECRET
242}
243
244/// Per-field secret operations the `Configurable` derive emits for every
245/// `#[secret]` field. Generalizes mask / restore / encrypt / decrypt / is_set
246/// across the supported shapes — `String`, `Option<String>`, `Vec<String>`,
247/// `HashMap<String, String>`, and `Option<HashMap<String, String>>` — so adding
248/// a new shape is a single trait impl rather than a fourth branch in the macro.
249///
250/// `encrypt_in_place` and `decrypt_in_place` are idempotent: encrypting an
251/// already-`enc2:`-prefixed value or decrypting a plaintext value is a no-op,
252/// detected via [`crate::security::SecretStore::is_encrypted`]. The `field`
253/// argument is the dotted config-path (e.g. `mcp.servers`); the impls suffix
254/// per-element coordinates (`[<idx>]` for `Vec`, `.<key>` for `HashMap`) so
255/// error messages point at the exact failed entry.
256pub trait SecretField {
257    /// Replace each non-empty inner string with [`MASKED_SECRET`].
258    fn mask(&mut self);
259
260    /// Restore inner strings that currently equal [`MASKED_SECRET`] from the
261    /// matching position in `current`. The dashboard write path relies on this
262    /// so re-posting an already-displayed masked value doesn't overwrite the
263    /// real secret in config.
264    fn restore_from(&mut self, current: &Self);
265
266    /// Encrypt every non-empty, not-already-encrypted inner string.
267    fn encrypt_in_place(
268        &mut self,
269        store: &crate::security::SecretStore,
270        field: &str,
271    ) -> anyhow::Result<()>;
272
273    /// Inverse of [`Self::encrypt_in_place`].
274    fn decrypt_in_place(
275        &mut self,
276        store: &crate::security::SecretStore,
277        field: &str,
278    ) -> anyhow::Result<()>;
279
280    /// Whether the field carries at least one non-empty inner string. Reported
281    /// back through [`SecretFieldInfo::is_set`].
282    fn is_set(&self) -> bool;
283}
284
285impl SecretField for String {
286    fn mask(&mut self) {
287        if !self.is_empty() {
288            *self = MASKED_SECRET.to_string();
289        }
290    }
291
292    fn restore_from(&mut self, current: &Self) {
293        if is_masked_secret(self) {
294            self.clone_from(current);
295        }
296    }
297
298    fn encrypt_in_place(
299        &mut self,
300        store: &crate::security::SecretStore,
301        field: &str,
302    ) -> anyhow::Result<()> {
303        use anyhow::Context;
304        if !self.is_empty() && !crate::security::SecretStore::is_encrypted(self) {
305            *self = store
306                .encrypt(self)
307                .with_context(|| format!("Failed to encrypt {field}"))?;
308        }
309        Ok(())
310    }
311
312    fn decrypt_in_place(
313        &mut self,
314        store: &crate::security::SecretStore,
315        field: &str,
316    ) -> anyhow::Result<()> {
317        use anyhow::Context;
318        if crate::security::SecretStore::is_encrypted(self) {
319            *self = store
320                .decrypt(self)
321                .with_context(|| format!("Failed to decrypt {field}"))?;
322        }
323        Ok(())
324    }
325
326    fn is_set(&self) -> bool {
327        !self.is_empty()
328    }
329}
330
331impl SecretField for Option<String> {
332    fn mask(&mut self) {
333        if let Some(inner) = self {
334            inner.mask();
335        }
336    }
337
338    fn restore_from(&mut self, current: &Self) {
339        if let (Some(inner), Some(cur)) = (self.as_mut(), current.as_ref()) {
340            inner.restore_from(cur);
341        }
342    }
343
344    fn encrypt_in_place(
345        &mut self,
346        store: &crate::security::SecretStore,
347        field: &str,
348    ) -> anyhow::Result<()> {
349        match self {
350            Some(inner) => inner.encrypt_in_place(store, field),
351            None => Ok(()),
352        }
353    }
354
355    fn decrypt_in_place(
356        &mut self,
357        store: &crate::security::SecretStore,
358        field: &str,
359    ) -> anyhow::Result<()> {
360        match self {
361            Some(inner) => inner.decrypt_in_place(store, field),
362            None => Ok(()),
363        }
364    }
365
366    fn is_set(&self) -> bool {
367        self.as_ref().is_some_and(|v| !v.is_empty())
368    }
369}
370
371impl SecretField for Vec<String> {
372    fn mask(&mut self) {
373        for element in self.iter_mut() {
374            element.mask();
375        }
376    }
377
378    fn restore_from(&mut self, current: &Self) {
379        for (element, cur) in self.iter_mut().zip(current.iter()) {
380            element.restore_from(cur);
381        }
382    }
383
384    fn encrypt_in_place(
385        &mut self,
386        store: &crate::security::SecretStore,
387        field: &str,
388    ) -> anyhow::Result<()> {
389        for (idx, element) in self.iter_mut().enumerate() {
390            element.encrypt_in_place(store, &format!("{field}[{idx}]"))?;
391        }
392        Ok(())
393    }
394
395    fn decrypt_in_place(
396        &mut self,
397        store: &crate::security::SecretStore,
398        field: &str,
399    ) -> anyhow::Result<()> {
400        for (idx, element) in self.iter_mut().enumerate() {
401            element.decrypt_in_place(store, &format!("{field}[{idx}]"))?;
402        }
403        Ok(())
404    }
405
406    fn is_set(&self) -> bool {
407        !self.is_empty()
408    }
409}
410
411impl SecretField for std::collections::HashMap<String, String> {
412    fn mask(&mut self) {
413        for value in self.values_mut() {
414            value.mask();
415        }
416    }
417
418    fn restore_from(&mut self, current: &Self) {
419        for (key, value) in self.iter_mut() {
420            if let Some(cur) = current.get(key) {
421                value.restore_from(cur);
422            }
423        }
424    }
425
426    fn encrypt_in_place(
427        &mut self,
428        store: &crate::security::SecretStore,
429        field: &str,
430    ) -> anyhow::Result<()> {
431        for (key, value) in self.iter_mut() {
432            value.encrypt_in_place(store, &format!("{field}.{key}"))?;
433        }
434        Ok(())
435    }
436
437    fn decrypt_in_place(
438        &mut self,
439        store: &crate::security::SecretStore,
440        field: &str,
441    ) -> anyhow::Result<()> {
442        for (key, value) in self.iter_mut() {
443            value.decrypt_in_place(store, &format!("{field}.{key}"))?;
444        }
445        Ok(())
446    }
447
448    fn is_set(&self) -> bool {
449        self.values().any(|v| !v.is_empty())
450    }
451}
452
453impl SecretField for Option<std::collections::HashMap<String, String>> {
454    fn mask(&mut self) {
455        if let Some(inner) = self {
456            inner.mask();
457        }
458    }
459
460    fn restore_from(&mut self, current: &Self) {
461        if let (Some(inner), Some(cur)) = (self.as_mut(), current.as_ref()) {
462            inner.restore_from(cur);
463        }
464    }
465
466    fn encrypt_in_place(
467        &mut self,
468        store: &crate::security::SecretStore,
469        field: &str,
470    ) -> anyhow::Result<()> {
471        match self {
472            Some(inner) => inner.encrypt_in_place(store, field),
473            None => Ok(()),
474        }
475    }
476
477    fn decrypt_in_place(
478        &mut self,
479        store: &crate::security::SecretStore,
480        field: &str,
481    ) -> anyhow::Result<()> {
482        match self {
483            Some(inner) => inner.decrypt_in_place(store, field),
484            None => Ok(()),
485        }
486    }
487
488    fn is_set(&self) -> bool {
489        self.as_ref()
490            .is_some_and(|m| m.values().any(|v| !v.is_empty()))
491    }
492}
493
494/// Stable wire-form for an addable section — a `HashMap<String, T>` (Map) or
495/// `Vec<T>` (List) field whose value type implements `Configurable`. The
496/// dashboard / CLI use this to surface `+ Add` affordances without
497/// hardcoding the section list. Auto-discovered by the `Configurable` derive.
498#[derive(Debug, Clone, Copy, PartialEq, Eq)]
499#[cfg_attr(
500    feature = "schema-export",
501    derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)
502)]
503#[cfg_attr(feature = "schema-export", serde(rename_all = "snake_case"))]
504pub enum MapKeyKind {
505    /// `HashMap<String, T>` — key is user-supplied; new value is default.
506    Map,
507    /// `Vec<T>` — entries are appended; the user-supplied "key" is stored
508    /// in the value type's natural identifier field (e.g. `name`, `hint`).
509    List,
510}
511
512#[derive(Debug, Clone, Copy)]
513#[cfg_attr(
514    feature = "schema-export",
515    derive(serde::Serialize, schemars::JsonSchema)
516)]
517pub struct MapKeySection {
518    /// Dotted section path, e.g. `providers.models`, `mcp.servers`.
519    pub path: &'static str,
520    /// Whether the section is a map or a list.
521    pub kind: MapKeyKind,
522    /// Rust type name of the value, e.g. `ModelProviderConfig`. For display only.
523    pub value_type: &'static str,
524    /// Doc comment on the field (flattened to one line). What the user sees
525    /// when picking which kind of thing to add.
526    pub description: &'static str,
527}
528
529/// One row emitted by the `Configurable` derive's `nested_option_entries()`
530/// method — every `#[nested] Option<XConfig>` field on a struct shows up here
531/// with its `present` bit and the per-field `#[display_name = "..."]` /
532/// `#[description = "..."]` metadata. The integrations registry consumes
533/// this verbatim instead of carrying its own per-field hand-list.
534#[derive(Debug, Clone, Copy)]
535pub struct NestedOptionEntry {
536    /// snake_case field name on the parent struct (e.g. `"telegram"`,
537    /// `"voice_duplex"`).
538    pub field: &'static str,
539    /// `true` when the parent struct's field is `Some(_)`.
540    pub present: bool,
541    /// Display name from `#[display_name = "..."]`; falls back to a
542    /// title-cased rendering of the snake_case field name when the
543    /// attribute is absent.
544    pub display_name: &'static str,
545    /// One-line summary from `#[description = "..."]`. Empty when the
546    /// attribute is absent.
547    pub description: &'static str,
548}
549
550/// One row emitted by the `Configurable` derive's `integration_descriptor()`
551/// method on structs annotated with `#[integration(...)]`. Used for nested
552/// toggleable configs (e.g. `BrowserConfig`, `CronConfig`) where the
553/// integration is "active" iff a named bool field on the struct is `true`.
554#[derive(Debug, Clone, Copy)]
555pub struct IntegrationDescriptor {
556    pub display_name: &'static str,
557    pub description: &'static str,
558    /// Free-form category label (e.g. `"ToolsAutomation"`). The
559    /// integrations registry maps this string to its own
560    /// `IntegrationCategory` enum so the schema crate doesn't have to
561    /// depend on it.
562    pub category: &'static str,
563    /// Snapshot of the named status field at the moment this descriptor
564    /// was built (`status_field = "enabled"` ⇒ `self.enabled`).
565    pub active: bool,
566}
567
568/// Metadata for one channel type, as returned by [`ChannelsConfig::channels`].
569#[derive(Debug, Clone)]
570pub struct ChannelInfo {
571    pub name: &'static str,
572    pub desc: &'static str,
573    pub configured: bool,
574}
575
576/// The trait for describing a channel
577pub trait ChannelConfig {
578    /// human-readable name
579    fn name() -> &'static str;
580    /// short description
581    fn desc() -> &'static str;
582}
583
584/// A menu item for `OnboardUi::select`, with an optional status badge
585/// (e.g. `[configured]` / `[not set]`) that backends render next to the label.
586#[derive(Debug, Clone)]
587pub struct SelectItem {
588    pub label: String,
589    pub badge: Option<String>,
590}
591
592impl SelectItem {
593    pub fn new(label: impl Into<String>) -> Self {
594        Self {
595            label: label.into(),
596            badge: None,
597        }
598    }
599
600    pub fn with_badge(label: impl Into<String>, badge: impl Into<String>) -> Self {
601        Self {
602            label: label.into(),
603            badge: Some(badge.into()),
604        }
605    }
606}
607
608/// Result of a single prompt — either the value the user chose, or a
609/// navigation signal. Backends return `Answer::Back` when the user presses
610/// the backend's back key (Esc on ratatui / dialoguer). Callers rewind.
611#[derive(Debug, Clone)]
612pub enum Answer<T> {
613    Value(T),
614    Back,
615}
616
617/// Prompt-surface the onboard orchestrator drives.
618///
619/// Async is deliberate: the orchestrator is already async (Config::load_or_init,
620/// Config::save), and a future gateway-backed onboarder (WebSocket → browser)
621/// needs to await network I/O per prompt. A sync trait would force that
622/// backend to bridge sync↔async via blocking threads and channels, which
623/// starves the tokio runtime under concurrent onboarding sessions. Blocking
624/// backends (dialoguer) wrap their calls in `tokio::task::spawn_blocking`.
625///
626/// Idempotency contract: prompts accept a `current` value and pre-populate it
627/// as the default. `secret(has_current=true)` returns `None` when the user
628/// declines to rotate; callers then skip the write. The orchestrator never
629/// calls `config.set_prop` unless the new value differs from `current`.
630#[async_trait::async_trait]
631pub trait OnboardUi: Send {
632    async fn confirm(&mut self, prompt: &str, default: bool) -> anyhow::Result<Answer<bool>>;
633
634    /// Single-line text/number/path input.
635    ///
636    /// - `current`: existing value to pre-fill into the editable buffer
637    ///   (edit mode — user lands on the prompt with the value typed in
638    ///   and can modify it before Enter).
639    /// - `placeholder`: a schema/runtime default to surface as ghost-text
640    ///   when the buffer is empty. Backends that support styled output
641    ///   render this dim; pressing Enter on an empty buffer commits the
642    ///   placeholder as the chosen value.
643    ///
644    /// At most one of `current` / `placeholder` should be `Some` at any
645    /// call site: if the user has set a value already, pre-fill it;
646    /// otherwise surface the default as ghost text. Passing both
647    /// devolves to pre-fill semantics (the placeholder is ignored).
648    async fn string(
649        &mut self,
650        prompt: &str,
651        current: Option<&str>,
652        placeholder: Option<&str>,
653    ) -> anyhow::Result<Answer<String>>;
654
655    /// `Answer::Value(Some(v))` = new secret entered. `Answer::Value(None)` =
656    /// user declined to update an existing secret (only when `has_current`).
657    /// `Answer::Back` = rewind.
658    async fn secret(
659        &mut self,
660        prompt: &str,
661        has_current: bool,
662    ) -> anyhow::Result<Answer<Option<String>>>;
663
664    async fn select(
665        &mut self,
666        prompt: &str,
667        items: &[SelectItem],
668        current: Option<usize>,
669    ) -> anyhow::Result<Answer<usize>>;
670
671    async fn editor(&mut self, hint: &str, initial: &str) -> anyhow::Result<Answer<String>>;
672
673    /// Announce a new section or subsection. `level == 1` = section
674    /// (Providers, Channels, …). `level == 2` = subsection within a section
675    /// (Hardware › Transport). Backends render these persistently so every
676    /// prompt remains anchored to its phase — rendered like Markdown
677    /// headings. `level == 1` resets any prior subsection.
678    fn heading(&mut self, level: u8, text: &str);
679    fn note(&mut self, msg: &str);
680    fn status(&mut self, msg: &str);
681    fn warn(&mut self, msg: &str);
682}
683
684#[cfg(test)]
685mod secret_field_tests {
686    use super::{MASKED_SECRET, SecretField};
687    use crate::security::SecretStore;
688    use std::collections::HashMap;
689    use tempfile::TempDir;
690
691    fn store() -> (TempDir, SecretStore) {
692        let tmp = TempDir::new().unwrap();
693        let store = SecretStore::new(tmp.path(), true);
694        (tmp, store)
695    }
696
697    #[test]
698    fn string_roundtrip_and_idempotent() {
699        let (_tmp, store) = store();
700        let mut s = String::from("sk-abc");
701        s.encrypt_in_place(&store, "test.s").unwrap();
702        assert!(SecretStore::is_encrypted(&s));
703        let enc1 = s.clone();
704        // idempotent: encrypting again must not double-wrap
705        s.encrypt_in_place(&store, "test.s").unwrap();
706        assert_eq!(s, enc1);
707        s.decrypt_in_place(&store, "test.s").unwrap();
708        assert_eq!(s, "sk-abc");
709    }
710
711    #[test]
712    fn string_empty_stays_empty() {
713        let (_tmp, store) = store();
714        let mut s = String::new();
715        s.encrypt_in_place(&store, "test.s").unwrap();
716        assert_eq!(s, "");
717        assert!(!s.is_set());
718    }
719
720    #[test]
721    fn string_mask_and_restore() {
722        let mut s = String::from("Bearer xyz");
723        let cur = String::from("Bearer xyz");
724        s.mask();
725        assert_eq!(s, MASKED_SECRET);
726        s.restore_from(&cur);
727        assert_eq!(s, "Bearer xyz");
728    }
729
730    #[test]
731    fn option_string_none_is_noop() {
732        let (_tmp, store) = store();
733        let mut v: Option<String> = None;
734        v.encrypt_in_place(&store, "test.o").unwrap();
735        v.decrypt_in_place(&store, "test.o").unwrap();
736        v.mask();
737        assert_eq!(v, None);
738        assert!(!v.is_set());
739    }
740
741    #[test]
742    fn option_string_some_roundtrip() {
743        let (_tmp, store) = store();
744        let mut v: Option<String> = Some("Bearer xyz".into());
745        v.encrypt_in_place(&store, "test.o").unwrap();
746        assert!(SecretStore::is_encrypted(v.as_ref().unwrap()));
747        v.decrypt_in_place(&store, "test.o").unwrap();
748        assert_eq!(v.as_deref(), Some("Bearer xyz"));
749        assert!(v.is_set());
750    }
751
752    #[test]
753    fn vec_string_roundtrip_per_element() {
754        let (_tmp, store) = store();
755        let mut v: Vec<String> = vec!["one".into(), "".into(), "two".into()];
756        v.encrypt_in_place(&store, "test.v").unwrap();
757        assert!(SecretStore::is_encrypted(&v[0]));
758        assert_eq!(v[1], "", "empty element must stay empty");
759        assert!(SecretStore::is_encrypted(&v[2]));
760        v.decrypt_in_place(&store, "test.v").unwrap();
761        assert_eq!(v, vec!["one", "", "two"]);
762    }
763
764    #[test]
765    fn hashmap_string_string_roundtrip_per_value() {
766        let (_tmp, store) = store();
767        let mut h: HashMap<String, String> = HashMap::from([
768            ("Authorization".into(), "Bearer sk-abc".into()),
769            ("X-Trace".into(), "req-123".into()),
770        ]);
771        h.encrypt_in_place(&store, "mcp.servers.foo.headers")
772            .unwrap();
773        for v in h.values() {
774            assert!(SecretStore::is_encrypted(v));
775        }
776        h.decrypt_in_place(&store, "mcp.servers.foo.headers")
777            .unwrap();
778        assert_eq!(
779            h.get("Authorization").map(String::as_str),
780            Some("Bearer sk-abc")
781        );
782        assert_eq!(h.get("X-Trace").map(String::as_str), Some("req-123"));
783        assert!(h.is_set());
784    }
785
786    #[test]
787    fn hashmap_string_string_mask_and_restore() {
788        let mut h: HashMap<String, String> =
789            HashMap::from([("Authorization".into(), "Bearer xyz".into())]);
790        let cur = h.clone();
791        h.mask();
792        assert_eq!(
793            h.get("Authorization").map(String::as_str),
794            Some(MASKED_SECRET)
795        );
796        h.restore_from(&cur);
797        assert_eq!(
798            h.get("Authorization").map(String::as_str),
799            Some("Bearer xyz")
800        );
801    }
802
803    #[test]
804    fn option_hashmap_none_is_noop() {
805        let (_tmp, store) = store();
806        let mut v: Option<HashMap<String, String>> = None;
807        v.encrypt_in_place(&store, "test.oh").unwrap();
808        v.decrypt_in_place(&store, "test.oh").unwrap();
809        v.mask();
810        assert!(v.is_none());
811        assert!(!v.is_set());
812    }
813
814    #[test]
815    fn option_hashmap_some_roundtrip() {
816        let (_tmp, store) = store();
817        let mut v: Option<HashMap<String, String>> =
818            Some(HashMap::from([("k".into(), "secret".into())]));
819        v.encrypt_in_place(&store, "test.oh").unwrap();
820        assert!(SecretStore::is_encrypted(
821            v.as_ref().unwrap().get("k").unwrap()
822        ));
823        v.decrypt_in_place(&store, "test.oh").unwrap();
824        assert_eq!(
825            v.as_ref().unwrap().get("k").map(String::as_str),
826            Some("secret")
827        );
828        assert!(v.is_set());
829    }
830
831    #[test]
832    fn hashmap_empty_is_not_set() {
833        let h: HashMap<String, String> = HashMap::new();
834        assert!(!h.is_set());
835        let oh: Option<HashMap<String, String>> = Some(HashMap::new());
836        assert!(!oh.is_set());
837    }
838
839    #[test]
840    fn hashmap_with_only_empty_values_is_not_set() {
841        // The trait contract for `is_set` is "at least one non-empty inner
842        // string". A map carrying placeholder keys with empty values has no
843        // secret material to encrypt or mask, so it must report not-set —
844        // otherwise the dashboard would render `***MASKED***` over a blank
845        // header row.
846        let h: HashMap<String, String> = HashMap::from([
847            ("Authorization".into(), String::new()),
848            ("X-Trace".into(), String::new()),
849        ]);
850        assert!(!h.is_set());
851
852        let oh: Option<HashMap<String, String>> =
853            Some(HashMap::from([("Authorization".into(), String::new())]));
854        assert!(!oh.is_set());
855
856        let mixed: HashMap<String, String> = HashMap::from([
857            ("Authorization".into(), "Bearer xyz".into()),
858            ("X-Trace".into(), String::new()),
859        ]);
860        assert!(mixed.is_set(), "any non-empty value makes the map set");
861    }
862
863    #[test]
864    fn encrypt_decrypt_failure_message_includes_field_path() {
865        let tmp = TempDir::new().unwrap();
866        let bad_store = SecretStore::new(tmp.path(), true);
867        // Construct a malformed enc2 string that will fail to decrypt.
868        let mut s = String::from("enc2:not-valid-hex");
869        let err = s
870            .decrypt_in_place(&bad_store, "mcp.servers.foo.headers.Authorization")
871            .expect_err("malformed ciphertext must fail");
872        let msg = format!("{err:#}");
873        assert!(
874            msg.contains("mcp.servers.foo.headers.Authorization"),
875            "error must include field path; got: {msg}"
876        );
877    }
878}