use std::collections::HashMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::error::{MktError, Result};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MktConfig {
#[serde(default)]
pub defaults: Defaults,
#[serde(default)]
pub profiles: HashMap<String, Profile>,
}
impl MktConfig {
pub fn load_from_file(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)?;
let config: Self = toml::from_str(&content)?;
Ok(config)
}
pub fn load() -> Result<Self> {
let file = super::config_file()?;
if file.exists() {
Self::load_from_file(&file)
} else {
Ok(Self::default())
}
}
pub fn profile(&self, name: &str) -> Result<&Profile> {
self.profiles
.get(name)
.ok_or_else(|| MktError::ConfigError(format!("Profile '{name}' not found")))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Defaults {
#[serde(default = "default_output")]
pub output: String,
#[serde(default = "default_profile")]
pub profile: String,
}
fn default_output() -> String {
"table".into()
}
fn default_profile() -> String {
"default".into()
}
impl Default for Defaults {
fn default() -> Self {
Self {
output: default_output(),
profile: default_profile(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Profile {
#[serde(default)]
pub provider: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<MetaConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub google: Option<GoogleConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tiktok: Option<TikTokConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub linkedin: Option<LinkedInConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetaConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub access_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ad_account_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub page_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ig_user_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub api_version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GoogleConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub developer_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_secret: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub refresh_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub customer_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TikTokConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub access_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub advertiser_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinkedInConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub access_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ad_account_id: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[allow(clippy::expect_used)]
fn parse_minimal_config() {
let toml_str = r#"
[defaults]
output = "json"
profile = "test"
[profiles.test]
provider = "meta"
[profiles.test.meta]
access_token = "EAAB123"
ad_account_id = "act_123"
"#;
let config: MktConfig = toml::from_str(toml_str).expect("parse TOML");
assert_eq!(config.defaults.output, "json");
assert_eq!(config.defaults.profile, "test");
let profile = config.profile("test").expect("profile exists");
assert_eq!(profile.provider, "meta");
let meta = profile.meta.as_ref().expect("meta config");
assert_eq!(meta.access_token.as_deref(), Some("EAAB123"));
assert_eq!(meta.ad_account_id.as_deref(), Some("act_123"));
}
#[test]
#[allow(clippy::expect_used)]
fn parse_empty_config() {
let toml_str = "";
let config: MktConfig = toml::from_str(toml_str).expect("parse TOML");
assert_eq!(config.defaults.output, "table");
assert_eq!(config.defaults.profile, "default");
assert!(config.profiles.is_empty());
}
#[test]
#[allow(clippy::panic)]
fn missing_profile_returns_error() {
let config = MktConfig::default();
let Err(err) = config.profile("nonexistent") else {
panic!("expected config error");
};
assert!(err.to_string().contains("not found"));
}
#[test]
#[allow(clippy::expect_used)]
fn config_with_multiple_profiles() {
let toml_str = r#"
[profiles.client-a]
provider = "meta"
[profiles.client-a.meta]
ad_account_id = "act_111"
[profiles.client-b]
provider = "google"
[profiles.client-b.google]
customer_id = "123-456"
"#;
let config: MktConfig = toml::from_str(toml_str).expect("parse TOML");
assert_eq!(config.profiles.len(), 2);
assert!(config.profile("client-a").is_ok());
assert!(config.profile("client-b").is_ok());
}
#[test]
fn defaults_are_applied() {
let defaults = Defaults::default();
assert_eq!(defaults.output, "table");
assert_eq!(defaults.profile, "default");
}
}