use crate::cli::display::{display_job, heading, line};
use crate::cli::settings;
use crate::cli::settings::Settings;
use crate::cli::urls;
use byte_unit::{Byte, UnitType};
use clap::Args;
use clap::{Parser, Subcommand};
use enum_iterator::all;
use log::{self, LevelFilter};
use packtrack::Result;
use packtrack::api::Filters;
use packtrack::api::Job;
use packtrack::api::{Context, track_urls};
use packtrack::cache::{Cache, JsonCache};
use packtrack::tracker::PackageStatus;
use packtrack::url_store::AnnotatedUrl;
use packtrack::utils::check_path_exists;
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Instant;
pub async fn main() -> Result<()> {
let args = Cli::parse();
let verbosity = match args.globals.verbosity.as_str() {
"0" | "off" => LevelFilter::Off,
"1" | "error" => LevelFilter::Error,
"2" | "warn" => LevelFilter::Warn,
"3" | "info" => LevelFilter::Info,
"4" | "debug" => LevelFilter::Debug,
"5" | "trace" => LevelFilter::Trace,
other => return Err(format!("Invalid verbosity: {other}").into()),
};
env_logger::Builder::new()
.filter(None, verbosity)
.init();
log::debug!("Verbosity {verbosity}");
let sets = settings::load()?;
let ctx = Context {
cache_seconds: args
.tracking
.cache_seconds
.unwrap_or(sets.cache_seconds.clone()),
use_cache: !args.tracking.no_cache,
filters: Filters {
url: args.tracking.url.clone(),
sender: args.tracking.sender.clone(),
recipient: args.tracking.recipient.clone(),
carrier: args.tracking.carrier.clone(),
},
default_postcode: args
.tracking
.postcode
.clone()
.or(sets.postcode.clone()),
preferred_language: args
.tracking
.language
.clone()
.or(sets.language.clone())
.unwrap_or(Context::default().preferred_language),
};
log::debug!("Cache seconds: {}", ctx.cache_seconds);
match args.subcommand {
None => track(&sets, &ctx, args.tracking).await?,
Some(Command::Url { command }) => {
handle_url_command(command, &sets).await?
}
Some(Command::Config { command }) => {
handle_config_command(command, sets)?
}
Some(Command::Cache { command }) => {
handle_cache_command(command, &sets).await?
}
}
Ok(())
}
async fn handle_cache_command(
command: CacheCommand,
settings: &Settings,
) -> Result<()> {
match command {
CacheCommand::Size => {
let cache = JsonCache::new()?;
let size = cache.size_bytes()?;
let human_readable =
Byte::from_u64(size).get_appropriate_unit(UnitType::Binary);
println!("{human_readable:#.1}");
}
CacheCommand::Prune { dry_run, args } => {
let urls_file = args
.urls_file
.as_ref()
.unwrap_or(&settings.urls_file);
log::info!("Using URLs file {urls_file:#?}");
let mut cache = JsonCache::new()?;
let cache_size_before = cache.size_bytes()?;
let keep: Vec<String> = urls::filter(&urls_file, None)?
.into_iter()
.map(|au| au.url)
.collect();
log::info!("Aiming to keep {} urls", keep.len());
for url in keep.iter() {
log::debug!("Keep {url}");
}
let removed_urls = cache.prune(&keep);
if dry_run {
println!("Would remove {} urls (dry run)", removed_urls.len());
for url in removed_urls {
log::debug!("Removed {url}");
}
} else {
cache.save().await?;
let cache_size_after = cache.size_bytes()?;
println!("Removed {} urls", removed_urls.len());
for url in &removed_urls {
log::debug!("Removed {url}");
}
if removed_urls.len() > 0 {
println!(
"Cache size reduced from {:#.1} to {:#.1}",
Byte::from_u64(cache_size_before)
.get_appropriate_unit(UnitType::Binary),
Byte::from_u64(cache_size_after)
.get_appropriate_unit(UnitType::Binary),
);
} else {
println!(
"Cache size is still {:#.1}",
Byte::from_u64(cache_size_before)
.get_appropriate_unit(UnitType::Binary),
)
}
}
}
}
Ok(())
}
async fn handle_url_command(
command: UrlCommand,
settings: &Settings,
) -> Result<()> {
let default_file = &settings.urls_file;
match command {
UrlCommand::Add {
url,
description,
args,
} => {
let file = args
.urls_file
.as_ref()
.unwrap_or(default_file);
let msg = format!("Added {url}");
let aurl = AnnotatedUrl::new(url, description);
match urls::add(file, aurl) {
Ok(()) => println!("{msg}"),
Err(err) => return Err(err),
}
}
UrlCommand::Remove { url, args } => {
let file = args
.urls_file
.as_ref()
.unwrap_or(default_file);
match urls::remove(file, url) {
Ok(removed) => {
println!("Removed urls:");
for url in removed {
println!("{url}");
}
}
Err(err) => return Err(err),
}
}
UrlCommand::List { query, args } => {
let file = args
.urls_file
.as_ref()
.unwrap_or(default_file);
let urls = urls::filter(file, query.as_deref())?;
for url in urls {
println!("{url}");
}
}
}
Ok(())
}
fn handle_config_command(command: ConfigCommand, sets: Settings) -> Result<()> {
match command {
ConfigCommand::List => settings::print()?,
ConfigCommand::Set { key, value } => {
let sets = sets.update(&key, value)?;
settings::save(&sets)?;
}
ConfigCommand::Reset => settings::reset()?,
}
Ok(())
}
#[derive(Parser)]
#[clap(args_conflicts_with_subcommands = true)]
#[command(version, about)]
struct Cli {
#[command(subcommand)]
subcommand: Option<Command>,
#[clap(flatten)]
tracking: TrackArgs,
#[clap(flatten)]
globals: GlobalArgs,
}
#[derive(Args)]
struct GlobalArgs {
#[arg(
short,
long,
global = true,
required = false,
default_value = "error"
)]
verbosity: String,
}
#[derive(Args)]
struct TrackArgs {
url: Option<String>,
#[arg(short, long, value_parser = check_path_exists)]
urls_file: Option<PathBuf>,
#[arg(short, long)]
sender: Option<String>,
#[arg(short, long)]
carrier: Option<String>,
#[arg(short, long)]
recipient: Option<String>,
#[arg(short = 'C', long)]
cache_seconds: Option<usize>,
#[arg(short, long)]
no_cache: bool,
#[arg(short, long)]
delivered: bool,
#[arg(short, long)]
language: Option<String>,
#[arg(short, long)]
postcode: Option<String>,
}
#[derive(Subcommand)]
enum Command {
Url {
#[command(subcommand)]
command: UrlCommand,
},
Config {
#[command(subcommand)]
command: ConfigCommand,
},
Cache {
#[command(subcommand)]
command: CacheCommand,
},
}
#[derive(Subcommand)]
enum UrlCommand {
List {
query: Option<String>,
#[clap(flatten)]
args: UrlArgs,
},
Add {
url: String,
#[arg(short, long)]
description: Option<String>,
#[clap(flatten)]
args: UrlArgs,
},
Remove {
url: String,
#[clap(flatten)]
args: UrlArgs,
},
}
#[derive(Args)]
struct UrlArgs {
#[arg(short, long, value_parser = check_path_exists)]
urls_file: Option<PathBuf>,
}
#[derive(Subcommand)]
enum CacheCommand {
Size,
Prune {
#[arg(long)]
dry_run: bool,
#[clap(flatten)]
args: UrlArgs,
},
}
#[derive(Subcommand)]
enum ConfigCommand {
List,
Set { key: String, value: String },
Reset,
}
fn display_jobs(jobs: Vec<Job>, delivered_detail: bool) {
let mut errors: Vec<Job> = vec![];
let mut jobs_by_status: HashMap<PackageStatus, Vec<Job>> = HashMap::new();
for job in jobs {
match &job.result {
Ok(package) => {
let status = package.status();
jobs_by_status
.entry(status)
.or_default()
.push(job);
}
Err(_) => errors.push(job),
}
}
for (status, packages) in jobs_by_status.iter_mut() {
if status == &PackageStatus::Delivered {
packages.sort_by(|a, b| {
let a_time = a.result.as_ref().unwrap().delivered;
let b_time = b.result.as_ref().unwrap().delivered;
a_time.cmp(&b_time)
});
}
if status == &PackageStatus::InTransit {
packages.sort_by(|a, b| {
let a_package = a.result.as_ref().unwrap();
let a_eta = a_package.eta.or(a_package
.eta_window
.as_ref()
.map(|w| w.start));
let b_package = b.result.as_ref().unwrap();
let b_eta = b_package.eta.or(b_package
.eta_window
.as_ref()
.map(|w| w.start));
a_eta.cmp(&b_eta)
});
}
}
let line = format!("\n{}\n", line());
for status in all::<PackageStatus>() {
let jobs = jobs_by_status
.entry(status.clone())
.or_insert(vec![]);
if jobs.len() > 0 {
let separator = match status {
PackageStatus::Delivered => {
if delivered_detail {
line.clone()
} else {
"\n".to_owned()
}
}
PackageStatus::InTransit => line.clone(),
};
heading(&status);
let s = jobs
.iter()
.map(|job| display_job(job, delivered_detail))
.collect::<Vec<_>>()
.join(&separator);
println!("{s}");
}
}
if errors.len() > 0 {
heading(&"errors");
let separator = line;
let s = errors
.iter()
.map(|job| display_job(job, delivered_detail))
.collect::<Vec<_>>()
.join(&separator);
println!("{s}");
}
}
async fn track(
settings: &Settings,
ctx: &Context,
track_args: TrackArgs,
) -> Result<()> {
let start = Instant::now();
let urls_file: &PathBuf = track_args
.urls_file
.as_ref()
.unwrap_or(&settings.urls_file);
let mut urls = urls::filter(urls_file, ctx.filters.url.as_deref())?;
if urls.len() == 0 && ctx.filters.url.is_some() {
urls = vec![AnnotatedUrl::new(
ctx.filters.url.clone().unwrap(),
Some("dynamic".into()),
)]
}
let jobs = track_urls(urls, ctx).await?;
display_jobs(jobs, track_args.delivered);
log::info!("track_all took {:?}", start.elapsed());
Ok(())
}