yt_sub/
user_settings_cli.rs1use eyre::{OptionExt, Result};
2use reqwest::Client;
3use serde_json::Value;
4use std::{
5 fs::File,
6 io::Write,
7 path::{Path, PathBuf},
8};
9
10use chrono::{DateTime, Duration, Utc};
11use home::home_dir;
12use yt_sub_core::{user_settings::API_HOST, UserSettings};
13
14#[allow(async_fn_in_trait)]
15pub trait UserSettingsCLI {
16 fn last_run_at(&self) -> DateTime<Utc>;
17 fn touch_last_run_at(&self) -> Result<()>;
18 fn init(path: Option<&PathBuf>) -> Result<UserSettings>;
19 fn read(path: Option<&PathBuf>) -> Result<UserSettings>;
20 fn save(&self, path: Option<&PathBuf>) -> Result<()>;
21 fn default_path() -> PathBuf;
22 async fn create_account(self, host: Option<&str>) -> Result<()>;
23 async fn delete_account(&self, host: Option<&str>) -> Result<()>;
24 async fn sync_account(&self, host: Option<&str>) -> Result<()>;
25}
26
27impl UserSettingsCLI for UserSettings {
28 fn last_run_at(&self) -> DateTime<Utc> {
29 let path = home_dir().unwrap().join(".yt-sub-rs/last_run_at.txt");
30 if Path::new(&path).exists() {
31 let last_run_at =
32 std::fs::read_to_string(&path).expect("Failed to read last_run_at file");
33 DateTime::parse_from_rfc3339(&last_run_at)
34 .expect("Failed to parse last_run_at file")
35 .with_timezone(&Utc)
36 } else {
37 Utc::now() - Duration::days(7)
38 }
39 }
40
41 fn touch_last_run_at(&self) -> Result<()> {
42 let last_run_at_path = last_run_at_path();
43 let last_run_at = Utc::now().to_rfc3339();
44 if let Some(parent) = Path::new(&last_run_at_path).parent() {
45 std::fs::create_dir_all(parent)?;
46 }
47 let mut file = File::create(last_run_at_path)?;
48 file.write_all(last_run_at.as_bytes())?;
49 Ok(())
50 }
51
52 fn init(path: Option<&PathBuf>) -> Result<Self> {
53 let default_path = Self::default_path();
54 let path = path.unwrap_or(&default_path);
55 if Path::new(path).exists() {
56 eyre::bail!(
57 "Config file at '{}' is already initialized!",
58 path.display()
59 );
60 }
61
62 let settings = Self::default(path.clone());
63 settings.save(Some(path))?;
64
65 Ok(settings)
66 }
67
68 fn read(path: Option<&PathBuf>) -> Result<Self> {
69 let default_path = Self::default_path();
70 let path = path.unwrap_or(&default_path);
71
72 if !Path::new(path).exists() {
73 eyre::bail!(
74 "Config file at '{}' does not exist! Run 'ytsub init' to initialize it.",
75 path.display()
76 )
77 }
78 let mut settings: Self = toml::from_str(&std::fs::read_to_string(path)?)?;
79 settings.path = path.clone();
80 Ok(settings)
81 }
82
83 fn save(&self, path: Option<&PathBuf>) -> Result<()> {
84 let res = toml::to_string(self).expect("Failed to serialize TOML");
85
86 let default_path = Self::default_path();
87 let path = path.unwrap_or(&default_path);
88
89 if let Some(parent) = Path::new(&path).parent() {
90 std::fs::create_dir_all(parent)?;
91 }
92
93 let mut file = File::create(path).expect("Failed to create file");
94 file.write_all(res.as_bytes())
95 .expect("Failed to write to file");
96 Ok(())
97 }
98
99 fn default_path() -> PathBuf {
100 home_dir().unwrap().join(".config/yt-sub-rs/config.toml")
101 }
102
103 async fn create_account(self, host: Option<&str>) -> Result<()> {
104 if self.api_key.is_some() {
105 eyre::bail!("Remote account is already registered.")
106 }
107
108 _ = self.get_slack_notifier().ok_or_eyre(
109 "You must configure a Slack notifier to register a remote account:
110https://github.com/pawurb/yt-sub-rs#notifiers-configuration",
111 )?;
112
113 let client = Client::new();
114 let host = host.unwrap_or(API_HOST);
115
116 let res = client
117 .post(format!("{}/account", host))
118 .json(&self)
119 .send()
120 .await?;
121
122 if res.status() != 201 {
123 let err_msg = res.text().await?;
124 eyre::bail!("Failed to register remote account: {err_msg}")
125 }
126
127 let res_json: Value = res.json().await?;
128 let remote_api_key = res_json["api_key"].as_str().unwrap().to_string();
129
130 let config_path = self.path.clone();
131
132 let settings = Self {
133 api_key: Some(remote_api_key),
134 ..self
135 };
136
137 settings.save(Some(&config_path))?;
138
139 Ok(())
140 }
141
142 async fn delete_account(&self, host: Option<&str>) -> Result<()> {
143 if self.api_key.is_none() {
144 eyre::bail!("Remote account is not registered.")
145 }
146
147 let client = Client::new();
148 let host = host.unwrap_or(API_HOST);
149
150 let res = client
151 .delete(format!("{}/account", host))
152 .header("X-API-KEY", self.api_key.clone().unwrap())
153 .send()
154 .await?;
155
156 if !res.status().is_success() {
157 let err_msg = res.text().await?;
158 eyre::bail!("Failed to delete remote account: {err_msg}")
159 }
160
161 Ok(())
162 }
163 async fn sync_account(&self, host: Option<&str>) -> Result<()> {
164 if self.api_key.is_none() {
165 eyre::bail!("Remote account is not registered!")
166 }
167
168 _ = self.get_slack_notifier().ok_or_eyre(
169 "You must configure a Slack notifier to update a remote account:
170https://github.com/pawurb/yt-sub-rs#notifiers-configuration",
171 )?;
172 let client = Client::new();
173 let host = host.unwrap_or(API_HOST);
174
175 let res = client
176 .put(format!("{}/account", host))
177 .json(&self)
178 .send()
179 .await?;
180
181 if res.status() != 200 {
182 let err_msg = res.text().await?;
183 eyre::bail!("Failed to update remote account: {err_msg}")
184 }
185
186 Ok(())
187 }
188}
189
190fn last_run_at_path() -> PathBuf {
191 home_dir().unwrap().join(".yt-sub-rs/last_run_at.txt")
192}
193
194#[cfg(test)]
195mod tests {
196 use mockito::Server;
197 use yt_sub_core::{
198 channel::Channel,
199 notifier::{Notifier, SlackConfig},
200 };
201
202 use crate::test_helpers::{test_config_path, Cleaner};
203
204 use super::*;
205
206 #[tokio::test]
207 async fn test_init_config_file() -> Result<()> {
208 let path = test_config_path();
209 let _cl = Cleaner { path: path.clone() };
210
211 let settings = UserSettings::init(Some(&path))?;
212 assert_eq!(settings, UserSettings::default(path));
213
214 Ok(())
215 }
216
217 #[tokio::test]
218 #[should_panic]
219 async fn test_init_twice() {
220 let path = test_config_path();
221 let _cl = Cleaner { path: path.clone() };
222
223 UserSettings::init(Some(&path)).expect("1st should not panic");
224 UserSettings::init(Some(&path)).expect("2nd should panic");
225 }
226
227 #[tokio::test]
228 async fn test_sync_settings_file() -> Result<()> {
229 let path = test_config_path();
230 let _cl = Cleaner { path: path.clone() };
231 let settings = UserSettings::init(Some(&path))?;
232
233 assert_eq!(settings.channels.len(), 0);
234
235 let channel = Channel {
236 channel_id: "CHANNEL_ID".to_string(),
237 handle: "CHANNEL_HANDLE".to_string(),
238 description: "CHANNEL_DESC".to_string(),
239 };
240
241 let mut channels = settings.channels.clone();
242 channels.extend(vec![channel]);
243
244 let settings = UserSettings {
245 channels,
246 ..settings
247 };
248
249 settings.save(Some(&path))?;
250
251 let updated = UserSettings::read(Some(&path))?;
252 assert_eq!(updated.channels.len(), 1);
253
254 Ok(())
255 }
256
257 #[tokio::test]
258 async fn test_delete_account_ok() -> Result<()> {
259 let mut server = Server::new_async().await;
260 let host = server.host_with_port();
261 let host = format!("http://{}", host);
262
263 let path = test_config_path();
264 let _cl = Cleaner { path: path.clone() };
265
266 let settings = build_settings(
267 Some(path.clone()),
268 Some("https://slack.com/XXX".to_string()),
269 None,
270 );
271
272 let settings = UserSettings {
273 api_key: Some("test".to_string()),
274 ..settings
275 };
276
277 let m = server
278 .mock("DELETE", "/account")
279 .match_header("X-API-KEY", "test")
280 .with_body("OK")
281 .create_async()
282 .await;
283
284 settings.delete_account(Some(&host)).await?;
285 m.assert_async().await;
286
287 Ok(())
288 }
289
290 #[tokio::test]
291 async fn test_create_account_ok() -> Result<()> {
292 let mut server = Server::new_async().await;
293 let host = server.host_with_port();
294 let host = format!("http://{}", host);
295
296 let path = test_config_path();
297 let _cl = Cleaner { path: path.clone() };
298
299 let settings = build_settings(
300 Some(path.clone()),
301 Some("https://slack.com/XXX".to_string()),
302 None,
303 );
304
305 let m = server
306 .mock("POST", "/account")
307 .with_body(r#"{"api_key": "REMOTE_API_KEY" }"#)
308 .with_status(201)
309 .create_async()
310 .await;
311
312 settings.create_account(Some(&host)).await?;
313 m.assert_async().await;
314
315 let settings = UserSettings::read(Some(&path))?;
316
317 assert_eq!(settings.api_key, Some("REMOTE_API_KEY".to_string()));
318
319 Ok(())
320 }
321
322 #[tokio::test]
323 async fn test_create_account_invalid() -> Result<()> {
324 let mut server = Server::new_async().await;
325 let host = server.host_with_port();
326 let host = format!("http://{}", host);
327 let m = server
328 .mock("POST", "/account")
329 .with_body(r#"Registration failed"#)
330 .with_status(400)
331 .create_async()
332 .await;
333
334 let settings = build_settings(None, Some("https://slack.com/XXX".to_string()), None);
335
336 if let Err(e) = settings.create_account(Some(&host)).await {
337 assert!(e.to_string().contains("Registration failed"));
338 } else {
339 panic!("Expected an error!");
340 }
341
342 m.assert_async().await;
343 Ok(())
344 }
345
346 #[tokio::test]
347 async fn test_sync_account_ok() -> Result<()> {
348 let mut server = Server::new_async().await;
349 let host = server.host_with_port();
350 let host = format!("http://{}", host);
351 let m = server
352 .mock("PUT", "/account")
353 .with_status(200)
354 .create_async()
355 .await;
356
357 let settings = build_settings(
358 None,
359 Some("https://slack.com/XXX".to_string()),
360 Some("test".into()),
361 );
362
363 settings.sync_account(Some(&host)).await?;
364
365 m.assert_async().await;
366 Ok(())
367 }
368
369 fn build_settings(
370 path: Option<PathBuf>,
371 slack_webhook: Option<String>,
372 api_key: Option<String>,
373 ) -> UserSettings {
374 let path = path.unwrap_or(test_config_path());
375 let mut settings = UserSettings::default(path);
376 settings.api_key = api_key;
377
378 if let Some(webhook) = slack_webhook {
379 let notifier = Notifier::Slack(SlackConfig {
380 webhook_url: webhook,
381 channel: "test".to_string(),
382 });
383
384 settings.notifiers = vec![notifier];
385 };
386
387 settings
388 }
389}