yt_sub/
user_settings_cli.rs

1use 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}