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    /// App secret enabling `appsecret_proof` on every Graph API call
118    /// (overridden by `MKT_META_APP_SECRET` env var). Optional: only
119    /// needed when the app has "Require App Secret" enabled.
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub app_secret: Option<String>,
122    /// Ad account ID (e.g. `act_123456789`).
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub ad_account_id: Option<String>,
125    /// Facebook Page ID for organic posts.
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub page_id: Option<String>,
128    /// Instagram user ID for IG publishing.
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub ig_user_id: Option<String>,
131    /// API version override (default: "v25.0").
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub api_version: Option<String>,
134}
135
136/// Google Ads provider configuration (stub).
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct GoogleConfig {
139    /// Developer token.
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub developer_token: Option<String>,
142    /// OAuth client ID.
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub client_id: Option<String>,
145    /// OAuth client secret.
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub client_secret: Option<String>,
148    /// OAuth refresh token.
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub refresh_token: Option<String>,
151    /// Customer ID (e.g. "123-456-7890").
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub customer_id: Option<String>,
154}
155
156/// TikTok for Business provider configuration (stub).
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct TikTokConfig {
159    /// Access token.
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub access_token: Option<String>,
162    /// Advertiser ID.
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub advertiser_id: Option<String>,
165}
166
167/// LinkedIn Marketing provider configuration (stub).
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct LinkedInConfig {
170    /// Access token.
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub access_token: Option<String>,
173    /// OAuth client ID.
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub client_id: Option<String>,
176    /// OAuth client secret.
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub client_secret: Option<String>,
179    /// OAuth refresh token.
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub refresh_token: Option<String>,
182    /// Ad account ID.
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub ad_account_id: Option<String>,
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    #[allow(clippy::expect_used)]
193    fn parse_minimal_config() {
194        let toml_str = r#"
195[defaults]
196output = "json"
197profile = "test"
198
199[profiles.test]
200provider = "meta"
201
202[profiles.test.meta]
203access_token = "EAAB123"
204ad_account_id = "act_123"
205"#;
206        let config: MktConfig = toml::from_str(toml_str).expect("parse TOML");
207        assert_eq!(config.defaults.output, "json");
208        assert_eq!(config.defaults.profile, "test");
209
210        let profile = config.profile("test").expect("profile exists");
211        assert_eq!(profile.provider, "meta");
212
213        let meta = profile.meta.as_ref().expect("meta config");
214        assert_eq!(meta.access_token.as_deref(), Some("EAAB123"));
215        assert_eq!(meta.ad_account_id.as_deref(), Some("act_123"));
216        assert_eq!(
217            meta.app_secret, None,
218            "app_secret is optional and defaults to None"
219        );
220    }
221
222    #[test]
223    #[allow(clippy::expect_used)]
224    fn parse_meta_app_secret() {
225        let toml_str = r#"
226[profiles.test.meta]
227access_token = "EAAB123"
228app_secret = "shhh-secret"
229"#;
230        let config: MktConfig = toml::from_str(toml_str).expect("parse TOML");
231        let profile = config.profile("test").expect("profile exists");
232        let meta = profile.meta.as_ref().expect("meta config");
233        assert_eq!(meta.app_secret.as_deref(), Some("shhh-secret"));
234    }
235
236    #[test]
237    #[allow(clippy::expect_used)]
238    fn parse_empty_config() {
239        let toml_str = "";
240        let config: MktConfig = toml::from_str(toml_str).expect("parse TOML");
241        assert_eq!(config.defaults.output, "table");
242        assert_eq!(config.defaults.profile, "default");
243        assert!(config.profiles.is_empty());
244    }
245
246    #[test]
247    #[allow(clippy::panic)]
248    fn missing_profile_returns_error() {
249        let config = MktConfig::default();
250        let Err(err) = config.profile("nonexistent") else {
251            panic!("expected config error");
252        };
253        assert!(err.to_string().contains("not found"));
254    }
255
256    #[test]
257    #[allow(clippy::expect_used)]
258    fn config_with_multiple_profiles() {
259        let toml_str = r#"
260[profiles.client-a]
261provider = "meta"
262
263[profiles.client-a.meta]
264ad_account_id = "act_111"
265
266[profiles.client-b]
267provider = "google"
268
269[profiles.client-b.google]
270customer_id = "123-456"
271"#;
272        let config: MktConfig = toml::from_str(toml_str).expect("parse TOML");
273        assert_eq!(config.profiles.len(), 2);
274        assert!(config.profile("client-a").is_ok());
275        assert!(config.profile("client-b").is_ok());
276    }
277
278    #[test]
279    fn defaults_are_applied() {
280        let defaults = Defaults::default();
281        assert_eq!(defaults.output, "table");
282        assert_eq!(defaults.profile, "default");
283    }
284}