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}