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}