Skip to main content

ani_lib/
history.rs

1use std::path::{Path, PathBuf};
2
3use crate::{AniError, Result};
4
5#[derive(Clone, Debug, PartialEq, Eq)]
6pub struct HistoryEntry {
7    pub episode: String,
8    pub show_id: String,
9    pub title: String,
10}
11
12#[derive(Clone, Debug)]
13pub struct HistoryStore {
14    path: PathBuf,
15}
16
17impl HistoryStore {
18    pub fn new(path: impl Into<PathBuf>) -> Self {
19        Self { path: path.into() }
20    }
21
22    pub fn platform_default() -> Result<Self> {
23        if let Some(path) = std::env::var_os("ANI_CLI_HIST_DIR") {
24            return Ok(Self::new(PathBuf::from(path).join("ani-hsts")));
25        }
26        let project = directories::ProjectDirs::from("org", "ani-cli", "ani-cli")
27            .ok_or_else(|| AniError::HistoryStateDirectory)?;
28        let directory = project
29            .state_dir()
30            .unwrap_or_else(|| project.data_local_dir());
31        Ok(Self::new(directory.join("ani-hsts")))
32    }
33
34    pub fn path(&self) -> &Path {
35        &self.path
36    }
37
38    pub async fn entries(&self) -> Result<Vec<HistoryEntry>> {
39        let text = match tokio::fs::read_to_string(&self.path).await {
40            Ok(value) => value,
41            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
42            Err(error) => return Err(error.into()),
43        };
44        Ok(text
45            .lines()
46            .filter_map(|line| {
47                let mut values = line.splitn(3, '\t');
48                Some(HistoryEntry {
49                    episode: values.next()?.into(),
50                    show_id: values.next()?.into(),
51                    title: values.next()?.into(),
52                })
53            })
54            .collect())
55    }
56
57    pub async fn update(&self, entry: HistoryEntry) -> Result<()> {
58        let mut entries = self.entries().await?;
59        entry_valid(&entry)?;
60        if let Some(existing) = entries
61            .iter_mut()
62            .find(|value| value.show_id == entry.show_id)
63        {
64            *existing = entry;
65        } else {
66            entries.push(entry);
67        }
68        self.write(&entries).await
69    }
70
71    pub async fn clear(&self) -> Result<()> {
72        self.write(&[]).await
73    }
74
75    async fn write(&self, entries: &[HistoryEntry]) -> Result<()> {
76        if let Some(parent) = self.path.parent() {
77            tokio::fs::create_dir_all(parent).await?;
78        }
79        let text = entries
80            .iter()
81            .map(|value| {
82                format!(
83                    "{}\t{}\t{}\n",
84                    value.episode,
85                    value.show_id,
86                    value.title.replace(['\t', '\n'], " ")
87                )
88            })
89            .collect::<String>();
90        let temporary = self.path.with_extension("new");
91        tokio::fs::write(&temporary, text).await?;
92        if tokio::fs::try_exists(&self.path).await? {
93            tokio::fs::remove_file(&self.path).await?;
94        }
95        tokio::fs::rename(temporary, &self.path).await?;
96        Ok(())
97    }
98}
99
100fn entry_valid(entry: &HistoryEntry) -> Result<()> {
101    if entry.episode.is_empty() || entry.show_id.is_empty() || entry.title.is_empty() {
102        Err(AniError::History(
103            "history entry contains an empty field".into(),
104        ))
105    } else {
106        Ok(())
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    #[tokio::test]
114    async fn reads_and_updates_legacy_format() {
115        let directory = tempfile::tempdir().unwrap();
116        let store = HistoryStore::new(directory.path().join("ani-hsts"));
117        store
118            .update(HistoryEntry {
119                episode: "1".into(),
120                show_id: "abc".into(),
121                title: "Anime".into(),
122            })
123            .await
124            .unwrap();
125        store
126            .update(HistoryEntry {
127                episode: "2".into(),
128                show_id: "abc".into(),
129                title: "Anime".into(),
130            })
131            .await
132            .unwrap();
133        assert_eq!(
134            store.entries().await.unwrap(),
135            vec![HistoryEntry {
136                episode: "2".into(),
137                show_id: "abc".into(),
138                title: "Anime".into()
139            }]
140        );
141    }
142}