This is an automated email from the ASF dual-hosted git repository.
piotr pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/master by this push:
new 3e848424b feat(cli): Add context create/delete CLI commands to
complete login/logout workflow (#2998)
3e848424b is described below
commit 3e848424bd062a0ca8d1cdaee5dfccf2799c8b9a
Author: Atharva Lade <[email protected]>
AuthorDate: Mon Mar 30 03:14:19 2026 -0500
feat(cli): Add context create/delete CLI commands to complete login/logout
workflow (#2998)
Closes #595
---
Cargo.lock | 1 +
core/cli/Cargo.toml | 4 +
core/cli/src/args/context.rs | 157 +++++++
core/cli/src/commands/binary_context/common.rs | 483 ++++++++++++++++++++-
.../src/commands/binary_context/create_context.rs | 94 ++++
.../src/commands/binary_context/delete_context.rs | 84 ++++
core/cli/src/commands/binary_context/mod.rs | 2 +
core/cli/src/main.rs | 9 +
core/integration/tests/cli/context/common.rs | 4 +
core/integration/tests/cli/context/mod.rs | 2 +
.../tests/cli/context/test_context_applied.rs | 4 +-
.../cli/context/test_context_create_command.rs | 239 ++++++++++
.../cli/context/test_context_delete_command.rs | 224 ++++++++++
.../tests/cli/context/test_context_list_command.rs | 6 +-
.../tests/cli/context/test_context_use_command.rs | 4 +-
15 files changed, 1305 insertions(+), 12 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index d43b9a2db..d10cb6e5b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5290,6 +5290,7 @@ dependencies = [
"secrecy",
"serde",
"serde_json",
+ "tempfile",
"thiserror 2.0.18",
"tokio",
"toml 1.0.7+spec-1.1.0",
diff --git a/core/cli/Cargo.toml b/core/cli/Cargo.toml
index 2a631cb24..ea3c1fce6 100644
--- a/core/cli/Cargo.toml
+++ b/core/cli/Cargo.toml
@@ -67,3 +67,7 @@ toml = { workspace = true }
tracing = { workspace = true }
tracing-appender = { workspace = true }
tracing-subscriber = { workspace = true, default-features = false }
+
+[dev-dependencies]
+tempfile = { workspace = true }
+tokio = { workspace = true, features = ["macros", "rt"] }
diff --git a/core/cli/src/args/context.rs b/core/cli/src/args/context.rs
index 8de74b76a..1c7d9089c 100644
--- a/core/cli/src/args/context.rs
+++ b/core/cli/src/args/context.rs
@@ -18,6 +18,8 @@
use crate::args::common::ListMode;
use clap::{Args, Subcommand};
+use iggy_cli::commands::binary_context::common::ContextConfig;
+use iggy_common::ArgsOptional;
#[derive(Debug, Clone, Subcommand)]
pub(crate) enum ContextAction {
@@ -35,6 +37,29 @@ pub(crate) enum ContextAction {
/// iggy context use default
#[clap(verbatim_doc_comment, visible_alias = "u")]
Use(ContextUseArgs),
+
+ /// Create a new context
+ ///
+ /// Creates a new named context in the contexts configuration file.
+ /// After creating a context, use 'iggy context use <name>' to activate it.
+ ///
+ /// Examples
+ /// iggy context create production --transport tcp --tcp-server-address
10.0.0.1:8090
+ /// iggy context create dev --transport http --http-api-url
http://localhost:3000
+ /// iggy context create local --username iggy --password iggy
+ #[clap(verbatim_doc_comment, visible_alias = "c")]
+ Create(ContextCreateArgs),
+
+ /// Delete an existing context
+ ///
+ /// Removes a named context from the contexts configuration file.
+ /// The 'default' context cannot be deleted. If the deleted context
+ /// was the active context, the active context resets to 'default'.
+ ///
+ /// Examples
+ /// iggy context delete production
+ #[clap(verbatim_doc_comment, visible_alias = "d")]
+ Delete(ContextDeleteArgs),
}
#[derive(Debug, Clone, Args)]
@@ -50,3 +75,135 @@ pub(crate) struct ContextUseArgs {
#[arg(value_parser = clap::value_parser!(String))]
pub(crate) context_name: String,
}
+
+#[derive(Debug, Clone, Args)]
+pub(crate) struct ContextCreateArgs {
+ /// Name of the context to create
+ #[arg(value_parser = clap::value_parser!(String))]
+ pub(crate) context_name: String,
+
+ /// The transport to use
+ ///
+ /// Valid values are "quic", "http", "tcp" and "ws".
+ #[clap(verbatim_doc_comment, long)]
+ pub(crate) transport: Option<String>,
+
+ /// The server address for the TCP transport
+ #[clap(long)]
+ pub(crate) tcp_server_address: Option<String>,
+
+ /// The API URL for the HTTP transport
+ #[clap(long)]
+ pub(crate) http_api_url: Option<String>,
+
+ /// The server address for the QUIC transport
+ #[clap(long)]
+ pub(crate) quic_server_address: Option<String>,
+
+ /// Flag to enable TLS for the TCP transport
+ #[clap(long)]
+ pub(crate) tcp_tls_enabled: Option<bool>,
+
+ /// Iggy server username
+ #[clap(short, long)]
+ pub(crate) username: Option<String>,
+
+ /// Iggy server password
+ #[clap(short, long)]
+ pub(crate) password: Option<String>,
+
+ /// Iggy server personal access token
+ #[clap(short, long)]
+ pub(crate) token: Option<String>,
+
+ /// Iggy server personal access token name
+ #[clap(short = 'n', long)]
+ pub(crate) token_name: Option<String>,
+}
+
+impl From<ContextCreateArgs> for ContextConfig {
+ fn from(args: ContextCreateArgs) -> Self {
+ ContextConfig {
+ username: args.username,
+ password: args.password,
+ token: args.token,
+ token_name: args.token_name,
+ iggy: ArgsOptional {
+ transport: args.transport,
+ tcp_server_address: args.tcp_server_address,
+ http_api_url: args.http_api_url,
+ quic_server_address: args.quic_server_address,
+ tcp_tls_enabled: args.tcp_tls_enabled,
+ ..Default::default()
+ },
+ extra: Default::default(),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Args)]
+pub(crate) struct ContextDeleteArgs {
+ /// Name of the context to delete
+ #[arg(value_parser = clap::value_parser!(String))]
+ pub(crate) context_name: String,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn should_convert_create_args_to_context_config() {
+ let args = ContextCreateArgs {
+ context_name: "production".to_string(),
+ transport: Some("tcp".to_string()),
+ tcp_server_address: Some("10.0.0.1:8090".to_string()),
+ http_api_url: None,
+ quic_server_address: None,
+ tcp_tls_enabled: Some(true),
+ username: Some("admin".to_string()),
+ password: Some("secret".to_string()),
+ token: Some("tok123".to_string()),
+ token_name: Some("my-token".to_string()),
+ };
+
+ let config: ContextConfig = args.into();
+
+ assert_eq!(config.username.as_deref(), Some("admin"));
+ assert_eq!(config.password.as_deref(), Some("secret"));
+ assert_eq!(config.token.as_deref(), Some("tok123"));
+ assert_eq!(config.token_name.as_deref(), Some("my-token"));
+ assert_eq!(config.iggy.transport.as_deref(), Some("tcp"));
+ assert_eq!(
+ config.iggy.tcp_server_address.as_deref(),
+ Some("10.0.0.1:8090")
+ );
+ assert!(config.iggy.http_api_url.is_none());
+ assert!(config.iggy.quic_server_address.is_none());
+ assert_eq!(config.iggy.tcp_tls_enabled, Some(true));
+ }
+
+ #[test]
+ fn should_convert_create_args_with_defaults() {
+ let args = ContextCreateArgs {
+ context_name: "minimal".to_string(),
+ transport: None,
+ tcp_server_address: None,
+ http_api_url: None,
+ quic_server_address: None,
+ tcp_tls_enabled: None,
+ username: None,
+ password: None,
+ token: None,
+ token_name: None,
+ };
+
+ let config: ContextConfig = args.into();
+
+ assert!(config.username.is_none());
+ assert!(config.password.is_none());
+ assert!(config.token.is_none());
+ assert!(config.token_name.is_none());
+ assert!(config.iggy.transport.is_none());
+ }
+}
diff --git a/core/cli/src/commands/binary_context/common.rs
b/core/cli/src/commands/binary_context/common.rs
index 1d96e5ae4..09009b7ef 100644
--- a/core/cli/src/commands/binary_context/common.rs
+++ b/core/cli/src/commands/binary_context/common.rs
@@ -19,8 +19,9 @@
use anyhow::{Context, Result, bail};
use dirs::home_dir;
use serde::{Deserialize, Serialize};
+use std::collections::BTreeMap;
use std::path::PathBuf;
-use std::{collections::HashMap, env::var, path};
+use std::{env::var, path};
use tokio::join;
use iggy_common::ArgsOptional;
@@ -31,7 +32,7 @@ static ACTIVE_CONTEXT_FILE_NAME: &str = ".active_context";
static CONTEXTS_FILE_NAME: &str = "contexts.toml";
pub(crate) static DEFAULT_CONTEXT_NAME: &str = "default";
-pub type ContextsConfigMap = HashMap<String, ContextConfig>;
+pub type ContextsConfigMap = BTreeMap<String, ContextConfig>;
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
pub struct ContextConfig {
@@ -49,6 +50,9 @@ pub struct ContextConfig {
#[serde(flatten)]
pub iggy: ArgsOptional,
+
+ #[serde(flatten)]
+ pub extra: BTreeMap<String, toml::Value>,
}
struct ContextState {
@@ -58,9 +62,11 @@ struct ContextState {
impl Default for ContextState {
fn default() -> Self {
+ let mut contexts = ContextsConfigMap::new();
+ contexts.insert(DEFAULT_CONTEXT_NAME.to_string(),
ContextConfig::default());
Self {
active_context: DEFAULT_CONTEXT_NAME.to_string(),
- contexts: HashMap::from([(DEFAULT_CONTEXT_NAME.to_string(),
ContextConfig::default())]),
+ contexts,
}
}
}
@@ -126,6 +132,75 @@ impl ContextManager {
Ok(context_state.contexts.clone())
}
+ pub async fn create_context(&mut self, name: &str, config: ContextConfig)
-> Result<()> {
+ validate_context_name(name)?;
+
+ if name == DEFAULT_CONTEXT_NAME {
+ bail!("cannot create a context named '{DEFAULT_CONTEXT_NAME}' - it
is reserved")
+ }
+
+ self.get_context_state().await?;
+ let cs = self.context_state.as_ref().unwrap();
+
+ if cs.contexts.contains_key(name) {
+ bail!("context '{name}' already exists in {CONTEXTS_FILE_NAME}")
+ }
+
+ let mut new_contexts = cs.contexts.clone();
+ new_contexts.insert(name.to_string(), config);
+
+ self.context_rw.ensure_iggy_home_exists().await?;
+ self.context_rw
+ .write_contexts(new_contexts.clone())
+ .await
+ .context(format!("failed writing contexts after creating
'{name}'"))?;
+
+ self.context_state.replace(ContextState {
+ active_context: cs.active_context.clone(),
+ contexts: new_contexts,
+ });
+
+ Ok(())
+ }
+
+ pub async fn delete_context(&mut self, name: &str) -> Result<()> {
+ if name == DEFAULT_CONTEXT_NAME {
+ bail!("cannot delete the '{DEFAULT_CONTEXT_NAME}' context")
+ }
+
+ self.get_context_state().await?;
+ let cs = self.context_state.as_ref().unwrap();
+
+ if !cs.contexts.contains_key(name) {
+ bail!("context '{name}' not found in {CONTEXTS_FILE_NAME}")
+ }
+
+ let mut new_contexts = cs.contexts.clone();
+ new_contexts.remove(name);
+
+ let active_context = if cs.active_context == name {
+ self.context_rw
+ .write_active_context(DEFAULT_CONTEXT_NAME)
+ .await
+ .context("failed resetting active context to default")?;
+ DEFAULT_CONTEXT_NAME.to_string()
+ } else {
+ cs.active_context.clone()
+ };
+
+ self.context_rw
+ .write_contexts(new_contexts.clone())
+ .await
+ .context(format!("failed writing contexts after deleting
'{name}'"))?;
+
+ self.context_state.replace(ContextState {
+ active_context,
+ contexts: new_contexts,
+ });
+
+ Ok(())
+ }
+
async fn get_context_state(&mut self) -> Result<&ContextState> {
if self.context_state.is_none() {
let (active_context_res, contexts_res) = join!(
@@ -214,7 +289,8 @@ impl ContextReaderWriter {
contexts_path.display()
))?;
- tokio::fs::write(contexts_path, contents).await?;
+ tokio::fs::write(&contexts_path, contents).await?;
+ Self::set_owner_only_permissions(&contexts_path).await?;
}
Ok(())
@@ -226,7 +302,7 @@ impl ContextReaderWriter {
if let Some(active_context_path) = maybe_active_context_path {
tokio::fs::read_to_string(active_context_path.clone())
.await
- .map(Some)
+ .map(|s| Some(s.trim().to_string()))
.or_else(|err| {
if err.kind() == std::io::ErrorKind::NotFound {
Ok(None)
@@ -258,6 +334,32 @@ impl ContextReaderWriter {
Ok(())
}
+ pub async fn ensure_iggy_home_exists(&self) -> Result<()> {
+ if let Some(ref iggy_home) = self.iggy_home
+ && !tokio::fs::try_exists(iggy_home).await.unwrap_or(false)
+ {
+ tokio::fs::create_dir_all(iggy_home).await.context(format!(
+ "failed creating iggy home directory {}",
+ iggy_home.display()
+ ))?;
+ }
+ Ok(())
+ }
+
+ #[cfg(unix)]
+ async fn set_owner_only_permissions(path: &PathBuf) -> Result<()> {
+ use std::os::unix::fs::PermissionsExt;
+ let perms = std::fs::Permissions::from_mode(0o600);
+ tokio::fs::set_permissions(path, perms)
+ .await
+ .context(format!("failed setting permissions on {}",
path.display()))
+ }
+
+ #[cfg(not(unix))]
+ async fn set_owner_only_permissions(_path: &PathBuf) -> Result<()> {
+ Ok(())
+ }
+
fn active_context_path(&self) -> Option<PathBuf> {
self.iggy_home
.clone()
@@ -281,3 +383,374 @@ pub fn iggy_home() -> Option<PathBuf> {
Err(_) => home_dir().map(|dir|
dir.join(path::Path::new(DEFAULT_IGGY_HOME_VALUE))),
}
}
+
+fn validate_context_name(name: &str) -> Result<()> {
+ if name.trim().is_empty() {
+ bail!("context name cannot be empty or whitespace-only")
+ }
+ if !name
+ .chars()
+ .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
+ {
+ bail!("context name must contain only alphanumeric characters,
hyphens, and underscores")
+ }
+ Ok(())
+}
+
+pub fn validate_transport(transport: &str) -> Result<()> {
+ use std::str::FromStr;
+ iggy_common::TransportProtocol::from_str(transport).map_err(|_| {
+ anyhow::anyhow!(
+ "invalid transport '{}' - valid values are: tcp, quic, http, ws",
+ transport
+ )
+ })?;
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use tempfile::tempdir;
+
+ fn test_manager(iggy_home: PathBuf) -> ContextManager {
+ ContextManager::new(ContextReaderWriter::new(Some(iggy_home)))
+ }
+
+ #[tokio::test]
+ async fn should_create_context() {
+ let dir = tempdir().unwrap();
+ let mut mgr = test_manager(dir.path().to_path_buf());
+
+ let config = ContextConfig {
+ username: Some("admin".to_string()),
+ iggy: ArgsOptional {
+ transport: Some("tcp".to_string()),
+ tcp_server_address: Some("10.0.0.1:8090".to_string()),
+ ..Default::default()
+ },
+ ..Default::default()
+ };
+
+ mgr.create_context("production", config).await.unwrap();
+
+ let contexts = mgr.get_contexts().await.unwrap();
+ assert!(contexts.contains_key("production"));
+ assert!(contexts.contains_key("default"));
+ assert_eq!(contexts.len(), 2);
+ }
+
+ #[tokio::test]
+ async fn should_reject_duplicate_context() {
+ let dir = tempdir().unwrap();
+ let mut mgr = test_manager(dir.path().to_path_buf());
+
+ mgr.create_context("test", ContextConfig::default())
+ .await
+ .unwrap();
+
+ let result = mgr.create_context("test",
ContextConfig::default()).await;
+ assert!(result.is_err());
+ assert!(result.unwrap_err().to_string().contains("already exists"));
+ }
+
+ #[tokio::test]
+ async fn should_reject_creating_default_context() {
+ let dir = tempdir().unwrap();
+ let mut mgr = test_manager(dir.path().to_path_buf());
+
+ let result = mgr
+ .create_context("default", ContextConfig::default())
+ .await;
+ assert!(result.is_err());
+ assert!(result.unwrap_err().to_string().contains("reserved"));
+ }
+
+ #[tokio::test]
+ async fn should_reject_empty_context_name() {
+ let dir = tempdir().unwrap();
+ let mut mgr = test_manager(dir.path().to_path_buf());
+
+ let result = mgr.create_context("", ContextConfig::default()).await;
+ assert!(result.is_err());
+ assert!(result.unwrap_err().to_string().contains("empty"));
+ }
+
+ #[tokio::test]
+ async fn should_reject_whitespace_only_context_name() {
+ let dir = tempdir().unwrap();
+ let mut mgr = test_manager(dir.path().to_path_buf());
+
+ let result = mgr.create_context(" ", ContextConfig::default()).await;
+ assert!(result.is_err());
+ assert!(result.unwrap_err().to_string().contains("empty"));
+ }
+
+ #[tokio::test]
+ async fn should_reject_context_name_with_special_chars() {
+ let dir = tempdir().unwrap();
+ let mut mgr = test_manager(dir.path().to_path_buf());
+
+ let result = mgr
+ .create_context("my context!", ContextConfig::default())
+ .await;
+ assert!(result.is_err());
+ assert!(result.unwrap_err().to_string().contains("alphanumeric"));
+ }
+
+ #[tokio::test]
+ async fn should_accept_context_name_with_hyphens_and_underscores() {
+ let dir = tempdir().unwrap();
+ let mut mgr = test_manager(dir.path().to_path_buf());
+
+ mgr.create_context("my-context_01", ContextConfig::default())
+ .await
+ .unwrap();
+
+ let contexts = mgr.get_contexts().await.unwrap();
+ assert!(contexts.contains_key("my-context_01"));
+ }
+
+ #[tokio::test]
+ async fn should_delete_context() {
+ let dir = tempdir().unwrap();
+ let mut mgr = test_manager(dir.path().to_path_buf());
+
+ mgr.create_context("staging", ContextConfig::default())
+ .await
+ .unwrap();
+
+ mgr.delete_context("staging").await.unwrap();
+
+ let contexts = mgr.get_contexts().await.unwrap();
+ assert!(!contexts.contains_key("staging"));
+ }
+
+ #[tokio::test]
+ async fn should_reject_deleting_default_context() {
+ let dir = tempdir().unwrap();
+ let mut mgr = test_manager(dir.path().to_path_buf());
+
+ let result = mgr.delete_context("default").await;
+ assert!(result.is_err());
+ assert!(result.unwrap_err().to_string().contains("cannot delete"));
+ }
+
+ #[tokio::test]
+ async fn should_reject_deleting_nonexistent_context() {
+ let dir = tempdir().unwrap();
+ let mut mgr = test_manager(dir.path().to_path_buf());
+
+ let result = mgr.delete_context("nope").await;
+ assert!(result.is_err());
+ assert!(result.unwrap_err().to_string().contains("not found"));
+ }
+
+ #[tokio::test]
+ async fn should_reset_active_to_default_when_deleting_active_context() {
+ let dir = tempdir().unwrap();
+ let mut mgr = test_manager(dir.path().to_path_buf());
+
+ mgr.create_context("dev", ContextConfig::default())
+ .await
+ .unwrap();
+ mgr.set_active_context_key("dev").await.unwrap();
+ assert_eq!(mgr.get_active_context_key().await.unwrap(), "dev");
+
+ mgr.delete_context("dev").await.unwrap();
+ assert_eq!(mgr.get_active_context_key().await.unwrap(), "default");
+ }
+
+ #[tokio::test]
+ async fn should_create_iggy_home_if_missing() {
+ let dir = tempdir().unwrap();
+ let nested = dir.path().join("sub").join("dir");
+ let mut mgr = test_manager(nested.clone());
+
+ assert!(!nested.exists());
+ mgr.create_context("test", ContextConfig::default())
+ .await
+ .unwrap();
+ assert!(nested.exists());
+ }
+
+ #[tokio::test]
+ async fn should_persist_context_config_fields() {
+ let dir = tempdir().unwrap();
+ let mut mgr = test_manager(dir.path().to_path_buf());
+
+ let config = ContextConfig {
+ username: Some("user1".to_string()),
+ password: Some("pass1".to_string()),
+ iggy: ArgsOptional {
+ transport: Some("http".to_string()),
+ http_api_url: Some("http://localhost:3000".to_string()),
+ ..Default::default()
+ },
+ ..Default::default()
+ };
+
+ mgr.create_context("myctx", config).await.unwrap();
+
+ let rw = ContextReaderWriter::new(Some(dir.path().to_path_buf()));
+ let saved = rw.read_contexts().await.unwrap().unwrap();
+ let ctx = saved.get("myctx").unwrap();
+ assert_eq!(ctx.username.as_deref(), Some("user1"));
+ assert_eq!(ctx.password.as_deref(), Some("pass1"));
+ assert_eq!(ctx.iggy.transport.as_deref(), Some("http"));
+ assert_eq!(
+ ctx.iggy.http_api_url.as_deref(),
+ Some("http://localhost:3000")
+ );
+ }
+
+ #[tokio::test]
+ async fn reader_writer_with_none_iggy_home_is_noop_for_paths() {
+ let rw = ContextReaderWriter::new(None);
+ assert!(rw.read_contexts().await.unwrap().is_none());
+ assert!(rw.read_active_context().await.unwrap().is_none());
+ rw.write_contexts(ContextsConfigMap::new()).await.unwrap();
+ rw.write_active_context("any").await.unwrap();
+ rw.ensure_iggy_home_exists().await.unwrap();
+ }
+
+ #[tokio::test]
+ async fn read_contexts_returns_none_when_file_missing() {
+ let dir = tempdir().unwrap();
+ let rw = ContextReaderWriter::new(Some(dir.path().to_path_buf()));
+ assert!(rw.read_contexts().await.unwrap().is_none());
+ }
+
+ #[tokio::test]
+ async fn read_active_context_returns_none_when_file_missing() {
+ let dir = tempdir().unwrap();
+ let rw = ContextReaderWriter::new(Some(dir.path().to_path_buf()));
+ assert!(rw.read_active_context().await.unwrap().is_none());
+ }
+
+ #[tokio::test]
+ async fn ensure_iggy_home_exists_skips_when_directory_already_exists() {
+ let dir = tempdir().unwrap();
+ let rw = ContextReaderWriter::new(Some(dir.path().to_path_buf()));
+ assert!(dir.path().exists());
+ rw.ensure_iggy_home_exists().await.unwrap();
+ }
+
+ #[tokio::test]
+ async fn should_fail_when_active_context_file_points_to_unknown_context() {
+ let dir = tempdir().unwrap();
+ let iggy_home = dir.path().to_path_buf();
+ tokio::fs::create_dir_all(&iggy_home).await.unwrap();
+ tokio::fs::write(iggy_home.join(ACTIVE_CONTEXT_FILE_NAME), "ghost")
+ .await
+ .unwrap();
+ let mut only_default = ContextsConfigMap::new();
+ only_default.insert(DEFAULT_CONTEXT_NAME.to_string(),
ContextConfig::default());
+ let contents = toml::to_string(&only_default).unwrap();
+ tokio::fs::write(iggy_home.join(CONTEXTS_FILE_NAME), contents)
+ .await
+ .unwrap();
+
+ let mut mgr = test_manager(iggy_home);
+ let err = mgr.get_contexts().await.unwrap_err();
+ assert!(err.to_string().contains("missing"));
+ }
+
+ #[tokio::test]
+ async fn should_reject_set_active_for_unknown_context() {
+ let dir = tempdir().unwrap();
+ let mut mgr = test_manager(dir.path().to_path_buf());
+ let err = mgr.set_active_context_key("nonexistent").await.unwrap_err();
+ assert!(err.to_string().contains("missing"));
+ }
+
+ #[tokio::test]
+ async fn should_get_active_context_config() {
+ let dir = tempdir().unwrap();
+ let mut mgr = test_manager(dir.path().to_path_buf());
+ let ctx = mgr.get_active_context().await.unwrap();
+ assert!(ctx.username.is_none());
+ assert!(ctx.password.is_none());
+ assert_eq!(
+ mgr.get_active_context_key().await.unwrap(),
+ DEFAULT_CONTEXT_NAME
+ );
+ }
+
+ #[tokio::test]
+ async fn should_trim_whitespace_from_active_context() {
+ let dir = tempdir().unwrap();
+ let iggy_home = dir.path().to_path_buf();
+ let rw = ContextReaderWriter::new(Some(iggy_home.clone()));
+ rw.write_active_context("dev").await.unwrap();
+ tokio::fs::write(iggy_home.join(ACTIVE_CONTEXT_FILE_NAME), "dev\n")
+ .await
+ .unwrap();
+ let result = rw.read_active_context().await.unwrap();
+ assert_eq!(result.as_deref(), Some("dev"));
+ }
+
+ #[cfg(unix)]
+ #[tokio::test]
+ async fn should_set_restrictive_permissions_on_contexts_file() {
+ use std::os::unix::fs::PermissionsExt;
+
+ let dir = tempdir().unwrap();
+ let mut mgr = test_manager(dir.path().to_path_buf());
+
+ mgr.create_context("secure-ctx", ContextConfig::default())
+ .await
+ .unwrap();
+
+ let contexts_path = dir.path().join(CONTEXTS_FILE_NAME);
+ let metadata = tokio::fs::metadata(&contexts_path).await.unwrap();
+ let mode = metadata.permissions().mode() & 0o777;
+ assert_eq!(mode, 0o600);
+ }
+
+ #[test]
+ fn should_validate_transport() {
+ assert!(validate_transport("tcp").is_ok());
+ assert!(validate_transport("quic").is_ok());
+ assert!(validate_transport("http").is_ok());
+ assert!(validate_transport("ws").is_ok());
+ assert!(validate_transport("foobar").is_err());
+ assert!(validate_transport("websocket").is_err());
+ }
+
+ #[test]
+ fn should_validate_context_name() {
+ assert!(validate_context_name("production").is_ok());
+ assert!(validate_context_name("my-ctx").is_ok());
+ assert!(validate_context_name("my_ctx_01").is_ok());
+ assert!(validate_context_name("").is_err());
+ assert!(validate_context_name(" ").is_err());
+ assert!(validate_context_name("my ctx").is_err());
+ assert!(validate_context_name("ctx!").is_err());
+ assert!(validate_context_name("a/b").is_err());
+ }
+
+ #[tokio::test]
+ async fn should_preserve_unknown_fields_through_round_trip() {
+ let dir = tempdir().unwrap();
+ let iggy_home = dir.path().to_path_buf();
+
+ let toml_with_extra = r#"[myctx]
+username = "admin"
+future_field = "preserved"
+"#;
+ tokio::fs::write(iggy_home.join(CONTEXTS_FILE_NAME), toml_with_extra)
+ .await
+ .unwrap();
+
+ let rw = ContextReaderWriter::new(Some(iggy_home.clone()));
+ let mut contexts = rw.read_contexts().await.unwrap().unwrap();
+ contexts.insert("newctx".to_string(), ContextConfig::default());
+ rw.write_contexts(contexts).await.unwrap();
+
+ let reloaded = rw.read_contexts().await.unwrap().unwrap();
+ let myctx = reloaded.get("myctx").unwrap();
+ assert_eq!(myctx.username.as_deref(), Some("admin"));
+ assert!(myctx.extra.contains_key("future_field"));
+ }
+}
diff --git a/core/cli/src/commands/binary_context/create_context.rs
b/core/cli/src/commands/binary_context/create_context.rs
new file mode 100644
index 000000000..76f3ad0e0
--- /dev/null
+++ b/core/cli/src/commands/binary_context/create_context.rs
@@ -0,0 +1,94 @@
+/* Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use async_trait::async_trait;
+use tracing::{Level, event};
+
+use crate::commands::cli_command::{CliCommand, PRINT_TARGET};
+use iggy_common::Client;
+
+use super::common::{ContextConfig, ContextManager, validate_transport};
+
+pub struct CreateContextCmd {
+ context_name: String,
+ context_config: ContextConfig,
+}
+
+impl CreateContextCmd {
+ pub fn new(context_name: String, context_config: ContextConfig) -> Self {
+ Self {
+ context_name,
+ context_config,
+ }
+ }
+}
+
+#[async_trait]
+impl CliCommand for CreateContextCmd {
+ fn explain(&self) -> String {
+ let context_name = &self.context_name;
+ format!("create context {context_name}")
+ }
+
+ fn login_required(&self) -> bool {
+ false
+ }
+
+ fn connection_required(&self) -> bool {
+ false
+ }
+
+ async fn execute_cmd(&mut self, _client: &dyn Client) ->
anyhow::Result<(), anyhow::Error> {
+ if let Some(ref transport) = self.context_config.iggy.transport {
+ validate_transport(transport)?;
+ }
+
+ let mut context_mgr = ContextManager::default();
+
+ context_mgr
+ .create_context(&self.context_name, self.context_config.clone())
+ .await?;
+
+ event!(target: PRINT_TARGET, Level::INFO, "context '{}' created
successfully", self.context_name);
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn should_return_explain_message() {
+ let cmd = CreateContextCmd::new("production".to_string(),
ContextConfig::default());
+ assert_eq!(cmd.explain(), "create context production");
+ }
+
+ #[test]
+ fn should_not_require_login() {
+ let cmd = CreateContextCmd::new("test".to_string(),
ContextConfig::default());
+ assert!(!cmd.login_required());
+ }
+
+ #[test]
+ fn should_not_require_connection() {
+ let cmd = CreateContextCmd::new("test".to_string(),
ContextConfig::default());
+ assert!(!cmd.connection_required());
+ }
+}
diff --git a/core/cli/src/commands/binary_context/delete_context.rs
b/core/cli/src/commands/binary_context/delete_context.rs
new file mode 100644
index 000000000..0593f32d4
--- /dev/null
+++ b/core/cli/src/commands/binary_context/delete_context.rs
@@ -0,0 +1,84 @@
+/* Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use async_trait::async_trait;
+use tracing::{Level, event};
+
+use crate::commands::cli_command::{CliCommand, PRINT_TARGET};
+use iggy_common::Client;
+
+use super::common::ContextManager;
+
+pub struct DeleteContextCmd {
+ context_name: String,
+}
+
+impl DeleteContextCmd {
+ pub fn new(context_name: String) -> Self {
+ Self { context_name }
+ }
+}
+
+#[async_trait]
+impl CliCommand for DeleteContextCmd {
+ fn explain(&self) -> String {
+ let context_name = &self.context_name;
+ format!("delete context {context_name}")
+ }
+
+ fn login_required(&self) -> bool {
+ false
+ }
+
+ fn connection_required(&self) -> bool {
+ false
+ }
+
+ async fn execute_cmd(&mut self, _client: &dyn Client) ->
anyhow::Result<(), anyhow::Error> {
+ let mut context_mgr = ContextManager::default();
+
+ context_mgr.delete_context(&self.context_name).await?;
+
+ event!(target: PRINT_TARGET, Level::INFO, "context '{}' deleted
successfully", self.context_name);
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn should_return_explain_message() {
+ let cmd = DeleteContextCmd::new("production".to_string());
+ assert_eq!(cmd.explain(), "delete context production");
+ }
+
+ #[test]
+ fn should_not_require_login() {
+ let cmd = DeleteContextCmd::new("test".to_string());
+ assert!(!cmd.login_required());
+ }
+
+ #[test]
+ fn should_not_require_connection() {
+ let cmd = DeleteContextCmd::new("test".to_string());
+ assert!(!cmd.connection_required());
+ }
+}
diff --git a/core/cli/src/commands/binary_context/mod.rs
b/core/cli/src/commands/binary_context/mod.rs
index c319b3ffd..bad021e21 100644
--- a/core/cli/src/commands/binary_context/mod.rs
+++ b/core/cli/src/commands/binary_context/mod.rs
@@ -18,5 +18,7 @@
pub mod common;
+pub mod create_context;
+pub mod delete_context;
pub mod get_contexts;
pub mod use_context;
diff --git a/core/cli/src/main.rs b/core/cli/src/main.rs
index 657f2e3c9..98c300ada 100644
--- a/core/cli/src/main.rs
+++ b/core/cli/src/main.rs
@@ -41,6 +41,8 @@ use iggy::client_provider::{self, ClientProviderConfig};
use iggy::clients::client::IggyClient;
use iggy::prelude::{Aes256GcmEncryptor, Args, EncryptorKind,
PersonalAccessTokenExpiry};
use iggy_cli::commands::binary_context::common::ContextManager;
+use iggy_cli::commands::binary_context::create_context::CreateContextCmd;
+use iggy_cli::commands::binary_context::delete_context::DeleteContextCmd;
use iggy_cli::commands::binary_context::use_context::UseContextCmd;
use iggy_cli::commands::binary_segments::delete_segments::DeleteSegmentsCmd;
use iggy_cli::commands::binary_system::snapshot::GetSnapshotCmd;
@@ -328,6 +330,13 @@ fn get_command(
ContextAction::Use(use_args) => {
Box::new(UseContextCmd::new(use_args.context_name.clone()))
}
+ ContextAction::Create(create_args) => {
+ let context_name = create_args.context_name.clone();
+ Box::new(CreateContextCmd::new(context_name,
create_args.into()))
+ }
+ ContextAction::Delete(delete_args) => {
+
Box::new(DeleteContextCmd::new(delete_args.context_name.clone()))
+ }
},
#[cfg(feature = "login-session")]
Command::Login(login_args) => Box::new(LoginCmd::new(
diff --git a/core/integration/tests/cli/context/common.rs
b/core/integration/tests/cli/context/common.rs
index 19c02184f..4ca2ca500 100644
--- a/core/integration/tests/cli/context/common.rs
+++ b/core/integration/tests/cli/context/common.rs
@@ -64,6 +64,10 @@ impl TestIggyContext {
self.context_manager.read_active_context().await.unwrap()
}
+ pub async fn read_saved_contexts(&self) -> Option<ContextsConfigMap> {
+ self.context_manager.read_contexts().await.unwrap()
+ }
+
pub fn get_contexts(&self) -> Option<ContextsConfigMap> {
self.maybe_contexts.clone()
}
diff --git a/core/integration/tests/cli/context/mod.rs
b/core/integration/tests/cli/context/mod.rs
index 974cd77ad..1bc857c6c 100644
--- a/core/integration/tests/cli/context/mod.rs
+++ b/core/integration/tests/cli/context/mod.rs
@@ -19,5 +19,7 @@
mod common;
mod test_context_applied;
+mod test_context_create_command;
+mod test_context_delete_command;
mod test_context_list_command;
mod test_context_use_command;
diff --git a/core/integration/tests/cli/context/test_context_applied.rs
b/core/integration/tests/cli/context/test_context_applied.rs
index 30a9457b3..99ccc93c1 100644
--- a/core/integration/tests/cli/context/test_context_applied.rs
+++ b/core/integration/tests/cli/context/test_context_applied.rs
@@ -26,7 +26,7 @@ use iggy_cli::commands::binary_context::common::ContextConfig;
use integration::harness::ServerHandle;
use predicates::str::{contains, starts_with};
use serial_test::parallel;
-use std::collections::HashMap;
+use std::collections::BTreeMap;
struct TestContextApplied {
set_transport_context: Option<String>,
@@ -37,7 +37,7 @@ struct TestContextApplied {
impl TestContextApplied {
fn new(set_transport_context: Option<String>, set_transport_arg:
Option<String>) -> Self {
let test_iggy_context = TestIggyContext::new(
- Some(HashMap::from([
+ Some(BTreeMap::from([
(
"default".to_string(),
ContextConfig {
diff --git a/core/integration/tests/cli/context/test_context_create_command.rs
b/core/integration/tests/cli/context/test_context_create_command.rs
new file mode 100644
index 000000000..fc1910bc5
--- /dev/null
+++ b/core/integration/tests/cli/context/test_context_create_command.rs
@@ -0,0 +1,239 @@
+/* Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use std::collections::BTreeMap;
+
+use crate::cli::common::{
+ CLAP_INDENT, IggyCmdCommand, IggyCmdTest, IggyCmdTestCase, TestHelpCmd,
USAGE_PREFIX,
+};
+use assert_cmd::assert::Assert;
+use async_trait::async_trait;
+use iggy::prelude::Client;
+use iggy_cli::commands::binary_context::common::ContextConfig;
+use predicates::str::contains;
+use serial_test::parallel;
+
+use super::common::TestIggyContext;
+
+struct TestContextCreateCmd {
+ test_iggy_context: TestIggyContext,
+ new_context_name: String,
+ transport: Option<String>,
+ tcp_server_address: Option<String>,
+}
+
+impl TestContextCreateCmd {
+ fn new(
+ test_iggy_context: TestIggyContext,
+ new_context_name: String,
+ transport: Option<String>,
+ tcp_server_address: Option<String>,
+ ) -> Self {
+ Self {
+ test_iggy_context,
+ new_context_name,
+ transport,
+ tcp_server_address,
+ }
+ }
+}
+
+#[async_trait]
+impl IggyCmdTestCase for TestContextCreateCmd {
+ async fn prepare_server_state(&mut self, _client: &dyn Client) {
+ self.test_iggy_context.prepare().await;
+ }
+
+ fn get_command(&self) -> IggyCmdCommand {
+ let mut cmd = IggyCmdCommand::new()
+ .env(
+ "IGGY_HOME",
+ self.test_iggy_context.get_iggy_home().to_str().unwrap(),
+ )
+ .arg("context")
+ .arg("create")
+ .arg(self.new_context_name.clone())
+ .with_env_credentials();
+
+ if let Some(transport) = &self.transport {
+ cmd = cmd.arg("--transport").arg(transport.clone());
+ }
+
+ if let Some(addr) = &self.tcp_server_address {
+ cmd = cmd.arg("--tcp-server-address").arg(addr.clone());
+ }
+
+ cmd
+ }
+
+ fn verify_command(&self, command_state: Assert) {
+ command_state.success().stdout(contains(format!(
+ "context '{}' created successfully",
+ self.new_context_name
+ )));
+ }
+
+ async fn verify_server_state(&self, _client: &dyn Client) {
+ let saved_contexts =
self.test_iggy_context.read_saved_contexts().await;
+ assert!(saved_contexts.is_some());
+ let contexts = saved_contexts.unwrap();
+ assert!(contexts.contains_key(&self.new_context_name));
+ }
+}
+
+#[tokio::test]
+#[parallel]
+pub async fn should_be_successful() {
+ let mut iggy_cmd_test = IggyCmdTest::default();
+ iggy_cmd_test.setup().await;
+
+ iggy_cmd_test
+ .execute_test(TestContextCreateCmd::new(
+ TestIggyContext::new(
+ Some(BTreeMap::from([(
+ "default".to_string(),
+ ContextConfig::default(),
+ )])),
+ None,
+ ),
+ "production".to_string(),
+ Some("tcp".to_string()),
+ Some("10.0.0.1:8090".to_string()),
+ ))
+ .await;
+}
+
+#[tokio::test]
+#[parallel]
+pub async fn should_create_minimal_context() {
+ let mut iggy_cmd_test = IggyCmdTest::default();
+ iggy_cmd_test.setup().await;
+
+ iggy_cmd_test
+ .execute_test(TestContextCreateCmd::new(
+ TestIggyContext::new(
+ Some(BTreeMap::from([(
+ "default".to_string(),
+ ContextConfig::default(),
+ )])),
+ None,
+ ),
+ "staging".to_string(),
+ None,
+ None,
+ ))
+ .await;
+}
+
+#[tokio::test]
+#[parallel]
+pub async fn should_help_match() {
+ let mut iggy_cmd_test = IggyCmdTest::default();
+
+ iggy_cmd_test
+ .execute_test_for_help_command(TestHelpCmd::new(
+ vec!["context", "create", "--help"],
+ format!(
+ r#"Create a new context
+
+Creates a new named context in the contexts configuration file.
+After creating a context, use 'iggy context use <name>' to activate it.
+
+Examples
+ iggy context create production --transport tcp --tcp-server-address
10.0.0.1:8090
+ iggy context create dev --transport http --http-api-url http://localhost:3000
+ iggy context create local --username iggy --password iggy
+
+{USAGE_PREFIX} context create [OPTIONS] <CONTEXT_NAME>
+
+Arguments:
+ <CONTEXT_NAME>
+{CLAP_INDENT}Name of the context to create
+
+Options:
+ --transport <TRANSPORT>
+{CLAP_INDENT}The transport to use
+{CLAP_INDENT}
+{CLAP_INDENT}Valid values are "quic", "http", "tcp" and "ws".
+
+ --tcp-server-address <TCP_SERVER_ADDRESS>
+{CLAP_INDENT}The server address for the TCP transport
+
+ --http-api-url <HTTP_API_URL>
+{CLAP_INDENT}The API URL for the HTTP transport
+
+ --quic-server-address <QUIC_SERVER_ADDRESS>
+{CLAP_INDENT}The server address for the QUIC transport
+
+ --tcp-tls-enabled <TCP_TLS_ENABLED>
+{CLAP_INDENT}Flag to enable TLS for the TCP transport
+{CLAP_INDENT}
+{CLAP_INDENT}[possible values: true, false]
+
+ -u, --username <USERNAME>
+{CLAP_INDENT}Iggy server username
+
+ -p, --password <PASSWORD>
+{CLAP_INDENT}Iggy server password
+
+ -t, --token <TOKEN>
+{CLAP_INDENT}Iggy server personal access token
+
+ -n, --token-name <TOKEN_NAME>
+{CLAP_INDENT}Iggy server personal access token name
+
+ -h, --help
+{CLAP_INDENT}Print help (see a summary with '-h')
+"#,
+ ),
+ ))
+ .await;
+}
+
+#[tokio::test]
+#[parallel]
+pub async fn should_short_help_match() {
+ let mut iggy_cmd_test = IggyCmdTest::default();
+
+ iggy_cmd_test
+ .execute_test_for_help_command(TestHelpCmd::new(
+ vec!["context", "create", "-h"],
+ format!(
+ r#"Create a new context
+
+{USAGE_PREFIX} context create [OPTIONS] <CONTEXT_NAME>
+
+Arguments:
+ <CONTEXT_NAME> Name of the context to create
+
+Options:
+ --transport <TRANSPORT> The transport to use
+ --tcp-server-address <TCP_SERVER_ADDRESS> The server address for the
TCP transport
+ --http-api-url <HTTP_API_URL> The API URL for the HTTP
transport
+ --quic-server-address <QUIC_SERVER_ADDRESS> The server address for the
QUIC transport
+ --tcp-tls-enabled <TCP_TLS_ENABLED> Flag to enable TLS for the
TCP transport [possible values: true, false]
+ -u, --username <USERNAME> Iggy server username
+ -p, --password <PASSWORD> Iggy server password
+ -t, --token <TOKEN> Iggy server personal access
token
+ -n, --token-name <TOKEN_NAME> Iggy server personal access
token name
+ -h, --help Print help (see more with
'--help')
+"#,
+ ),
+ ))
+ .await;
+}
diff --git a/core/integration/tests/cli/context/test_context_delete_command.rs
b/core/integration/tests/cli/context/test_context_delete_command.rs
new file mode 100644
index 000000000..c21d04d2e
--- /dev/null
+++ b/core/integration/tests/cli/context/test_context_delete_command.rs
@@ -0,0 +1,224 @@
+/* Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use std::collections::BTreeMap;
+
+use crate::cli::common::{
+ CLAP_INDENT, IggyCmdCommand, IggyCmdTest, IggyCmdTestCase, TestHelpCmd,
USAGE_PREFIX,
+};
+use assert_cmd::assert::Assert;
+use async_trait::async_trait;
+use iggy::prelude::Client;
+use iggy_cli::commands::binary_context::common::ContextConfig;
+use predicates::str::contains;
+use serial_test::parallel;
+
+use super::common::TestIggyContext;
+
+struct TestContextDeleteCmd {
+ test_iggy_context: TestIggyContext,
+ context_to_delete: String,
+}
+
+impl TestContextDeleteCmd {
+ fn new(test_iggy_context: TestIggyContext, context_to_delete: String) ->
Self {
+ Self {
+ test_iggy_context,
+ context_to_delete,
+ }
+ }
+}
+
+#[async_trait]
+impl IggyCmdTestCase for TestContextDeleteCmd {
+ async fn prepare_server_state(&mut self, _client: &dyn Client) {
+ self.test_iggy_context.prepare().await;
+ }
+
+ fn get_command(&self) -> IggyCmdCommand {
+ IggyCmdCommand::new()
+ .env(
+ "IGGY_HOME",
+ self.test_iggy_context.get_iggy_home().to_str().unwrap(),
+ )
+ .arg("context")
+ .arg("delete")
+ .arg(self.context_to_delete.clone())
+ .with_env_credentials()
+ }
+
+ fn verify_command(&self, command_state: Assert) {
+ command_state.success().stdout(contains(format!(
+ "context '{}' deleted successfully",
+ self.context_to_delete
+ )));
+ }
+
+ async fn verify_server_state(&self, _client: &dyn Client) {
+ let saved_contexts =
self.test_iggy_context.read_saved_contexts().await;
+ assert!(saved_contexts.is_some());
+ let contexts = saved_contexts.unwrap();
+ assert!(!contexts.contains_key(&self.context_to_delete));
+ }
+}
+
+#[tokio::test]
+#[parallel]
+pub async fn should_be_successful() {
+ let mut iggy_cmd_test = IggyCmdTest::default();
+ iggy_cmd_test.setup().await;
+
+ iggy_cmd_test
+ .execute_test(TestContextDeleteCmd::new(
+ TestIggyContext::new(
+ Some(BTreeMap::from([
+ ("default".to_string(), ContextConfig::default()),
+ ("production".to_string(), ContextConfig::default()),
+ ])),
+ None,
+ ),
+ "production".to_string(),
+ ))
+ .await;
+}
+
+#[tokio::test]
+#[parallel]
+pub async fn should_reset_active_context_on_delete() {
+ let mut iggy_cmd_test = IggyCmdTest::default();
+ iggy_cmd_test.setup().await;
+
+ let test_context = TestIggyContext::new(
+ Some(BTreeMap::from([
+ ("default".to_string(), ContextConfig::default()),
+ ("staging".to_string(), ContextConfig::default()),
+ ])),
+ Some("staging".to_string()),
+ );
+
+ let test_cmd = TestContextDeleteActiveCmd::new(test_context,
"staging".to_string());
+
+ iggy_cmd_test.execute_test(test_cmd).await;
+}
+
+struct TestContextDeleteActiveCmd {
+ test_iggy_context: TestIggyContext,
+ context_to_delete: String,
+}
+
+impl TestContextDeleteActiveCmd {
+ fn new(test_iggy_context: TestIggyContext, context_to_delete: String) ->
Self {
+ Self {
+ test_iggy_context,
+ context_to_delete,
+ }
+ }
+}
+
+#[async_trait]
+impl IggyCmdTestCase for TestContextDeleteActiveCmd {
+ async fn prepare_server_state(&mut self, _client: &dyn Client) {
+ self.test_iggy_context.prepare().await;
+ }
+
+ fn get_command(&self) -> IggyCmdCommand {
+ IggyCmdCommand::new()
+ .env(
+ "IGGY_HOME",
+ self.test_iggy_context.get_iggy_home().to_str().unwrap(),
+ )
+ .arg("context")
+ .arg("delete")
+ .arg(self.context_to_delete.clone())
+ .with_env_credentials()
+ }
+
+ fn verify_command(&self, command_state: Assert) {
+ command_state.success().stdout(contains(format!(
+ "context '{}' deleted successfully",
+ self.context_to_delete
+ )));
+ }
+
+ async fn verify_server_state(&self, _client: &dyn Client) {
+ let saved_contexts =
self.test_iggy_context.read_saved_contexts().await;
+ assert!(saved_contexts.is_some());
+ let contexts = saved_contexts.unwrap();
+ assert!(!contexts.contains_key(&self.context_to_delete));
+
+ let active_key = self.test_iggy_context.read_saved_context_key().await;
+ assert_eq!(active_key, Some("default".to_string()));
+ }
+}
+
+#[tokio::test]
+#[parallel]
+pub async fn should_help_match() {
+ let mut iggy_cmd_test = IggyCmdTest::default();
+
+ iggy_cmd_test
+ .execute_test_for_help_command(TestHelpCmd::new(
+ vec!["context", "delete", "--help"],
+ format!(
+ r#"Delete an existing context
+
+Removes a named context from the contexts configuration file.
+The 'default' context cannot be deleted. If the deleted context
+was the active context, the active context resets to 'default'.
+
+Examples
+ iggy context delete production
+
+{USAGE_PREFIX} context delete <CONTEXT_NAME>
+
+Arguments:
+ <CONTEXT_NAME>
+{CLAP_INDENT}Name of the context to delete
+
+Options:
+ -h, --help
+{CLAP_INDENT}Print help (see a summary with '-h')
+"#,
+ ),
+ ))
+ .await;
+}
+
+#[tokio::test]
+#[parallel]
+pub async fn should_short_help_match() {
+ let mut iggy_cmd_test = IggyCmdTest::default();
+
+ iggy_cmd_test
+ .execute_test_for_help_command(TestHelpCmd::new(
+ vec!["context", "delete", "-h"],
+ format!(
+ r#"Delete an existing context
+
+{USAGE_PREFIX} context delete <CONTEXT_NAME>
+
+Arguments:
+ <CONTEXT_NAME> Name of the context to delete
+
+Options:
+ -h, --help Print help (see more with '--help')
+"#,
+ ),
+ ))
+ .await;
+}
diff --git a/core/integration/tests/cli/context/test_context_list_command.rs
b/core/integration/tests/cli/context/test_context_list_command.rs
index 0656ce0e4..e428368bd 100644
--- a/core/integration/tests/cli/context/test_context_list_command.rs
+++ b/core/integration/tests/cli/context/test_context_list_command.rs
@@ -16,7 +16,7 @@
* under the License.
*/
-use std::collections::HashMap;
+use std::collections::BTreeMap;
use crate::cli::common::{
CLAP_INDENT, IggyCmdCommand, IggyCmdTest, IggyCmdTestCase, TestHelpCmd,
USAGE_PREFIX,
@@ -98,7 +98,7 @@ pub async fn should_be_successful() {
iggy_cmd_test
.execute_test(TestContextListCmd::new(TestIggyContext::new(
- Some(HashMap::from([
+ Some(BTreeMap::from([
("default".to_string(), ContextConfig::default()),
("second".to_string(), ContextConfig::default()),
])),
@@ -115,7 +115,7 @@ pub async fn should_display_active_context() {
iggy_cmd_test
.execute_test(TestContextListCmd::new(TestIggyContext::new(
- Some(HashMap::from([
+ Some(BTreeMap::from([
(
"default".to_string(),
ContextConfig {
diff --git a/core/integration/tests/cli/context/test_context_use_command.rs
b/core/integration/tests/cli/context/test_context_use_command.rs
index 9be8be0ea..4cad1a0c9 100644
--- a/core/integration/tests/cli/context/test_context_use_command.rs
+++ b/core/integration/tests/cli/context/test_context_use_command.rs
@@ -16,7 +16,7 @@
* under the License.
*/
-use std::collections::HashMap;
+use std::collections::BTreeMap;
use crate::cli::common::{
CLAP_INDENT, IggyCmdCommand, IggyCmdTest, IggyCmdTestCase, TestHelpCmd,
USAGE_PREFIX,
@@ -87,7 +87,7 @@ pub async fn should_be_successful() {
iggy_cmd_test
.execute_test(TestContextUseCmd::new(
TestIggyContext::new(
- Some(HashMap::from([
+ Some(BTreeMap::from([
("default".to_string(), ContextConfig::default()),
("second".to_string(), ContextConfig::default()),
])),