Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/cfkv/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ thiserror.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
xdg = "2.5"
lazy_static = "1.4"
17 changes: 17 additions & 0 deletions crates/cfkv/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,23 @@ pub enum StorageCommands {
#[arg(short, long)]
name: Option<String>,
},

/// Export storages to a file
Export {
/// Output file path
#[arg(short, long)]
file: Option<PathBuf>,
},

/// Import storages from a file
Import {
/// Input file path
#[arg(short, long)]
file: PathBuf,
},

/// Load storages from environment variables
LoadEnv,
}

#[derive(Subcommand)]
Expand Down
209 changes: 209 additions & 0 deletions crates/cfkv/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};

/// Format for exporting/importing storages
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct StorageExport {
pub storages: HashMap<String, Storage>,
pub active_storage: Option<String>,
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Storage {
pub name: String,
Expand Down Expand Up @@ -224,11 +231,86 @@ impl Config {
)))
}
}

/// Export storages to JSON format
pub fn export_to_json(&self) -> Result<String> {
let export = StorageExport {
storages: self.storages.clone(),
active_storage: self.active_storage.clone(),
};
Ok(serde_json::to_string_pretty(&export)?)
}

/// Import storages from JSON format
pub fn import_from_json(&mut self, json: &str) -> Result<()> {
let export: StorageExport = serde_json::from_str(json)?;
self.storages = export.storages;
self.active_storage = export.active_storage;
Ok(())
}

/// Load or create storages from environment variables
/// Looks for variables in the format: CFKV_STORAGE_<NAME>_<FIELD>
/// Example: CFKV_STORAGE_PROD_ACCOUNT_ID, CFKV_STORAGE_PROD_NAMESPACE_ID, CFKV_STORAGE_PROD_API_TOKEN
pub fn load_from_env() -> Result<HashMap<String, Storage>> {
let mut storages = HashMap::new();
let mut storage_names = std::collections::HashSet::new();

// Scan environment variables for CFKV_STORAGE_* pattern
for (key, _value) in std::env::vars() {
if key.starts_with("CFKV_STORAGE_") {
let parts: Vec<&str> = key.split('_').collect();
if parts.len() >= 4 {
// Format: CFKV_STORAGE_<NAME>_<FIELD>
let storage_name = parts[2].to_lowercase();
storage_names.insert(storage_name);
}
}
}

// Load each storage
for storage_name in storage_names {
let account_id_key = format!("CFKV_STORAGE_{}_ACCOUNT_ID", storage_name.to_uppercase());
let namespace_id_key =
format!("CFKV_STORAGE_{}_NAMESPACE_ID", storage_name.to_uppercase());
let api_token_key = format!("CFKV_STORAGE_{}_API_TOKEN", storage_name.to_uppercase());

if let (Ok(account_id), Ok(namespace_id), Ok(api_token)) = (
std::env::var(&account_id_key),
std::env::var(&namespace_id_key),
std::env::var(&api_token_key),
) {
let storage = Storage {
name: storage_name.clone(),
account_id,
namespace_id,
api_token,
};
storages.insert(storage_name, storage);
}
}

Ok(storages)
}

/// Merge environment variable storages with existing config
pub fn merge_from_env(&mut self) -> Result<()> {
let env_storages = Self::load_from_env()?;
for (name, storage) in env_storages {
self.storages.insert(name, storage);
}
Ok(())
}
}

#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;

lazy_static::lazy_static! {
static ref ENV_TEST_LOCK: Mutex<()> = Mutex::new(());
}

#[test]
fn test_config_default() {
Expand Down Expand Up @@ -391,4 +473,131 @@ mod tests {
let deserialized: Config = serde_json::from_str(&json).unwrap();
assert_eq!(config.storages.len(), deserialized.storages.len());
}

#[test]
fn test_export_to_json() {
let mut config = Config::default();
config.add_storage(
"prod".to_string(),
"acc123".to_string(),
"ns456".to_string(),
"token789".to_string(),
);
config.add_storage(
"dev".to_string(),
"acc999".to_string(),
"ns999".to_string(),
"token999".to_string(),
);
config.set_active_storage("prod".to_string()).unwrap();

let json = config.export_to_json().unwrap();
assert!(json.contains("prod"));
assert!(json.contains("dev"));
assert!(json.contains("active_storage"));
}

#[test]
fn test_import_from_json() {
let json = r#"{
"storages": {
"prod": {
"name": "prod",
"account_id": "acc123",
"namespace_id": "ns456",
"api_token": "token789"
},
"dev": {
"name": "dev",
"account_id": "acc999",
"namespace_id": "ns999",
"api_token": "token999"
}
},
"active_storage": "prod"
}"#;

let mut config = Config::default();
config.import_from_json(json).unwrap();

assert_eq!(config.storages.len(), 2);
assert_eq!(config.active_storage, Some("prod".to_string()));
assert!(config.get_storage("prod").is_some());
assert!(config.get_storage("dev").is_some());
}

#[test]
fn test_load_from_env() {
let _guard = ENV_TEST_LOCK.lock().unwrap();

// Use a unique storage name to avoid conflicts with other tests
let unique_name = format!("testload{}", std::process::id());
let upper_name = unique_name.to_uppercase();

// This test requires environment variables to be set
// In a real scenario, these would be set by the shell
std::env::set_var(
format!("CFKV_STORAGE_{}_ACCOUNT_ID", upper_name),
"test_acc",
);
std::env::set_var(
format!("CFKV_STORAGE_{}_NAMESPACE_ID", upper_name),
"test_ns",
);
std::env::set_var(
format!("CFKV_STORAGE_{}_API_TOKEN", upper_name),
"test_token",
);

let storages = Config::load_from_env().unwrap();
assert!(storages.contains_key(&unique_name));

let storage = storages.get(&unique_name).unwrap();
assert_eq!(storage.account_id, "test_acc");
assert_eq!(storage.namespace_id, "test_ns");
assert_eq!(storage.api_token, "test_token");

// Cleanup
std::env::remove_var(format!("CFKV_STORAGE_{}_ACCOUNT_ID", upper_name));
std::env::remove_var(format!("CFKV_STORAGE_{}_NAMESPACE_ID", upper_name));
std::env::remove_var(format!("CFKV_STORAGE_{}_API_TOKEN", upper_name));
}

#[test]
fn test_merge_from_env() {
let _guard = ENV_TEST_LOCK.lock().unwrap();

// Use a unique storage name to avoid conflicts with other tests
let unique_name = format!("testmerge{}", std::process::id());
let upper_name = unique_name.to_uppercase();

std::env::set_var(format!("CFKV_STORAGE_{}_ACCOUNT_ID", upper_name), "env_acc");
std::env::set_var(
format!("CFKV_STORAGE_{}_NAMESPACE_ID", upper_name),
"env_ns",
);
std::env::set_var(
format!("CFKV_STORAGE_{}_API_TOKEN", upper_name),
"env_token",
);

let mut config = Config::default();
config.add_storage(
"prod".to_string(),
"prod_acc".to_string(),
"prod_ns".to_string(),
"prod_token".to_string(),
);

config.merge_from_env().unwrap();

assert_eq!(config.storages.len(), 2);
assert!(config.get_storage("prod").is_some());
assert!(config.get_storage(&unique_name).is_some());

// Cleanup
std::env::remove_var(format!("CFKV_STORAGE_{}_ACCOUNT_ID", upper_name));
std::env::remove_var(format!("CFKV_STORAGE_{}_NAMESPACE_ID", upper_name));
std::env::remove_var(format!("CFKV_STORAGE_{}_API_TOKEN", upper_name));
}
}
51 changes: 51 additions & 0 deletions crates/cfkv/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,57 @@ async fn handle_storage_command(
};
println!("{}", output);
}
StorageCommands::Export { file } => {
let json = config.export_to_json()?;

if let Some(output_path) = file {
fs::write(&output_path, &json)?;
println!(
"{}",
Formatter::format_success(
&format!("Storages exported to '{}'", output_path.display()),
format
)
);
} else {
println!("{}", json);
}
}
StorageCommands::Import { file } => {
let json = fs::read_to_string(&file)?;
config.import_from_json(&json)?;
config.save(config_path)?;
println!(
"{}",
Formatter::format_success(
&format!("Storages imported from '{}'", file.display()),
format
)
);
}
StorageCommands::LoadEnv => {
config.merge_from_env()?;
config.save(config_path)?;
let env_storages = config::Config::load_from_env()?;
if env_storages.is_empty() {
println!(
"{}",
Formatter::format_text("No storages found in environment variables", format)
);
} else {
let count = env_storages.len();
println!(
"{}",
Formatter::format_success(
&format!("Loaded {} storage(ies) from environment variables", count),
format
)
);
for (name, _) in env_storages {
println!(" - {}", name);
}
}
}
}

Ok(())
Expand Down
Loading