mirror of
https://github.com/TeFiLeDo/listload.git
synced 2024-11-23 20:56:17 +01:00
list creation and selection
This commit is contained in:
parent
8276d92741
commit
74cbd9d6f8
@ -11,6 +11,8 @@ anyhow = "1.0.71"
|
|||||||
clap = { version = "4.3.8", features = ["cargo", "derive", "env", "wrap_help"] }
|
clap = { version = "4.3.8", features = ["cargo", "derive", "env", "wrap_help"] }
|
||||||
directories = "5.0.1"
|
directories = "5.0.1"
|
||||||
downloader = { version = "0.2.7", default-features = false, features = ["rustls-tls"] }
|
downloader = { version = "0.2.7", default-features = false, features = ["rustls-tls"] }
|
||||||
|
regex = "1.8.4"
|
||||||
serde = { version = "1.0.164", features = ["derive"] }
|
serde = { version = "1.0.164", features = ["derive"] }
|
||||||
serde_json = "1.0.99"
|
serde_json = "1.0.99"
|
||||||
toml = "0.7.5"
|
toml = "0.7.5"
|
||||||
|
url = { version = "2.4.0", features = ["serde"] }
|
||||||
|
93
src/main.rs
93
src/main.rs
@ -1,13 +1,16 @@
|
|||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::{ensure, Context};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use config::Config;
|
use config::Config;
|
||||||
use directories::{ProjectDirs, UserDirs};
|
use directories::{ProjectDirs, UserDirs};
|
||||||
use persistent_state::PersistentState;
|
use persistent_state::PersistentState;
|
||||||
|
use target_list::TargetList;
|
||||||
|
|
||||||
mod config;
|
mod config;
|
||||||
mod persistent_state;
|
mod persistent_state;
|
||||||
|
mod target;
|
||||||
|
mod target_list;
|
||||||
|
|
||||||
static USER_DIRS: OnceLock<UserDirs> = OnceLock::new();
|
static USER_DIRS: OnceLock<UserDirs> = OnceLock::new();
|
||||||
static PROJ_DIRS: OnceLock<ProjectDirs> = OnceLock::new();
|
static PROJ_DIRS: OnceLock<ProjectDirs> = OnceLock::new();
|
||||||
@ -44,12 +47,58 @@ fn main() -> anyhow::Result<()> {
|
|||||||
CMD::Config => {
|
CMD::Config => {
|
||||||
println!("{cfg}");
|
println!("{cfg}");
|
||||||
}
|
}
|
||||||
CMD::License => {
|
|
||||||
panic!("license passed first check");
|
|
||||||
}
|
|
||||||
CMD::PersistentState => {
|
CMD::PersistentState => {
|
||||||
println!("{persistent}");
|
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()
|
persistent.save_to_default_file()
|
||||||
@ -70,4 +119,40 @@ enum CMD {
|
|||||||
License,
|
License,
|
||||||
/// Print the current persistent state.
|
/// Print the current persistent state.
|
||||||
PersistentState,
|
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<String>,
|
||||||
|
},
|
||||||
|
/// Select an existing list.
|
||||||
|
Select {
|
||||||
|
/// The name of the list.
|
||||||
|
///
|
||||||
|
/// The special value `none` deselects all lists.
|
||||||
|
#[clap(group = "target")]
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
@ -8,13 +8,29 @@ use std::{
|
|||||||
use anyhow::{bail, ensure, Context};
|
use anyhow::{bail, ensure, Context};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::PROJ_DIRS;
|
use crate::{target_list::TargetList, PROJ_DIRS};
|
||||||
|
|
||||||
const PERSISTENT_FILE: &str = "persistent_state.json";
|
const PERSISTENT_FILE: &str = "persistent_state.json";
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize, Serialize)]
|
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||||
#[serde(deny_unknown_fields, default)]
|
#[serde(deny_unknown_fields, default)]
|
||||||
pub struct PersistentState {}
|
pub struct PersistentState {
|
||||||
|
list: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
impl PersistentState {
|
||||||
pub fn read_from_default_file() -> anyhow::Result<Self> {
|
pub fn read_from_default_file() -> anyhow::Result<Self> {
|
||||||
@ -79,6 +95,10 @@ impl PersistentState {
|
|||||||
|
|
||||||
impl Display for PersistentState {
|
impl Display for PersistentState {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
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")
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
63
src/target.rs
Normal file
63
src/target.rs
Normal file
@ -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<Url>,
|
||||||
|
file: PathBuf,
|
||||||
|
comment: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Target {
|
||||||
|
pub fn new(urls: Vec<Url>, file: &Path, comment: Option<&str>) -> anyhow::Result<Self> {
|
||||||
|
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<Download> 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::<Vec<_>>()
|
||||||
|
.as_ref(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
.file_name(&self.file)
|
||||||
|
}
|
||||||
|
}
|
129
src/target_list.rs
Normal file
129
src/target_list.rs
Normal file
@ -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<Self> {
|
||||||
|
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<Self> {
|
||||||
|
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> {
|
||||||
|
Self::load_in(name, &Self::list_dir())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_in(name: &str, directory: &Path) -> anyhow::Result<Self> {
|
||||||
|
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<String>,
|
||||||
|
targets: Vec<Target>,
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user