Move to global config, rewrite fetching

This commit is contained in:
Malte Tammena 2021-10-21 20:47:56 +02:00
parent 6da8a95a3b
commit 15e5133f8c
14 changed files with 608 additions and 463 deletions

View file

@ -47,7 +47,10 @@ mod wrapper;
pub use fetchable::Fetchable;
pub use wrapper::clear_cache as clear;
use crate::error::{Error, Result, ResultExt};
use crate::{
config::CONF,
error::{Error, Result, ResultExt},
};
/// Returned by most functions in this module.
type TextAndHeaders = (String, Headers);
@ -81,12 +84,12 @@ enum CacheResult<T> {
}
/// Wrapper around [`fetch`] for responses that contain json.
pub fn fetch_json<U, T>(client: &Client, url: U, local_ttl: Duration) -> Result<T>
pub fn fetch_json<U, T>(url: U, local_ttl: Duration) -> Result<T>
where
U: IntoUrl,
T: DeserializeOwned,
{
fetch(client, url, local_ttl, |text, _| {
fetch(&CONF.client, url, local_ttl, |text, _| {
// TODO: Check content header?
serde_json::from_str(&text).map_err(|why| Error::Deserializing(why, "fetching json"))
})

View file

@ -26,13 +26,36 @@ impl<T> Fetchable<T> {
let value = f()?;
*self = Self::Fetched(value);
// This is safe, since we've just fetched successfully
Ok(self.unwrap())
Ok(self.assume_fetched())
}
}
}
pub fn fetch_mut<F>(&mut self, f: F) -> Result<&mut T>
where
F: FnOnce() -> Result<T>,
{
match self {
Self::Fetched(ref mut value) => Ok(value),
Self::None => {
let value = f()?;
*self = Self::Fetched(value);
// This is safe, since we've just fetched successfully
Ok(self.assume_fetched_mut())
}
}
}
/// Panics if the resource doesn't exist
fn unwrap(&self) -> &T {
fn assume_fetched(&self) -> &T {
match self {
Self::Fetched(value) => value,
Self::None => panic!("Called .unwrap() on a Fetchable that is not fetched!"),
}
}
/// Panics if the resource doesn't exist
fn assume_fetched_mut(&mut self) -> &mut T {
match self {
Self::Fetched(value) => value,
Self::None => panic!("Called .unwrap() on a Fetchable that is not fetched!"),

View file

@ -1,4 +1,8 @@
use std::collections::HashMap;
use chrono::NaiveDate;
use itertools::Itertools;
use lazy_static::lazy_static;
use serde::{Deserialize, Serialize};
use tracing::info;
@ -6,8 +10,16 @@ mod de;
mod ser;
use crate::{
cache::Fetchable, config::CanteensState, error::Result, geoip, get_sane_terminal_dimensions,
meal::Meal, pagination::PaginatedList, print_json, ENDPOINT, TTL_CANTEENS,
cache::{fetch_json, Fetchable},
config::{
args::{CloseCommand, Command, GeoCommand},
CONF,
},
error::Result,
geoip, get_sane_terminal_dimensions,
meal::Meal,
pagination::PaginatedList,
print_json, ENDPOINT, TTL_CANTEENS, TTL_MEALS,
};
use self::ser::CanteenCompleteWithoutMeals;
@ -16,13 +28,20 @@ pub type CanteenId = usize;
const ADRESS_INDENT: &str = " ";
lazy_static! {
static ref EMPTY: Vec<Meal> = Vec::new();
}
#[derive(Debug, Clone, Deserialize)]
#[serde(from = "de::CanteenDeserialized")]
pub struct Canteen {
id: CanteenId,
#[serde(flatten)]
meta: Fetchable<Meta>,
meals: Fetchable<Vec<Meal>>,
/// A map from dates to lists of meals.
///
/// The list of dates itself is fetchable as are the lists of meals.
meals: Fetchable<HashMap<NaiveDate, Fetchable<Vec<Meal>>>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -33,6 +52,13 @@ pub struct Meta {
coordinates: Option<[f32; 2]>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(try_from = "de::DayDeserialized")]
pub struct Day {
date: NaiveDate,
closed: bool,
}
impl Meta {
pub fn fetch(id: CanteenId) -> Result<Self> {
todo!()
@ -40,7 +66,28 @@ impl Meta {
}
impl Canteen {
pub fn print(&mut self, state: &CanteensState) -> Result<()> {
/// Infer canteens from the config.
///
/// # Command
/// - Meals:
/// - Close: Canteens close to the current location
/// - Else: Canteen given by id
/// - Else: Panic!
pub fn infer() -> Result<Vec<Self>> {
match CONF.cmd() {
Command::Meals(cmd) => match cmd.close {
Some(CloseCommand::Close(ref geo)) => Self::fetch_for_geo(geo, false),
None => {
let id = CONF.canteen_id()?;
Ok(vec![id.into()])
}
},
Command::Canteens(cmd) => Self::fetch_for_geo(&cmd.geo, cmd.all),
Command::Tags => unreachable!("BUG: This is not relevant here"),
}
}
pub fn print(&mut self) -> Result<()> {
let (width, _) = get_sane_terminal_dimensions();
let address = textwrap::fill(
self.address()?,
@ -50,9 +97,9 @@ impl Canteen {
);
println!(
"{} {}\n{}",
color!(state: format!("{:>4}", self.id); bold, bright_yellow),
color!(state: self.meta()?.name; bold),
color!(state: address; bright_black),
color!(format!("{:>4}", self.id); bold, bright_yellow),
color!(self.meta()?.name; bold),
color!(address; bright_black),
);
Ok(())
}
@ -76,36 +123,30 @@ impl Canteen {
})
}
pub fn fetch(state: &CanteensState) -> Result<Vec<Self>> {
let url = if state.cmd.all {
info!("Fetching all canteens");
format!("{}/canteens", ENDPOINT)
} else {
let (lat, long) = geoip::fetch(state)?;
info!(
"Fetching canteens for lat: {}, long: {} with radius: {}",
lat, long, state.cmd.geo.radius
);
format!(
"{}/canteens?near[lat]={}&near[lng]={}&near[dist]={}",
ENDPOINT, lat, long, state.cmd.geo.radius,
)
};
PaginatedList::from(&state.client, url, *TTL_CANTEENS)?.try_flatten_and_collect()
}
pub fn print_all(state: &CanteensState, canteens: &mut [Self]) -> Result<()> {
if state.args.json {
pub fn print_all(canteens: &mut [Self]) -> Result<()> {
if CONF.args.json {
Self::print_all_json(canteens)
} else {
for canteen in canteens {
println!();
canteen.print(state)?;
canteen.print()?;
}
Ok(())
}
}
pub fn meals_at_mut(&mut self, date: &NaiveDate) -> Result<Option<&mut Vec<Meal>>> {
let id = self.id();
let dates = self.meals.fetch_mut(|| fetch_dates_for_canteen(self.id))?;
match dates.get_mut(date) {
Some(meals) => {
let meals = meals.fetch_mut(|| fetch_meals(id, date))?;
Ok(Some(meals))
}
None => Ok(None),
}
}
fn print_all_json(canteens: &mut [Self]) -> Result<()> {
let serializable: Vec<_> = canteens
.iter_mut()
@ -117,4 +158,46 @@ impl Canteen {
fn meta(&mut self) -> Result<&Meta> {
self.meta.fetch(|| Meta::fetch(self.id))
}
fn fetch_for_geo(geo: &GeoCommand, all: bool) -> Result<Vec<Self>> {
let url = if all {
info!("Fetching all canteens");
format!("{}/canteens", ENDPOINT)
} else {
let (lat, long) = geoip::infer()?;
info!(
"Fetching canteens for lat: {}, long: {} with radius: {}",
lat, long, geo.radius
);
format!(
"{}/canteens?near[lat]={}&near[lng]={}&near[dist]={}",
ENDPOINT, lat, long, geo.radius,
)
};
PaginatedList::from(&CONF.client, url, *TTL_CANTEENS)?.try_flatten_and_collect()
}
}
fn fetch_dates_for_canteen(id: CanteenId) -> Result<HashMap<NaiveDate, Fetchable<Vec<Meal>>>> {
let url = format!("{}/canteens/{}/days", ENDPOINT, id,);
let days: Vec<Day> = fetch_json(url, *TTL_MEALS)?;
Ok(days
.into_iter()
.map(|day| (day.date, Fetchable::None))
.collect())
}
fn fetch_meals(id: CanteenId, date: &NaiveDate) -> Result<Vec<Meal>> {
let url = format!("{}/canteens/{}/days/{}/meals", ENDPOINT, id, date);
fetch_json(url, *TTL_MEALS)
}
impl From<CanteenId> for Canteen {
fn from(id: CanteenId) -> Self {
Self {
id,
meta: Fetchable::None,
meals: Fetchable::None,
}
}
}

View file

@ -1,6 +1,7 @@
use chrono::NaiveDate;
use serde::Deserialize;
use crate::cache::Fetchable;
use crate::{cache::Fetchable, error::Error};
use super::{CanteenId, Meta};
@ -28,3 +29,20 @@ impl From<CanteenDeserialized> for super::Canteen {
}
}
}
#[derive(Debug, Deserialize)]
pub struct DayDeserialized {
date: String,
closed: bool,
}
impl TryFrom<DayDeserialized> for super::Day {
type Error = Error;
fn try_from(raw: DayDeserialized) -> Result<Self, Self::Error> {
Ok(Self {
date: NaiveDate::parse_from_str(&raw.date, "%Y-%m-%d").map_err(Error::InvalidDate)?,
closed: raw.closed,
})
}
}

View file

@ -57,8 +57,6 @@ pub enum Command {
Tags,
/// Default. Show meals.
Meals(MealsCommand),
/// Shortcut for `mensa meals -d tomorrow`
Tomorrow(MealsCommand),
}
#[derive(Debug, StructOpt)]
@ -100,7 +98,8 @@ pub struct MealsCommand {
#[structopt(long, short,
env = "MENSA_DATE",
parse(try_from_str = parse_human_date),
default_value = "today")]
default_value = "today",
global = true)]
pub date: NaiveDate,
/// Canteen ID for which to fetch meals.

View file

@ -1,17 +1,20 @@
use chrono::NaiveDate;
use lazy_static::lazy_static;
use reqwest::blocking::Client;
use serde::Deserialize;
use structopt::clap::arg_enum;
use structopt::{clap::arg_enum, StructOpt};
use std::{collections::HashSet, fs, path::Path, time::Duration as StdDuration};
use crate::{
canteen::CanteenId,
config::args::{parse_human_date, Command},
error::{Error, Result, ResultExt},
DIR,
};
use self::{
args::{Args, CanteensCommand, MealsCommand},
args::{Args, MealsCommand},
rule::{RegexRule, Rule, TagRule},
};
@ -19,9 +22,123 @@ pub mod args;
pub mod rule;
lazy_static! {
pub static ref CONF: Config = Config::assemble().unwrap();
static ref REQUEST_TIMEOUT: StdDuration = StdDuration::from_secs(10);
}
#[derive(Debug)]
pub struct Config {
pub config: Option<ConfigFile>,
pub client: Client,
pub args: Args,
}
impl Config {
fn assemble() -> Result<Self> {
let args = Args::from_args();
let default_config_path = || DIR.config_dir().join("config.toml");
let path = args.config.clone().unwrap_or_else(default_config_path);
let config = ConfigFile::load_or_log(path);
let client = Client::builder()
.timeout(*REQUEST_TIMEOUT)
.build()
.map_err(Error::Reqwest)?;
Ok(Config {
config,
client,
args,
})
}
/// Easy reference to the Command
pub fn cmd(&self) -> &Command {
lazy_static! {
static ref DEFAULT: Command = Command::Meals(MealsCommand::default());
}
match self.args.command {
Some(ref cmd) => cmd,
None => &*DEFAULT,
}
}
pub fn canteen_id(&self) -> Result<CanteenId> {
// Get the default canteen id from the config file
let default = || self.config.as_ref()?.default_canteen_id;
let id = match self.cmd() {
Command::Meals(cmd) => cmd.canteen_id,
_ => None,
};
id.or_else(default).ok_or(Error::CanteenIdMissing)
}
pub fn date(&self) -> &NaiveDate {
lazy_static! {
static ref DEFAULT: NaiveDate = parse_human_date("today").unwrap();
}
match self.cmd() {
Command::Meals(cmd) => &cmd.date,
_ => &*DEFAULT,
}
}
pub fn price_tags(&self) -> HashSet<PriceTags> {
let from_file = || Some(self.config.as_ref()?.price_tags.clone());
match self.cmd() {
Command::Meals(cmd) => match cmd.price.clone() {
Some(prices) => prices.into_iter().collect(),
None => from_file().unwrap_or_default(),
},
_ => from_file().unwrap_or_default(),
}
}
pub fn get_filter_rule(&self) -> Rule {
match self.cmd() {
Command::Meals(cmd) => {
let conf_filter = || Some(self.config.as_ref()?.filter.clone());
let args_filter = Rule {
name: RegexRule::from_arg_parts(&cmd.filter_name, &cmd.no_filter_name),
tag: TagRule {
add: cmd.filter_tag.clone(),
sub: cmd.no_filter_tag.clone(),
},
category: RegexRule::from_arg_parts(&cmd.filter_cat, &cmd.no_filter_cat),
};
if cmd.overwrite_filter {
args_filter
} else {
conf_filter().unwrap_or_default().joined(args_filter)
}
}
_ => {
unreachable!("Filters should not be relevant here")
}
}
}
pub fn get_favourites_rule(&self) -> Rule {
match self.cmd() {
Command::Meals(cmd) => {
let conf_favs = || Some(self.config.as_ref()?.favs.clone());
let args_favs = Rule {
name: RegexRule::from_arg_parts(&cmd.favs_name, &cmd.no_favs_name),
tag: TagRule {
add: cmd.favs_tag.clone(),
sub: cmd.no_favs_tag.clone(),
},
category: RegexRule::from_arg_parts(&cmd.favs_cat, &cmd.no_favs_cat),
};
if cmd.overwrite_favs {
args_favs
} else {
conf_favs().unwrap_or_default().joined(args_favs)
}
}
_ => unreachable!("Favourite rules should not be relevant here"),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all(deserialize = "kebab-case"))]
pub struct ConfigFile {
@ -34,7 +151,6 @@ pub struct ConfigFile {
#[serde(default)]
favs: Rule,
}
arg_enum! {
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Deserialize)]
pub enum PriceTags {
@ -54,100 +170,3 @@ impl ConfigFile {
.log_err()
}
}
pub type CanteensState<'s> = State<'s, CanteensCommand>;
pub type MealsState<'s> = State<'s, MealsCommand>;
#[derive(Debug, Clone)]
pub struct State<'s, Cmd> {
pub config: Option<ConfigFile>,
pub client: Client,
pub args: &'s Args,
pub cmd: &'s Cmd,
}
impl<'s> State<'s, ()> {
pub fn assemble(args: &'s Args) -> Result<Self> {
let default_config_path = || DIR.config_dir().join("config.toml");
let path = args.config.clone().unwrap_or_else(default_config_path);
let config = ConfigFile::load_or_log(path);
let client = Client::builder()
.timeout(*REQUEST_TIMEOUT)
.build()
.map_err(Error::Reqwest)?;
Ok(Self {
config,
client,
args,
cmd: &(),
})
}
}
impl<'s, Cmd> State<'s, Cmd> {
pub fn from<OldCmd>(old: State<'s, OldCmd>, cmd: &'s Cmd) -> Self {
Self {
config: old.config,
client: old.client,
args: old.args,
cmd,
}
}
}
impl MealsState<'_> {
pub fn canteen_id(&self) -> Result<usize> {
// Get the default canteen id from the config file
let default = || self.config.as_ref()?.default_canteen_id;
self.cmd
.canteen_id
.or_else(default)
.ok_or(Error::CanteenIdMissing)
}
pub fn date(&self) -> &chrono::NaiveDate {
&self.cmd.date
}
pub fn price_tags(&self) -> HashSet<PriceTags> {
let from_file = || Some(self.config.as_ref()?.price_tags.clone());
match self.cmd.price.clone() {
Some(prices) => prices.into_iter().collect(),
None => from_file().unwrap_or_default(),
}
}
pub fn get_filter(&self) -> Rule {
let conf_filter = || Some(self.config.as_ref()?.filter.clone());
let args_filter = Rule {
name: RegexRule::from_arg_parts(&self.cmd.filter_name, &self.cmd.no_filter_name),
tag: TagRule {
add: self.cmd.filter_tag.clone(),
sub: self.cmd.no_filter_tag.clone(),
},
category: RegexRule::from_arg_parts(&self.cmd.filter_cat, &self.cmd.no_filter_cat),
};
if self.cmd.overwrite_filter {
args_filter
} else {
conf_filter().unwrap_or_default().joined(args_filter)
}
}
pub fn get_favs_rule(&self) -> Rule {
let conf_favs = || Some(self.config.as_ref()?.favs.clone());
let args_favs = Rule {
name: RegexRule::from_arg_parts(&self.cmd.favs_name, &self.cmd.no_favs_name),
tag: TagRule {
add: self.cmd.favs_tag.clone(),
sub: self.cmd.no_favs_tag.clone(),
},
category: RegexRule::from_arg_parts(&self.cmd.favs_cat, &self.cmd.no_favs_cat),
};
if self.cmd.overwrite_favs {
args_favs
} else {
conf_favs().unwrap_or_default().joined(args_favs)
}
}
}

View file

@ -4,7 +4,7 @@ use std::convert::TryFrom;
use crate::{
error::{Error, Result},
meal::Meal,
meal::MealComplete,
tag::Tag,
};
@ -42,7 +42,7 @@ struct RawRegexRule {
}
impl Rule {
pub fn is_match(&self, meal: &Meal) -> bool {
pub fn is_match(&self, meal: &MealComplete) -> bool {
let all_adds_empty =
self.tag.is_empty_add() && self.category.is_empty_add() && self.name.is_empty_add();
let any_add = self.tag.is_match_add(meal)
@ -64,12 +64,12 @@ impl Rule {
}
impl TagRule {
fn is_match_add(&self, meal: &Meal) -> bool {
self.add.iter().any(|tag| meal.tags.contains(tag))
fn is_match_add(&self, meal: &MealComplete) -> bool {
self.add.iter().any(|tag| meal.meta.tags.contains(tag))
}
fn is_match_sub(&self, meal: &Meal) -> bool {
self.sub.iter().any(|tag| meal.tags.contains(tag))
fn is_match_sub(&self, meal: &MealComplete) -> bool {
self.sub.iter().any(|tag| meal.meta.tags.contains(tag))
}
fn is_empty_add(&self) -> bool {
@ -101,16 +101,16 @@ impl RegexRule {
Self { add, sub }
}
fn is_match_add(&self, meal: &Meal) -> bool {
fn is_match_add(&self, meal: &MealComplete) -> bool {
match self.add {
Some(ref rset) => rset.is_match(&meal.category),
Some(ref rset) => rset.is_match(&meal.meta.category),
None => false,
}
}
fn is_match_sub(&self, meal: &Meal) -> bool {
fn is_match_sub(&self, meal: &MealComplete) -> bool {
match self.sub {
Some(ref rset) => rset.is_match(&meal.category),
Some(ref rset) => rset.is_match(&meal.meta.category),
None => false,
}
}

View file

@ -33,6 +33,8 @@ pub enum Error {
NonSuccessStatusCode(String, reqwest::StatusCode),
#[error("read invalid utf8 bytes")]
DecodingUtf8(#[source] std::string::FromUtf8Error),
#[error("invalid date encountered: {_0}")]
InvalidDate(#[source] chrono::ParseError),
}
pub trait ResultExt<T> {

View file

@ -2,10 +2,16 @@
use chrono::Duration;
use lazy_static::lazy_static;
use reqwest::blocking::Client;
use serde::Deserialize;
use crate::{cache::fetch_json, config::CanteensState, error::Result};
use crate::{
cache::fetch_json,
config::{
args::{CloseCommand, Command},
CONF,
},
error::Result,
};
lazy_static! {
static ref TTL_GEOIP: Duration = Duration::minutes(5);
@ -21,26 +27,34 @@ struct LatLong {
longitude: f32,
}
/// Derive Latitude and Longitude from the [`CanteensState`].
/// Infer Latitude and Longitude from the config.
///
/// This will use the cli arguments if given and
/// fetch any missing values from api.geoip.rs.
pub fn fetch(state: &CanteensState) -> Result<(f32, f32)> {
let geo = &state.cmd.geo;
Ok(if geo.lat.is_none() || geo.long.is_none() {
let guessed = fetch_geoip(&state.client)?;
pub fn infer() -> Result<(f32, f32)> {
let (lat, long) = match CONF.cmd() {
Command::Canteens(cmd) => (cmd.geo.lat, cmd.geo.long),
Command::Meals(cmd) => match &cmd.close {
Some(CloseCommand::Close(geo)) => (geo.lat, geo.long),
None => (None, None),
},
Command::Tags => (None, None),
};
let (lat, long) = if lat.is_none() || long.is_none() {
let guessed = fetch_geoip()?;
(
geo.lat.unwrap_or(guessed.latitude),
geo.long.unwrap_or(guessed.longitude),
lat.unwrap_or(guessed.latitude),
long.unwrap_or(guessed.longitude),
)
} else {
// Cannot panic, due to above if
(geo.lat.unwrap(), geo.long.unwrap())
})
(lat.unwrap(), long.unwrap())
};
Ok((lat, long))
}
/// Fetch geoip for current ip.
fn fetch_geoip(client: &Client) -> Result<LatLong> {
fn fetch_geoip() -> Result<LatLong> {
let url = "https://api.geoip.rs";
fetch_json(client, url, *TTL_GEOIP)
fetch_json(url, *TTL_GEOIP)
}

View file

@ -64,7 +64,6 @@ use chrono::Duration;
use directories_next::ProjectDirs;
use lazy_static::lazy_static;
use serde::Serialize;
use structopt::StructOpt;
use tracing::error;
/// Colorizes the output.
@ -72,11 +71,11 @@ use tracing::error;
/// This will colorize for Stdout based on heuristics and colors
/// from the [`owo_colors`] library.
macro_rules! color {
($state:ident: $what:expr; $($fn:ident),+) => {
($what:expr; $($fn:ident),+) => {
{
use owo_colors::{OwoColorize, Stream};
use crate::config::args::ColorWhen;
match $state.args.color {
match crate::config::CONF.args.color {
ColorWhen::Always => {
$what $(. $fn())+ .to_string()
}
@ -93,8 +92,8 @@ macro_rules! color {
}
macro_rules! if_plain {
($state:ident: $fancy:expr, $plain:expr) => {
if $state.args.plain {
($fancy:expr, $plain:expr) => {
if crate::config::CONF.args.plain {
$plain
} else {
$fancy
@ -113,10 +112,7 @@ mod tag;
use crate::{
canteen::Canteen,
config::{
args::{parse_human_date, Args, Command, MealsCommand},
State,
},
config::{args::Command, CONF},
error::{Error, Result, ResultExt},
meal::Meal,
tag::Tag,
@ -129,6 +125,7 @@ lazy_static! {
static ref DIR: ProjectDirs =
ProjectDirs::from("rocks", "tammena", "mensa").expect("Could not detect home directory");
static ref TTL_CANTEENS: Duration = Duration::days(1);
static ref TTL_MEALS: Duration = Duration::hours(1);
}
fn main() -> Result<()> {
@ -143,41 +140,22 @@ fn main() -> Result<()> {
fn real_main() -> Result<()> {
// Initialize logger
tracing_subscriber::fmt::init();
// Construct client, load config and assemble cli args
let args = Args::from_args();
let state = State::assemble(&args)?;
// Clear cache if requested
if state.args.clear_cache {
if CONF.args.clear_cache {
cache::clear()?;
}
// Match over the user requested command
match &state.args.command {
Some(Command::Meals(cmd)) => {
let state = State::from(state, cmd);
let meals = Meal::fetch(&state)?;
Meal::print_all(&state, &meals)?;
match CONF.cmd() {
Command::Meals(_) => {
let mut canteens = Canteen::infer()?;
Meal::print_for_all_canteens(&mut canteens)?;
}
Some(Command::Tomorrow(cmd)) => {
// Works like the meals command. But we replace the date with tomorrow!
let mut cmd = cmd.clone();
cmd.date = parse_human_date("tomorrow").unwrap();
let state = State::from(state, &cmd);
let meals = Meal::fetch(&state)?;
Meal::print_all(&state, &meals)?;
Command::Canteens(_) => {
let mut canteens = Canteen::infer()?;
Canteen::print_all(&mut canteens)?;
}
Some(Command::Canteens(cmd)) => {
let state = State::from(state, cmd);
let mut canteens = Canteen::fetch(&state)?;
Canteen::print_all(&state, &mut canteens)?;
}
Some(Command::Tags) => {
Tag::print_all(&state)?;
}
None => {
let cmd = MealsCommand::default();
let state = State::from(state, &cmd);
let meals = Meal::fetch(&state)?;
Meal::print_all(&state, &meals)?;
Command::Tags => {
Tag::print_all()?;
}
}
Ok(())

57
src/meal/de.rs Normal file
View file

@ -0,0 +1,57 @@
use serde::Deserialize;
use std::collections::HashSet;
use crate::{cache::Fetchable, tag::Tag};
use super::{MealId, Meta, Note, Prices};
#[derive(Debug, Deserialize)]
#[cfg_attr(debug, serde(deny_unknown_fields))]
pub struct Meal {
id: MealId,
name: String,
notes: Vec<String>,
prices: Prices,
category: String,
}
impl Meal {
/// Parse notes and return them split into [`Tag`]s and descriptions.
fn parse_and_split_notes(&self) -> (HashSet<Tag>, HashSet<String>) {
self.notes
.iter()
.cloned()
.flat_map(|raw| Note::parse_str(&raw))
.fold(
(HashSet::new(), HashSet::new()),
|(mut tags, mut descs), note| {
match note {
Note::Tag(tag) => {
tags.insert(tag);
}
Note::Desc(other) => {
descs.insert(other);
}
}
(tags, descs)
},
)
}
}
impl From<Meal> for super::Meal {
fn from(raw: Meal) -> Self {
let (tags, descs) = raw.parse_and_split_notes();
Self {
id: raw.id,
meta: Fetchable::Fetched(Meta {
name: raw.name,
prices: raw.prices,
category: raw.category,
tags,
descs,
}),
}
}
}

View file

@ -1,47 +1,36 @@
use chrono::Duration;
use core::fmt;
use itertools::Itertools;
use lazy_static::lazy_static;
use serde::{Deserialize, Serialize};
use unicode_width::UnicodeWidthStr;
use std::collections::{HashMap, HashSet};
use crate::{
cache::fetch_json,
canteen::{Canteen, CanteenId},
config::{
args::{CanteensCommand, CloseCommand, GeoCommand, MealsCommand},
MealsState, PriceTags,
},
error::{pass_info, Result, ResultExt},
get_sane_terminal_dimensions, print_json,
tag::Tag,
State, ENDPOINT,
use std::{
collections::{HashMap, HashSet},
fmt,
};
const NAME_PRE: &str = " ╭───╴";
const NAME_PRE_PLAIN: &str = " - ";
const NAME_CONTINUE_PRE: &str = "";
const NAME_CONTINUE_PRE_PLAIN: &str = " ";
const OTHER_NOTE_PRE: &str = " ├╴";
const OTHER_NOTE_PRE_PLAIN: &str = " ";
const OTHER_NOTE_CONTINUE_PRE: &str = "";
const OTHER_NOTE_CONTINUE_PRE_PLAIN: &str = " ";
const CATEGORY_PRE: &str = " ├─╴";
const CATEGORY_PRE_PLAIN: &str = " ";
const PRICES_PRE: &str = " ╰╴";
const PRICES_PRE_PLAIN: &str = " ";
mod de;
mod ser;
lazy_static! {
static ref TTL_MEALS: Duration = Duration::hours(1);
use crate::{
cache::Fetchable,
canteen::{Canteen, CanteenId},
config::{PriceTags, CONF},
error::Result,
print_json,
tag::Tag,
};
pub use self::ser::MealComplete;
pub type MealId = usize;
#[derive(Debug, Clone, Deserialize)]
#[serde(from = "de::Meal")]
pub struct Meal {
pub id: MealId,
pub meta: Fetchable<Meta>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(from = "RawMeal")]
pub struct Meal {
#[serde(rename = "id")]
pub _id: usize,
pub struct Meta {
pub name: String,
pub tags: HashSet<Tag>,
pub descs: HashSet<String>,
@ -49,16 +38,6 @@ pub struct Meal {
pub category: String,
}
#[derive(Debug, Deserialize)]
#[cfg_attr(debug, serde(deny_unknown_fields))]
struct RawMeal {
id: usize,
name: String,
notes: Vec<String>,
prices: Prices,
category: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(debug, serde(deny_unknown_fields))]
pub struct Prices {
@ -73,176 +52,69 @@ enum Note {
Desc(String),
}
impl RawMeal {
/// Parse notes and return them split into [`Tag`]s and descriptions.
fn parse_and_split_notes(&self) -> (HashSet<Tag>, HashSet<String>) {
self.notes
.iter()
.cloned()
.flat_map(|raw| Note::parse_str(&raw))
.fold(
(HashSet::new(), HashSet::new()),
|(mut tags, mut descs), note| {
match note {
Note::Tag(tag) => {
tags.insert(tag);
}
Note::Desc(other) => {
descs.insert(other);
}
}
(tags, descs)
},
)
impl Meta {
fn fetch(id: MealId) -> Result<Meta> {
todo!()
}
}
impl Meal {
/// Print this [`Meal`] to the terminal.
pub fn print(&self, state: &State<MealsCommand>, highlight: bool) {
let (width, _height) = get_sane_terminal_dimensions();
// Print meal name
self.print_name_to_terminal(state, width, highlight);
// Get notes, i.e. allergenes, descriptions, tags
self.print_category_and_primary_tags(state, highlight);
self.print_descriptions(state, width, highlight);
self.print_price_and_secondary_tags(state, highlight);
pub fn print(&mut self, highlight: bool) -> Result<()> {
self.complete()?.print(highlight);
Ok(())
}
/// Fetch the meals.
///
/// This will respect passed cli arguments and the configuration.
pub fn fetch(state: &MealsState) -> Result<HashMap<CanteenId, Vec<Self>>> {
match state.cmd.close {
Some(CloseCommand::Close(ref close)) => {
let meals = fetch_canteens_for_close_command(state, close)?
.into_iter()
.map(
|canteen| match fetch_meals_for_canteen_id(state, canteen.id()) {
Ok(meals) => Ok((canteen.id(), meals)),
Err(why) => Err(why),
},
)
// Drop any failed canteens with a warning
.filter_map(ResultExt::log_warn)
.collect();
Ok(meals)
}
None => {
let id = state.canteen_id()?;
let meals = fetch_meals_for_canteen_id(state, id)?;
let mut map = HashMap::new();
map.insert(id, meals);
Ok(map)
}
}
pub fn meta(&mut self) -> Result<&Meta> {
self.meta.fetch(|| Meta::fetch(self.id))
}
pub fn complete(&mut self) -> Result<MealComplete<'_>> {
Ok(MealComplete {
id: self.id,
meta: self.meta()?,
})
}
/// Print the given meals.
///
/// Thi will respect passed cli arguments and the configuration.
pub fn print_all(state: &MealsState, meals: &HashMap<CanteenId, Vec<Self>>) -> Result<()> {
/// This will respect passed cli arguments and the configuration.
pub fn print_for_all_canteens(canteens: &mut [Canteen]) -> Result<()> {
// Load the filter which is used to select which meals to print.
let filter = state.get_filter();
let filter = CONF.get_filter_rule();
// Load the favourites which will be used for marking meals.
let favs = state.get_favs_rule();
let favs = CONF.get_favourites_rule();
// The day for which to print meals
let day = CONF.date();
// Filter all meals
let meal_map = meals.iter().map(|(id, meals)| {
let keep = meals.iter().filter(|meal| filter.is_match(meal));
(id, keep.collect::<Vec<_>>())
let meals = canteens.iter_mut().map(|canteen| {
let id = canteen.id();
let meals: Vec<_> = match canteen.meals_at_mut(day)? {
Some(meals) => meals
.iter_mut()
.map(|meal| meal.complete())
.filter_ok(|meal| filter.is_match(meal))
.try_collect()?,
None => vec![],
};
Result::Ok((id, meals))
});
if state.args.json {
print_json(&meal_map.collect::<HashMap<_, _>>())
if CONF.args.json {
let map: HashMap<CanteenId, Vec<_>> = meals.try_collect()?;
print_json(&map)
} else {
for (id, meals) in meal_map {
println!("{}", id);
for res in meals {
let (canteen_id, meals) = res?;
println!("{}", canteen_id);
for meal in meals {
let is_fav = favs.is_match(meal);
let is_fav = favs.is_match(&meal);
println!();
meal.print(state, is_fav);
meal.print(is_fav);
}
}
Ok(())
}
}
fn print_name_to_terminal(&self, state: &MealsState, width: usize, highlight: bool) {
let max_name_width = width - NAME_PRE.width();
let mut name_parts = textwrap::wrap(&self.name, max_name_width).into_iter();
// There will always be a first part of the splitted string
let first_name_part = name_parts.next().unwrap();
let pre = if_plain!(state: NAME_PRE, NAME_PRE_PLAIN);
println!(
"{}{}",
hl_if(state, highlight, pre),
color!(state: hl_if(state, highlight, first_name_part); bold),
);
for name_part in name_parts {
let name_part = hl_if(state, highlight, name_part);
let pre = if_plain!(state: NAME_CONTINUE_PRE, NAME_CONTINUE_PRE_PLAIN);
println!(
"{}{}",
hl_if(state, highlight, pre),
color!(state: name_part; bold),
);
}
}
fn print_category_and_primary_tags(&self, state: &MealsState, highlight: bool) {
let mut tag_str = self
.tags
.iter()
.filter(|tag| tag.is_primary())
.map(|tag| tag.as_id(state));
let tag_str_colored = if_plain!(
state: color!(state: tag_str.join(" "); bright_black),
tag_str.join(", ")
);
let pre = if_plain!(state: CATEGORY_PRE, CATEGORY_PRE_PLAIN);
let comma_if_plain = if_plain!(state: "", ",");
println!(
"{}{}{} {}",
hl_if(state, highlight, pre),
color!(state: self.category; bright_blue),
color!(state: comma_if_plain; bright_black),
tag_str_colored
);
}
fn print_descriptions(&self, state: &MealsState, width: usize, highlight: bool) {
let pre = if_plain!(state: OTHER_NOTE_PRE, OTHER_NOTE_PRE_PLAIN);
let pre_continue = if_plain!(
state: OTHER_NOTE_CONTINUE_PRE,
OTHER_NOTE_CONTINUE_PRE_PLAIN
);
let max_note_width = width - OTHER_NOTE_PRE.width();
for note in &self.descs {
let mut note_parts = textwrap::wrap(note, max_note_width).into_iter();
// There will always be a first part in the splitted string
println!(
"{}{}",
hl_if(state, highlight, pre),
note_parts.next().unwrap()
);
for part in note_parts {
println!("{}{}", hl_if(state, highlight, pre_continue), part);
}
}
}
fn print_price_and_secondary_tags(&self, state: &State<MealsCommand>, highlight: bool) {
let prices = self.prices.to_terminal_string(state);
let mut secondary: Vec<_> = self.tags.iter().filter(|tag| tag.is_secondary()).collect();
secondary.sort_unstable();
let secondary_str = secondary.iter().map(|tag| tag.as_id(state)).join(" ");
let pre = if_plain!(state: PRICES_PRE, PRICES_PRE_PLAIN);
println!(
"{}{} {}",
hl_if(state, highlight, pre),
prices,
color!(state: secondary_str; bright_black),
);
}
}
impl Note {
@ -257,8 +129,8 @@ impl Note {
}
impl Prices {
fn to_terminal_string(&self, state: &State<MealsCommand>) -> String {
let price_tags = state.price_tags();
fn to_terminal_string(&self) -> String {
let price_tags = CONF.price_tags();
let price_tags = if price_tags.is_empty() {
// Print all of them
vec![self.students, self.employees, self.others]
@ -278,56 +150,23 @@ impl Prices {
let price_tags: Vec<_> = price_tags
.into_iter()
.map(|tag| format!("{:.2}", tag))
.map(|tag| color!(state: tag; bright_green))
.map(|tag| color!(tag; bright_green))
.collect();
match price_tags.len() {
0 => String::new(),
_ => {
let slash = color!(state: " / "; bright_black);
let slash = color!(" / "; bright_black);
format!(
"{} {} {}",
color!(state: "("; bright_black),
color!("("; bright_black),
price_tags.join(&slash),
color!(state: ")"; bright_black),
color!(")"; bright_black),
)
}
}
}
}
fn hl_if<Cmd, S>(state: &State<Cmd>, highlight: bool, text: S) -> String
where
S: fmt::Display,
{
if highlight {
color!(state: text; bright_yellow)
} else {
format!("{}", text)
}
}
fn fetch_canteens_for_close_command(
state: &MealsState,
close: &GeoCommand,
) -> Result<Vec<Canteen>> {
let canteens_cmd = CanteensCommand {
all: false,
geo: close.clone(),
};
let canteen_state = State::from(state.clone(), &canteens_cmd);
Canteen::fetch(&canteen_state)
}
fn fetch_meals_for_canteen_id(state: &MealsState, canteen_id: usize) -> Result<Vec<Meal>> {
let url = format!(
"{}/canteens/{}/days/{}/meals",
ENDPOINT,
canteen_id,
state.date()
);
fetch_json(&state.client, url, *TTL_MEALS)
}
impl fmt::Display for Note {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
@ -336,17 +175,3 @@ impl fmt::Display for Note {
}
}
}
impl From<RawMeal> for Meal {
fn from(raw: RawMeal) -> Self {
let (tags, descs) = pass_info(&raw).parse_and_split_notes();
Self {
_id: raw.id,
name: raw.name,
prices: raw.prices,
category: raw.category,
tags,
descs,
}
}
}

124
src/meal/ser.rs Normal file
View file

@ -0,0 +1,124 @@
use core::fmt;
use itertools::Itertools;
use serde::Serialize;
use unicode_width::UnicodeWidthStr;
use crate::get_sane_terminal_dimensions;
use super::{MealId, Meta};
const NAME_PRE: &str = " ╭───╴";
const NAME_PRE_PLAIN: &str = " - ";
const NAME_CONTINUE_PRE: &str = "";
const NAME_CONTINUE_PRE_PLAIN: &str = " ";
const OTHER_NOTE_PRE: &str = " ├╴";
const OTHER_NOTE_PRE_PLAIN: &str = " ";
const OTHER_NOTE_CONTINUE_PRE: &str = "";
const OTHER_NOTE_CONTINUE_PRE_PLAIN: &str = " ";
const CATEGORY_PRE: &str = " ├─╴";
const CATEGORY_PRE_PLAIN: &str = " ";
const PRICES_PRE: &str = " ╰╴";
const PRICES_PRE_PLAIN: &str = " ";
#[derive(Debug, Serialize)]
pub struct MealComplete<'c> {
pub id: MealId,
#[serde(flatten)]
pub meta: &'c Meta,
}
impl<'c> MealComplete<'c> {
/// Print this [`MealComplete`] to the terminal.
pub fn print(&self, highlight: bool) {
let (width, _height) = get_sane_terminal_dimensions();
// Print meal name
self.print_name_to_terminal(width, highlight);
// Get notes, i.e. allergenes, descriptions, tags
self.print_category_and_primary_tags(highlight);
self.print_descriptions(width, highlight);
self.print_price_and_secondary_tags(highlight);
}
fn print_name_to_terminal(&self, width: usize, highlight: bool) {
let max_name_width = width - NAME_PRE.width();
let mut name_parts = textwrap::wrap(&self.meta.name, max_name_width).into_iter();
// There will always be a first part of the splitted string
let first_name_part = name_parts.next().unwrap();
let pre = if_plain!(NAME_PRE, NAME_PRE_PLAIN);
println!(
"{}{}",
hl_if(highlight, pre),
color!(hl_if(highlight, first_name_part); bold),
);
for name_part in name_parts {
let name_part = hl_if(highlight, name_part);
let pre = if_plain!(NAME_CONTINUE_PRE, NAME_CONTINUE_PRE_PLAIN);
println!("{}{}", hl_if(highlight, pre), color!(name_part; bold),);
}
}
fn print_category_and_primary_tags(&self, highlight: bool) {
let mut tag_str = self
.meta
.tags
.iter()
.filter(|tag| tag.is_primary())
.map(|tag| tag.as_id());
let tag_str_colored =
if_plain!(color!(tag_str.join(" "); bright_black), tag_str.join(", "));
let pre = if_plain!(CATEGORY_PRE, CATEGORY_PRE_PLAIN);
let comma_if_plain = if_plain!("", ",");
println!(
"{}{}{} {}",
hl_if(highlight, pre),
color!(self.meta.category; bright_blue),
color!(comma_if_plain; bright_black),
tag_str_colored
);
}
fn print_descriptions(&self, width: usize, highlight: bool) {
let pre = if_plain!(OTHER_NOTE_PRE, OTHER_NOTE_PRE_PLAIN);
let pre_continue = if_plain!(OTHER_NOTE_CONTINUE_PRE, OTHER_NOTE_CONTINUE_PRE_PLAIN);
let max_note_width = width - OTHER_NOTE_PRE.width();
for note in &self.meta.descs {
let mut note_parts = textwrap::wrap(note, max_note_width).into_iter();
// There will always be a first part in the splitted string
println!("{}{}", hl_if(highlight, pre), note_parts.next().unwrap());
for part in note_parts {
println!("{}{}", hl_if(highlight, pre_continue), part);
}
}
}
fn print_price_and_secondary_tags(&self, highlight: bool) {
let prices = self.meta.prices.to_terminal_string();
let mut secondary: Vec<_> = self
.meta
.tags
.iter()
.filter(|tag| tag.is_secondary())
.collect();
secondary.sort_unstable();
let secondary_str = secondary.iter().map(|tag| tag.as_id()).join(" ");
let pre = if_plain!(PRICES_PRE, PRICES_PRE_PLAIN);
println!(
"{}{} {}",
hl_if(highlight, pre),
prices,
color!(secondary_str; bright_black),
);
}
}
fn hl_if<S>(highlight: bool, text: S) -> String
where
S: fmt::Display,
{
if highlight {
color!(text; bright_yellow)
} else {
format!("{}", text)
}
}

View file

@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
use strum::{Display, EnumIter, IntoEnumIterator};
use unicode_width::UnicodeWidthStr;
use crate::{config::State, error::Result, get_sane_terminal_dimensions, print_json};
use crate::{config::CONF, error::Result, get_sane_terminal_dimensions, print_json};
const ID_WIDTH: usize = 4;
const TEXT_INDENT: &str = " ";
@ -161,14 +161,14 @@ impl Tag {
///
/// Will respect any settings given, i.e. emojis will be used
/// unless the output should be plain.
pub fn as_id<Cmd>(&self, state: &State<Cmd>) -> String {
pub fn as_id(&self) -> String {
match self {
Self::Vegan => if_plain!(state: "🌱".into(), "Vegan".into()),
Self::Vegetarian => if_plain!(state:"🧀".into(), "Vegetarian".into()),
Self::Pig => if_plain!(state:"🐖".into(), "Pig".into()),
Self::Fish => if_plain!(state:"🐟".into(), "Fish".into()),
Self::Cow => if_plain!(state:"🐄".into(), "Cow".into()),
Self::Poultry => if_plain!(state:"🐓".into(), "Poultry".into()),
Self::Vegan => if_plain!("🌱".into(), "Vegan".into()),
Self::Vegetarian => if_plain!("🧀".into(), "Vegetarian".into()),
Self::Pig => if_plain!("🐖".into(), "Pig".into()),
Self::Fish => if_plain!("🐟".into(), "Fish".into()),
Self::Cow => if_plain!("🐄".into(), "Cow".into()),
Self::Poultry => if_plain!("🐓".into(), "Poultry".into()),
_ => {
// If no special emoji is available, just use the id
let number: u8 = (*self).into();
@ -180,11 +180,11 @@ impl Tag {
/// Print this tag.
///
/// Does **not** respect `--json`, use [`Self::print_all`].
pub fn print<Cmd>(&self, state: &State<Cmd>) {
let emoji = if state.args.plain && self.is_primary() {
pub fn print(&self) {
let emoji = if CONF.args.plain && self.is_primary() {
format!("{:>width$}", "-", width = ID_WIDTH)
} else {
let emoji = self.as_id(state);
let emoji = self.as_id();
let emoji_len = emoji.width();
format!(
"{}{}",
@ -201,20 +201,20 @@ impl Tag {
);
println!(
"{} {}\n{}",
color!(state: emoji; bright_yellow, bold),
color!(state: self; bold),
color!(state: description; bright_black),
color!(emoji; bright_yellow, bold),
color!(self; bold),
color!(description; bright_black),
);
}
/// Print all tags.
pub fn print_all<Cmd>(state: &State<Cmd>) -> Result<()> {
if state.args.json {
Self::print_all_json(state)
pub fn print_all() -> Result<()> {
if CONF.args.json {
Self::print_all_json()
} else {
for tag in Tag::iter() {
println!();
tag.print(state);
tag.print();
}
Ok(())
}
@ -227,11 +227,11 @@ impl Tag {
/// - name: The name of the tag.
/// - desc: A simple description.
///
fn print_all_json<Cmd>(state: &State<Cmd>) -> Result<()> {
fn print_all_json() -> Result<()> {
let tags: Vec<HashMap<&str, String>> = Tag::iter()
.map(|tag| {
vec![
("id", tag.as_id(state)),
("id", tag.as_id()),
("name", tag.to_string()),
("desc", tag.describe().to_owned()),
]