pub mod cli;
pub mod format;
pub mod interp;
pub mod value;
use std::io::{Read, Write};
use std::path::Path;
use anyhow::{Context, anyhow, bail};
use knf_core::{
MergeError, MergeOptions, RuleError, RuleErrors, Rules, Strategy, Value, merge_with,
};
use knf_dotted::{PathError, PathLeaf};
use knf_interp::{InterpError, Problem};
use cli::Cli;
use format::{Format, SourceName};
use interp::ProcessEnv;
const STDIN: &str = "-";
pub fn run(cli: Cli) -> anyhow::Result<()> {
let opts = merge_options(&cli)?;
let mut set_layers: Vec<Value> = Vec::with_capacity(cli.set.len());
for path_leaf in &cli.set {
let typed = PathLeaf::<serde_json::Value>::from(path_leaf.clone());
let json = serde_json::Value::try_from(typed).map_err(name_the_set_flag)?;
set_layers.push(value::from_json(json));
}
let mut layers: Vec<Value> = Vec::new();
let mut input_formats: Vec<Format> = Vec::new();
for path in &cli.files {
let (name, format, text) = read_input(path, cli.input_format)?;
let value = format::parse(format, &text, &name)?;
input_formats.push(format);
layers.push(value);
}
layers.extend(set_layers);
let out_format = resolve_output_format(cli.format, &input_formats)?;
let merged = merge_with(layers, &opts).map_err(name_the_flag)?;
let merged = if cli.interpolate {
knf_interp::interpolate(merged, &ProcessEnv).map_err(explain_interp)?
} else {
merged
};
let text = format::emit(merged, out_format, !cli.compact, cli.null_as.as_deref())?;
write_stdout(&text)
}
fn read_input(
path: &Path,
override_format: Option<Format>,
) -> anyhow::Result<(SourceName, Format, String)> {
if path.as_os_str() == STDIN {
let format = override_format.context(
"`-` reads stdin, which has no extension: pass --input-format json or --input-format toml",
)?;
let mut text = String::new();
std::io::stdin()
.read_to_string(&mut text)
.context("reading stdin")?;
return Ok((SourceName::Stdin, format, text));
}
if path.is_dir() {
bail!(
"`{}` is a directory; knf takes files as layers\n\
help: `knf {}/*.toml` merges its files as layers",
path.display(),
path.display(),
);
}
let format = match override_format {
Some(format) => format,
None => Format::from_path(path).with_context(|| {
format!(
"cannot infer a format from `{}`: pass --input-format json or --input-format toml",
path.display()
)
})?,
};
let text =
std::fs::read_to_string(path).with_context(|| format!("reading `{}`", path.display()))?;
Ok((SourceName::File(path.to_path_buf()), format, text))
}
pub fn resolve_output_format(
explicit: Option<Format>,
inputs: &[Format],
) -> anyhow::Result<Format> {
if let Some(format) = explicit {
return Ok(format);
}
let mut distinct: Vec<Format> = Vec::new();
for format in inputs {
if !distinct.contains(format) {
distinct.push(*format);
}
}
match distinct.as_slice() {
[] => Ok(Format::Json),
[only] => Ok(*only),
mixed => {
let names: Vec<String> = mixed.iter().map(Format::to_string).collect();
bail!(
"inputs mix {} formats; -f is required to choose the output format\n\
help: pass -f json or -f toml",
names.join(" and "),
)
}
}
}
pub fn merge_options(cli: &Cli) -> anyhow::Result<MergeOptions> {
let flags = [
("--append", &cli.append, Strategy::Append),
("--replace", &cli.replace, Strategy::Replace),
("--fail", &cli.fail, Strategy::Fail),
];
let mut rules: Vec<(Vec<String>, Strategy)> = Vec::new();
for (flag, paths, strategy) in flags {
for path in paths {
let keys = path
.clone()
.try_into_keys()
.map_err(|err| name_the_rule_flag(err, flag))?;
rules.push((keys, strategy));
}
}
Ok(MergeOptions {
strict: cli.strict,
rules: Rules::build(rules).map_err(explain_rules)?,
})
}
fn name_the_rule_flag(err: PathError, flag: &str) -> anyhow::Error {
match err {
PathError::IndexInKeyPath { .. } => {
anyhow!("{err}\nhelp: {flag} takes a key path; a rule cannot name an array element")
}
other => other.into(),
}
}
fn name_the_set_flag(err: PathError) -> anyhow::Error {
match err {
PathError::IndexInKeyPath { .. } => anyhow!(
"{err}\nhelp: --set takes KEY.PATH=VALUE; an index like servers[0] can be read\n \
by a ${{...}} reference but never written — put the value in a file instead"
),
other => other.into(),
}
}
fn explain_rules(errors: RuleErrors) -> anyhow::Error {
const FLAGS: &str = "--append, --replace and --fail";
let mut help = String::new();
if errors
.errors()
.iter()
.any(|e| matches!(e, RuleError::Conflict { .. }))
{
help.push_str(&format!(
"\nhelp: a path may be named by only one of {FLAGS}"
));
}
if errors
.errors()
.iter()
.any(|e| matches!(e, RuleError::Unreachable { .. }))
{
help.push_str(&format!(
"\nhelp: {FLAGS} take the whole value at their path, so a rule below one can never fire"
));
}
anyhow!("{errors}{help}")
}
fn name_the_flag(err: MergeError) -> anyhow::Error {
let help = match err {
MergeError::Locked { .. } => {
"help: --fail pins a path to the first layer that sets it; drop the flag or the later value"
}
MergeError::AppendKind { .. } => "help: --append needs an array on both sides",
MergeError::TypeConflict { .. } => return err.into(),
};
anyhow!("{err}\n{help}")
}
fn explain_interp(err: InterpError) -> anyhow::Error {
let mut help = String::new();
match &err {
InterpError::Cycle(_) => {
help.push_str("\nhelp: a reference may not resolve, directly or indirectly, to itself")
}
InterpError::Problems(problems) => {
let has = |f: fn(&Problem) -> bool| problems.iter().any(f);
let syntax = has(|p| matches!(p, Problem::Syntax { .. }));
let unresolved = has(|p| matches!(p, Problem::Unresolved { .. }));
if syntax {
help.push_str(
"\nhelp: a reference is `${key.path}` (with `[n]` for array elements) or `${env:NAME}`; write `$$` for a literal `$`",
);
}
if unresolved {
help.push_str(
"\nhelp: `${key.path}` names a key in the merged document, `${env:NAME}` an environment variable",
);
}
if has(|p| matches!(p, Problem::NotStringifiable { .. })) {
help.push_str(
"\nhelp: an object or array reference must be the whole string, not embedded in one",
);
}
if syntax || unresolved {
help.push_str("\nhelp: drop --interpolate to pass `${...}` through untouched");
}
}
}
anyhow!("{err}{help}")
}
pub fn write_stdout(text: &str) -> anyhow::Result<()> {
match std::io::stdout().write_all(text.as_bytes()) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
Err(e) => Err(e).context("writing to stdout"),
}
}