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}