Skip to main content

zeroclaw_config/
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 crate::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/// Test whether `path` equals `prefix` or sits beneath it at a `.` segment
72/// boundary. A bare `starts_with` is wrong here: prefix `agents.aaa` must
73/// not match `agents.aaalore.workspace`.
74pub fn path_matches_prefix(path: &str, prefix: &str) -> bool {
75    match path.strip_prefix(prefix) {
76        Some(rest) => {
77            prefix.is_empty() || rest.is_empty() || rest.starts_with('.') || prefix.ends_with('.')
78        }
79        None => false,
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn memory_excludes_hide_inactive_backends() {
89        // sqlite active → hide qdrant + postgres subsections, keep sqlite
90        // open-timeout
91        let ex = memory_backend_excludes("sqlite");
92        assert!(ex.contains(&"qdrant."));
93        assert!(ex.contains(&"postgres."));
94        assert!(!ex.contains(&"sqlite-open-timeout-secs"));
95        assert!(!ex.contains(&"conversation-retention-days"));
96
97        // qdrant active → hide sqlite-only knobs + postgres
98        let ex = memory_backend_excludes("qdrant");
99        assert!(!ex.contains(&"qdrant."));
100        assert!(ex.contains(&"postgres."));
101        assert!(ex.contains(&"sqlite-open-timeout-secs"));
102        assert!(ex.contains(&"conversation-retention-days"));
103    }
104
105    #[test]
106    fn excluded_paths_for_memory_uses_active_backend() {
107        let mut cfg = Config::default();
108        cfg.memory.backend = "sqlite".into();
109        let paths = excluded_paths(&cfg, "memory");
110        assert!(paths.iter().any(|p| p == "memory.qdrant."));
111        assert!(paths.iter().any(|p| p == "memory.postgres."));
112    }
113
114    #[test]
115    fn is_excluded_handles_sub_table_marker() {
116        let excludes = vec!["memory.qdrant.".to_string(), "memory.foo".to_string()];
117        // Sub-table prefix matches anything under it.
118        assert!(is_excluded("memory.qdrant.url", &excludes));
119        assert!(is_excluded("memory.qdrant.api-key", &excludes));
120        // Exact matches still work.
121        assert!(is_excluded("memory.foo", &excludes));
122        // Unrelated paths don't match.
123        assert!(!is_excluded("memory.postgres.url", &excludes));
124        assert!(!is_excluded("memory.foobar", &excludes));
125    }
126
127    #[test]
128    fn path_matches_prefix_requires_segment_boundary() {
129        // Exact match and children.
130        assert!(path_matches_prefix("agents.aaa", "agents.aaa"));
131        assert!(path_matches_prefix("agents.aaa.workspace", "agents.aaa"));
132        assert!(path_matches_prefix("agents.aaa.memory.limit", "agents.aaa"));
133        // Sibling aliases sharing a string prefix must NOT match (#7376-class bug).
134        assert!(!path_matches_prefix(
135            "agents.aaalore.workspace",
136            "agents.aaa"
137        ));
138        assert!(!path_matches_prefix(
139            "agents.aaatools.identity",
140            "agents.aaa"
141        ));
142        assert!(!path_matches_prefix("agents.aaalore", "agents.aaa"));
143        // Dot-terminated prefixes keep their sub-table semantics.
144        assert!(path_matches_prefix("agents.aaa.workspace", "agents.aaa."));
145        assert!(!path_matches_prefix("agents.aab.workspace", "agents.aaa."));
146        // Top-level sections.
147        assert!(path_matches_prefix("memory.backend", "memory"));
148        assert!(!path_matches_prefix("memory.backend", "mem"));
149        assert!(!path_matches_prefix("unrelated", "agents.aaa"));
150        // Empty prefix matches everything (no-filter semantics, parity
151        // with the bare starts_with behavior it replaced).
152        assert!(path_matches_prefix("anything.at.all", ""));
153        assert!(path_matches_prefix("", ""));
154    }
155}