Skip to main content

zeroclaw_runtime/onboard/
field_visibility.rs

1//! Per-section field visibility helpers.
2//!
3//! Used by both the CLI wizard (`onboard::offer_advanced_settings`) and the
4//! gateway HTTP endpoints (`/api/config/list` for filtering). One source of
5//! truth so the CLI and dashboard can't disagree about which fields apply.
6//!
7//! Per-provider-family field exclusion is GONE as of #6273 — the typed-family
8//! ModelProviders container only exposes fields that genuinely apply to each
9//! family (every typed `*ModelProviderConfig` carries only its own surface),
10//! so there's nothing to suppress. Memory-backend exclusion stays because the
11//! `[memory]` section is still a single struct carrying every backend's
12//! sub-tables (the typed-family pattern hasn't been applied there).
13
14use zeroclaw_config::schema::Config;
15
16/// Exclude list for the top-level `[memory]` walk based on the active backend.
17///
18/// `MemoryConfig` carries fields and nested subsections for every backend
19/// (sqlite-only knobs, `[memory.qdrant]`, `[memory.postgres]`); only the
20/// active backend's surface is relevant. Each entry is a path SUFFIX after
21/// the `memory.` prefix in `prop_fields()`. Sub-table fields are matched
22/// by leading segment (`qdrant.`, `postgres.`).
23pub fn memory_backend_excludes(backend: &str) -> Vec<&'static str> {
24    let mut out = Vec::new();
25    if backend != "sqlite" {
26        out.push("sqlite-open-timeout-secs");
27        out.push("conversation-retention-days");
28    }
29    if backend != "qdrant" {
30        out.push("qdrant.");
31    }
32    if backend != "postgres" {
33        out.push("postgres.");
34    }
35    out
36}
37
38/// Compute the set of full property paths to hide when a client requests
39/// `prefix`. Returns an empty vec for prefixes that don't have visibility
40/// rules (most of the schema).
41///
42/// This is the single entry point the gateway's `/api/config/list` handler
43/// calls — it inspects the requested prefix, looks at the live config to
44/// resolve any state-dependent rules (e.g. `memory.backend`), and returns
45/// the absolute paths to drop from the response.
46pub fn excluded_paths(cfg: &Config, prefix: &str) -> Vec<String> {
47    if prefix == "memory" || prefix.is_empty() {
48        let backend = if cfg.memory.backend.is_empty() {
49            "sqlite"
50        } else {
51            cfg.memory.backend.as_str()
52        };
53        return memory_backend_excludes(backend)
54            .into_iter()
55            .map(|leaf| format!("memory.{leaf}"))
56            .collect();
57    }
58
59    Vec::new()
60}
61
62/// Test whether `path` is one of the excluded entries returned from
63/// `excluded_paths`. Handles both exact matches and sub-table prefix
64/// markers (`"memory.qdrant."` matches every `memory.qdrant.*`).
65pub fn is_excluded(path: &str, excludes: &[String]) -> bool {
66    excludes
67        .iter()
68        .any(|e| path == e || (e.ends_with('.') && path.starts_with(e)))
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    #[test]
76    fn memory_excludes_hide_inactive_backends() {
77        // sqlite active → hide qdrant + postgres subsections, keep sqlite
78        // open-timeout
79        let ex = memory_backend_excludes("sqlite");
80        assert!(ex.contains(&"qdrant."));
81        assert!(ex.contains(&"postgres."));
82        assert!(!ex.contains(&"sqlite-open-timeout-secs"));
83        assert!(!ex.contains(&"conversation-retention-days"));
84
85        // qdrant active → hide sqlite-only knobs + postgres
86        let ex = memory_backend_excludes("qdrant");
87        assert!(!ex.contains(&"qdrant."));
88        assert!(ex.contains(&"postgres."));
89        assert!(ex.contains(&"sqlite-open-timeout-secs"));
90        assert!(ex.contains(&"conversation-retention-days"));
91    }
92
93    #[test]
94    fn excluded_paths_for_memory_uses_active_backend() {
95        let mut cfg = Config::default();
96        cfg.memory.backend = "sqlite".into();
97        let paths = excluded_paths(&cfg, "memory");
98        assert!(paths.iter().any(|p| p == "memory.qdrant."));
99        assert!(paths.iter().any(|p| p == "memory.postgres."));
100    }
101
102    #[test]
103    fn is_excluded_handles_sub_table_marker() {
104        let excludes = vec!["memory.qdrant.".to_string(), "memory.foo".to_string()];
105        // Sub-table prefix matches anything under it.
106        assert!(is_excluded("memory.qdrant.url", &excludes));
107        assert!(is_excluded("memory.qdrant.api-key", &excludes));
108        // Exact matches still work.
109        assert!(is_excluded("memory.foo", &excludes));
110        // Unrelated paths don't match.
111        assert!(!is_excluded("memory.postgres.url", &excludes));
112        assert!(!is_excluded("memory.foobar", &excludes));
113    }
114}