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(AniError::HistoryStateDirectoryError)?;
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(AniError::io_error(error, "reading history file")),
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)
78                .await
79                .map_err(|error| AniError::io_error(error, "creating history directory"))?;
80        }
81        let text = entries
82            .iter()
83            .map(|value| {
84                format!(
85                    "{}\t{}\t{}\n",
86                    value.episode,
87                    value.show_id,
88                    value.title.replace(['\t', '\n'], " ")
89                )
90            })
91            .collect::<String>();
92        let temporary = self.path.with_extension("new");
93        tokio::fs::write(&temporary, text)
94            .await
95            .map_err(|error| AniError::io_error(error, "writing history file"))?;
96        if tokio::fs::try_exists(&self.path)
97            .await
98            .map_err(|error| AniError::io_error(error, "checking history file"))?
99        {
100            tokio::fs::remove_file(&self.path)
101                .await
102                .map_err(|error| AniError::io_error(error, "replacing history file"))?;
103        }
104        tokio::fs::rename(temporary, &self.path)
105            .await
106            .map_err(|error| AniError::io_error(error, "finalizing history file"))?;
107        Ok(())
108    }
109}
110
111fn entry_valid(entry: &HistoryEntry) -> Result<()> {
112    if entry.episode.is_empty() || entry.show_id.is_empty() || entry.title.is_empty() {
113        Err(AniError::InvalidHistoryEntry {
114            reason: "history entry contains an empty field".into(),
115        })
116    } else {
117        Ok(())
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    #[tokio::test]
125    async fn reads_and_updates_legacy_format() {
126        let directory = tempfile::tempdir().unwrap();
127        let store = HistoryStore::new(directory.path().join("ani-hsts"));
128        store
129            .update(HistoryEntry {
130                episode: "1".into(),
131                show_id: "abc".into(),
132                title: "Anime".into(),
133            })
134            .await
135            .unwrap();
136        store
137            .update(HistoryEntry {
138                episode: "2".into(),
139                show_id: "abc".into(),
140                title: "Anime".into(),
141            })
142            .await
143            .unwrap();
144        assert_eq!(
145            store.entries().await.unwrap(),
146            vec![HistoryEntry {
147                episode: "2".into(),
148                show_id: "abc".into(),
149                title: "Anime".into()
150            }]
151        );
152    }
153}