Skip to main content

mkt_core/config/
profile.rs

1//! Profile and configuration types.
2//!
3//! The config file is a TOML file with a `[defaults]` section and
4//! one or more `[profiles.<name>]` sections.
5
6use std::collections::HashMap;
7use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::{MktError, Result};
12
13/// Top-level configuration loaded from `config.toml`.
14#[derive(Debug, Clone, Default, Serialize, Deserialize)]
15pub struct MktConfig {
16    /// Global defaults.
17    #[serde(default)]
18    pub defaults: Defaults,
19    /// Named profiles.
20    #[serde(default)]
21    pub profiles: HashMap<String, Profile>,
22}
23
24impl MktConfig {
25    /// Load configuration from a TOML file.
26    ///
27    /// # Errors
28    ///
29    /// Returns an error if the file cannot be read or contains invalid TOML.
30    pub fn load_from_file(path: &Path) -> Result<Self> {
31        let content = std::fs::read_to_string(path)?;
32        let config: Self = toml::from_str(&content)?;
33        Ok(config)
34    }
35
36    /// Load config from the default location, returning a default config if not found.
37    ///
38    /// # Errors
39    ///
40    /// Returns an error if the config directory cannot be determined or
41    /// the config file exists but contains invalid TOML.
42    pub fn load() -> Result<Self> {
43        let file = super::config_file()?;
44        if file.exists() {
45            Self::load_from_file(&file)
46        } else {
47            Ok(Self::default())
48        }
49    }
50
51    /// Get a profile by name, falling back to the default profile.
52    ///
53    /// # Errors
54    ///
55    /// Returns [`MktError::ConfigError`] if the named profile does not exist.
56    pub fn profile(&self, name: &str) -> Result<&Profile> {
57        self.profiles
58            .get(name)
59            .ok_or_else(|| MktError::ConfigError(format!("Profile '{name}' not found")))
60    }
61}
62
63/// Global defaults.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct Defaults {
66    /// Default output format.
67    #[serde(default = "default_output")]
68    pub output: String,
69    /// Default profile name.
70    #[serde(default = "default_profile")]
71    pub profile: String,
72}
73
74fn default_output() -> String {
75    "table".into()
76}
77
78fn default_profile() -> String {
79    "default".into()
80}
81
82impl Default for Defaults {
83    fn default() -> Self {
84        Self {
85            output: default_output(),
86            profile: default_profile(),
87        }
88    }
89}
90
91/// A named profile containing provider-specific configurations.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct Profile {
94    /// The default provider for this profile.
95    #[serde(default)]
96    pub provider: String,
97    /// Meta provider config.
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub meta: Option<MetaConfig>,
100    /// Google Ads provider config.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub google: Option<GoogleConfig>,
103    /// TikTok provider config.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub tiktok: Option<TikTokConfig>,
106    /// LinkedIn provider config.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub linkedin: Option<LinkedInConfig>,
109}
110
111/// Meta (Facebook/Instagram) provider configuration.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct MetaConfig {
114    /// User access token (overridden by `MKT_META_ACCESS_TOKEN` env var).
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub access_token: Option<String>,
117    /// Ad account ID (e.g. `act_123456789`).
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub ad_account_id: Option<String>,
120    /// Facebook Page ID for organic posts.
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub page_id: Option<String>,
123    /// Instagram user ID for IG publishing.
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub ig_user_id: Option<String>,
126    /// API version override (default: "v25.0").
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub api_version: Option<String>,
129}
130
131/// Google Ads provider configuration (stub).
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct GoogleConfig {
134    /// Developer token.
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub developer_token: Option<String>,
137    /// OAuth client ID.
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub client_id: Option<String>,
140    /// OAuth client secret.
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub client_secret: Option<String>,
143    /// OAuth refresh token.
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub refresh_token: Option<String>,
146    /// Customer ID (e.g. "123-456-7890").
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub customer_id: Option<String>,
149}
150
151/// TikTok for Business provider configuration (stub).
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct TikTokConfig {
154    /// Access token.
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub access_token: Option<String>,
157    /// Advertiser ID.
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub advertiser_id: Option<String>,
160}
161
162/// LinkedIn Marketing provider configuration (stub).
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct LinkedInConfig {
165    /// Access token.
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub access_token: Option<String>,
168    /// Ad account ID.
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub ad_account_id: Option<String>,
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    #[allow(clippy::expect_used)]
179    fn parse_minimal_config() {
180        let toml_str = r#"
181[defaults]
182output = "json"
183profile = "test"
184
185[profiles.test]
186provider = "meta"
187
188[profiles.test.meta]
189access_token = "EAAB123"
190ad_account_id = "act_123"
191"#;
192        let config: MktConfig = toml::from_str(toml_str).expect("parse TOML");
193        assert_eq!(config.defaults.output, "json");
194        assert_eq!(config.defaults.profile, "test");
195
196        let profile = config.profile("test").expect("profile exists");
197        assert_eq!(profile.provider, "meta");
198
199        let meta = profile.meta.as_ref().expect("meta config");
200        assert_eq!(meta.access_token.as_deref(), Some("EAAB123"));
201        assert_eq!(meta.ad_account_id.as_deref(), Some("act_123"));
202    }
203
204    #[test]
205    #[allow(clippy::expect_used)]
206    fn parse_empty_config() {
207        let toml_str = "";
208        let config: MktConfig = toml::from_str(toml_str).expect("parse TOML");
209        assert_eq!(config.defaults.output, "table");
210        assert_eq!(config.defaults.profile, "default");
211        assert!(config.profiles.is_empty());
212    }
213
214    #[test]
215    #[allow(clippy::panic)]
216    fn missing_profile_returns_error() {
217        let config = MktConfig::default();
218        let Err(err) = config.profile("nonexistent") else {
219            panic!("expected config error");
220        };
221        assert!(err.to_string().contains("not found"));
222    }
223
224    #[test]
225    #[allow(clippy::expect_used)]
226    fn config_with_multiple_profiles() {
227        let toml_str = r#"
228[profiles.client-a]
229provider = "meta"
230
231[profiles.client-a.meta]
232ad_account_id = "act_111"
233
234[profiles.client-b]
235provider = "google"
236
237[profiles.client-b.google]
238customer_id = "123-456"
239"#;
240        let config: MktConfig = toml::from_str(toml_str).expect("parse TOML");
241        assert_eq!(config.profiles.len(), 2);
242        assert!(config.profile("client-a").is_ok());
243        assert!(config.profile("client-b").is_ok());
244    }
245
246    #[test]
247    fn defaults_are_applied() {
248        let defaults = Defaults::default();
249        assert_eq!(defaults.output, "table");
250        assert_eq!(defaults.profile, "default");
251    }
252}