mod convert;
use std::{
collections::HashMap,
path::{Path, PathBuf},
str::FromStr,
};
use access_json::JSONQuery;
use anyhow::{Result, bail};
use async_trait::async_trait;
use futures::future::try_join_all;
use rustc_hash::FxHashMap;
use serde_json::Value;
use tokio::fs;
use url::Url;
use itertools::Itertools;
use super::{Provider, Warnings};
use crate::Hydration;
use convert::{ini2json, toml2json};
#[derive(Default)]
pub struct File {
urls: FxHashMap<Url, String>,
}
impl File {
pub fn new() -> Self {
Default::default()
}
}
#[async_trait]
impl Provider for File {
fn add(&mut self, value: String) -> Result<()> {
match Url::parse(&value) {
Ok(url)
if url.scheme() == "file"
&& url.query_pairs().into_iter().any(|(k, _v)| k == "query") =>
{
self.urls.insert(url, value);
Ok(())
}
_ => bail!("Not an file scheme or missing ?query=.field part"),
}
}
fn name(&self) -> &'static str {
"File"
}
fn install_url(&self) -> &'static str {
"https://github.com/zifeo/lade#file-loader"
}
fn has_work(&self) -> bool {
!self.urls.is_empty()
}
async fn resolve(
&self,
cwd: &Path,
_: &HashMap<String, String>,
_: &Warnings,
) -> Result<Hydration> {
let fetches = self
.urls
.iter()
.into_group_map_by(|(raw_url, value)| {
let url = urlencoding::decode(
&value
.replace("file://", "")
.replace(&format!("?{}", raw_url.query().unwrap()), ""),
)
.expect("invalid percent-encoding in file:// URL")
.into_owned();
let user = directories::UserDirs::new().expect("cannot get HOME location");
if url.starts_with("~/") {
user.home_dir()
.join(url.chars().skip(2).collect::<String>())
} else if url.starts_with("$HOME/") {
user.home_dir()
.join(url.chars().skip(6).collect::<String>())
} else {
cwd.join(url)
}
})
.into_iter()
.map(|(mut path, group)| async move {
if !path.starts_with(PathBuf::from_str("/")?) {
path = cwd.join(path);
}
let format = path
.extension()
.expect("no file format found")
.to_str()
.expect("cannot get file format");
let str = fs::read_to_string(&path)
.await
.unwrap_or_else(|_| panic!("cannot read file {}", path.display()));
let json = match format {
"yaml" | "yml" => serde_yaml::from_str::<Value>(&str)?,
"json" => serde_json::from_str::<Value>(&str)?,
"toml" => toml2json(toml::from_str::<toml::Value>(&str)?),
"ini" => ini2json(ini::Ini::load_from_str(&str)?),
_ => bail!("unsupported file format: {}", format),
};
let hydration = group
.into_iter()
.map(|(url, value)| {
let query = url
.query_pairs()
.into_iter()
.find(|(k, _v)| k == "query")
.unwrap()
.1;
let compiled = JSONQuery::parse(&query)
.unwrap_or_else(|_| panic!("cannot compile query {}", query));
let res = compiled
.execute(&json)
.unwrap()
.unwrap_or_else(|| panic!("no query result for {}", query));
let output = match res {
Value::String(s) => s,
x => x.to_string(),
};
(value.clone(), output)
})
.collect::<Hydration>();
Ok(hydration)
})
.collect::<Vec<_>>();
Ok(try_join_all(fetches).await?.into_iter().flatten().collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use tempfile::tempdir;
#[test]
fn test_add_routing() {
let mut p = File::new();
assert!(
p.add("file:///path/to/config.json?query=.key".to_string())
.is_ok()
);
assert!(p.add("file:///path/to/config.json".to_string()).is_err());
assert!(p.add("vault://host/mount/key/field".to_string()).is_err());
}
async fn resolve_file(
dir: &tempfile::TempDir,
filename: &str,
content: &str,
query: &str,
) -> String {
let path = dir.path().join(filename);
std::fs::write(&path, content).unwrap();
let url = format!("file://{}?query={}", path.display(), query);
let mut p = File::new();
p.add(url.clone()).unwrap();
p.resolve(dir.path(), &HashMap::new(), &Warnings::default())
.await
.unwrap()
.remove(&url)
.unwrap()
}
#[tokio::test]
async fn test_resolve_json_file() {
let dir = tempdir().unwrap();
assert_eq!(
resolve_file(&dir, "config.json", r#"{"key":"myvalue"}"#, ".key").await,
"myvalue"
);
}
#[tokio::test]
async fn test_resolve_yaml_file() {
let dir = tempdir().unwrap();
assert_eq!(
resolve_file(&dir, "config.yaml", "key: yamlvalue\n", ".key").await,
"yamlvalue"
);
}
#[tokio::test]
async fn test_resolve_toml_file() {
let dir = tempdir().unwrap();
assert_eq!(
resolve_file(&dir, "config.toml", "key = \"tomlvalue\"\n", ".key").await,
"tomlvalue"
);
}
#[tokio::test]
async fn test_resolve_ini_file() {
let dir = tempdir().unwrap();
assert_eq!(
resolve_file(
&dir,
"config.ini",
"[section]\npassword = inivalue\n",
".section.password"
)
.await,
"inivalue"
);
}
#[tokio::test]
async fn test_resolve_percent_encoded_path() {
let dir = tempdir().unwrap();
let path = dir.path().join("my config.json");
std::fs::write(&path, r#"{"key":"spaced_value"}"#).unwrap();
let encoded_path = path.display().to_string().replace(' ', "%20");
let url = format!("file://{}?query=.key", encoded_path);
let mut p = File::new();
p.add(url.clone()).unwrap();
let result = p
.resolve(dir.path(), &HashMap::new(), &Warnings::default())
.await
.unwrap()
.remove(&url)
.unwrap();
assert_eq!(result, "spaced_value");
}
#[tokio::test]
async fn test_resolve_nested_json_query() {
let dir = tempdir().unwrap();
assert_eq!(
resolve_file(
&dir,
"config.json",
r#"{"db":{"password":"nested_pass"}}"#,
".db.password"
)
.await,
"nested_pass"
);
}
}