Skip to main content

lastfm_client/
file_handler.rs

1use chrono::Local;
2use serde::Serialize;
3use std::collections::HashMap;
4use std::fs::{self, File, OpenOptions};
5use std::io::{BufRead, BufReader, Result, Write as _};
6
7#[cfg(feature = "sqlite")]
8use rusqlite::{Connection as SqliteConnection, OpenFlags};
9
10use crate::types::TrackPlayInfo;
11
12/// File format options for saving track data
13#[derive(Debug)]
14#[non_exhaustive]
15pub enum FileFormat {
16    /// Save as JSON format with pretty printing
17    Json,
18    /// Save as CSV format with headers
19    Csv,
20    /// Save as NDJSON (Newline Delimited JSON) - one compact JSON object per line
21    Ndjson,
22}
23
24/// Handler for file I/O operations (JSON and CSV)
25#[derive(Debug)]
26#[non_exhaustive]
27pub struct FileHandler;
28
29impl FileHandler {
30    /// Save data to a file in the data directory.
31    ///
32    /// Files are saved to the `data/` directory (created if it doesn't exist) with a timestamp in the filename.
33    ///
34    /// # Arguments
35    /// * `data` - Data to save (must implement Serialize)
36    /// * `format` - File format to save as (`FileFormat::Json` for JSON or `FileFormat::Csv` for CSV)
37    /// * `filename_prefix` - Prefix for the filename. The final filename will be `{prefix}_{timestamp}.{extension}`
38    ///
39    /// # Errors
40    /// * `std::io::Error` - If the file cannot be opened or written to, or if the data directory cannot be created
41    /// * `serde_json::Error` - If the JSON cannot be serialized
42    ///
43    /// # Returns
44    /// * `Result<String>` - Full path to the saved file (e.g., `data/recent_tracks_20240101_120000.json`)
45    pub fn save<T: Serialize>(
46        data: &[T],
47        format: &FileFormat,
48        filename_prefix: &str,
49    ) -> Result<String> {
50        // Create data directory if it doesn't exist
51        fs::create_dir_all("data")?;
52
53        // Generate timestamp
54        let timestamp = Local::now().format("%Y%m%d_%H%M%S");
55
56        // Create filename with timestamp
57        let filename = format!(
58            "data/{}_{}.{}",
59            filename_prefix,
60            timestamp,
61            match format {
62                FileFormat::Json => "json",
63                FileFormat::Csv => "csv",
64                FileFormat::Ndjson => "ndjson",
65            }
66        );
67
68        match format {
69            FileFormat::Json => {
70                // Special case: if T is a HashMap with track info
71                if std::any::type_name::<T>()
72                    == std::any::type_name::<HashMap<String, TrackPlayInfo>>()
73                    && let Some(single_item) = data.first()
74                {
75                    Self::save_single(single_item, &filename)?;
76                    return Ok(filename);
77                }
78                Self::save_as_json(data, &filename)
79            }
80            FileFormat::Csv => crate::csv_export::save_as_csv_dispatch(data, &filename),
81            FileFormat::Ndjson => Self::save_as_ndjson(data, &filename),
82        }?;
83
84        Ok(filename)
85    }
86
87    /// Save data to a JSON file.
88    ///
89    /// # Arguments
90    /// * `data` - Data to save
91    /// * `filename` - Filename to save as
92    fn save_as_json<T: Serialize>(data: &[T], filename: &str) -> Result<()> {
93        let json = serde_json::to_string_pretty(data)?;
94        let mut file = File::create(filename)?;
95
96        file.write_all(json.as_bytes())?;
97
98        Ok(())
99    }
100
101    /// Save data to an NDJSON file - one compact JSON object per line.
102    ///
103    /// # Arguments
104    /// * `data` - Data to save
105    /// * `filename` - Filename to save as
106    fn save_as_ndjson<T: Serialize>(data: &[T], filename: &str) -> Result<()> {
107        let mut file = File::create(filename)?;
108        for item in data {
109            let line = serde_json::to_string(item)?;
110            file.write_all(line.as_bytes())?;
111            file.write_all(b"\n")?;
112        }
113        Ok(())
114    }
115
116    /// Append items to an existing NDJSON file as new lines.
117    ///
118    /// # Arguments
119    /// * `data` - Data to append
120    /// * `file_path` - Path to the target file
121    fn append_ndjson_lines<T: Serialize>(data: &[T], file_path: &str) -> Result<()> {
122        let mut file = OpenOptions::new().append(true).open(file_path)?;
123        for item in data {
124            let line = serde_json::to_string(item)?;
125            file.write_all(line.as_bytes())?;
126            file.write_all(b"\n")?;
127        }
128        Ok(())
129    }
130
131    /// Load existing NDJSON data from a file - one item per line.
132    ///
133    /// # Arguments
134    /// * `file_path` - Path to the NDJSON file to read
135    ///
136    /// # Errors
137    /// * `std::io::Error` - If the file cannot be opened
138    /// * `serde_json::Error` - If a line cannot be deserialized into `T`
139    pub fn load_ndjson<T: serde::de::DeserializeOwned>(file_path: &str) -> Result<Vec<T>> {
140        let file = File::open(file_path)?;
141        let reader = BufReader::new(file);
142        let mut items = Vec::new();
143        for line in reader.lines() {
144            let line = line?;
145            if line.is_empty() {
146                continue;
147            }
148            let item: T = serde_json::from_str(&line)?;
149            items.push(item);
150        }
151        Ok(items)
152    }
153
154    /// Append new items to an existing NDJSON file, or create it if it does not exist.
155    ///
156    /// # Arguments
157    /// * `new_data` - New items to append
158    /// * `file_path` - Path to the target NDJSON file
159    ///
160    /// # Errors
161    /// * `std::io::Error` - If the file cannot be opened or written
162    pub fn append_or_create_ndjson<T: Serialize>(new_data: &[T], file_path: &str) -> Result<()> {
163        if std::path::Path::new(file_path).exists() {
164            Self::append_ndjson_lines(new_data, file_path)
165        } else {
166            if let Some(parent) = std::path::Path::new(file_path).parent() {
167                fs::create_dir_all(parent)?;
168            }
169            Self::save_as_ndjson(new_data, file_path)
170        }
171    }
172
173    /// Save a single item to a JSON file
174    ///
175    /// # Errors
176    /// * `std::io::Error` - If there was an error reading or writing the file
177    /// * `serde_json::Error` - If there was an error serializing the data
178    ///
179    /// # Arguments
180    /// * `data` - Data to save
181    /// * `filename` - Filename to save as
182    pub fn save_single<T: Serialize>(data: &T, filename: &str) -> Result<()> {
183        let json = serde_json::to_string_pretty(data)?;
184        let mut file = File::create(filename)?;
185        file.write_all(json.as_bytes())?;
186        Ok(())
187    }
188
189    /// Load existing JSON data from a file.
190    ///
191    /// # Arguments
192    /// * `file_path` - Path to the JSON file to read
193    ///
194    /// # Errors
195    /// * `std::io::Error` - If the file cannot be opened
196    /// * `serde_json::Error` - If the JSON cannot be deserialized into `Vec<T>`
197    pub fn load<T: serde::de::DeserializeOwned>(file_path: &str) -> Result<Vec<T>> {
198        let file = File::open(file_path)?;
199        let data: Vec<T> = serde_json::from_reader(file)?;
200
201        Ok(data)
202    }
203
204    /// Return the path of the sidecar metadata file for `file_path`.
205    ///
206    /// The sidecar stores the latest known Unix timestamp so subsequent update calls do not
207    /// need to deserialize the full data file.
208    #[must_use]
209    pub fn sidecar_path(file_path: &str) -> String {
210        format!("{file_path}.meta")
211    }
212
213    /// Read the latest timestamp from a sidecar metadata file.
214    ///
215    /// Returns `None` if the sidecar does not exist or cannot be parsed.
216    #[must_use]
217    pub fn read_sidecar_timestamp(file_path: &str) -> Option<u32> {
218        fs::read_to_string(Self::sidecar_path(file_path))
219            .ok()
220            .and_then(|s| s.trim().parse().ok())
221    }
222
223    /// Write a timestamp to the sidecar metadata file associated with `file_path`.
224    ///
225    /// # Errors
226    /// * `std::io::Error` - If the sidecar file cannot be written
227    pub fn write_sidecar_timestamp(file_path: &str, timestamp: u32) -> Result<()> {
228        fs::write(Self::sidecar_path(file_path), timestamp.to_string())
229    }
230
231    /// Append new items to an existing CSV file, or create it with headers if it does not exist.
232    ///
233    /// When appending to an existing file the header row is omitted so it is not duplicated.
234    ///
235    /// # Arguments
236    /// * `new_data` - New items to append
237    /// * `file_path` - Path to the target CSV file
238    ///
239    /// # Errors
240    /// * `std::io::Error` - If the file cannot be opened or written
241    /// * `csv::Error` - If serialization fails
242    pub fn append_or_create_csv<T: Serialize>(new_data: &[T], file_path: &str) -> Result<()> {
243        if std::path::Path::new(file_path).exists() {
244            crate::csv_export::append_csv_rows_dispatch(new_data, file_path)?;
245        } else {
246            if let Some(parent) = std::path::Path::new(file_path).parent() {
247                fs::create_dir_all(parent)?;
248            }
249            crate::csv_export::save_as_csv_dispatch(new_data, file_path)?;
250        }
251        Ok(())
252    }
253
254    /// Save data to a new `SQLite` database file.
255    ///
256    /// Creates a timestamped `.db` file under `data/`. All rows are inserted in a single
257    /// transaction for performance.
258    ///
259    /// # Arguments
260    /// * `data` - Data to save (must implement `SqliteExportable`)
261    /// * `filename_prefix` - Prefix for the generated filename
262    ///
263    /// # Errors
264    /// * `std::io::Error` - If the data directory cannot be created or the database cannot be opened or written
265    ///
266    /// # Returns
267    /// * `Result<String>` - Full path to the saved database file (e.g., `data/recent_tracks_20240101_120000.db`)
268    #[cfg(feature = "sqlite")]
269    pub fn save_sqlite<T: crate::sqlite::SqliteExportable>(
270        data: &[T],
271        filename_prefix: &str,
272    ) -> Result<String> {
273        fs::create_dir_all("data")?;
274        let timestamp = Local::now().format("%Y%m%d_%H%M%S");
275        let filename = format!("data/{filename_prefix}_{timestamp}.db");
276
277        let mut conn =
278            SqliteConnection::open(&filename).map_err(|e| std::io::Error::other(e.to_string()))?;
279
280        conn.execute_batch(T::create_table_sql())
281            .map_err(|e| std::io::Error::other(e.to_string()))?;
282
283        let tx = conn
284            .transaction()
285            .map_err(|e| std::io::Error::other(e.to_string()))?;
286
287        {
288            let mut stmt = tx
289                .prepare(T::insert_sql())
290                .map_err(|e| std::io::Error::other(e.to_string()))?;
291
292            for item in data {
293                item.bind_and_execute(&mut stmt)
294                    .map_err(|e| std::io::Error::other(e.to_string()))?;
295            }
296        }
297
298        tx.commit()
299            .map_err(|e| std::io::Error::other(e.to_string()))?;
300
301        Ok(filename)
302    }
303
304    /// Append new items to an existing `SQLite` database, or create it if it does not exist.
305    ///
306    /// Opens the database at `file_path`, creates the table if it does not already exist,
307    /// and inserts all rows in a single transaction.
308    ///
309    /// # Arguments
310    /// * `data` - Data to insert
311    /// * `file_path` - Path to the target `.db` file
312    ///
313    /// # Errors
314    /// * `std::io::Error` - If the file cannot be opened or the data cannot be written
315    #[cfg(feature = "sqlite")]
316    pub fn append_or_create_sqlite<T: crate::sqlite::SqliteExportable>(
317        data: &[T],
318        file_path: &str,
319    ) -> Result<()> {
320        if let Some(parent) = std::path::Path::new(file_path).parent()
321            && !parent.as_os_str().is_empty()
322        {
323            fs::create_dir_all(parent)?;
324        }
325
326        let mut conn =
327            SqliteConnection::open(file_path).map_err(|e| std::io::Error::other(e.to_string()))?;
328
329        conn.execute_batch(T::create_table_sql())
330            .map_err(|e| std::io::Error::other(e.to_string()))?;
331
332        let tx = conn
333            .transaction()
334            .map_err(|e| std::io::Error::other(e.to_string()))?;
335
336        {
337            let mut stmt = tx
338                .prepare(T::insert_sql())
339                .map_err(|e| std::io::Error::other(e.to_string()))?;
340
341            for item in data {
342                item.bind_and_execute(&mut stmt)
343                    .map_err(|e| std::io::Error::other(e.to_string()))?;
344            }
345        }
346
347        tx.commit()
348            .map_err(|e| std::io::Error::other(e.to_string()))?;
349
350        Ok(())
351    }
352
353    /// Load all rows from a `SQLite` database into a [`crate::types::TrackList`].
354    ///
355    /// Opens the database at `file_path` and runs `T::select_sql()`, mapping
356    /// each row with `T::from_row`. The returned `TrackList<T>` supports all
357    /// analysis methods (`to_set()`, `top_artists()`, `by_date()`, etc.).
358    ///
359    /// Fields that are not persisted in the database schema (such as `image`,
360    /// `streamable`, and human-readable date strings) are reconstructed with
361    /// empty/default values. See [`crate::sqlite::SqliteLoadable`] for details.
362    ///
363    /// # Arguments
364    /// * `file_path` - Path to the `.db` file produced by `fetch_and_save_sqlite`
365    ///   or `fetch_and_update_sqlite`. Relative paths are resolved from the **process
366    ///   current working directory** (for `cargo run`, that is normally the package
367    ///   root where `Cargo.toml` lives, not `target/release/`).
368    ///
369    /// # Errors
370    /// * `std::io::Error` - If the database cannot be opened or the query fails
371    ///
372    /// # Example
373    ///
374    /// ```ignore
375    /// use lastfm_client::{file_handler::FileHandler, RecentTrack};
376    ///
377    /// let tracks = FileHandler::load_sqlite::<RecentTrack>("data/recent_tracks.db")?;
378    /// let top = tracks.to_set();        // TrackList<ScoredTrack>
379    /// let artists = tracks.top_artists(); // TrackList<ScoredArtist>
380    /// println!("Streak: {} day(s)", tracks.streak());
381    /// ```
382    #[cfg(feature = "sqlite")]
383    pub fn load_sqlite<T: crate::sqlite::SqliteLoadable>(
384        file_path: &str,
385    ) -> std::io::Result<crate::types::TrackList<T>> {
386        let path = std::path::Path::new(file_path);
387        if !path.is_file() {
388            let cwd = std::env::current_dir()
389                .map_or_else(|_| "<unavailable>".to_string(), |p| p.display().to_string());
390            return Err(std::io::Error::new(
391                std::io::ErrorKind::NotFound,
392                format!("SQLite database not found at {file_path:?} (resolved from cwd {cwd:?})"),
393            ));
394        }
395
396        let conn = SqliteConnection::open_with_flags(
397            path,
398            OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
399        )
400        .map_err(|e| std::io::Error::other(e.to_string()))?;
401
402        let mut stmt = conn
403            .prepare(T::select_sql())
404            .map_err(|e| std::io::Error::other(e.to_string()))?;
405
406        let rows = stmt
407            .query_map([], |row| T::from_row(row))
408            .map_err(|e| std::io::Error::other(e.to_string()))?;
409
410        let items: rusqlite::Result<Vec<T>> = rows.collect();
411        let items = items.map_err(|e| std::io::Error::other(e.to_string()))?;
412
413        Ok(crate::types::TrackList::from(items))
414    }
415
416    /// Query the maximum `date_uts` value stored in a `SQLite` table.
417    ///
418    /// Used by the update flow to determine the latest timestamp already present in the
419    /// database, so only newer records need to be fetched from the API.
420    ///
421    /// Returns `None` if the file does not exist, the table is empty, or the query fails.
422    ///
423    /// # Arguments
424    /// * `file_path` - Path to the `.db` file
425    /// * `table_name` - Name of the table to query
426    #[cfg(feature = "sqlite")]
427    #[must_use]
428    pub fn read_sqlite_max_timestamp(file_path: &str, table_name: &str) -> Option<u32> {
429        if !std::path::Path::new(file_path).exists() {
430            return None;
431        }
432        let conn = SqliteConnection::open(file_path).ok()?;
433        conn.query_row(
434            &format!("SELECT MAX(date_uts) FROM {table_name}"),
435            [],
436            |row| row.get::<_, Option<u32>>(0),
437        )
438        .ok()
439        .flatten()
440    }
441
442    /// Prepend new items to an existing JSON file, or create the file if it does not exist.
443    ///
444    /// New items are placed before existing items so the result remains sorted newest-first,
445    /// which matches the order returned by the Last.fm API.
446    ///
447    /// # Arguments
448    /// * `new_data` - New items to prepend
449    /// * `file_path` - Path to the target JSON file
450    ///
451    /// # Errors
452    /// * `std::io::Error` - If the file cannot be read or written
453    /// * `serde_json::Error` - If serialization or deserialization fails
454    pub fn prepend_json<T: Serialize + serde::de::DeserializeOwned + Clone>(
455        new_data: &[T],
456        file_path: &str,
457    ) -> Result<()> {
458        let existing: Vec<T> = if std::path::Path::new(file_path).exists() {
459            Self::load(file_path)?
460        } else {
461            // Ensure the parent directory exists before creating the file
462            if let Some(parent) = std::path::Path::new(file_path).parent() {
463                fs::create_dir_all(parent)?;
464            }
465            vec![]
466        };
467
468        let mut combined = new_data.to_vec();
469        combined.extend(existing);
470        Self::save_as_json(&combined, file_path)
471    }
472}