use std::path::{Component, Path, PathBuf};
use crate::layer::{Layer, LayerCtx, LayerError, LayerOutput};
use crate::source::{FileScope, Origin, SourceKind};
use crate::value::Value;
type Preprocess = Box<dyn Fn(&str) -> Result<String, String> + Send + Sync>;
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum Format {
#[cfg(feature = "toml")]
Toml,
#[cfg(feature = "json")]
Json,
#[cfg(feature = "yaml")]
Yaml,
}
impl Format {
pub fn of(path: &Path) -> Option<Self> {
match path.extension().and_then(|e| e.to_str()) {
#[cfg(feature = "toml")]
Some("toml") => Some(Self::Toml),
#[cfg(feature = "json")]
Some("json") => Some(Self::Json),
#[cfg(feature = "yaml")]
Some("yaml") | Some("yml") => Some(Self::Yaml),
_ => None,
}
}
}
pub struct FileLayer {
paths: Vec<PathBuf>,
scope: FileScope,
format: Option<Format>,
prefix: Option<String>,
preprocess: Option<Preprocess>,
}
impl FileLayer {
pub fn at(path: impl Into<PathBuf>, scope: FileScope) -> Self {
Self {
paths: vec![path.into()],
scope,
format: None,
prefix: None,
preprocess: None,
}
}
pub fn find_up(
name: &str,
from: impl AsRef<Path>,
ceiling: Option<&Path>,
scope: FileScope,
) -> Self {
let from = normalize(from.as_ref());
let ceiling = ceiling.map(normalize);
let mut found = Vec::new();
let mut dir = Some(from.as_path());
while let Some(current) = dir {
if let Some(ceiling) = &ceiling {
if !current.starts_with(ceiling) {
break;
}
}
found.push(current.join(name));
if ceiling.as_deref() == Some(current) {
break;
}
dir = current.parent();
}
found.reverse();
Self {
paths: found,
scope,
format: None,
prefix: None,
preprocess: None,
}
}
pub fn as_format(mut self, format: Format) -> Self {
self.format = Some(format);
self
}
pub fn under(mut self, table: impl Into<String>) -> Self {
self.prefix = Some(table.into());
self
}
pub fn preprocess(
mut self,
f: impl Fn(&str) -> Result<String, String> + Send + Sync + 'static,
) -> Self {
self.preprocess = Some(Box::new(f));
self
}
pub fn paths(&self) -> &[PathBuf] {
&self.paths
}
fn read(&self, path: &Path, ctx: &LayerCtx, out: &mut LayerOutput) -> Result<(), LayerError> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => match broken_link(path) {
Some(link) => {
return Err(LayerError::Unreadable {
source: path.display().to_string(),
why: format!("`{}` leads nowhere", link.display()),
})
}
None => return Ok(()),
},
Err(err) => {
return Err(LayerError::Unreadable {
source: path.display().to_string(),
why: err.to_string(),
})
}
};
let text = match &self.preprocess {
Some(f) => f(&text).map_err(|why| LayerError::Unreadable {
source: path.display().to_string(),
why,
})?,
None => text,
};
let format =
self.format
.or_else(|| Format::of(path))
.ok_or_else(|| LayerError::Unreadable {
source: path.display().to_string(),
why: "cannot tell what format this is; name it with `as_format`".to_string(),
})?;
let names_a_setting = |key: &str| ctx.registry().names_file_value(key);
let flat =
parse(format, &text, self.prefix.as_deref(), &names_a_setting).map_err(|why| {
LayerError::Unreadable {
source: path.display().to_string(),
why,
}
})?;
for (key, found) in flat {
let origin = Origin::file(format!("{}#{key}", path.display()), self.scope);
match found {
Read::Text(raw) => match ctx.entry_for_key(&key, &raw, origin) {
Ok(entry) => out.push(entry),
Err(warning) => out.warn(warning),
},
Read::Shaped(value) => match ctx.entry_from_value(&key, value, origin) {
Ok(entry) => out.push(entry),
Err(warning) => out.warn(warning),
},
}
}
Ok(())
}
}
impl Layer for FileLayer {
fn source(&self) -> SourceKind {
SourceKind::FILE
}
fn load(&self, ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
let mut out = LayerOutput::new();
for path in &self.paths {
self.read(path, ctx, &mut out)?;
}
Ok(out)
}
}
enum Read {
Text(String),
Shaped(Value),
}
fn parse(
format: Format,
text: &str,
prefix: Option<&str>,
names_a_setting: &dyn Fn(&str) -> bool,
) -> Result<Vec<(String, Read)>, String> {
match format {
#[cfg(feature = "toml")]
Format::Toml => {
let value: toml::Value = toml::from_str(text).map_err(|e| e.to_string())?;
let value = match prefix {
Some(table) => match value.get(table) {
Some(inner) if inner.is_table() => inner.clone(),
Some(_) => {
return Err(format!(
"`{table}` should be a table of settings, and is not"
))
}
None => return Ok(Vec::new()),
},
None => value,
};
let mut flat = Vec::new();
flatten_toml(String::new(), &value, names_a_setting, &mut flat);
Ok(flat)
}
#[cfg(feature = "json")]
Format::Json => {
let value: serde_json::Value = serde_json::from_str(text).map_err(|e| e.to_string())?;
if !(value.is_object() || value.is_null()) {
return Err("the file should be a table of settings, and is not".into());
}
let value = match prefix {
Some(table) => match value.get(table) {
Some(inner) if inner.is_object() => inner.clone(),
Some(serde_json::Value::Null) | None => return Ok(Vec::new()),
Some(_) => {
return Err(format!(
"`{table}` should be a table of settings, and is not"
))
}
},
None => value,
};
let mut flat = Vec::new();
flatten_json(String::new(), &value, names_a_setting, &mut flat);
Ok(flat)
}
#[cfg(feature = "yaml")]
Format::Yaml => {
let root = yaml_serde::from_str::<yaml_serde::Value>(text).map_err(|e| {
let message = e.to_string();
match message.contains("more than one document") {
true => {
"a settings file is one document, and this is more than one".to_string()
}
false => message,
}
})?;
if !(root.is_mapping() || root.is_null()) {
return Err("the file should be a table of settings, and is not".into());
}
let value = match prefix {
Some(table) => match root.get(table).map(untagged) {
Some(inner) if inner.is_mapping() => inner,
Some(yaml_serde::Value::Null) | None => return Ok(Vec::new()),
Some(_) => {
return Err(format!(
"`{table}` should be a table of settings, and is not"
))
}
},
None => &root,
};
let mut flat = Vec::new();
flatten_yaml(String::new(), value, names_a_setting, &mut flat)?;
Ok(flat)
}
}
}
fn normalize(path: &Path) -> PathBuf {
if let Ok(real) = path.canonicalize() {
return real;
}
let absolute = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
let mut out = PathBuf::new();
for part in absolute.components() {
match part {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
other => {
out.push(other);
if let Ok(real) = out.canonicalize() {
out = real;
}
}
}
}
out
}
fn broken_link(path: &Path) -> Option<PathBuf> {
let mut current = Some(path);
while let Some(step) = current {
match step.symlink_metadata() {
Ok(meta) => {
return (meta.is_symlink() && step.metadata().is_err()).then(|| step.to_path_buf());
}
Err(_) => current = step.parent(),
}
}
None
}
fn joined(prefix: &str, key: &str) -> String {
if prefix.is_empty() {
key.to_string()
} else {
format!("{prefix}.{key}")
}
}
#[cfg(feature = "toml")]
fn flatten_toml(
prefix: String,
value: &toml::Value,
names_a_setting: &dyn Fn(&str) -> bool,
out: &mut Vec<(String, Read)>,
) {
match value {
toml::Value::Table(_) if !prefix.is_empty() && names_a_setting(&prefix) => {
out.push((prefix, Read::Shaped(table_toml(value))));
}
toml::Value::Table(table) => {
for (key, inner) in table {
flatten_toml(joined(&prefix, key), inner, names_a_setting, out);
}
}
toml::Value::Array(_) => out.push((prefix, Read::Shaped(table_toml(value)))),
scalar => out.push((prefix, Read::Text(scalar_toml(scalar)))),
}
}
#[cfg(feature = "toml")]
fn table_toml(value: &toml::Value) -> Value {
match value {
toml::Value::Table(table) => Value::Map(
table
.iter()
.map(|(key, inner)| (key.clone(), table_toml(inner)))
.collect(),
),
toml::Value::Array(items) => Value::List(items.iter().map(table_toml).collect()),
toml::Value::Boolean(b) => Value::Bool(*b),
toml::Value::Integer(i) => Value::Int(*i),
toml::Value::Float(f) => Value::Float(*f),
other => Value::String(scalar_toml(other)),
}
}
#[cfg(feature = "toml")]
fn scalar_toml(value: &toml::Value) -> String {
match value {
toml::Value::String(s) => s.clone(),
other => other.to_string(),
}
}
#[cfg(feature = "json")]
fn flatten_json(
prefix: String,
value: &serde_json::Value,
names_a_setting: &dyn Fn(&str) -> bool,
out: &mut Vec<(String, Read)>,
) {
match value {
serde_json::Value::Object(_) if !prefix.is_empty() && names_a_setting(&prefix) => {
out.push((prefix, Read::Shaped(table_json(value))));
}
serde_json::Value::Null => {}
serde_json::Value::Object(map) => {
for (key, inner) in map {
flatten_json(joined(&prefix, key), inner, names_a_setting, out);
}
}
serde_json::Value::Array(_) => out.push((prefix, Read::Shaped(table_json(value)))),
scalar => out.push((prefix, Read::Text(scalar_json(scalar)))),
}
}
#[cfg(feature = "json")]
fn table_json(value: &serde_json::Value) -> Value {
match value {
serde_json::Value::Object(map) => Value::Map(
map.iter()
.filter(|(_, inner)| !inner.is_null())
.map(|(key, inner)| (key.clone(), table_json(inner)))
.collect(),
),
serde_json::Value::Array(items) => Value::List(
items
.iter()
.filter(|item| !item.is_null())
.map(table_json)
.collect(),
),
serde_json::Value::Bool(b) => Value::Bool(*b),
serde_json::Value::Number(n) => match n.as_i64() {
Some(i) => Value::Int(i),
None => Value::Float(n.as_f64().unwrap_or_default()),
},
other => Value::String(scalar_json(other)),
}
}
#[cfg(feature = "json")]
fn scalar_json(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
}
}
#[cfg(feature = "yaml")]
const MERGE_KEY: &str = "<<";
#[cfg(feature = "yaml")]
fn flatten_yaml(
prefix: String,
value: &yaml_serde::Value,
names_a_setting: &dyn Fn(&str) -> bool,
out: &mut Vec<(String, Read)>,
) -> Result<(), String> {
match untagged(value) {
yaml_serde::Value::Mapping(_) if !prefix.is_empty() && names_a_setting(&prefix) => {
let table = table_yaml(value, &prefix)?;
out.push((prefix, Read::Shaped(table)));
}
yaml_serde::Value::Null => {}
yaml_serde::Value::Mapping(map) => {
for (key, inner) in yaml_entries(map, &prefix)? {
flatten_yaml(joined(&prefix, &key), inner, names_a_setting, out)?;
}
}
yaml_serde::Value::Sequence(_) => {
let list = table_yaml(value, &prefix)?;
out.push((prefix, Read::Shaped(list)));
}
scalar => out.push((prefix, Read::Text(scalar_yaml(scalar)))),
}
Ok(())
}
#[cfg(feature = "yaml")]
fn table_yaml(value: &yaml_serde::Value, at: &str) -> Result<Value, String> {
Ok(match untagged(value) {
yaml_serde::Value::Mapping(map) => Value::Map(
yaml_entries(map, at)?
.into_iter()
.filter(|(_, inner)| !inner.is_null())
.map(|(key, inner)| table_yaml(inner, at).map(|inner| (key, inner)))
.collect::<Result<_, _>>()?,
),
yaml_serde::Value::Sequence(items) => Value::List(
items
.iter()
.filter(|item| !item.is_null())
.map(|item| table_yaml(item, at))
.collect::<Result<_, _>>()?,
),
yaml_serde::Value::Bool(b) => Value::Bool(*b),
yaml_serde::Value::Number(n) => match n.as_i64() {
Some(i) => Value::Int(i),
None => Value::Float(n.as_f64().unwrap_or_default()),
},
other => Value::String(scalar_yaml(other)),
})
}
#[cfg(feature = "yaml")]
fn scalar_yaml(value: &yaml_serde::Value) -> String {
match value {
yaml_serde::Value::String(s) => s.clone(),
yaml_serde::Value::Bool(b) => b.to_string(),
yaml_serde::Value::Number(n) => n.to_string(),
_ => "null".to_string(),
}
}
#[cfg(feature = "yaml")]
fn untagged(value: &yaml_serde::Value) -> &yaml_serde::Value {
let mut value = value;
while let yaml_serde::Value::Tagged(tagged) = value {
value = &tagged.value;
}
value
}
#[cfg(feature = "yaml")]
fn describe(at: &str) -> String {
match at.is_empty() {
true => "the file".to_string(),
false => format!("`{at}`"),
}
}
#[cfg(feature = "yaml")]
fn yaml_entries<'a>(
mapping: &'a yaml_serde::Mapping,
at: &str,
) -> Result<Vec<(String, &'a yaml_serde::Value)>, String> {
let mut entries: Vec<(String, &yaml_serde::Value)> = Vec::new();
let mut merged: Vec<(String, &yaml_serde::Value)> = Vec::new();
for (key, value) in mapping {
if key.as_str() == Some(MERGE_KEY) {
for source in merge_sources(untagged(value)).ok_or_else(|| {
format!(
"{} merges `{MERGE_KEY}` from something that is not a mapping",
describe(at)
)
})? {
merged.extend(yaml_entries(source, at)?);
}
continue;
}
entries.push((
yaml_key(key)
.ok_or_else(|| format!("{} has a key that is not a name", describe(at)))?,
value,
));
}
for (key, value) in merged {
if !entries.iter().any(|(existing, _)| *existing == key) {
entries.push((key, value));
}
}
Ok(entries)
}
#[cfg(feature = "yaml")]
fn merge_sources(value: &yaml_serde::Value) -> Option<Vec<&yaml_serde::Mapping>> {
match value {
yaml_serde::Value::Mapping(map) => Some(vec![map]),
yaml_serde::Value::Sequence(items) => items.iter().map(|item| item.as_mapping()).collect(),
_ => None,
}
}
#[cfg(feature = "yaml")]
fn yaml_key(key: &yaml_serde::Value) -> Option<String> {
match untagged(key) {
yaml_serde::Value::String(s) => Some(s.clone()),
yaml_serde::Value::Bool(b) => Some(b.to_string()),
yaml_serde::Value::Number(n) => Some(n.to_string()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::{PropMeta, Registry};
use crate::resolve::{resolve, Layers};
use crate::ty::{Parser, Ty};
use crate::value::Value;
static PROPS: &[PropMeta] = &[
PropMeta::new("jobs", Ty::Uint),
PropMeta {
parse: Some(Parser::ListByComma),
..PropMeta::new("exclude", Ty::List(&Ty::String))
},
PropMeta::new("task.output", Ty::String),
PropMeta {
parse: Some(Parser::ListByColon),
..PropMeta::new("path", Ty::List(&Ty::String))
},
PropMeta::new("plain_list", Ty::List(&Ty::String)),
PropMeta::new("either", Ty::Any),
PropMeta {
scope: crate::registry::Scope::Global,
..PropMeta::new("trusted", Ty::Bool)
},
PropMeta {
merge: crate::registry::Merge::Deep,
..PropMeta::new("url_replacements", Ty::Map(&Ty::String))
},
];
const REGISTRY: Registry = Registry::new(PROPS);
struct Tree(PathBuf);
impl Tree {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir()
.join(format!("usage_config_files_{}_{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir");
Self(dir)
}
fn write(&self, rel: &str, text: &str) -> PathBuf {
let path = self.0.join(rel);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("parent");
}
std::fs::write(&path, text).expect("write");
path
}
}
impl Drop for Tree {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[cfg(feature = "toml")]
#[test]
fn a_file_supplies_values_the_spec_understands() {
let tree = Tree::new("basic");
let path = tree.write(
"hk.toml",
"jobs = 4\nexclude = [\"target\", \"vendor\"]\n\n[task]\noutput = \"prefix\"\n",
);
let layer = FileLayer::at(&path, FileScope::Project);
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("should resolve");
assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(4)));
assert_eq!(
resolved.get_key("task.output"),
Some(&Value::from("prefix"))
);
assert_eq!(
resolved.get_key("exclude"),
Some(&Value::List(vec![
Value::from("target"),
Value::from("vendor")
]))
);
let origin = resolved.origin_key("jobs").unwrap();
assert!(origin.describe().ends_with("hk.toml#jobs"), "{origin:?}");
}
#[cfg(feature = "toml")]
#[test]
fn a_setting_that_is_a_table_arrives_as_one() {
let tree = Tree::new("maps");
let path = tree.write(
"hk.toml",
"[url_replacements]\n\"https://a\" = \"https://b\"\n\"https://c\" = \"https://d\"\n",
);
let layer = FileLayer::at(&path, FileScope::Project);
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("should resolve");
assert_eq!(
resolved.get_key("url_replacements"),
Some(&Value::Map(
[
("https://a".to_string(), Value::from("https://b")),
("https://c".to_string(), Value::from("https://d")),
]
.into_iter()
.collect()
))
);
assert!(resolved.warnings.is_empty(), "{:?}", resolved.warnings);
let higher = tree.write(
"higher.toml",
"[url_replacements]\n\"https://a\" = \"https://z\"\n",
);
let lower = FileLayer::at(&path, FileScope::System);
let upper = FileLayer::at(&higher, FileScope::Project);
let resolved =
resolve(REGISTRY, Layers::new().then(&upper).then(&lower)).expect("should resolve");
let Some(Value::Map(merged)) = resolved.get_key("url_replacements") else {
panic!(
"expected a table: {:?}",
resolved.get_key("url_replacements")
);
};
assert_eq!(merged.get("https://a"), Some(&Value::from("https://z")));
assert_eq!(merged.get("https://c"), Some(&Value::from("https://d")));
}
#[cfg(feature = "toml")]
#[test]
fn an_array_keeps_the_boundaries_the_file_gave_it() {
let tree = Tree::new("arrays");
let path = tree.write(
"hk.toml",
"exclude = [\"a,b\", \"c\"]\npath = [\"/bin\", \"/usr/bin\"]\nplain_list = [\"one\", \"two\"]\n",
);
let layer = FileLayer::at(&path, FileScope::Project);
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("should resolve");
assert_eq!(
resolved.get_key("exclude"),
Some(&Value::List(vec![Value::from("a,b"), Value::from("c")])),
"an item containing the separator survives"
);
assert_eq!(
resolved.get_key("path"),
Some(&Value::List(vec![
Value::from("/bin"),
Value::from("/usr/bin")
])),
"a colon-parsed list is not re-split on commas"
);
assert_eq!(
resolved.get_key("plain_list"),
Some(&Value::List(vec![Value::from("one"), Value::from("two")])),
"a list with no named parser is still a list"
);
let path = tree.write("text.toml", "path = \"/bin:/usr/bin\"\n");
let layer = FileLayer::at(&path, FileScope::Project);
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("should resolve");
assert_eq!(
resolved.get_key("path"),
Some(&Value::List(vec![
Value::from("/bin"),
Value::from("/usr/bin")
]))
);
}
#[cfg(feature = "toml")]
#[test]
fn a_table_under_a_type_usage_cannot_know_is_still_the_value() {
let tree = Tree::new("any");
let path = tree.write("hk.toml", "[either]\nnested = \"yes\"\n");
let layer = FileLayer::at(&path, FileScope::Project);
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("should resolve");
assert_eq!(
resolved.get_key("either"),
Some(&Value::Map(
[("nested".to_string(), Value::from("yes"))]
.into_iter()
.collect()
))
);
assert!(resolved.warnings.is_empty(), "{:?}", resolved.warnings);
}
#[cfg(feature = "toml")]
#[test]
fn a_table_where_a_scalar_was_declared_is_reported() {
let tree = Tree::new("emptytable");
let path = tree.write("hk.toml", "[jobs]\n");
let layer = FileLayer::at(&path, FileScope::Project);
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("should resolve");
assert_eq!(resolved.get_key("jobs"), None);
assert!(
resolved
.warnings
.iter()
.any(|w| w.message.contains("jobs expected")),
"{:?}",
resolved.warnings
);
}
#[cfg(feature = "toml")]
#[test]
fn a_shaped_value_of_the_wrong_type_is_still_a_warning() {
let tree = Tree::new("shaped");
let path = tree.write(
"hk.toml",
"jobs = 2\n[url_replacements]\n\"https://a\" = { nested = true }\n",
);
let layer = FileLayer::at(&path, FileScope::Project);
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("should resolve");
assert_eq!(resolved.get_key("url_replacements"), None);
assert!(
resolved.warnings[0]
.message
.contains("url_replacements expected"),
"{:?}",
resolved.warnings
);
assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(2)));
}
#[cfg(feature = "toml")]
#[test]
fn a_file_that_cannot_be_read_is_not_treated_as_absent() {
let tree = Tree::new("unreadable");
let dir = tree.0.join("hk.toml");
std::fs::create_dir_all(&dir).expect("a directory where a file should be");
let layer = FileLayer::at(&dir, FileScope::Project);
let err = resolve(REGISTRY, Layers::new().then(&layer)).expect_err("should fail");
assert!(err.to_string().contains("hk.toml"), "{err}");
}
#[cfg(feature = "toml")]
#[test]
fn a_ceiling_written_differently_is_still_a_ceiling() {
let tree = Tree::new("relative");
let deep = tree.0.join("a").join("b");
std::fs::create_dir_all(&deep).expect("dirs");
let absolute = FileLayer::find_up("hk.toml", &deep, Some(&tree.0), FileScope::Project);
let indirect = tree.0.join("a").join("..");
let roundabout = FileLayer::find_up("hk.toml", &deep, Some(&indirect), FileScope::Project);
assert_eq!(
roundabout.paths().len(),
absolute.paths().len(),
"the walk should stop in the same place: {:?}",
roundabout.paths()
);
}
#[cfg(feature = "toml")]
#[test]
fn a_ceiling_that_is_not_there_and_written_the_long_way_round_is_still_a_boundary() {
let cwd = std::env::current_dir().expect("cwd");
let root = format!("usage_config_absent_dots_{}", std::process::id());
let from = cwd.join(&root).join("project").join("src");
let ceiling = PathBuf::from(&root).join("nowhere").join("..");
assert!(!from.exists(), "the point is that it is not there");
let layer = FileLayer::find_up("hk.toml", &from, Some(&ceiling), FileScope::Project);
assert_eq!(
layer.paths(),
[
cwd.join(&root).join("hk.toml"),
cwd.join(&root).join("project").join("hk.toml"),
from.join("hk.toml"),
],
"`{}/nowhere/..` is `{}`",
root,
root
);
}
#[cfg(all(unix, feature = "toml"))]
#[test]
fn a_directory_that_is_not_there_below_a_link_that_is_still_lands_inside_the_ceiling() {
let tree = Tree::new("link_dir");
let target = tree.0.join("target");
std::fs::create_dir_all(&target).expect("dirs");
let link = tree.0.join("link");
std::os::unix::fs::symlink(&target, &link).expect("symlink");
let from = link.join("not-created-yet");
let layer = FileLayer::find_up("hk.toml", &from, Some(&target), FileScope::Project);
let real_target = target.canonicalize().expect("exists");
assert_eq!(
layer.paths(),
[
real_target.join("hk.toml"),
real_target.join("not-created-yet").join("hk.toml"),
],
"the walk is inside the ceiling, by way of the link"
);
}
#[cfg(all(unix, feature = "toml"))]
#[test]
fn a_link_to_a_file_that_is_gone_is_not_the_same_as_no_file() {
let tree = Tree::new("dangling");
let link = tree.0.join("hk.toml");
std::os::unix::fs::symlink(tree.0.join("gone.toml"), &link).expect("symlink");
let layer = FileLayer::at(&link, FileScope::Project);
let err = resolve(REGISTRY, Layers::new().then(&layer)).expect_err("should fail");
assert!(err.to_string().contains("hk.toml"), "{err}");
assert!(err.to_string().contains("leads nowhere"), "{err}");
tree.write("gone.toml", "jobs = 6\n");
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("should resolve");
assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(6)));
}
#[cfg(all(unix, feature = "toml"))]
#[test]
fn a_file_under_a_link_that_leads_nowhere_is_reported_too() {
let tree = Tree::new("dangling_dir");
let link = tree.0.join("config");
std::os::unix::fs::symlink(tree.0.join("not-mounted"), &link).expect("symlink");
let layer = FileLayer::at(link.join("hk.toml"), FileScope::Project);
let err = resolve(REGISTRY, Layers::new().then(&layer)).expect_err("should fail");
assert!(err.to_string().contains("config` leads nowhere"), "{err}");
let plain = FileLayer::at(
tree.0.join("never-made").join("hk.toml"),
FileScope::Project,
);
let resolved = resolve(REGISTRY, Layers::new().then(&plain)).expect("should resolve");
assert!(resolved.warnings.is_empty(), "{:?}", resolved.warnings);
}
#[cfg(all(unix, feature = "toml"))]
#[test]
fn a_step_back_out_of_a_link_goes_where_the_link_pointed() {
let tree = Tree::new("link_dots");
let target = tree.0.join("a").join("target");
std::fs::create_dir_all(&target).expect("dirs");
let link = tree.0.join("link");
std::os::unix::fs::symlink(&target, &link).expect("symlink");
let from = link.join("..").join("x");
let ceiling = tree.0.join("a");
let layer = FileLayer::find_up("hk.toml", &from, Some(&ceiling), FileScope::Project);
let real = ceiling.canonicalize().expect("exists");
assert_eq!(
layer.paths(),
[real.join("hk.toml"), real.join("x").join("hk.toml")],
"the walk should be under {}",
real.display()
);
}
#[cfg(feature = "toml")]
#[test]
fn a_ceiling_that_does_not_exist_yet_is_still_a_boundary() {
let cwd = std::env::current_dir().expect("cwd");
let root = format!("usage_config_absent_ceiling_{}", std::process::id());
let from = cwd.join(&root).join("project").join("src");
let ceiling = PathBuf::from(&root);
assert!(!from.exists(), "the point is that it is not there");
let layer = FileLayer::find_up("hk.toml", &from, Some(&ceiling), FileScope::Project);
assert_eq!(
layer.paths(),
[
cwd.join(&root).join("hk.toml"),
cwd.join(&root).join("project").join("hk.toml"),
from.join("hk.toml"),
],
"farthest first, stopping at the ceiling"
);
}
#[cfg(feature = "toml")]
#[test]
fn a_missing_file_is_not_a_failure_but_an_unreadable_one_is() {
let tree = Tree::new("absent");
let layer = FileLayer::at(tree.0.join("nothing.toml"), FileScope::Project);
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("should resolve");
assert_eq!(resolved.get_key("jobs"), None);
assert!(resolved.warnings.is_empty());
let path = tree.write("broken.toml", "jobs = \n");
let layer = FileLayer::at(&path, FileScope::Project);
let err = resolve(REGISTRY, Layers::new().then(&layer)).expect_err("should fail");
assert!(err.to_string().contains("broken.toml"), "{err}");
}
#[cfg(feature = "toml")]
#[test]
fn the_nearest_file_in_a_find_up_chain_wins() {
let tree = Tree::new("findup");
tree.write("hk.toml", "jobs = 1\n");
let deep = tree.0.join("a").join("b");
std::fs::create_dir_all(&deep).expect("dirs");
tree.write("a/hk.toml", "jobs = 2\n");
tree.write("a/b/hk.toml", "jobs = 3\n");
let layer = FileLayer::find_up("hk.toml", &deep, Some(&tree.0), FileScope::Project);
assert_eq!(layer.paths().len(), 3, "root, a, a/b");
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("should resolve");
assert_eq!(
resolved.get_key("jobs"),
Some(&Value::Int(3)),
"the nearest"
);
let contributors: Vec<String> = resolved
.contributors_key("jobs")
.iter()
.map(|o| o.describe().to_string())
.collect();
assert_eq!(contributors.len(), 3, "{contributors:?}");
assert!(contributors[0].contains("hk.toml"));
assert!(
contributors[2].contains("b"),
"the nearest is last: {contributors:?}"
);
}
#[cfg(feature = "toml")]
#[test]
fn the_ceiling_stops_the_walk() {
let tree = Tree::new("ceiling");
let deep = tree.0.join("a").join("b");
std::fs::create_dir_all(&deep).expect("dirs");
let bounded = FileLayer::find_up("hk.toml", &deep, Some(&tree.0), FileScope::Project);
assert_eq!(bounded.paths().len(), 3);
let unbounded = FileLayer::find_up("hk.toml", &deep, None, FileScope::Project);
assert!(
unbounded.paths().len() > 3,
"without a ceiling the walk reaches the root: {:?}",
unbounded.paths()
);
}
#[cfg(feature = "toml")]
#[test]
fn settings_can_live_in_a_table_of_their_own() {
let tree = Tree::new("prefix");
let path = tree.write(
"mise.toml",
"[tools]\nnode = \"20\"\n\n[settings]\njobs = 8\n\n[settings.task]\noutput = \"interleave\"\n",
);
let layer = FileLayer::at(&path, FileScope::Project).under("settings");
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("should resolve");
assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(8)));
assert_eq!(
resolved.get_key("task.output"),
Some(&Value::from("interleave"))
);
assert!(resolved.warnings.is_empty(), "{:?}", resolved.warnings);
let path = tree.write("empty.toml", "[tools]\nnode = \"20\"\n");
let layer = FileLayer::at(&path, FileScope::Project).under("settings");
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("should resolve");
assert_eq!(resolved.get_key("jobs"), None);
let path = tree.write("wrong.toml", "settings = 4\njobs = 9\n");
let layer = FileLayer::at(&path, FileScope::Project).under("settings");
let err = resolve(REGISTRY, Layers::new().then(&layer)).expect_err("should fail");
assert!(err.to_string().contains("should be a table"), "{err}");
assert!(err.to_string().contains("wrong.toml"), "{err}");
}
#[cfg(feature = "toml")]
#[test]
fn a_key_nobody_knows_is_a_warning_and_the_rest_of_the_file_still_applies() {
let tree = Tree::new("unknown");
let path = tree.write("hk.toml", "jobs = 2\nfrom_the_future = true\n");
let layer = FileLayer::at(&path, FileScope::Project);
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("should resolve");
assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(2)));
assert_eq!(resolved.warnings.len(), 1, "{:?}", resolved.warnings);
assert!(
resolved.warnings[0].message.contains("from_the_future"),
"{:?}",
resolved.warnings[0]
);
}
#[cfg(feature = "toml")]
#[test]
fn a_value_of_the_wrong_type_costs_only_its_own_key() {
let tree = Tree::new("badvalue");
let path = tree.write("hk.toml", "jobs = \"lots\"\n[task]\noutput = \"prefix\"\n");
let layer = FileLayer::at(&path, FileScope::System);
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("should resolve");
assert_eq!(resolved.get_key("jobs"), None);
assert_eq!(
resolved.get_key("task.output"),
Some(&Value::from("prefix")),
"the rest of the file still applies"
);
assert!(
resolved.warnings[0].message.contains("jobs expected"),
"{:?}",
resolved.warnings[0]
);
}
#[cfg(feature = "toml")]
#[test]
fn a_project_file_cannot_set_what_the_spec_says_it_cannot() {
let tree = Tree::new("scope");
let path = tree.write("hk.toml", "trusted = true\n");
let project = FileLayer::at(&path, FileScope::Project);
let resolved = resolve(REGISTRY, Layers::new().then(&project)).expect("should resolve");
assert_eq!(resolved.get_key("trusted"), None);
assert!(resolved.warnings[0]
.message
.contains("trusted cannot be set"));
let global = FileLayer::at(&path, FileScope::Global);
let resolved = resolve(REGISTRY, Layers::new().then(&global)).expect("should resolve");
assert_eq!(resolved.get_key("trusted"), Some(&Value::Bool(true)));
}
#[cfg(feature = "toml")]
#[test]
fn text_can_be_transformed_before_it_is_parsed() {
let tree = Tree::new("preprocess");
let path = tree.write("hk.toml", "jobs = {{ cores }}\n");
let layer = FileLayer::at(&path, FileScope::Project)
.preprocess(|text| Ok(text.replace("{{ cores }}", "6")));
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("should resolve");
assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(6)));
let layer = FileLayer::at(&path, FileScope::Project)
.preprocess(|_| Err("no such variable `cores`".to_string()));
let err = resolve(REGISTRY, Layers::new().then(&layer)).expect_err("should fail");
assert!(err.to_string().contains("no such variable"), "{err}");
assert!(err.to_string().contains("hk.toml"), "{err}");
}
#[cfg(feature = "json")]
#[test]
fn a_json_null_is_a_key_that_is_not_there() {
let tree = Tree::new("nulls");
let path = tree.write(
"hk.json",
"{\"task\": {\"output\": null}, \"plain_list\": null, \"jobs\": 3, \"url_replacements\": {\"a\": null, \"b\": \"c\"}}",
);
let layer = FileLayer::at(&path, FileScope::Project);
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("should resolve");
assert_eq!(resolved.get_key("task.output"), None);
assert_eq!(resolved.get_key("plain_list"), None);
assert!(resolved.warnings.is_empty(), "{:?}", resolved.warnings);
assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(3)));
assert_eq!(
resolved.get_key("url_replacements"),
Some(&Value::Map(
[("b".to_string(), Value::from("c"))].into_iter().collect()
))
);
let path = tree.write(
"mise.json",
"{\"tools\": {\"node\": \"20\"}, \"settings\": null}",
);
let layer = FileLayer::at(&path, FileScope::Project).under("settings");
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("should resolve");
assert_eq!(resolved.get_key("jobs"), None);
assert!(resolved.warnings.is_empty(), "{:?}", resolved.warnings);
}
#[cfg(feature = "json")]
#[test]
fn a_json_file_that_is_not_a_table_of_settings_says_so() {
let tree = Tree::new("json_root");
for (name, text) in [("list.json", "[1, 2]"), ("scalar.json", "\"text\"")] {
let path = tree.write(name, text);
let layer = FileLayer::at(&path, FileScope::Project);
let err = resolve(REGISTRY, Layers::new().then(&layer)).expect_err("should fail");
assert!(err.to_string().contains("should be a table"), "{err}");
assert!(err.to_string().contains(name), "{err}");
}
let path = tree.write("null.json", "null");
let layer = FileLayer::at(&path, FileScope::Project);
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("should resolve");
assert_eq!(resolved.get_key("jobs"), None);
assert!(resolved.warnings.is_empty(), "{:?}", resolved.warnings);
let path = tree.write("under.json", "[1, 2]");
let layer = FileLayer::at(&path, FileScope::Project).under("settings");
let err = resolve(REGISTRY, Layers::new().then(&layer)).expect_err("should fail");
assert!(err.to_string().contains("should be a table"), "{err}");
assert!(err.to_string().contains("under.json"), "{err}");
}
#[cfg(feature = "json")]
#[test]
fn json_reads_the_same_settings_as_toml() {
let tree = Tree::new("json");
let path = tree.write(
"hk.json",
"{\"jobs\": 4, \"exclude\": [\"target\"], \"task\": {\"output\": \"prefix\"}}",
);
let layer = FileLayer::at(&path, FileScope::Project);
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("should resolve");
assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(4)));
assert_eq!(
resolved.get_key("task.output"),
Some(&Value::from("prefix"))
);
assert_eq!(
resolved.get_key("exclude"),
Some(&Value::List(vec![Value::from("target")]))
);
}
#[cfg(feature = "yaml")]
#[test]
fn yaml_reads_the_same_settings_as_toml() {
let tree = Tree::new("yaml");
let path = tree.write(
"hk.yaml",
"jobs: 4\nexclude:\n - a,b\n - c\ntask:\n output: prefix\n",
);
let layer = FileLayer::at(&path, FileScope::Project);
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("should resolve");
assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(4)));
assert_eq!(
resolved.get_key("task.output"),
Some(&Value::from("prefix"))
);
assert_eq!(
resolved.get_key("exclude"),
Some(&Value::List(vec![Value::from("a,b"), Value::from("c")]))
);
let origin = resolved.origin_key("jobs").unwrap();
assert!(origin.describe().ends_with("hk.yaml#jobs"), "{origin:?}");
let path = tree.write("mise.yml", "tools:\n node: '20'\nsettings:\n jobs: 9\n");
let layer = FileLayer::at(&path, FileScope::Project).under("settings");
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("should resolve");
assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(9)));
assert!(resolved.warnings.is_empty(), "{:?}", resolved.warnings);
}
#[cfg(feature = "yaml")]
#[test]
fn a_yaml_file_that_says_nothing_is_not_a_file_that_is_wrong() {
let tree = Tree::new("yaml_empty");
for (name, text) in [
("empty.yaml", ""),
("comments.yaml", "# nothing to see\n"),
("marker.yaml", "---\n"),
("null.yaml", "null\n"),
] {
let path = tree.write(name, text);
let layer = FileLayer::at(&path, FileScope::Project);
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect(name);
assert_eq!(resolved.get_key("jobs"), None, "{name}");
assert!(
resolved.warnings.is_empty(),
"{name}: {:?}",
resolved.warnings
);
}
}
#[cfg(feature = "yaml")]
#[test]
fn a_yaml_file_that_is_not_a_table_of_settings_says_so() {
let tree = Tree::new("yaml_root");
for (name, text) in [("list.yaml", "- 1\n- 2\n"), ("scalar.yaml", "text\n")] {
let path = tree.write(name, text);
let layer = FileLayer::at(&path, FileScope::Project);
let err = resolve(REGISTRY, Layers::new().then(&layer)).expect_err("should fail");
assert!(err.to_string().contains("should be a table"), "{err}");
assert!(err.to_string().contains(name), "{err}");
}
let path = tree.write("under.yaml", "settings: 4\n");
let layer = FileLayer::at(&path, FileScope::Project).under("settings");
let err = resolve(REGISTRY, Layers::new().then(&layer)).expect_err("should fail");
assert!(
err.to_string().contains("`settings` should be a table"),
"{err}"
);
let path = tree.write("other.yaml", "tools:\n node: '20'\n");
let layer = FileLayer::at(&path, FileScope::Project).under("settings");
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("should resolve");
assert_eq!(resolved.get_key("jobs"), None);
assert!(resolved.warnings.is_empty(), "{:?}", resolved.warnings);
}
#[cfg(feature = "yaml")]
#[test]
fn more_than_one_yaml_document_is_reported_as_the_file_it_is() {
let tree = Tree::new("yaml_docs");
let path = tree.write("hk.yaml", "jobs: 4\n---\njobs: 8\n");
let layer = FileLayer::at(&path, FileScope::Project);
let err = resolve(REGISTRY, Layers::new().then(&layer)).expect_err("should fail");
assert!(err.to_string().contains("one document"), "{err}");
assert!(err.to_string().contains("hk.yaml"), "{err}");
}
#[cfg(feature = "yaml")]
#[test]
fn a_yaml_null_is_a_key_that_is_not_there() {
let tree = Tree::new("yaml_nulls");
let path = tree.write(
"hk.yaml",
"jobs: 3\ntask:\n output:\nplain_list: ~\nurl_replacements:\n a: null\n b: c\n",
);
let layer = FileLayer::at(&path, FileScope::Project);
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("should resolve");
assert_eq!(resolved.get_key("task.output"), None);
assert_eq!(resolved.get_key("plain_list"), None);
assert!(resolved.warnings.is_empty(), "{:?}", resolved.warnings);
assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(3)));
assert_eq!(
resolved.get_key("url_replacements"),
Some(&Value::Map(
[("b".to_string(), Value::from("c"))].into_iter().collect()
))
);
}
#[cfg(feature = "yaml")]
#[test]
fn a_yaml_key_that_is_a_number_is_the_name_it_looks_like() {
let tree = Tree::new("yaml_keys");
let path = tree.write(
"hk.yaml",
"url_replacements:\n 18: eighteen\n true: yes-really\n",
);
let layer = FileLayer::at(&path, FileScope::Project);
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("should resolve");
assert_eq!(
resolved.get_key("url_replacements"),
Some(&Value::Map(
[
("18".to_string(), Value::from("eighteen")),
("true".to_string(), Value::from("yes-really")),
]
.into_iter()
.collect()
))
);
let path = tree.write("complex.yaml", "task:\n ? [a, b]\n : value\n");
let layer = FileLayer::at(&path, FileScope::Project);
let err = resolve(REGISTRY, Layers::new().then(&layer)).expect_err("should fail");
assert!(err.to_string().contains("`task` has a key"), "{err}");
assert!(err.to_string().contains("complex.yaml"), "{err}");
}
#[cfg(feature = "yaml")]
#[test]
fn a_merge_key_brings_the_settings_it_points_at() {
let tree = Tree::new("yaml_merge");
let path = tree.write(
"hk.yaml",
"defaults: &defaults\n jobs: 4\n task:\n output: prefix\nsettings:\n <<: *defaults\n jobs: 8\n",
);
let layer = FileLayer::at(&path, FileScope::Project).under("settings");
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("should resolve");
assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(8)));
assert_eq!(
resolved.get_key("task.output"),
Some(&Value::from("prefix"))
);
assert!(resolved.warnings.is_empty(), "{:?}", resolved.warnings);
let path = tree.write(
"many.yaml",
"a: &a\n jobs: 1\nb: &b\n jobs: 2\n trusted: true\nsettings:\n <<: [*a, *b]\n",
);
let layer = FileLayer::at(&path, FileScope::System).under("settings");
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("should resolve");
assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(1)));
assert_eq!(resolved.get_key("trusted"), Some(&Value::Bool(true)));
let path = tree.write(
"tagged.yaml",
"a: &a\n jobs: 7\nsettings:\n !Merge <<: *a\n",
);
let layer = FileLayer::at(&path, FileScope::Project).under("settings");
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("should resolve");
assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(7)));
assert!(resolved.warnings.is_empty(), "{:?}", resolved.warnings);
let path = tree.write("bad.yaml", "settings:\n <<: 4\n");
let layer = FileLayer::at(&path, FileScope::Project).under("settings");
let err = resolve(REGISTRY, Layers::new().then(&layer)).expect_err("should fail");
assert!(err.to_string().contains("not a mapping"), "{err}");
}
#[cfg(feature = "yaml")]
#[test]
fn a_yaml_tag_does_not_change_what_a_value_is() {
let tree = Tree::new("yaml_tags");
let path = tree.write(
"hk.yaml",
"jobs: !!int 4\nurl_replacements: !Table\n a: b\n",
);
let layer = FileLayer::at(&path, FileScope::Project);
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("should resolve");
assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(4)));
assert_eq!(
resolved.get_key("url_replacements"),
Some(&Value::Map(
[("a".to_string(), Value::from("b"))].into_iter().collect()
))
);
assert!(resolved.warnings.is_empty(), "{:?}", resolved.warnings);
}
#[cfg(feature = "toml")]
#[test]
fn a_format_nothing_can_infer_is_named_rather_than_guessed() {
let tree = Tree::new("format");
let path = tree.write(".hkrc", "jobs = 5\n");
let layer = FileLayer::at(&path, FileScope::Project);
let err = resolve(REGISTRY, Layers::new().then(&layer)).expect_err("should fail");
assert!(err.to_string().contains("cannot tell what format"), "{err}");
let layer = FileLayer::at(&path, FileScope::Project).as_format(Format::Toml);
let resolved = resolve(REGISTRY, Layers::new().then(&layer)).expect("should resolve");
assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(5)));
}
}