iggy_cli/commands/binary_context/
common.rs1use 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}