zenkey_fleet/
context_store.rs1use std::collections::BTreeMap;
30use std::path::PathBuf;
31
32use anyhow::{Context as _, Result, bail};
33use serde::{Deserialize, Serialize};
34
35#[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 #[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#[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
72pub 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
80fn 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
100pub 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
116pub 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
133pub 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
148pub 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
158pub 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 #[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 #[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 #[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}