Skip to main content

zenkey_fleet/
context_store.rs

1//! Named connection contexts — the store shared by every explorer (issue #35).
2//!
3//! Born in zenctl (the nats-CLI model, issue #12) and moved into the engine so
4//! zenctl and zengui resolve the same named contexts from the same file. One
5//! fleet, two explorers, one config.
6//!
7//! ```toml
8//! current = "lab"
9//!
10//! [context.lab]
11//! base = "zensight"
12//! connect = ["tcp/127.0.0.1:7447"]
13//! # listen = [], scouting = false, timeout = 5, registry = ["/abs/registry"]
14//! ```
15//!
16//! **Path policy.** The explorer-neutral home is
17//! `~/.config/zenkey-explorer/config.toml`. Reads fall back to the legacy
18//! `~/.config/zenctl/config.toml` when the neutral file does not exist, so an
19//! existing zenctl setup keeps working; writes always go to the neutral path
20//! (a one-way migration — the legacy file is left untouched, never deleted).
21//! `ZENKEY_EXPLORER_CONFIG_DIR` (or the legacy `ZENCTL_CONFIG_DIR`) overrides
22//! the directory outright (tests, multi-config setups) — an override names
23//! *the* directory: no fallback chain applies.
24//!
25//! **Everything here is pure and fallible.** No process-global caches, no
26//! `exit()`: a GUI must render a bad config as a banner and keep its window;
27//! zenctl turns the `Err` into its own exit code at its own edge.
28
29use std::collections::BTreeMap;
30use std::path::PathBuf;
31
32use anyhow::{Context as _, Result, bail};
33use serde::{Deserialize, Serialize};
34
35/// One named context's stored settings. All optional: a context only pins
36/// what it pins; flags fill the rest.
37#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
38#[serde(deny_unknown_fields)]
39pub struct StoredContext {
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub base: Option<String>,
42    #[serde(default, skip_serializing_if = "Vec::is_empty")]
43    pub connect: Vec<String>,
44    #[serde(default, skip_serializing_if = "Vec::is_empty")]
45    pub listen: Vec<String>,
46    #[serde(default, skip_serializing_if = "Vec::is_empty")]
47    pub registry: Vec<PathBuf>,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub scouting: Option<bool>,
50    /// Path to a zenoh JSON5 config file (#122) — the passthrough that makes
51    /// a secured bus (TLS/QUIC/usrpwd) reachable. The explorer's own knobs
52    /// apply on top: flag > env > context > file.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub zenoh_config: Option<PathBuf>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub timeout: Option<u64>,
57}
58
59/// The whole config file.
60#[derive(Debug, Clone, Default, Serialize, Deserialize)]
61pub struct ConfigFile {
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub current: Option<String>,
64    #[serde(
65        default,
66        rename = "context",
67        skip_serializing_if = "BTreeMap::is_empty"
68    )]
69    pub contexts: BTreeMap<String, StoredContext>,
70}
71
72/// The path writes go to (and reads prefer).
73pub fn config_path() -> PathBuf {
74    if let Some(dir) = override_dir() {
75        return dir.join("config.toml");
76    }
77    neutral_dir().join("config.toml")
78}
79
80/// The legacy read-fallback path, when no override is in force.
81fn legacy_path() -> Option<PathBuf> {
82    if override_dir().is_some() {
83        return None;
84    }
85    dirs::config_dir().map(|d| d.join("zenctl").join("config.toml"))
86}
87
88fn override_dir() -> Option<PathBuf> {
89    std::env::var_os("ZENKEY_EXPLORER_CONFIG_DIR")
90        .or_else(|| std::env::var_os("ZENCTL_CONFIG_DIR"))
91        .map(PathBuf::from)
92}
93
94fn neutral_dir() -> PathBuf {
95    dirs::config_dir()
96        .map(|d| d.join("zenkey-explorer"))
97        .unwrap_or_else(|| PathBuf::from(".zenkey-explorer"))
98}
99
100/// Load the config: the neutral path, else the legacy zenctl path, else empty.
101/// A *malformed* file is an error at whichever path supplied it — silently
102/// treating a broken config as absent would make edits mysteriously vanish.
103pub fn load() -> Result<ConfigFile> {
104    for path in [Some(config_path()), legacy_path()].into_iter().flatten() {
105        match std::fs::read_to_string(&path) {
106            Ok(src) => {
107                return toml::from_str(&src)
108                    .with_context(|| format!("bad config file {}", path.display()));
109            }
110            Err(_) => continue,
111        }
112    }
113    Ok(ConfigFile::default())
114}
115
116/// Where a context's cached slices live (issue #54).
117///
118/// Keyed by context name so two deployments cannot complete each other's
119/// producers, and `"default"` for an invocation with no named context —
120/// which is a real configuration (flags only), not an absence.
121///
122/// The cache is a *convenience*, never a source of truth: it feeds shell
123/// completion and nothing else reads it without saying so. That is why it
124/// sits under the cache dir, where an OS is free to delete it.
125pub fn cache_dir(context: Option<&str>) -> PathBuf {
126    let root = override_dir()
127        .map(|d| d.join("cache"))
128        .or_else(|| dirs::cache_dir().map(|d| d.join("zenkey-explorer")))
129        .unwrap_or_else(|| PathBuf::from(".zenkey-explorer-cache"));
130    root.join(context.unwrap_or("default")).join("slices")
131}
132
133/// The name of the context this invocation resolves to — the cache key. The
134/// same precedence [`active`] uses, minus the lookup, so a completion can find
135/// the cache without loading (or failing on) the config file.
136pub fn active_name(explicit: Option<&str>) -> Option<String> {
137    if let Some(name) = explicit {
138        return Some(name.to_string());
139    }
140    if let Ok(name) =
141        std::env::var("ZENKEY_EXPLORER_CONTEXT").or_else(|_| std::env::var("ZENCTL_CONTEXT"))
142    {
143        return Some(name);
144    }
145    load().ok().and_then(|c| c.current)
146}
147
148/// Save to the neutral path (creating the directory), never to the legacy one.
149pub fn save(config: &ConfigFile) -> Result<()> {
150    let path = config_path();
151    if let Some(dir) = path.parent() {
152        std::fs::create_dir_all(dir).with_context(|| format!("cannot create {}", dir.display()))?;
153    }
154    let rendered = toml::to_string_pretty(config).context("config serializes")?;
155    std::fs::write(&path, rendered).with_context(|| format!("cannot write {}", path.display()))
156}
157
158/// The context the current invocation should use: `explicit` by name, else
159/// `ZENKEY_EXPLORER_CONTEXT`/`ZENCTL_CONTEXT`, else the file's `current`
160/// pointer, else nothing.
161///
162/// An explicitly named context that does not exist is an error (the user
163/// asked for something specific); a dangling `current` pointer is a stale
164/// file, not a hard error.
165pub fn active(explicit: Option<&str>) -> Result<Option<StoredContext>> {
166    let config = load()?;
167    let env_named = std::env::var("ZENKEY_EXPLORER_CONTEXT")
168        .or_else(|_| std::env::var("ZENCTL_CONTEXT"))
169        .ok();
170    let was_named = explicit.is_some() || env_named.is_some();
171    let name = explicit
172        .map(str::to_string)
173        .or(env_named)
174        .or(config.current.clone());
175    let Some(name) = name else { return Ok(None) };
176    match config.contexts.get(&name) {
177        Some(c) => Ok(Some(c.clone())),
178        None if was_named => {
179            bail!(
180                "context {name:?} not found in {} — `zenctl context list`",
181                config_path().display()
182            )
183        }
184        None => Ok(None),
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    /// Serialize a config through the store and read it back — the format is
193    /// the contract both explorers share.
194    #[test]
195    fn config_round_trips_through_toml() {
196        let mut cfg = ConfigFile {
197            current: Some("lab".into()),
198            ..Default::default()
199        };
200        cfg.contexts.insert(
201            "lab".into(),
202            StoredContext {
203                base: Some("zensight".into()),
204                connect: vec!["tcp/127.0.0.1:7447".into()],
205                listen: vec![],
206                registry: vec![PathBuf::from("/tmp/reg")],
207                scouting: Some(false),
208                timeout: Some(5),
209                zenoh_config: None,
210            },
211        );
212        let rendered = toml::to_string_pretty(&cfg).unwrap();
213        let back: ConfigFile = toml::from_str(&rendered).unwrap();
214        assert_eq!(back.current.as_deref(), Some("lab"));
215        assert_eq!(back.contexts["lab"], cfg.contexts["lab"]);
216    }
217
218    /// The legacy zenctl file format parses unchanged — the migration is a
219    /// path change, not a format change.
220    #[test]
221    fn legacy_zenctl_files_parse_unchanged() {
222        let legacy = r#"
223current = "lab"
224
225[context.lab]
226base = "zensight"
227connect = ["tcp/127.0.0.1:7447"]
228"#;
229        let cfg: ConfigFile = toml::from_str(legacy).unwrap();
230        assert_eq!(cfg.contexts["lab"].base.as_deref(), Some("zensight"));
231    }
232
233    /// An unknown field is a spelling mistake surfaced, not silently dropped.
234    #[test]
235    fn unknown_context_fields_are_rejected() {
236        let bad = r#"
237[context.lab]
238bse = "typo"
239"#;
240        assert!(toml::from_str::<ConfigFile>(bad).is_err());
241    }
242}