Skip to main content

zeroclaw_config/
traits.rs

1/// Sentinel rendered for unset / `None` / empty config values during display.
2/// Never a valid stored value: the write path rejects it so it cannot round-trip
3/// into persisted config.
4pub const UNSET_DISPLAY: &str = "<unset>";
5
6/// Describes a single secret field discovered via `#[derive(Configurable)]`.
7#[derive(Debug, Clone)]
8pub struct SecretFieldInfo {
9    /// Full dotted name (e.g. `channels.matrix.access-token`)
10    pub name: &'static str,
11    /// Category for grouping in `zeroclaw config list`
12    pub category: &'static str,
13    /// Whether this field currently has a non-empty value
14    pub is_set: bool,
15}
16
17/// Runtime type classification for config property values.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum PropKind {
21    String,
22    Bool,
23    Integer,
24    Float,
25    /// An enum or other serde-serializable type (parsed as TOML string).
26    Enum,
27    /// A `Vec<String>` field; set via comma-separated input.
28    StringArray,
29    /// A `Vec<T>` field where `T` is a serializable struct (e.g. `Vec<McpServerConfig>`,
30    /// `Vec<PeripheralBoardConfig>`). Round-tripped on the wire as a JSON array of
31    /// objects; the dashboard renders a per-row sub-form using the JSON Schema
32    /// from `OPTIONS /api/config` to discover the element type's field shape.
33    /// Schema v3 / #5947 will migrate the load-bearing ones (mcp.servers etc.)
34    /// to `HashMap<String, T>` keyed tables; until then this kind covers them.
35    ObjectArray,
36    /// A struct-shaped scalar field (e.g. `Option<ModelPricing>`). Round-tripped
37    /// on the wire as a JSON object; the dashboard renders a sub-form for the
38    /// inner fields using the JSON Schema from `OPTIONS /api/config`. Distinct
39    /// from `String`, which inserts the raw value as a TOML string and breaks
40    /// the serde round-trip for typed structs.
41    Object,
42}
43
44/// Maps Rust types to PropKind at compile time.
45/// Scalars have explicit impls; the blanket impl catches everything
46/// else as `PropKind::Enum`.
47pub trait HasPropKind {
48    const PROP_KIND: PropKind;
49
50    /// Terminal field names whose values must be redacted when this type is
51    /// displayed as an object/object-array prop. Most prop kinds have no
52    /// nested secret surface; Configurable object-array element types can
53    /// override this by delegating to their generated `secret_field_terminals`.
54    fn display_secret_terminals() -> Vec<&'static str> {
55        Vec::new()
56    }
57}
58
59macro_rules! impl_prop_kind {
60    ($kind:expr, $($ty:ty),+) => {
61        $(impl HasPropKind for $ty { const PROP_KIND: PropKind = $kind; })+
62    };
63}
64
65impl_prop_kind!(PropKind::Bool, bool);
66impl_prop_kind!(PropKind::String, String);
67impl_prop_kind!(PropKind::Float, f64, f32);
68impl_prop_kind!(
69    PropKind::Integer,
70    u8,
71    u16,
72    u32,
73    u64,
74    usize,
75    i8,
76    i16,
77    i32,
78    i64,
79    isize
80);
81impl HasPropKind for Vec<String> {
82    const PROP_KIND: PropKind = PropKind::StringArray;
83}
84
85// The per-category provider-ref newtypes (defined in `crate::providers`)
86// serialize as plain strings; the schema-tooling layer treats them as
87// strings too.
88impl HasPropKind for crate::providers::ModelProviderRef {
89    const PROP_KIND: PropKind = PropKind::String;
90}
91impl HasPropKind for Vec<crate::providers::ModelProviderRef> {
92    const PROP_KIND: PropKind = PropKind::StringArray;
93}
94impl HasPropKind for crate::providers::TtsProviderRef {
95    const PROP_KIND: PropKind = PropKind::String;
96}
97impl HasPropKind for crate::providers::TranscriptionProviderRef {
98    const PROP_KIND: PropKind = PropKind::String;
99}
100impl HasPropKind for crate::providers::ChannelRef {
101    const PROP_KIND: PropKind = PropKind::String;
102}
103impl HasPropKind for Vec<crate::providers::ChannelRef> {
104    const PROP_KIND: PropKind = PropKind::StringArray;
105}
106
107// Multi-agent typed primitives. AgentAlias / PeerGroupName /
108// PeerUsername round-trip as plain strings; AccessMode and
109// MemoryBackendKind are enums.
110impl HasPropKind for crate::multi_agent::AgentAlias {
111    const PROP_KIND: PropKind = PropKind::String;
112}
113impl HasPropKind for crate::multi_agent::PeerGroupName {
114    const PROP_KIND: PropKind = PropKind::String;
115}
116impl HasPropKind for crate::multi_agent::PeerUsername {
117    const PROP_KIND: PropKind = PropKind::String;
118}
119impl HasPropKind for crate::multi_agent::AccessMode {
120    const PROP_KIND: PropKind = PropKind::Enum;
121}
122impl HasPropKind for crate::multi_agent::MemoryBackendKind {
123    const PROP_KIND: PropKind = PropKind::Enum;
124}
125impl HasPropKind for crate::multi_agent::OutputModality {
126    const PROP_KIND: PropKind = PropKind::Enum;
127}
128impl HasPropKind for Vec<crate::multi_agent::AgentAlias> {
129    const PROP_KIND: PropKind = PropKind::StringArray;
130}
131impl HasPropKind for Vec<crate::multi_agent::PeerUsername> {
132    const PROP_KIND: PropKind = PropKind::StringArray;
133}
134impl HasPropKind
135    for std::collections::BTreeMap<crate::multi_agent::AgentAlias, crate::multi_agent::AccessMode>
136{
137    // Serialized as a TOML inline table: `{ beta = "read", gamma = "read_write" }`.
138    const PROP_KIND: PropKind = PropKind::Object;
139}
140
141// Vec<struct> fields are surfaced as PropKind::ObjectArray — each
142// element renders as a per-row sub-form on the dashboard rather than a
143// chip. The Configurable derive routes `<Vec<T> as HasPropKind>::PROP_KIND`
144// for every Vec field, so a missing impl here surfaces as a "trait bound
145// not satisfied" compile error pointing at the field. Add the impl in
146// the same module that defines the type if traits.rs's crate scope is
147// too narrow.
148impl HasPropKind for Vec<crate::schema::ClassificationRule> {
149    const PROP_KIND: PropKind = PropKind::ObjectArray;
150}
151impl HasPropKind for Vec<crate::schema::EmbeddingRouteConfig> {
152    const PROP_KIND: PropKind = PropKind::ObjectArray;
153}
154impl HasPropKind for Vec<crate::schema::GoogleWorkspaceAllowedOperation> {
155    const PROP_KIND: PropKind = PropKind::ObjectArray;
156}
157impl HasPropKind for Vec<crate::schema::McpServerConfig> {
158    const PROP_KIND: PropKind = PropKind::ObjectArray;
159
160    fn display_secret_terminals() -> Vec<&'static str> {
161        crate::schema::McpServerConfig::secret_field_terminals()
162    }
163}
164impl HasPropKind for Vec<crate::schema::ModelRouteConfig> {
165    const PROP_KIND: PropKind = PropKind::ObjectArray;
166}
167impl HasPropKind for Vec<crate::schema::NevisRoleMappingConfig> {
168    const PROP_KIND: PropKind = PropKind::ObjectArray;
169}
170impl HasPropKind for Vec<crate::schema::PeripheralBoardConfig> {
171    const PROP_KIND: PropKind = PropKind::ObjectArray;
172}
173impl HasPropKind for Vec<crate::schema::ToolFilterGroup> {
174    const PROP_KIND: PropKind = PropKind::ObjectArray;
175}
176
177/// Security classification for credential-shaped config surfaces.
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub enum CredentialSurfaceClass {
180    EncryptedSecret,
181    PathOnlyReference,
182    PublicValue,
183    ExternalAuthStore,
184    LegacyEnvPath,
185    RequiresFollowUp,
186}
187
188/// Tab grouping for config fields and UI surfaces. Each variant maps to a
189/// tab in the TUI and gateway dashboard. Serializes to its PascalCase
190/// variant name on the wire.
191///
192/// Field-partition tabs (`Connection`, `Model`, …) are used as `#[tab(...)]`
193/// annotations on schema structs. Composite tabs (`Personality`, `Skills`,
194/// `PeerGroups`, `Costs`) are rendered by dedicated UI components but share
195/// the same enum so both frontends speak one vocabulary.
196#[derive(
197    Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize,
198)]
199pub enum ConfigTab {
200    #[default]
201    /// No tab grouping — field appears in a flat list.
202    None,
203
204    // ── Shared (providers + channels) ──
205    Connection,
206    Advanced,
207
208    // ── Providers ──
209    Model,
210
211    // ── Channels ──
212    Behavior,
213
214    // ── Agents: field partitions ──
215    General,
216    Channels,
217    Providers,
218    Bundles,
219    Cron,
220    Tuning,
221    Workspace,
222    Memory,
223
224    // ── Agents: composite (custom-component) tabs ──
225    PeerGroups,
226    Personality,
227
228    // ── MCP ──
229    Settings,
230    Servers,
231
232    // ── Cost ──
233    Limits,
234    Costs,
235
236    // ── Skill bundles ──
237    Skills,
238    Aliases,
239}
240
241impl ConfigTab {
242    /// Display label for the tab bar. Returns `""` for `None`.
243    pub fn label(self) -> &'static str {
244        match self {
245            Self::None => "",
246            Self::Connection => "Connection",
247            Self::Advanced => "Advanced",
248            Self::Model => "Model",
249            Self::Behavior => "Behavior",
250            Self::General => "General",
251            Self::Channels => "Channels",
252            Self::Providers => "Providers",
253            Self::Bundles => "Bundles",
254            Self::Cron => "Cron",
255            Self::Tuning => "Tuning",
256            Self::Workspace => "Workspace",
257            Self::Memory => "Memory",
258            Self::PeerGroups => "Peer Groups",
259            Self::Personality => "Personality",
260            Self::Settings => "Settings",
261            Self::Servers => "Servers",
262            Self::Limits => "Limits",
263            Self::Costs => "Costs",
264            Self::Skills => "Skills",
265            Self::Aliases => "Aliases",
266        }
267    }
268
269    /// `true` when this is the `None` variant (no tab grouping).
270    pub fn is_none(&self) -> bool {
271        matches!(self, Self::None)
272    }
273}
274
275impl std::fmt::Display for ConfigTab {
276    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
277        f.write_str(self.label())
278    }
279}
280
281/// Describes a single property field discovered via `#[derive(Configurable)]`.
282#[derive(Clone)]
283pub struct PropFieldInfo {
284    /// Full dotted name (e.g. `channels.telegram.draft-update-interval-ms`).
285    /// Owned so the `HashMap<String, T>` branch of the derive can inject the
286    /// runtime map key into the path (`model_providers.anthropic.api-key`)
287    /// — `&'static str` can't carry user-supplied keys.
288    pub name: String,
289    /// Category for grouping in property listings
290    pub category: &'static str,
291    /// Current value formatted for display (secrets show `"****"`)
292    pub display_value: String,
293    /// Raw Rust type string for display (e.g. `"bool"`, `"u64"`, `"Option<StreamMode>"`)
294    pub type_hint: &'static str,
295    /// Runtime type classification
296    pub kind: PropKind,
297    /// Whether this field is marked `#[secret]`
298    pub is_secret: bool,
299    /// Returns valid variant names for enum fields (None for non-enum fields)
300    pub enum_variants: Option<fn() -> Vec<String>>,
301    /// Field's `///` doc comment, flattened to a single line. Empty string
302    /// when the field has no doc comment. Onboard uses this as human-readable
303    /// prompt text instead of the raw kebab-case field name.
304    pub description: &'static str,
305    /// Whether this field's value is derived from a secret (`#[derived_from_secret]`).
306    /// Subject to the same write-only / no-readback rules as `#[secret]`.
307    /// Reserved for future schema additions; currently no fields are derived.
308    pub derived_from_secret: bool,
309    /// Explicit security classification for credential-shaped surfaces.
310    pub credential_class: Option<CredentialSurfaceClass>,
311    /// Tab grouping for this field. `ConfigTab::None` when the field has
312    /// no tab annotation (flat display, no tab bar).
313    pub tab: ConfigTab,
314}
315
316impl PropKind {
317    /// Stable lowercase-kebab wire name matching the serde serialization.
318    /// Useful when consumers need the tag as a `&'static str` without
319    /// going through serde round-trip.
320    pub fn wire_name(self) -> &'static str {
321        match self {
322            Self::String => "string",
323            Self::Bool => "bool",
324            Self::Integer => "integer",
325            Self::Float => "float",
326            Self::Enum => "enum",
327            Self::StringArray => "string_array",
328            Self::ObjectArray => "object_array",
329            Self::Object => "object",
330        }
331    }
332}
333
334impl PropFieldInfo {
335    pub fn is_enum(&self) -> bool {
336        self.enum_variants.is_some()
337    }
338}
339
340impl std::fmt::Debug for PropFieldInfo {
341    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
342        f.debug_struct("PropFieldInfo")
343            .field("name", &self.name)
344            .field("kind", &self.kind)
345            .field("is_secret", &self.is_secret)
346            .field("credential_class", &self.credential_class)
347            .field("tab", &self.tab)
348            .finish_non_exhaustive()
349    }
350}
351
352/// Mask and restore secret fields on config structs.
353///
354/// Automatically implemented by `#[derive(Configurable)]` for any struct that
355/// has fields annotated with `#[secret]` or `#[nested]`. A blanket impl covers
356/// `HashMap<String, T: MaskSecrets>` so the trait propagates through alias maps
357/// without any per-type boilerplate.
358pub trait MaskSecrets {
359    fn mask_secrets(&mut self);
360    fn restore_secrets_from(&mut self, current: &Self);
361}
362
363impl<T: MaskSecrets> MaskSecrets for std::collections::HashMap<String, T> {
364    fn mask_secrets(&mut self) {
365        for v in self.values_mut() {
366            v.mask_secrets();
367        }
368    }
369    fn restore_secrets_from(&mut self, current: &Self) {
370        for (k, v) in self.iter_mut() {
371            if let Some(cur) = current.get(k) {
372                v.restore_secrets_from(cur);
373            }
374        }
375    }
376}
377
378impl<T: MaskSecrets> MaskSecrets for Vec<T> {
379    fn mask_secrets(&mut self) {
380        for v in self.iter_mut() {
381            v.mask_secrets();
382        }
383    }
384    fn restore_secrets_from(&mut self, current: &Self) {
385        for (v, cur) in self.iter_mut().zip(current.iter()) {
386            v.restore_secrets_from(cur);
387        }
388    }
389}
390
391pub const MASKED_SECRET: &str = "***MASKED***";
392
393pub fn is_masked_secret(value: &str) -> bool {
394    value == MASKED_SECRET
395}
396
397/// Per-field secret operations the `Configurable` derive emits for every
398/// `#[secret]` field. Generalizes mask / restore / encrypt / decrypt / is_set
399/// across the supported shapes — `String`, `Option<String>`, `Vec<String>`,
400/// `HashMap<String, String>`, and `Option<HashMap<String, String>>` — so adding
401/// a new shape is a single trait impl rather than a fourth branch in the macro.
402///
403/// `encrypt_in_place` and `decrypt_in_place` are idempotent: encrypting an
404/// already-`enc2:`-prefixed value or decrypting a plaintext value is a no-op,
405/// detected via [`crate::security::SecretStore::is_encrypted`]. The `field`
406/// argument is the dotted config-path (e.g. `mcp.servers`); the impls suffix
407/// per-element coordinates (`[<idx>]` for `Vec`, `.<key>` for `HashMap`) so
408/// error messages point at the exact failed entry.
409pub trait SecretField {
410    /// Replace each non-empty inner string with [`MASKED_SECRET`].
411    fn mask(&mut self);
412
413    /// Restore inner strings that currently equal [`MASKED_SECRET`] from the
414    /// matching position in `current`. The dashboard write path relies on this
415    /// so re-posting an already-displayed masked value doesn't overwrite the
416    /// real secret in config.
417    fn restore_from(&mut self, current: &Self);
418
419    /// Encrypt every non-empty, not-already-encrypted inner string.
420    fn encrypt_in_place(
421        &mut self,
422        store: &crate::security::SecretStore,
423        field: &str,
424    ) -> anyhow::Result<()>;
425
426    /// Inverse of [`Self::encrypt_in_place`].
427    fn decrypt_in_place(
428        &mut self,
429        store: &crate::security::SecretStore,
430        field: &str,
431    ) -> anyhow::Result<()>;
432
433    /// Whether the field carries at least one non-empty inner string. Reported
434    /// back through [`SecretFieldInfo::is_set`].
435    fn is_set(&self) -> bool;
436}
437
438impl SecretField for String {
439    fn mask(&mut self) {
440        if !self.is_empty() {
441            *self = MASKED_SECRET.to_string();
442        }
443    }
444
445    fn restore_from(&mut self, current: &Self) {
446        if is_masked_secret(self) {
447            self.clone_from(current);
448        }
449    }
450
451    fn encrypt_in_place(
452        &mut self,
453        store: &crate::security::SecretStore,
454        field: &str,
455    ) -> anyhow::Result<()> {
456        use anyhow::Context;
457        if !self.is_empty() && !crate::security::SecretStore::is_encrypted(self) {
458            *self = store
459                .encrypt(self)
460                .with_context(|| format!("Failed to encrypt {field}"))?;
461        }
462        Ok(())
463    }
464
465    fn decrypt_in_place(
466        &mut self,
467        store: &crate::security::SecretStore,
468        field: &str,
469    ) -> anyhow::Result<()> {
470        use anyhow::Context;
471        if crate::security::SecretStore::is_encrypted(self) {
472            *self = store
473                .decrypt(self)
474                .with_context(|| format!("Failed to decrypt {field}"))?;
475        }
476        Ok(())
477    }
478
479    fn is_set(&self) -> bool {
480        !self.is_empty()
481    }
482}
483
484impl SecretField for Option<String> {
485    fn mask(&mut self) {
486        if let Some(inner) = self {
487            inner.mask();
488        }
489    }
490
491    fn restore_from(&mut self, current: &Self) {
492        if let (Some(inner), Some(cur)) = (self.as_mut(), current.as_ref()) {
493            inner.restore_from(cur);
494        }
495    }
496
497    fn encrypt_in_place(
498        &mut self,
499        store: &crate::security::SecretStore,
500        field: &str,
501    ) -> anyhow::Result<()> {
502        match self {
503            Some(inner) => inner.encrypt_in_place(store, field),
504            None => Ok(()),
505        }
506    }
507
508    fn decrypt_in_place(
509        &mut self,
510        store: &crate::security::SecretStore,
511        field: &str,
512    ) -> anyhow::Result<()> {
513        match self {
514            Some(inner) => inner.decrypt_in_place(store, field),
515            None => Ok(()),
516        }
517    }
518
519    fn is_set(&self) -> bool {
520        self.as_ref().is_some_and(|v| !v.is_empty())
521    }
522}
523
524impl SecretField for Vec<String> {
525    fn mask(&mut self) {
526        for element in self.iter_mut() {
527            element.mask();
528        }
529    }
530
531    fn restore_from(&mut self, current: &Self) {
532        for (element, cur) in self.iter_mut().zip(current.iter()) {
533            element.restore_from(cur);
534        }
535    }
536
537    fn encrypt_in_place(
538        &mut self,
539        store: &crate::security::SecretStore,
540        field: &str,
541    ) -> anyhow::Result<()> {
542        for (idx, element) in self.iter_mut().enumerate() {
543            element.encrypt_in_place(store, &format!("{field}[{idx}]"))?;
544        }
545        Ok(())
546    }
547
548    fn decrypt_in_place(
549        &mut self,
550        store: &crate::security::SecretStore,
551        field: &str,
552    ) -> anyhow::Result<()> {
553        for (idx, element) in self.iter_mut().enumerate() {
554            element.decrypt_in_place(store, &format!("{field}[{idx}]"))?;
555        }
556        Ok(())
557    }
558
559    fn is_set(&self) -> bool {
560        !self.is_empty()
561    }
562}
563
564impl SecretField for std::collections::HashMap<String, String> {
565    fn mask(&mut self) {
566        for value in self.values_mut() {
567            value.mask();
568        }
569    }
570
571    fn restore_from(&mut self, current: &Self) {
572        for (key, value) in self.iter_mut() {
573            if let Some(cur) = current.get(key) {
574                value.restore_from(cur);
575            }
576        }
577    }
578
579    fn encrypt_in_place(
580        &mut self,
581        store: &crate::security::SecretStore,
582        field: &str,
583    ) -> anyhow::Result<()> {
584        for (key, value) in self.iter_mut() {
585            value.encrypt_in_place(store, &format!("{field}.{key}"))?;
586        }
587        Ok(())
588    }
589
590    fn decrypt_in_place(
591        &mut self,
592        store: &crate::security::SecretStore,
593        field: &str,
594    ) -> anyhow::Result<()> {
595        for (key, value) in self.iter_mut() {
596            value.decrypt_in_place(store, &format!("{field}.{key}"))?;
597        }
598        Ok(())
599    }
600
601    fn is_set(&self) -> bool {
602        self.values().any(|v| !v.is_empty())
603    }
604}
605
606impl SecretField for Option<std::collections::HashMap<String, String>> {
607    fn mask(&mut self) {
608        if let Some(inner) = self {
609            inner.mask();
610        }
611    }
612
613    fn restore_from(&mut self, current: &Self) {
614        if let (Some(inner), Some(cur)) = (self.as_mut(), current.as_ref()) {
615            inner.restore_from(cur);
616        }
617    }
618
619    fn encrypt_in_place(
620        &mut self,
621        store: &crate::security::SecretStore,
622        field: &str,
623    ) -> anyhow::Result<()> {
624        match self {
625            Some(inner) => inner.encrypt_in_place(store, field),
626            None => Ok(()),
627        }
628    }
629
630    fn decrypt_in_place(
631        &mut self,
632        store: &crate::security::SecretStore,
633        field: &str,
634    ) -> anyhow::Result<()> {
635        match self {
636            Some(inner) => inner.decrypt_in_place(store, field),
637            None => Ok(()),
638        }
639    }
640
641    fn is_set(&self) -> bool {
642        self.as_ref()
643            .is_some_and(|m| m.values().any(|v| !v.is_empty()))
644    }
645}
646
647/// Stable wire-form for an addable section — a `HashMap<String, T>` (Map) or
648/// `Vec<T>` (List) field whose value type implements `Configurable`. The
649/// dashboard / CLI use this to surface `+ Add` affordances without
650/// hardcoding the section list. Auto-discovered by the `Configurable` derive.
651#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
652#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
653#[serde(rename_all = "snake_case")]
654pub enum MapKeyKind {
655    /// `HashMap<String, T>` — key is user-supplied; new value is default.
656    Map,
657    /// `Vec<T>` — entries are appended; the user-supplied "key" is stored
658    /// in the value type's natural identifier field (e.g. `name`, `hint`).
659    List,
660}
661
662#[derive(Debug, Clone, Copy, serde::Serialize)]
663#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
664pub struct MapKeySection {
665    /// Dotted section path, e.g. `providers.models`, `mcp.servers`.
666    pub path: &'static str,
667    /// Whether the section is a map or a list.
668    pub kind: MapKeyKind,
669    /// Rust type name of the value, e.g. `ModelProviderConfig`. For display only.
670    pub value_type: &'static str,
671    /// Doc comment on the field (flattened to one line). What the user sees
672    /// when picking which kind of thing to add.
673    pub description: &'static str,
674}
675
676/// Serializable wire representation of a config field for API consumers
677/// (RPC dispatch, gateway, TUI). Single source of truth — replaces the
678/// gateway's local `ListEntry` and the RPC dispatch's ad-hoc JSON.
679///
680/// Built from [`PropFieldInfo`] via [`ConfigFieldEntry::from_prop_field`].
681#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
682pub struct ConfigFieldEntry {
683    pub path: String,
684    pub category: String,
685    pub kind: PropKind,
686    pub type_hint: String,
687    #[serde(skip_serializing_if = "Option::is_none")]
688    pub value: Option<serde_json::Value>,
689    pub populated: bool,
690    pub is_secret: bool,
691    #[serde(default)]
692    pub is_env_overridden: bool,
693    #[serde(default, skip_serializing_if = "Vec::is_empty")]
694    pub enum_variants: Vec<String>,
695    pub description: String,
696    #[serde(skip_serializing_if = "Option::is_none")]
697    pub section: Option<String>,
698    /// Tab grouping. `ConfigTab::None` = no tab grouping (flat display).
699    #[serde(default, skip_serializing_if = "ConfigTab::is_none")]
700    pub tab: ConfigTab,
701}
702
703impl ConfigFieldEntry {
704    /// Convert a [`PropFieldInfo`] (server-side introspection) into its wire
705    /// representation. Secrets are masked (value omitted). The caller supplies
706    /// `is_env_overridden` from `Config::prop_is_env_overridden`.
707    pub fn from_prop_field(info: PropFieldInfo, is_env_overridden: bool) -> Self {
708        let populated = info.display_value != crate::traits::UNSET_DISPLAY;
709        let is_sensitive = info.is_secret || info.derived_from_secret;
710        let value = if is_sensitive {
711            None
712        } else {
713            Some(serde_json::Value::String(info.display_value))
714        };
715        let enum_variants = info.enum_variants.map(|f| f()).unwrap_or_default();
716        let section = crate::sections::Section::from_key(info.name.split('.').next().unwrap_or(""))
717            .map(|s| s.as_str().to_string());
718
719        Self {
720            path: info.name,
721            category: info.category.to_string(),
722            kind: info.kind,
723            type_hint: info.type_hint.to_string(),
724            value,
725            populated,
726            is_secret: is_sensitive,
727            is_env_overridden,
728            enum_variants,
729            description: info.description.to_string(),
730            section,
731            tab: info.tab,
732        }
733    }
734}
735
736/// One row emitted by the `Configurable` derive's `nested_option_entries()`
737/// method — every `#[nested] Option<XConfig>` field on a struct shows up here
738/// with its `present` bit and the per-field `#[display_name = "..."]` /
739/// `#[description = "..."]` metadata. The integrations registry consumes
740/// this verbatim instead of carrying its own per-field hand-list.
741#[derive(Debug, Clone, Copy)]
742pub struct NestedOptionEntry {
743    /// snake_case field name on the parent struct (e.g. `"telegram"`,
744    /// `"voice_duplex"`).
745    pub field: &'static str,
746    /// `true` when the parent struct's field is `Some(_)`.
747    pub present: bool,
748    /// Display name from `#[display_name = "..."]`; falls back to a
749    /// title-cased rendering of the snake_case field name when the
750    /// attribute is absent.
751    pub display_name: &'static str,
752    /// One-line summary from `#[description = "..."]`. Empty when the
753    /// attribute is absent.
754    pub description: &'static str,
755}
756
757/// One row emitted by the `Configurable` derive's `integration_descriptor()`
758/// method on structs annotated with `#[integration(...)]`. Used for nested
759/// toggleable configs (e.g. `BrowserConfig`, `CronConfig`) where the
760/// integration is "active" iff a named bool field on the struct is `true`.
761#[derive(Debug, Clone, Copy)]
762pub struct IntegrationDescriptor {
763    pub display_name: &'static str,
764    pub description: &'static str,
765    /// Free-form category label (e.g. `"ToolsAutomation"`). The
766    /// integrations registry maps this string to its own
767    /// `IntegrationCategory` enum so the schema crate doesn't have to
768    /// depend on it.
769    pub category: &'static str,
770    /// Snapshot of the named status field at the moment this descriptor
771    /// was built (`status_field = "enabled"` ⇒ `self.enabled`).
772    pub active: bool,
773}
774
775/// Metadata for one channel type, as returned by [`crate::schema::ChannelsConfig::channels`].
776#[derive(Debug, Clone)]
777pub struct ChannelInfo {
778    /// Canonical kebab-case identifier used in config TOML
779    /// (`[channels.<kind>]`). Matches the field name on
780    /// `ChannelsConfig` so Quickstart and other surfaces can
781    /// reuse the schema's own labeling without a parallel map.
782    pub kind: &'static str,
783    pub name: &'static str,
784    pub desc: &'static str,
785    pub configured: bool,
786}
787
788/// The trait for describing a channel
789pub trait ChannelConfig {
790    /// human-readable name
791    fn name() -> &'static str;
792    /// short description
793    fn desc() -> &'static str;
794}
795
796#[cfg(test)]
797mod secret_field_tests {
798    use super::{MASKED_SECRET, SecretField};
799    use crate::security::SecretStore;
800    use std::collections::HashMap;
801    use tempfile::TempDir;
802
803    fn store() -> (TempDir, SecretStore) {
804        let tmp = TempDir::new().unwrap();
805        let store = SecretStore::new(tmp.path(), true);
806        (tmp, store)
807    }
808
809    #[test]
810    fn string_roundtrip_and_idempotent() {
811        let (_tmp, store) = store();
812        let mut s = String::from("sk-abc");
813        s.encrypt_in_place(&store, "test.s").unwrap();
814        assert!(SecretStore::is_encrypted(&s));
815        let enc1 = s.clone();
816        // idempotent: encrypting again must not double-wrap
817        s.encrypt_in_place(&store, "test.s").unwrap();
818        assert_eq!(s, enc1);
819        s.decrypt_in_place(&store, "test.s").unwrap();
820        assert_eq!(s, "sk-abc");
821    }
822
823    #[test]
824    fn string_empty_stays_empty() {
825        let (_tmp, store) = store();
826        let mut s = String::new();
827        s.encrypt_in_place(&store, "test.s").unwrap();
828        assert_eq!(s, "");
829        assert!(!s.is_set());
830    }
831
832    #[test]
833    fn string_mask_and_restore() {
834        let mut s = String::from("Bearer xyz");
835        let cur = String::from("Bearer xyz");
836        s.mask();
837        assert_eq!(s, MASKED_SECRET);
838        s.restore_from(&cur);
839        assert_eq!(s, "Bearer xyz");
840    }
841
842    #[test]
843    fn option_string_none_is_noop() {
844        let (_tmp, store) = store();
845        let mut v: Option<String> = None;
846        v.encrypt_in_place(&store, "test.o").unwrap();
847        v.decrypt_in_place(&store, "test.o").unwrap();
848        v.mask();
849        assert_eq!(v, None);
850        assert!(!v.is_set());
851    }
852
853    #[test]
854    fn option_string_some_roundtrip() {
855        let (_tmp, store) = store();
856        let mut v: Option<String> = Some("Bearer xyz".into());
857        v.encrypt_in_place(&store, "test.o").unwrap();
858        assert!(SecretStore::is_encrypted(v.as_ref().unwrap()));
859        v.decrypt_in_place(&store, "test.o").unwrap();
860        assert_eq!(v.as_deref(), Some("Bearer xyz"));
861        assert!(v.is_set());
862    }
863
864    #[test]
865    fn vec_string_roundtrip_per_element() {
866        let (_tmp, store) = store();
867        let mut v: Vec<String> = vec!["one".into(), "".into(), "two".into()];
868        v.encrypt_in_place(&store, "test.v").unwrap();
869        assert!(SecretStore::is_encrypted(&v[0]));
870        assert_eq!(v[1], "", "empty element must stay empty");
871        assert!(SecretStore::is_encrypted(&v[2]));
872        v.decrypt_in_place(&store, "test.v").unwrap();
873        assert_eq!(v, vec!["one", "", "two"]);
874    }
875
876    #[test]
877    fn hashmap_string_string_roundtrip_per_value() {
878        let (_tmp, store) = store();
879        let mut h: HashMap<String, String> = HashMap::from([
880            ("Authorization".into(), "Bearer sk-abc".into()),
881            ("X-Trace".into(), "req-123".into()),
882        ]);
883        h.encrypt_in_place(&store, "mcp.servers.foo.headers")
884            .unwrap();
885        for v in h.values() {
886            assert!(SecretStore::is_encrypted(v));
887        }
888        h.decrypt_in_place(&store, "mcp.servers.foo.headers")
889            .unwrap();
890        assert_eq!(
891            h.get("Authorization").map(String::as_str),
892            Some("Bearer sk-abc")
893        );
894        assert_eq!(h.get("X-Trace").map(String::as_str), Some("req-123"));
895        assert!(h.is_set());
896    }
897
898    #[test]
899    fn hashmap_string_string_mask_and_restore() {
900        let mut h: HashMap<String, String> =
901            HashMap::from([("Authorization".into(), "Bearer xyz".into())]);
902        let cur = h.clone();
903        h.mask();
904        assert_eq!(
905            h.get("Authorization").map(String::as_str),
906            Some(MASKED_SECRET)
907        );
908        h.restore_from(&cur);
909        assert_eq!(
910            h.get("Authorization").map(String::as_str),
911            Some("Bearer xyz")
912        );
913    }
914
915    #[test]
916    fn option_hashmap_none_is_noop() {
917        let (_tmp, store) = store();
918        let mut v: Option<HashMap<String, String>> = None;
919        v.encrypt_in_place(&store, "test.oh").unwrap();
920        v.decrypt_in_place(&store, "test.oh").unwrap();
921        v.mask();
922        assert!(v.is_none());
923        assert!(!v.is_set());
924    }
925
926    #[test]
927    fn option_hashmap_some_roundtrip() {
928        let (_tmp, store) = store();
929        let mut v: Option<HashMap<String, String>> =
930            Some(HashMap::from([("k".into(), "secret".into())]));
931        v.encrypt_in_place(&store, "test.oh").unwrap();
932        assert!(SecretStore::is_encrypted(
933            v.as_ref().unwrap().get("k").unwrap()
934        ));
935        v.decrypt_in_place(&store, "test.oh").unwrap();
936        assert_eq!(
937            v.as_ref().unwrap().get("k").map(String::as_str),
938            Some("secret")
939        );
940        assert!(v.is_set());
941    }
942
943    #[test]
944    fn hashmap_empty_is_not_set() {
945        let h: HashMap<String, String> = HashMap::new();
946        assert!(!h.is_set());
947        let oh: Option<HashMap<String, String>> = Some(HashMap::new());
948        assert!(!oh.is_set());
949    }
950
951    #[test]
952    fn hashmap_with_only_empty_values_is_not_set() {
953        // The trait contract for `is_set` is "at least one non-empty inner
954        // string". A map carrying placeholder keys with empty values has no
955        // secret material to encrypt or mask, so it must report not-set —
956        // otherwise the dashboard would render `***MASKED***` over a blank
957        // header row.
958        let h: HashMap<String, String> = HashMap::from([
959            ("Authorization".into(), String::new()),
960            ("X-Trace".into(), String::new()),
961        ]);
962        assert!(!h.is_set());
963
964        let oh: Option<HashMap<String, String>> =
965            Some(HashMap::from([("Authorization".into(), String::new())]));
966        assert!(!oh.is_set());
967
968        let mixed: HashMap<String, String> = HashMap::from([
969            ("Authorization".into(), "Bearer xyz".into()),
970            ("X-Trace".into(), String::new()),
971        ]);
972        assert!(mixed.is_set(), "any non-empty value makes the map set");
973    }
974
975    #[test]
976    fn encrypt_decrypt_failure_message_includes_field_path() {
977        let tmp = TempDir::new().unwrap();
978        let bad_store = SecretStore::new(tmp.path(), true);
979        // Construct a malformed enc2 string that will fail to decrypt.
980        let mut s = String::from("enc2:not-valid-hex");
981        let err = s
982            .decrypt_in_place(&bad_store, "mcp.servers.foo.headers.Authorization")
983            .expect_err("malformed ciphertext must fail");
984        let msg = format!("{err:#}");
985        assert!(
986            msg.contains("mcp.servers.foo.headers.Authorization"),
987            "error must include field path; got: {msg}"
988        );
989    }
990}