1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5use crate::style_ser;
6use color_eyre::Result;
7use curl::easy::Easy;
8use eyre::{eyre, Report, WrapErr};
9use nu_ansi_term::{Color, Style};
10use rink_core::output::fmt::FmtToken;
11use rink_core::parsing::datetime;
12use rink_core::Context;
13use rink_core::{loader::gnu_units, CURRENCY_FILE, DATES_FILE, DEFAULT_FILE};
14use serde_derive::{Deserialize, Serialize};
15use std::env;
16use std::ffi::OsString;
17use std::io::{ErrorKind, Read, Seek, SeekFrom, Write};
18use std::path::Path;
19use std::path::PathBuf;
20use std::time::Duration;
21use std::{
22 collections::HashMap,
23 fs::{read_to_string, File},
24};
25use ubyte::ByteUnit;
26
27fn file_to_string(mut file: File) -> Result<String> {
28 let mut string = String::new();
29 let _ = file.read_to_string(&mut string)?;
30 Ok(string)
31}
32
33pub fn config_path(name: &str) -> Result<PathBuf> {
34 let mut path = dirs::config_dir().ok_or_else(|| eyre!("Could not find config directory"))?;
35 path.push("rink");
36 path.push(name);
37 Ok(path)
38}
39
40#[derive(Serialize, Deserialize, Clone)]
41#[serde(default, deny_unknown_fields)]
42pub struct Config {
…lines 43–54 unchanged (12 lines)
55pub struct Rink {
56 /// Which prompt to render when run interactively.
57 pub prompt: String,
58 /// Use multi-line output for lists.
59 pub long_output: bool,
60}
61
62#[derive(Serialize, Deserialize, Clone)]
63#[serde(default, deny_unknown_fields)]
64pub struct Currency {
65 /// Set to false to disable currency loading entirely.
66 pub enabled: bool,
67 /// Set to false to only reuse the existing cached currency data.
68 pub fetch_on_startup: bool,
69 /// Which web endpoint should be used to download currency data?
70 pub endpoint: String,
71 /// How long to cache for.
72 #[serde(with = "humantime_serde")]
73 pub cache_duration: Duration,
74 /// How long to wait before timing out requests.
…lines 75–173 unchanged (99 lines)
174 }
175}
176
177impl Default for Currency {
178 fn default() -> Self {
179 Currency {
180 enabled: true,
181 fetch_on_startup: true,
182 endpoint: "https://rinkcalc.app/data/currency.json".to_owned(),
183 cache_duration: Duration::from_secs(60 * 60), // 1 hour
184 timeout: Duration::from_secs(2),
185 }
186 }
187}
188
189impl Default for Rink {
190 fn default() -> Self {
191 Rink {
192 prompt: "> ".to_owned(),
193 long_output: false,
194 }
…lines 195–225 unchanged (31 lines)
226 theme.unwrap_or(&self.default_theme)
227 } else {
228 &self.disabled_theme
229 }
230 }
231}
232
233fn read_from_search_path(
234 filename: &str,
235 paths: &[PathBuf],
236 default: Option<&'static str>,
237) -> Result<Vec<String>> {
238 let result: Vec<String> = paths
239 .iter()
240 .filter_map(|path| {
241 let mut buf = PathBuf::from(path);
242 buf.push(filename);
243 read_to_string(buf).ok()
…lines 244–256 unchanged (13 lines)
257 ))
258 } else {
259 Ok(result)
260 }
261}
262
263pub fn force_refresh_currency(config: &Currency) -> Result<String> {
264 println!("Fetching...");
265 let start = std::time::Instant::now();
266 let mut path = dirs::cache_dir().ok_or_else(|| eyre!("Could not find cache directory"))?;
267 path.push("rink");
268 path.push("currency.json");
269 let file = download_to_file(&path, &config.endpoint, config.timeout)
270 .wrap_err("Fetching currency data failed")?;
271 let delta = std::time::Instant::now() - start;
272 let metadata = file
273 .metadata()
274 .wrap_err("Fetched currency file, but failed to read file metadata")?;
275 let len = metadata.len();
276 Ok(format!(
277 "Fetched {len} byte currency file after {}ms",
278 delta.as_millis()
279 ))
280}
281
282fn load_live_currency(config: &Currency) -> Result<String> {
283 let duration = if config.fetch_on_startup {
284 Some(config.cache_duration)
285 } else {
286 None
287 };
288 let file = cached("currency.json", &config.endpoint, duration, config.timeout)?;
289 let contents = file_to_string(file)?;
290 Ok(contents)
291}
292
293fn try_load_currency(config: &Currency, ctx: &mut Context, search_path: &[PathBuf]) -> Result<()> {
294 let base = read_from_search_path("currency.units", search_path, CURRENCY_FILE)?
295 .into_iter()
296 .collect::<Vec<_>>()
297 .join("\n");
298 let live_defs = load_live_currency(config)?;
299 ctx.load_currency(&live_defs, &base.as_str())
300 .map_err(|err| eyre!("{err}"))?;
301 Ok(())
302}
303
304pub fn read_config(override_path: Option<&str>) -> Result<Config> {
305 let path = if let Some(path) = override_path {
306 PathBuf::from(path)
307 } else {
308 config_path("config.toml")?
309 };
310 match read_to_string(path) {
311 // Hard fail if the file has invalid TOML.
312 Ok(result) => toml::from_str(&result).wrap_err("While parsing config.toml"),
313 Err(err) if err.kind() == ErrorKind::NotFound => {
314 if let Some(override_path) = override_path {
…lines 315–357 unchanged (43 lines)
358 ctx.load(rink_core::ast::Defs { defs: unit_defs })
359 .map_err(|err| eyre!(err))?;
360 ctx.load_dates(datetime::parse_datefile(&dates));
361
362 // Load currency data.
363 if config.currency.enabled {
364 match try_load_currency(&config.currency, &mut ctx, &search_path) {
365 Ok(()) => (),
366 Err(err) => {
367 println!("{:?}", err.wrap_err("Failed to load currency data"));
368 }
369 }
370 }
371
372 Ok(ctx)
373}
374
375fn read_if_current(file: File, expiration: Option<Duration>) -> Result<File> {
376 use std::time::SystemTime;
377
378 let stats = file.metadata()?;
379 let mtime = stats.modified()?;
380 let now = SystemTime::now();
381 let elapsed = now.duration_since(mtime)?;
382 if let Some(expiration) = expiration {
383 if elapsed > expiration {
384 return Err(eyre!("File is out of date"));
385 }
386 }
387 Ok(file)
388}
389
390pub fn download_to_file(path: &Path, url: &str, timeout: Duration) -> Result<File> {
391 use std::fs::create_dir_all;
392
393 create_dir_all(path.parent().unwrap())?;
394
395 // Given a filename like `foo.json`, names the temp file something
396 // similar, like `foo.ABCDEF.json`.
397 let mut prefix = path.file_stem().unwrap().to_owned();
398 prefix.push(".");
399 let mut suffix = OsString::from(".");
400 suffix.push(path.extension().unwrap());
401
402 // The temp file should be created in the same directory as its
403 // final home, as the default temporary file directory might not be
404 // the same filesystem.
405 let mut temp_file = tempfile::Builder::new()
406 .prefix(&prefix)
407 .suffix(&suffix)
408 .tempfile_in(path.parent().unwrap())?;
409
410 let mut easy = Easy::new();
411 easy.url(url)?;
412 easy.useragent(&format!(
413 "rink-cli {} <{}>",
414 env!("CARGO_PKG_VERSION"),
415 env!("CARGO_PKG_REPOSITORY")
416 ))?;
417 easy.timeout(timeout)?;
418
419 let mut write_handle = temp_file.as_file_mut().try_clone()?;
420 easy.write_function(move |data| {
421 write_handle
422 .write(data)
423 .map_err(|_| curl::easy::WriteError::Pause)
424 })?;
425 easy.perform()?;
426
427 let status = easy.response_code()?;
428 if status != 200 {
429 return Err(eyre!(
430 "Received status {} while downloading {}",
431 status,
432 url
433 ));
434 }
435
436 temp_file.as_file_mut().sync_all()?;
437 temp_file.as_file_mut().seek(SeekFrom::Start(0))?;
438
439 temp_file
440 .persist(path)
441 .wrap_err("Failed to write to cache dir")
442}
443
444fn cached(
445 filename: &str,
446 url: &str,
447 expiration: Option<Duration>,
448 timeout: Duration,
449) -> Result<File> {
450 let mut path = dirs::cache_dir().ok_or_else(|| eyre!("Could not find cache directory"))?;
451 path.push("rink");
452 path.push(filename);
453
454 // 1. Return file if it exists and is up to date.
455 // 2. Try to download a new version of the file and return it.
456 // 3. If that fails, return the stale file if it exists.
457
458 if let Ok(file) = File::open(&path) {
459 if let Ok(result) = read_if_current(file, expiration) {
460 return Ok(result);
461 }
462 }
463
464 let err = match download_to_file(&path, url, timeout) {
465 Ok(result) => return Ok(result),
466 Err(err) => err,
467 };
468
469 if let Ok(file) = File::open(&path) {
470 // Indicate error even though we're returning success.
471 println!(
472 "{:?}",
473 Report::wrap_err(
474 err,
475 format!("Failed to refresh {}, using stale version", url)
476 )
477 );
478 Ok(file)
479 } else {
480 Err(err).wrap_err_with(|| format!("Failed to fetch {}", url))
481 }
482}
483