Skip to main content

iggy_cli/commands/binary_context/
common.rs

1/* Licensed to the Apache Software Foundation (ASF) under one
2 * or more contributor license agreements.  See the NOTICE file
3 * distributed with this work for additional information
4 * regarding copyright ownership.  The ASF licenses this file
5 * to you under the Apache License, Version 2.0 (the
6 * "License"); you may not use this file except in compliance
7 * with the License.  You may obtain a copy of the License at
8 *
9 *   http://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing,
12 * software distributed under the License is distributed on an
13 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14 * KIND, either express or implied.  See the License for the
15 * specific language governing permissions and limitations
16 * under the License.
17 */
18
19use anyhow::{Context, Result, bail};
20use dirs::home_dir;
21use serde::{Deserialize, Serialize};
22use std::collections::BTreeMap;
23use std::path::PathBuf;
24use std::{env::var, path};
25use tokio::join;
26
27use iggy_common::ArgsOptional;
28
29static ENV_IGGY_HOME: &str = "IGGY_HOME";
30static DEFAULT_IGGY_HOME_VALUE: &str = ".iggy";
31static ACTIVE_CONTEXT_FILE_NAME: &str = ".active_context";
32static CONTEXTS_FILE_NAME: &str = "contexts.toml";
33pub(crate) static DEFAULT_CONTEXT_NAME: &str = "default";
34
35pub type ContextsConfigMap = BTreeMap<String, ContextConfig>;
36
37#[derive(Deserialize, Serialize, Clone, Debug, Default)]
38pub struct ContextConfig {
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub username: Option<String>,
41
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub password: Option<String>,
44
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub token: Option<String>,
47
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub token_name: Option<String>,
50
51    #[serde(flatten)]
52    pub iggy: ArgsOptional,
53
54    #[serde(flatten)]
55    pub extra: BTreeMap<String, toml::Value>,
56}
57
58struct ContextState {
59    active_context: String,
60    contexts: ContextsConfigMap,
61}
62
63impl Default for ContextState {
64    fn default() -> Self {
65        let mut contexts = ContextsConfigMap::new();
66        contexts.insert(DEFAULT_CONTEXT_NAME.to_string(), ContextConfig::default());
67        Self {
68            active_context: DEFAULT_CONTEXT_NAME.to_string(),
69            contexts,
70        }
71    }
72}
73
74pub struct ContextManager {
75    context_rw: ContextReaderWriter,
76    context_state: Option<ContextState>,
77}
78
79impl Default for ContextManager {
80    fn default() -> Self {
81        Self::new(ContextReaderWriter::default())
82    }
83}
84
85impl ContextManager {
86    pub fn new(context_rw: ContextReaderWriter) -> Self {
87        Self {
88            context_rw,
89            context_state: None,
90        }
91    }
92
93    pub async fn get_active_context(&mut self) -> Result<ContextConfig> {
94        let active_context_key = self.get_active_context_key().await?;
95        let contexts = self.get_contexts().await?;
96
97        let active_context = contexts
98            .get(&active_context_key)
99            .ok_or_else(|| anyhow::anyhow!("active context key not found in contexts"))?;
100
101        Ok(active_context.clone())
102    }
103
104    pub async fn set_active_context_key(&mut self, context_name: &str) -> Result<()> {
105        self.get_context_state().await?;
106        let cs = self.context_state.take().unwrap();
107
108        if !cs.contexts.contains_key(context_name) {
109            bail!("context key '{context_name}' is missing from {CONTEXTS_FILE_NAME}")
110        }
111
112        self.context_rw
113            .write_active_context(context_name)
114            .await
115            .context(format!("failed writing active context '{context_name}'"))?;
116
117        self.context_state.replace(ContextState {
118            active_context: context_name.to_string(),
119            contexts: cs.contexts,
120        });
121
122        Ok(())
123    }
124
125    pub async fn get_active_context_key(&mut self) -> Result<String> {
126        let context_state = self.get_context_state().await?;
127        Ok(context_state.active_context.clone())
128    }
129
130    pub async fn get_contexts(&mut self) -> Result<ContextsConfigMap> {
131        let context_state = self.get_context_state().await?;
132        Ok(context_state.contexts.clone())
133    }
134
135    pub async fn create_context(&mut self, name: &str, config: ContextConfig) -> Result<()> {
136        validate_context_name(name)?;
137
138        if name == DEFAULT_CONTEXT_NAME {
139            bail!("cannot create a context named '{DEFAULT_CONTEXT_NAME}' - it is reserved")
140        }
141
142        self.get_context_state().await?;
143        let cs = self.context_state.as_ref().unwrap();
144
145        if cs.contexts.contains_key(name) {
146            bail!("context '{name}' already exists in {CONTEXTS_FILE_NAME}")
147        }
148
149        let mut new_contexts = cs.contexts.clone();
150        new_contexts.insert(name.to_string(), config);
151
152        self.context_rw
153            .write_contexts(new_contexts.clone())
154            .await
155            .context(format!("failed writing contexts after creating '{name}'"))?;
156
157        self.context_state.replace(ContextState {
158            active_context: cs.active_context.clone(),
159            contexts: new_contexts,
160        });
161
162        Ok(())
163    }
164
165    pub async fn delete_context(&mut self, name: &str) -> Result<()> {
166        if name == DEFAULT_CONTEXT_NAME {
167            bail!("cannot delete the '{DEFAULT_CONTEXT_NAME}' context")
168        }
169
170        self.get_context_state().await?;
171        let cs = self.context_state.as_ref().unwrap();
172
173        if !cs.contexts.contains_key(name) {
174            bail!("context '{name}' not found in {CONTEXTS_FILE_NAME}")
175        }
176
177        let mut new_contexts = cs.contexts.clone();
178        new_contexts.remove(name);
179
180        let active_context = if cs.active_context == name {
181            self.context_rw
182                .write_active_context(DEFAULT_CONTEXT_NAME)
183                .await
184                .context("failed resetting active context to default")?;
185            DEFAULT_CONTEXT_NAME.to_string()
186        } else {
187            cs.active_context.clone()
188        };
189
190        self.context_rw
191            .write_contexts(new_contexts.clone())
192            .await
193            .context(format!("failed writing contexts after deleting '{name}'"))?;
194
195        self.context_state.replace(ContextState {
196            active_context,
197            contexts: new_contexts,
198        });
199
200        Ok(())
201    }
202
203    async fn get_context_state(&mut self) -> Result<&ContextState> {
204        if self.context_state.is_none() {
205            let (active_context_res, contexts_res) = join!(
206                self.context_rw.read_active_context(),
207                self.context_rw.read_contexts()
208            );
209
210            let (maybe_active_context, maybe_contexts) = active_context_res
211                .and_then(|a| contexts_res.map(|b| (a, b)))
212                .context("could not read context state")?;
213
214            let mut context_state = ContextState::default();
215
216            if let Some(contexts) = maybe_contexts {
217                context_state.contexts.extend(contexts)
218            }
219
220            if let Some(active_context) = maybe_active_context {
221                if !context_state.contexts.contains_key(&active_context) {
222                    bail!("context key '{active_context}' is missing from {CONTEXTS_FILE_NAME}")
223                }
224                context_state.active_context = active_context;
225            }
226
227            self.context_state.replace(context_state);
228        }
229
230        Ok(self.context_state.as_ref().unwrap())
231    }
232}
233
234pub struct ContextReaderWriter {
235    iggy_home: Option<PathBuf>,
236}
237
238impl ContextReaderWriter {
239    pub fn from_env() -> Self {
240        Self::new(iggy_home())
241    }
242
243    pub fn new(iggy_home: Option<PathBuf>) -> Self {
244        Self { iggy_home }
245    }
246
247    pub async fn read_contexts(&self) -> Result<Option<ContextsConfigMap>> {
248        let maybe_contexts_path = &self.contexts_path();
249
250        if let Some(contexts_path) = maybe_contexts_path {
251            let maybe_contents = tokio::fs::read_to_string(contexts_path)
252                .await
253                .map(Some)
254                .or_else(|err| {
255                    if err.kind() == std::io::ErrorKind::NotFound {
256                        Ok(None)
257                    } else {
258                        Err(err)
259                    }
260                })
261                .context(format!(
262                    "failed reading contexts file {}",
263                    contexts_path.display()
264                ))?;
265
266            if let Some(contents) = maybe_contents {
267                let contexts: ContextsConfigMap =
268                    toml::from_str(contents.as_str()).context(format!(
269                        "failed deserializing contexts file {}",
270                        contexts_path.display()
271                    ))?;
272
273                Ok(Some(contexts))
274            } else {
275                Ok(None)
276            }
277        } else {
278            Ok(None)
279        }
280    }
281
282    pub async fn write_contexts(&self, contexts: ContextsConfigMap) -> Result<()> {
283        let maybe_contexts_path = self.contexts_path();
284
285        if let Some(contexts_path) = maybe_contexts_path {
286            let contents = toml::to_string(&contexts).context(format!(
287                "failed serializing contexts file {}",
288                contexts_path.display()
289            ))?;
290
291            self.ensure_iggy_home_exists().await?;
292            tokio::fs::write(&contexts_path, contents).await?;
293            Self::set_owner_only_permissions(&contexts_path).await?;
294        }
295
296        Ok(())
297    }
298
299    pub async fn read_active_context(&self) -> Result<Option<String>> {
300        let maybe_active_context_path = self.active_context_path();
301
302        if let Some(active_context_path) = maybe_active_context_path {
303            tokio::fs::read_to_string(active_context_path.clone())
304                .await
305                .map(|s| Some(s.trim().to_string()))
306                .or_else(|err| {
307                    if err.kind() == std::io::ErrorKind::NotFound {
308                        Ok(None)
309                    } else {
310                        Err(err)
311                    }
312                })
313                .context(format!(
314                    "failed reading active context file {}",
315                    active_context_path.display()
316                ))
317        } else {
318            Ok(None)
319        }
320    }
321
322    pub async fn write_active_context(&self, context_name: &str) -> Result<()> {
323        self.ensure_iggy_home_exists().await?;
324        let maybe_active_context_path = self.active_context_path();
325
326        if let Some(active_context_path) = maybe_active_context_path {
327            tokio::fs::write(active_context_path.clone(), context_name)
328                .await
329                .context(format!(
330                    "failed writing active context file {}",
331                    active_context_path.to_string_lossy()
332                ))?;
333        }
334
335        Ok(())
336    }
337
338    pub async fn ensure_iggy_home_exists(&self) -> Result<()> {
339        if let Some(ref iggy_home) = self.iggy_home
340            && !tokio::fs::try_exists(iggy_home).await.unwrap_or(false)
341        {
342            tokio::fs::create_dir_all(iggy_home).await.context(format!(
343                "failed creating iggy home directory {}",
344                iggy_home.display()
345            ))?;
346        }
347        Ok(())
348    }
349
350    #[cfg(unix)]
351    async fn set_owner_only_permissions(path: &PathBuf) -> Result<()> {
352        use std::os::unix::fs::PermissionsExt;
353        let perms = std::fs::Permissions::from_mode(0o600);
354        tokio::fs::set_permissions(path, perms)
355            .await
356            .context(format!("failed setting permissions on {}", path.display()))
357    }
358
359    #[cfg(not(unix))]
360    async fn set_owner_only_permissions(_path: &PathBuf) -> Result<()> {
361        Ok(())
362    }
363
364    fn active_context_path(&self) -> Option<PathBuf> {
365        self.iggy_home
366            .clone()
367            .map(|pb| pb.join(ACTIVE_CONTEXT_FILE_NAME))
368    }
369
370    fn contexts_path(&self) -> Option<PathBuf> {
371        self.iggy_home.clone().map(|pb| pb.join(CONTEXTS_FILE_NAME))
372    }
373}
374
375impl Default for ContextReaderWriter {
376    fn default() -> Self {
377        ContextReaderWriter::new(iggy_home())
378    }
379}
380
381pub fn iggy_home() -> Option<PathBuf> {
382    match var(ENV_IGGY_HOME) {
383        Ok(home) => Some(PathBuf::from(home)),
384        Err(_) => home_dir().map(|dir| dir.join(path::Path::new(DEFAULT_IGGY_HOME_VALUE))),
385    }
386}
387
388fn validate_context_name(name: &str) -> Result<()> {
389    if name.trim().is_empty() {
390        bail!("context name cannot be empty or whitespace-only")
391    }
392    if !name
393        .chars()
394        .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
395    {
396        bail!("context name must contain only alphanumeric characters, hyphens, and underscores")
397    }
398    Ok(())
399}
400
401pub fn validate_transport(transport: &str) -> Result<()> {
402    use std::str::FromStr;
403    iggy_common::TransportProtocol::from_str(transport).map_err(|_| {
404        anyhow::anyhow!(
405            "invalid transport '{}' - valid values are: tcp, quic, http, ws",
406            transport
407        )
408    })?;
409    Ok(())
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use tempfile::tempdir;
416
417    fn test_manager(iggy_home: PathBuf) -> ContextManager {
418        ContextManager::new(ContextReaderWriter::new(Some(iggy_home)))
419    }
420
421    #[tokio::test]
422    async fn should_create_context() {
423        let dir = tempdir().unwrap();
424        let mut mgr = test_manager(dir.path().to_path_buf());
425
426        let config = ContextConfig {
427            username: Some("admin".to_string()),
428            iggy: ArgsOptional {
429                transport: Some("tcp".to_string()),
430                tcp_server_address: Some("10.0.0.1:8090".to_string()),
431                ..Default::default()
432            },
433            ..Default::default()
434        };
435
436        mgr.create_context("production", config).await.unwrap();
437
438        let contexts = mgr.get_contexts().await.unwrap();
439        assert!(contexts.contains_key("production"));
440        assert!(contexts.contains_key("default"));
441        assert_eq!(contexts.len(), 2);
442    }
443
444    #[tokio::test]
445    async fn should_reject_duplicate_context() {
446        let dir = tempdir().unwrap();
447        let mut mgr = test_manager(dir.path().to_path_buf());
448
449        mgr.create_context("test", ContextConfig::default())
450            .await
451            .unwrap();
452
453        let result = mgr.create_context("test", ContextConfig::default()).await;
454        assert!(result.is_err());
455        assert!(result.unwrap_err().to_string().contains("already exists"));
456    }
457
458    #[tokio::test]
459    async fn should_reject_creating_default_context() {
460        let dir = tempdir().unwrap();
461        let mut mgr = test_manager(dir.path().to_path_buf());
462
463        let result = mgr
464            .create_context("default", ContextConfig::default())
465            .await;
466        assert!(result.is_err());
467        assert!(result.unwrap_err().to_string().contains("reserved"));
468    }
469
470    #[tokio::test]
471    async fn should_reject_empty_context_name() {
472        let dir = tempdir().unwrap();
473        let mut mgr = test_manager(dir.path().to_path_buf());
474
475        let result = mgr.create_context("", ContextConfig::default()).await;
476        assert!(result.is_err());
477        assert!(result.unwrap_err().to_string().contains("empty"));
478    }
479
480    #[tokio::test]
481    async fn should_reject_whitespace_only_context_name() {
482        let dir = tempdir().unwrap();
483        let mut mgr = test_manager(dir.path().to_path_buf());
484
485        let result = mgr.create_context("  ", ContextConfig::default()).await;
486        assert!(result.is_err());
487        assert!(result.unwrap_err().to_string().contains("empty"));
488    }
489
490    #[tokio::test]
491    async fn should_reject_context_name_with_special_chars() {
492        let dir = tempdir().unwrap();
493        let mut mgr = test_manager(dir.path().to_path_buf());
494
495        let result = mgr
496            .create_context("my context!", ContextConfig::default())
497            .await;
498        assert!(result.is_err());
499        assert!(result.unwrap_err().to_string().contains("alphanumeric"));
500    }
501
502    #[tokio::test]
503    async fn should_accept_context_name_with_hyphens_and_underscores() {
504        let dir = tempdir().unwrap();
505        let mut mgr = test_manager(dir.path().to_path_buf());
506
507        mgr.create_context("my-context_01", ContextConfig::default())
508            .await
509            .unwrap();
510
511        let contexts = mgr.get_contexts().await.unwrap();
512        assert!(contexts.contains_key("my-context_01"));
513    }
514
515    #[tokio::test]
516    async fn should_delete_context() {
517        let dir = tempdir().unwrap();
518        let mut mgr = test_manager(dir.path().to_path_buf());
519
520        mgr.create_context("staging", ContextConfig::default())
521            .await
522            .unwrap();
523
524        mgr.delete_context("staging").await.unwrap();
525
526        let contexts = mgr.get_contexts().await.unwrap();
527        assert!(!contexts.contains_key("staging"));
528    }
529
530    #[tokio::test]
531    async fn should_reject_deleting_default_context() {
532        let dir = tempdir().unwrap();
533        let mut mgr = test_manager(dir.path().to_path_buf());
534
535        let result = mgr.delete_context("default").await;
536        assert!(result.is_err());
537        assert!(result.unwrap_err().to_string().contains("cannot delete"));
538    }
539
540    #[tokio::test]
541    async fn should_reject_deleting_nonexistent_context() {
542        let dir = tempdir().unwrap();
543        let mut mgr = test_manager(dir.path().to_path_buf());
544
545        let result = mgr.delete_context("nope").await;
546        assert!(result.is_err());
547        assert!(result.unwrap_err().to_string().contains("not found"));
548    }
549
550    #[tokio::test]
551    async fn should_reset_active_to_default_when_deleting_active_context() {
552        let dir = tempdir().unwrap();
553        let mut mgr = test_manager(dir.path().to_path_buf());
554
555        mgr.create_context("dev", ContextConfig::default())
556            .await
557            .unwrap();
558        mgr.set_active_context_key("dev").await.unwrap();
559        assert_eq!(mgr.get_active_context_key().await.unwrap(), "dev");
560
561        mgr.delete_context("dev").await.unwrap();
562        assert_eq!(mgr.get_active_context_key().await.unwrap(), "default");
563    }
564
565    #[tokio::test]
566    async fn should_create_iggy_home_if_missing() {
567        let dir = tempdir().unwrap();
568        let nested = dir.path().join("sub").join("dir");
569        let mut mgr = test_manager(nested.clone());
570
571        assert!(!nested.exists());
572        mgr.create_context("test", ContextConfig::default())
573            .await
574            .unwrap();
575        assert!(nested.exists());
576    }
577
578    #[tokio::test]
579    async fn should_persist_context_config_fields() {
580        let dir = tempdir().unwrap();
581        let mut mgr = test_manager(dir.path().to_path_buf());
582
583        let config = ContextConfig {
584            username: Some("user1".to_string()),
585            password: Some("pass1".to_string()),
586            iggy: ArgsOptional {
587                transport: Some("http".to_string()),
588                http_api_url: Some("http://localhost:3000".to_string()),
589                ..Default::default()
590            },
591            ..Default::default()
592        };
593
594        mgr.create_context("myctx", config).await.unwrap();
595
596        let rw = ContextReaderWriter::new(Some(dir.path().to_path_buf()));
597        let saved = rw.read_contexts().await.unwrap().unwrap();
598        let ctx = saved.get("myctx").unwrap();
599        assert_eq!(ctx.username.as_deref(), Some("user1"));
600        assert_eq!(ctx.password.as_deref(), Some("pass1"));
601        assert_eq!(ctx.iggy.transport.as_deref(), Some("http"));
602        assert_eq!(
603            ctx.iggy.http_api_url.as_deref(),
604            Some("http://localhost:3000")
605        );
606    }
607
608    #[tokio::test]
609    async fn reader_writer_with_none_iggy_home_is_noop_for_paths() {
610        let rw = ContextReaderWriter::new(None);
611        assert!(rw.read_contexts().await.unwrap().is_none());
612        assert!(rw.read_active_context().await.unwrap().is_none());
613        rw.write_contexts(ContextsConfigMap::new()).await.unwrap();
614        rw.write_active_context("any").await.unwrap();
615        rw.ensure_iggy_home_exists().await.unwrap();
616    }
617
618    #[tokio::test]
619    async fn read_contexts_returns_none_when_file_missing() {
620        let dir = tempdir().unwrap();
621        let rw = ContextReaderWriter::new(Some(dir.path().to_path_buf()));
622        assert!(rw.read_contexts().await.unwrap().is_none());
623    }
624
625    #[tokio::test]
626    async fn read_active_context_returns_none_when_file_missing() {
627        let dir = tempdir().unwrap();
628        let rw = ContextReaderWriter::new(Some(dir.path().to_path_buf()));
629        assert!(rw.read_active_context().await.unwrap().is_none());
630    }
631
632    #[tokio::test]
633    async fn ensure_iggy_home_exists_skips_when_directory_already_exists() {
634        let dir = tempdir().unwrap();
635        let rw = ContextReaderWriter::new(Some(dir.path().to_path_buf()));
636        assert!(dir.path().exists());
637        rw.ensure_iggy_home_exists().await.unwrap();
638    }
639
640    #[tokio::test]
641    async fn should_fail_when_active_context_file_points_to_unknown_context() {
642        let dir = tempdir().unwrap();
643        let iggy_home = dir.path().to_path_buf();
644        tokio::fs::create_dir_all(&iggy_home).await.unwrap();
645        tokio::fs::write(iggy_home.join(ACTIVE_CONTEXT_FILE_NAME), "ghost")
646            .await
647            .unwrap();
648        let mut only_default = ContextsConfigMap::new();
649        only_default.insert(DEFAULT_CONTEXT_NAME.to_string(), ContextConfig::default());
650        let contents = toml::to_string(&only_default).unwrap();
651        tokio::fs::write(iggy_home.join(CONTEXTS_FILE_NAME), contents)
652            .await
653            .unwrap();
654
655        let mut mgr = test_manager(iggy_home);
656        let err = mgr.get_contexts().await.unwrap_err();
657        assert!(err.to_string().contains("missing"));
658    }
659
660    #[tokio::test]
661    async fn should_reject_set_active_for_unknown_context() {
662        let dir = tempdir().unwrap();
663        let mut mgr = test_manager(dir.path().to_path_buf());
664        let err = mgr.set_active_context_key("nonexistent").await.unwrap_err();
665        assert!(err.to_string().contains("missing"));
666    }
667
668    #[tokio::test]
669    async fn should_get_active_context_config() {
670        let dir = tempdir().unwrap();
671        let mut mgr = test_manager(dir.path().to_path_buf());
672        let ctx = mgr.get_active_context().await.unwrap();
673        assert!(ctx.username.is_none());
674        assert!(ctx.password.is_none());
675        assert_eq!(
676            mgr.get_active_context_key().await.unwrap(),
677            DEFAULT_CONTEXT_NAME
678        );
679    }
680
681    #[tokio::test]
682    async fn should_trim_whitespace_from_active_context() {
683        let dir = tempdir().unwrap();
684        let iggy_home = dir.path().to_path_buf();
685        let rw = ContextReaderWriter::new(Some(iggy_home.clone()));
686        rw.write_active_context("dev").await.unwrap();
687        tokio::fs::write(iggy_home.join(ACTIVE_CONTEXT_FILE_NAME), "dev\n")
688            .await
689            .unwrap();
690        let result = rw.read_active_context().await.unwrap();
691        assert_eq!(result.as_deref(), Some("dev"));
692    }
693
694    #[cfg(unix)]
695    #[tokio::test]
696    async fn should_set_restrictive_permissions_on_contexts_file() {
697        use std::os::unix::fs::PermissionsExt;
698
699        let dir = tempdir().unwrap();
700        let mut mgr = test_manager(dir.path().to_path_buf());
701
702        mgr.create_context("secure-ctx", ContextConfig::default())
703            .await
704            .unwrap();
705
706        let contexts_path = dir.path().join(CONTEXTS_FILE_NAME);
707        let metadata = tokio::fs::metadata(&contexts_path).await.unwrap();
708        let mode = metadata.permissions().mode() & 0o777;
709        assert_eq!(mode, 0o600);
710    }
711
712    #[test]
713    fn should_validate_transport() {
714        assert!(validate_transport("tcp").is_ok());
715        assert!(validate_transport("quic").is_ok());
716        assert!(validate_transport("http").is_ok());
717        assert!(validate_transport("ws").is_ok());
718        assert!(validate_transport("foobar").is_err());
719        assert!(validate_transport("websocket").is_err());
720    }
721
722    #[test]
723    fn should_validate_context_name() {
724        assert!(validate_context_name("production").is_ok());
725        assert!(validate_context_name("my-ctx").is_ok());
726        assert!(validate_context_name("my_ctx_01").is_ok());
727        assert!(validate_context_name("").is_err());
728        assert!(validate_context_name("  ").is_err());
729        assert!(validate_context_name("my ctx").is_err());
730        assert!(validate_context_name("ctx!").is_err());
731        assert!(validate_context_name("a/b").is_err());
732    }
733
734    #[tokio::test]
735    async fn should_preserve_unknown_fields_through_round_trip() {
736        let dir = tempdir().unwrap();
737        let iggy_home = dir.path().to_path_buf();
738
739        let toml_with_extra = r#"[myctx]
740username = "admin"
741future_field = "preserved"
742"#;
743        tokio::fs::write(iggy_home.join(CONTEXTS_FILE_NAME), toml_with_extra)
744            .await
745            .unwrap();
746
747        let rw = ContextReaderWriter::new(Some(iggy_home.clone()));
748        let mut contexts = rw.read_contexts().await.unwrap().unwrap();
749        contexts.insert("newctx".to_string(), ContextConfig::default());
750        rw.write_contexts(contexts).await.unwrap();
751
752        let reloaded = rw.read_contexts().await.unwrap().unwrap();
753        let myctx = reloaded.get("myctx").unwrap();
754        assert_eq!(myctx.username.as_deref(), Some("admin"));
755        assert!(myctx.extra.contains_key("future_field"));
756    }
757}