mkt_core/config/
profile.rs1use std::collections::HashMap;
7use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::{MktError, Result};
12
13#[derive(Debug, Clone, Default, Serialize, Deserialize)]
15pub struct MktConfig {
16 #[serde(default)]
18 pub defaults: Defaults,
19 #[serde(default)]
21 pub profiles: HashMap<String, Profile>,
22}
23
24impl MktConfig {
25 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct Defaults {
66 #[serde(default = "default_output")]
68 pub output: String,
69 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct Profile {
94 #[serde(default)]
96 pub provider: String,
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub meta: Option<MetaConfig>,
100 #[serde(skip_serializing_if = "Option::is_none")]
102 pub google: Option<GoogleConfig>,
103 #[serde(skip_serializing_if = "Option::is_none")]
105 pub tiktok: Option<TikTokConfig>,
106 #[serde(skip_serializing_if = "Option::is_none")]
108 pub linkedin: Option<LinkedInConfig>,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct MetaConfig {
114 #[serde(skip_serializing_if = "Option::is_none")]
116 pub access_token: Option<String>,
117 #[serde(skip_serializing_if = "Option::is_none")]
121 pub app_secret: Option<String>,
122 #[serde(skip_serializing_if = "Option::is_none")]
124 pub ad_account_id: Option<String>,
125 #[serde(skip_serializing_if = "Option::is_none")]
127 pub page_id: Option<String>,
128 #[serde(skip_serializing_if = "Option::is_none")]
130 pub ig_user_id: Option<String>,
131 #[serde(skip_serializing_if = "Option::is_none")]
133 pub api_version: Option<String>,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct GoogleConfig {
139 #[serde(skip_serializing_if = "Option::is_none")]
141 pub developer_token: Option<String>,
142 #[serde(skip_serializing_if = "Option::is_none")]
144 pub client_id: Option<String>,
145 #[serde(skip_serializing_if = "Option::is_none")]
147 pub client_secret: Option<String>,
148 #[serde(skip_serializing_if = "Option::is_none")]
150 pub refresh_token: Option<String>,
151 #[serde(skip_serializing_if = "Option::is_none")]
153 pub customer_id: Option<String>,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct TikTokConfig {
159 #[serde(skip_serializing_if = "Option::is_none")]
161 pub access_token: Option<String>,
162 #[serde(skip_serializing_if = "Option::is_none")]
164 pub advertiser_id: Option<String>,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct LinkedInConfig {
170 #[serde(skip_serializing_if = "Option::is_none")]
172 pub access_token: Option<String>,
173 #[serde(skip_serializing_if = "Option::is_none")]
175 pub client_id: Option<String>,
176 #[serde(skip_serializing_if = "Option::is_none")]
178 pub client_secret: Option<String>,
179 #[serde(skip_serializing_if = "Option::is_none")]
181 pub refresh_token: Option<String>,
182 #[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}