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")]
119 pub ad_account_id: Option<String>,
120 #[serde(skip_serializing_if = "Option::is_none")]
122 pub page_id: Option<String>,
123 #[serde(skip_serializing_if = "Option::is_none")]
125 pub ig_user_id: Option<String>,
126 #[serde(skip_serializing_if = "Option::is_none")]
128 pub api_version: Option<String>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct GoogleConfig {
134 #[serde(skip_serializing_if = "Option::is_none")]
136 pub developer_token: Option<String>,
137 #[serde(skip_serializing_if = "Option::is_none")]
139 pub client_id: Option<String>,
140 #[serde(skip_serializing_if = "Option::is_none")]
142 pub client_secret: Option<String>,
143 #[serde(skip_serializing_if = "Option::is_none")]
145 pub refresh_token: Option<String>,
146 #[serde(skip_serializing_if = "Option::is_none")]
148 pub customer_id: Option<String>,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct TikTokConfig {
154 #[serde(skip_serializing_if = "Option::is_none")]
156 pub access_token: Option<String>,
157 #[serde(skip_serializing_if = "Option::is_none")]
159 pub advertiser_id: Option<String>,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct LinkedInConfig {
165 #[serde(skip_serializing_if = "Option::is_none")]
167 pub access_token: Option<String>,
168 #[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}