From b1c1b4f551be7523b0b1f03e7f4e02a519fbe094 Mon Sep 17 00:00:00 2001 From: Adrian Wannenmacher Date: Sat, 30 Sep 2023 02:35:18 +0200 Subject: [PATCH] very basic list management --- Cargo.toml | 3 ++ src/cli.rs | 11 ------ src/cli/list.rs | 33 ++++++++++++++++ src/cli/mod.rs | 22 +++++++++++ src/data.rs | 28 +++++++++++++ src/main.rs | 67 +++++++++++++++++++++++++++++-- src/store.rs | 103 ++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 253 insertions(+), 14 deletions(-) delete mode 100644 src/cli.rs create mode 100644 src/cli/list.rs create mode 100644 src/cli/mod.rs create mode 100644 src/data.rs create mode 100644 src/store.rs diff --git a/Cargo.toml b/Cargo.toml index 21a54f2..c0c2d79 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,10 @@ keywords = ["download", "http", "https"] [dependencies] anyhow = "1.0.75" clap = { version = "4.4.6", features = ["env", "derive", "wrap_help", "cargo", "unicode"] } +directories = "5.0.1" +fs4 = "0.6.6" human-panic = "1.2.1" +rand = "0.8.5" reqwest = { version = "0.11.20", features = ["brotli", "deflate", "gzip"] } serde = { version = "1.0.188", features = ["derive"] } serde_json = "1.0.107" diff --git a/src/cli.rs b/src/cli.rs deleted file mode 100644 index 71baa37..0000000 --- a/src/cli.rs +++ /dev/null @@ -1,11 +0,0 @@ -use clap::{Parser, Subcommand}; - -#[derive(Debug, Parser)] -#[clap(author, version, about)] -pub struct CLI { - #[clap(subcommand)] - command: Command, -} - -#[derive(Debug, Subcommand)] -pub enum Command {} diff --git a/src/cli/list.rs b/src/cli/list.rs new file mode 100644 index 0000000..9d59bc3 --- /dev/null +++ b/src/cli/list.rs @@ -0,0 +1,33 @@ +use clap::Subcommand; + +#[derive(Debug, Subcommand)] +pub enum ListCommand { + /// Create a new download list. + #[clap(visible_alias = "new", visible_alias = "c")] + Create { + /// A unique name, which will be used to refer to the list. + name: String, + /// A short description of the lists purpose. + #[clap(long, short, default_value = "", hide_default_value = true)] + description: String, + }, + /// Delete a download list. + #[clap(visible_alias = "rm", visible_alias = "d")] + Delete { + /// The name of the list to remove. + name: String, + }, + /// Update an existing list. + /// + /// Only values specified for this command are changed. + #[clap(visible_alias = "u")] + Update { + /// The name of the list to change. + name: String, + /// A short description of the lists purpose. + /// + /// Set this to an empty string (e.g. pass "" as value) to remove the current description. + #[clap(long, short)] + description: Option, + }, +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..64dd911 --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,22 @@ +use clap::{Parser, Subcommand}; + +mod list; + +pub use list::*; + +#[derive(Debug, Parser)] +#[clap(author, version, about)] +pub struct CLI { + #[clap(subcommand)] + pub command: Command, +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// Manage entire download lists. + #[clap(visible_alias = "l")] + List { + #[clap(subcommand)] + command: ListCommand, + }, +} diff --git a/src/data.rs b/src/data.rs new file mode 100644 index 0000000..03053f0 --- /dev/null +++ b/src/data.rs @@ -0,0 +1,28 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize)] +pub struct DownloadList { + name: String, + description: String, +} + +impl DownloadList { + pub fn new(name: String) -> Self { + Self { + name, + description: String::new(), + } + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn description(&self) -> &str { + &self.description + } + + pub fn set_description(&mut self, description: String) { + self.description = description; + } +} diff --git a/src/main.rs b/src/main.rs index b7469ec..fed175c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,72 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use anyhow::{ensure, Context}; use clap::Parser; +use directories::ProjectDirs; + +use crate::store::DownloadListStore; mod cli; +mod data; +mod store; -#[tokio::main] -async fn main() -> anyhow::Result<()> { +fn main() -> anyhow::Result<()> { human_panic::setup_panic!(); let cli = cli::CLI::parse(); + let (state, data) = prepare_directories()?; - Ok(()) + match cli.command { + cli::Command::List { command } => match command { + cli::ListCommand::Update { name, description } => { + let mut list = DownloadListStore::load(&name, &data)?; + + if let Some(description) = description { + list.set_description(description); + } + + list.save() + } + cli::ListCommand::Create { name, description } => { + let mut list = DownloadListStore::new(name, &data)?; + + list.set_description(description); + + list.save() + } + cli::ListCommand::Delete { name } => DownloadListStore::delete(&name, &data), + }, + } +} + +fn prepare_directories() -> anyhow::Result<(PathBuf, PathBuf)> { + let dirs = ProjectDirs::from("dev", "TFLD", "listload") + .context("failed to discover project directories")?; + + let state = dirs.state_dir().unwrap_or_else(|| dirs.data_local_dir()); + prepare_directory(state)?; + + let data = dirs.data_dir(); + prepare_directory(data)?; + + Ok((state.to_path_buf(), data.to_path_buf())) +} + +fn prepare_directory(path: &Path) -> anyhow::Result<()> { + if path.is_dir() { + return Ok(()); + } + + ensure!( + !path.exists(), + "required directory is not a directory: {}", + path.display() + ); + + fs::create_dir_all(path).context(format!( + "failed to create required directory: {}", + path.display() + )) } diff --git a/src/store.rs b/src/store.rs new file mode 100644 index 0000000..bb7ea9a --- /dev/null +++ b/src/store.rs @@ -0,0 +1,103 @@ +use std::{ + fs::{self, File, OpenOptions}, + io::{BufReader, BufWriter, Seek}, + ops::{Deref, DerefMut}, + path::Path, +}; + +use anyhow::{ensure, Context}; +use fs4::FileExt; + +use crate::data::DownloadList; + +#[derive(Debug)] +pub struct DownloadListStore { + list: DownloadList, + file: File, +} + +impl DownloadListStore { + pub fn new(name: String, directory: &Path) -> anyhow::Result { + let path = Self::path_in_directory(directory, &name); + + let file = OpenOptions::new() + .create_new(true) + .read(true) + .write(true) + .open(&path) + .context("failed to create list file")?; + + file.lock_exclusive() + .context("failed to acquire list file lock")?; + + Ok(Self { + list: DownloadList::new(name), + file, + }) + } + + pub fn load(name: &str, directory: &Path) -> anyhow::Result { + let path = Self::path_in_directory(directory, name); + + let file = OpenOptions::new() + .create(false) + .read(true) + .write(true) + .open(&path) + .context("failed to open list file")?; + + file.lock_exclusive() + .context("failed to acquire list file lock")?; + + let reader = BufReader::new(&file); + let list: DownloadList = + serde_json::from_reader(reader).context("failed to deserialize list")?; + + ensure!( + name == list.name(), + "list name mismatch: found {name} instead of {}", + list.name() + ); + + Ok(Self { list, file }) + } + + pub fn save(&mut self) -> anyhow::Result<()> { + self.file.set_len(0).context("failed to clear list file")?; + self.file + .seek(std::io::SeekFrom::Start(0)) + .context("failed to find list file start")?; + + let writer = BufWriter::new(&self.file); + + if cfg!(debug_assertions) { + serde_json::to_writer_pretty(writer, &self.list) + } else { + serde_json::to_writer(writer, &self.list) + } + .context("failed to write list file") + } + + pub fn delete(name: &str, directory: &Path) -> anyhow::Result<()> { + fs::remove_file(Self::path_in_directory(directory, name)) + .context("failed to remove list file") + } + + fn path_in_directory(directory: &Path, name: &str) -> std::path::PathBuf { + directory.join(name).with_extension("json") + } +} + +impl Deref for DownloadListStore { + type Target = DownloadList; + + fn deref(&self) -> &Self::Target { + &self.list + } +} + +impl DerefMut for DownloadListStore { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.list + } +}