From 74cbd9d6f819c18a73f41e7b5e612b8d533eac7c Mon Sep 17 00:00:00 2001 From: Adrian Wannenmacher Date: Tue, 27 Jun 2023 21:04:51 +0200 Subject: [PATCH] list creation and selection --- Cargo.toml | 2 + src/main.rs | 93 +++++++++++++++++++++++++++-- src/persistent_state.rs | 26 +++++++- src/target.rs | 63 ++++++++++++++++++++ src/target_list.rs | 129 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 306 insertions(+), 7 deletions(-) create mode 100644 src/target.rs create mode 100644 src/target_list.rs diff --git a/Cargo.toml b/Cargo.toml index 091c61e..a0c3459 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,8 @@ anyhow = "1.0.71" clap = { version = "4.3.8", features = ["cargo", "derive", "env", "wrap_help"] } directories = "5.0.1" downloader = { version = "0.2.7", default-features = false, features = ["rustls-tls"] } +regex = "1.8.4" serde = { version = "1.0.164", features = ["derive"] } serde_json = "1.0.99" toml = "0.7.5" +url = { version = "2.4.0", features = ["serde"] } diff --git a/src/main.rs b/src/main.rs index 8dc6427..ce017c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,16 @@ use std::sync::OnceLock; -use anyhow::Context; +use anyhow::{ensure, Context}; use clap::{Parser, Subcommand}; use config::Config; use directories::{ProjectDirs, UserDirs}; use persistent_state::PersistentState; +use target_list::TargetList; mod config; mod persistent_state; +mod target; +mod target_list; static USER_DIRS: OnceLock = OnceLock::new(); static PROJ_DIRS: OnceLock = OnceLock::new(); @@ -44,12 +47,58 @@ fn main() -> anyhow::Result<()> { CMD::Config => { println!("{cfg}"); } - CMD::License => { - panic!("license passed first check"); - } CMD::PersistentState => { println!("{persistent}"); } + CMD::List { cmd } => 'list: { + if let ListCommand::Create { + name, + keep_current_active, + comment, + } = cmd + { + if TargetList::exists(&name) { + eprintln!("list already exists"); + } else { + TargetList::new(&name, comment.as_ref().map(|c| c.as_str())) + .context("failed to create target list")?; + } + + if !keep_current_active { + persistent.set_list(&name); + } + + break 'list; + } else if let ListCommand::Select { name } = cmd { + if name == "none" { + persistent.clear_list(); + } else if !TargetList::exists(&name) { + eprintln!("list doesn't exist"); + } else { + persistent.set_list(&name); + } + break 'list; + } + + let list = persistent.list().context("no list selected")?; + let mut list = TargetList::load(&list).context("failed to load list")?; + + match cmd { + ListCommand::Create { + name: _, + keep_current_active: _, + comment: _, + } + | ListCommand::Select { name: _ } => { + panic!("late list command"); + } + } + + list.save().context("failed to save list")?; + } + CMD::License => { + panic!("late command"); + } } persistent.save_to_default_file() @@ -70,4 +119,40 @@ enum CMD { License, /// Print the current persistent state. PersistentState, + /// Target list operations. + List { + #[clap(subcommand)] + cmd: ListCommand, + }, +} + +#[derive(Subcommand)] +enum ListCommand { + /// Create a new list. + Create { + /// The new lists name. + /// + /// The name must start with a lowercase letter (`a-z`). After that, it consists of at least + /// one lowercase letter (`a-z`) or number (`0-9`). It may also contain nonconsecutive + /// underscores (`_`), but must not end with one. The name must not be `none`. + /// + /// Valid examples: default, version15, my_4_funny_pictures + /// + /// Invalid examples: none, 14, _hi, hi_, h__i + name: String, + /// Don't activate the newly created list. + #[clap(long, short)] + keep_current_active: bool, + /// A comment to remember what a list is for. + #[clap(long, short)] + comment: Option, + }, + /// Select an existing list. + Select { + /// The name of the list. + /// + /// The special value `none` deselects all lists. + #[clap(group = "target")] + name: String, + }, } diff --git a/src/persistent_state.rs b/src/persistent_state.rs index 44f009c..d752841 100644 --- a/src/persistent_state.rs +++ b/src/persistent_state.rs @@ -8,13 +8,29 @@ use std::{ use anyhow::{bail, ensure, Context}; use serde::{Deserialize, Serialize}; -use crate::PROJ_DIRS; +use crate::{target_list::TargetList, PROJ_DIRS}; const PERSISTENT_FILE: &str = "persistent_state.json"; #[derive(Debug, Default, Deserialize, Serialize)] #[serde(deny_unknown_fields, default)] -pub struct PersistentState {} +pub struct PersistentState { + list: Option, +} + +impl PersistentState { + pub fn list(&self) -> Option<&str> { + self.list.as_ref().map(|l| l.as_str()) + } + + pub fn set_list(&mut self, list: &str) { + self.list = Some(list.to_string()); + } + + pub fn clear_list(&mut self) { + self.list = None; + } +} impl PersistentState { pub fn read_from_default_file() -> anyhow::Result { @@ -79,6 +95,10 @@ impl PersistentState { impl Display for PersistentState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "no persistent state yet") + write!( + f, + "current list: {}", + self.list.as_ref().map(AsRef::as_ref).unwrap_or("none") + ) } } diff --git a/src/target.rs b/src/target.rs new file mode 100644 index 0000000..f09a596 --- /dev/null +++ b/src/target.rs @@ -0,0 +1,63 @@ +use std::{ + fmt::Display, + path::{Path, PathBuf}, +}; + +use anyhow::ensure; +use downloader::Download; +use serde::{Deserialize, Serialize}; +use url::Url; + +#[derive(Debug, Deserialize, Serialize)] +pub struct Target { + urls: Vec, + file: PathBuf, + comment: Option, +} + +impl Target { + pub fn new(urls: Vec, file: &Path, comment: Option<&str>) -> anyhow::Result { + ensure!(!urls.is_empty(), "at least one url is required"); + ensure!( + file.is_file() || !file.exists(), + "file must be file or nonexistent" + ); + + Ok(Self { + urls, + file: file.to_path_buf(), + comment: comment.map(ToString::to_string), + }) + } +} + +impl Display for Target { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}{}", + self.comment + .clone() + .map(|c| format!("{c}: ")) + .unwrap_or_default(), + self.file.display() + ) + } +} + +impl Into for Target { + fn into(self) -> Download { + match self.urls.len() { + 0 => panic!("target without url"), + 1 => Download::new(self.urls[0].as_str()), + _ => Download::new_mirrored( + self.urls + .iter() + .map(|u| u.as_str()) + .collect::>() + .as_ref(), + ), + } + .file_name(&self.file) + } +} diff --git a/src/target_list.rs b/src/target_list.rs new file mode 100644 index 0000000..9253acc --- /dev/null +++ b/src/target_list.rs @@ -0,0 +1,129 @@ +use std::{ + fs::{create_dir_all, File, OpenOptions}, + io::{BufReader, BufWriter}, + path::{Path, PathBuf}, +}; + +use anyhow::{ensure, Context, Result}; +use regex::Regex; +use serde::{Deserialize, Serialize}; + +use crate::{target::Target, PROJ_DIRS}; + +#[derive(Debug)] +pub struct TargetList { + inner: InnerTargetList, + file: File, +} + +impl TargetList { + pub fn name(&self) -> &str { + &self.inner.name + } +} + +impl TargetList { + fn list_dir() -> PathBuf { + let dirs = PROJ_DIRS.get().expect("directories not initialized"); + let mut path = dirs.data_dir().to_path_buf(); + path.push("lists"); + path + } + + fn list_file(dir: &Path, name: &str) -> PathBuf { + let mut path = dir.to_path_buf(); + path.push(name); + path.set_extension("json"); + path + } + + pub fn exists(name: &str) -> bool { + Self::exists_in(name, &Self::list_dir()) + } + + pub fn exists_in(name: &str, directory: &Path) -> bool { + Self::list_file(directory, name).is_file() + } + + pub fn new(name: &str, comment: Option<&str>) -> anyhow::Result { + let dirs = PROJ_DIRS.get().expect("directories not initialized"); + + let dir = Self::list_dir(); + ensure!( + dir.is_dir() || !dir.exists(), + "list directory is neither a directory nor nonexistant" + ); + + if !dir.exists() { + create_dir_all(&dir).context("failed to create list directory")?; + } + + Self::new_in(name, comment, &dir) + } + + pub fn new_in(name: &str, comment: Option<&str>, directory: &Path) -> anyhow::Result { + ensure!(directory.is_dir(), "directory isn't a directory"); + + ensure!(name != "none", "name is \"none\""); + let name_regex = Regex::new("^[a-z](_?[a-z0-9])+$").expect("correct name regex"); + ensure!(name_regex.is_match(name), "name is forbidden"); + + let path = Self::list_file(directory, name); + ensure!(!path.is_file(), "target list file already exists"); + ensure!( + !path.exists(), + "target list file is neither a file nor nonexistent", + ); + + let file = File::create(path).context("failed to create target list file")?; + + let mut ret = Self { + file, + inner: InnerTargetList { + name: name.to_string(), + comment: comment.map(ToString::to_string), + targets: Vec::new(), + }, + }; + ret.save().context("failed to save no target list file")?; + + Ok(ret) + } + + pub fn load(name: &str) -> anyhow::Result { + Self::load_in(name, &Self::list_dir()) + } + + pub fn load_in(name: &str, directory: &Path) -> anyhow::Result { + ensure!(directory.is_dir(), "directory isn't a directory"); + + let path = Self::list_file(directory, name); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(false) + .append(false) + .truncate(false) + .open(path) + .context("failed to open target list file")?; + + let inner: InnerTargetList = serde_json::from_reader(BufReader::new(&file)) + .context("failed to parse target list file")?; + ensure!(inner.name == name, "list name missmatch"); + + Ok(Self { inner, file }) + } + + pub fn save(&mut self) -> anyhow::Result<()> { + self.file.set_len(0); + serde_json::to_writer(BufWriter::new(&self.file), &self.inner) + .context("failed to save target list") + } +} + +#[derive(Debug, Deserialize, Serialize)] +struct InnerTargetList { + name: String, + comment: Option, + targets: Vec, +}