1pub mod cli;
4pub mod format;
5pub mod interp;
6pub mod value;
7
8use std::io::{Read, Write};
9use std::path::Path;
10
11use anyhow::{Context, anyhow, bail};
12use knf_core::{
13 MergeError, MergeOptions, RuleError, RuleErrors, Rules, Strategy, Value, merge_with,
14};
15use knf_dotted::{PathError, PathLeaf};
16use knf_interp::{InterpError, Problem};
17
18use cli::Cli;
19use format::{Format, SourceName};
20use interp::ProcessEnv;
21
22const STDIN: &str = "-";
24
25pub fn run(cli: Cli) -> anyhow::Result<()> {
31 let opts = merge_options(&cli)?;
34
35 let mut set_layers: Vec<Value> = Vec::with_capacity(cli.set.len());
40 for path_leaf in &cli.set {
41 let typed = PathLeaf::<serde_json::Value>::from(path_leaf.clone());
42 let json = serde_json::Value::try_from(typed).map_err(name_the_set_flag)?;
43 set_layers.push(value::from_json(json));
44 }
45
46 let mut layers: Vec<Value> = Vec::new();
47 let mut input_formats: Vec<Format> = Vec::new();
48
49 for path in &cli.files {
50 let (name, format, text) = read_input(path, cli.input_format)?;
51 let value = format::parse(format, &text, &name)?;
52 input_formats.push(format);
53 layers.push(value);
54 }
55 layers.extend(set_layers);
56
57 let out_format = resolve_output_format(cli.format, &input_formats)?;
58
59 let merged = merge_with(layers, &opts).map_err(name_the_flag)?;
60 let merged = if cli.interpolate {
66 knf_interp::interpolate(merged, &ProcessEnv).map_err(explain_interp)?
67 } else {
68 merged
69 };
70 let text = format::emit(merged, out_format, !cli.compact, cli.null_as.as_deref())?;
71 write_stdout(&text)
72}
73
74fn read_input(
76 path: &Path,
77 override_format: Option<Format>,
78) -> anyhow::Result<(SourceName, Format, String)> {
79 if path.as_os_str() == STDIN {
80 let format = override_format.context(
81 "`-` reads stdin, which has no extension: pass --input-format json or --input-format toml",
82 )?;
83 let mut text = String::new();
84 std::io::stdin()
85 .read_to_string(&mut text)
86 .context("reading stdin")?;
87 return Ok((SourceName::Stdin, format, text));
88 }
89
90 if path.is_dir() {
91 bail!(
92 "`{}` is a directory; knf takes files as layers\n\
93 help: `knf {}/*.toml` merges its files as layers",
94 path.display(),
95 path.display(),
96 );
97 }
98
99 let format = match override_format {
100 Some(format) => format,
101 None => Format::from_path(path).with_context(|| {
102 format!(
103 "cannot infer a format from `{}`: pass --input-format json or --input-format toml",
104 path.display()
105 )
106 })?,
107 };
108 let text =
109 std::fs::read_to_string(path).with_context(|| format!("reading `{}`", path.display()))?;
110 Ok((SourceName::File(path.to_path_buf()), format, text))
111}
112
113pub fn resolve_output_format(
118 explicit: Option<Format>,
119 inputs: &[Format],
120) -> anyhow::Result<Format> {
121 if let Some(format) = explicit {
122 return Ok(format);
123 }
124 let mut distinct: Vec<Format> = Vec::new();
125 for format in inputs {
126 if !distinct.contains(format) {
127 distinct.push(*format);
128 }
129 }
130 match distinct.as_slice() {
131 [] => Ok(Format::Json),
133 [only] => Ok(*only),
134 mixed => {
135 let names: Vec<String> = mixed.iter().map(Format::to_string).collect();
136 bail!(
137 "inputs mix {} formats; -f is required to choose the output format\n\
138 help: pass -f json or -f toml",
139 names.join(" and "),
140 )
141 }
142 }
143}
144
145pub fn merge_options(cli: &Cli) -> anyhow::Result<MergeOptions> {
150 let flags = [
151 ("--append", &cli.append, Strategy::Append),
152 ("--replace", &cli.replace, Strategy::Replace),
153 ("--fail", &cli.fail, Strategy::Fail),
154 ];
155 let mut rules: Vec<(Vec<String>, Strategy)> = Vec::new();
156 for (flag, paths, strategy) in flags {
157 for path in paths {
158 let keys = path
161 .clone()
162 .try_into_keys()
163 .map_err(|err| name_the_rule_flag(err, flag))?;
164 rules.push((keys, strategy));
165 }
166 }
167
168 Ok(MergeOptions {
169 strict: cli.strict,
170 rules: Rules::build(rules).map_err(explain_rules)?,
171 })
172}
173
174fn name_the_rule_flag(err: PathError, flag: &str) -> anyhow::Error {
177 match err {
178 PathError::IndexInKeyPath { .. } => {
179 anyhow!("{err}\nhelp: {flag} takes a key path; a rule cannot name an array element")
180 }
181 other => other.into(),
182 }
183}
184
185fn name_the_set_flag(err: PathError) -> anyhow::Error {
187 match err {
188 PathError::IndexInKeyPath { .. } => anyhow!(
189 "{err}\nhelp: --set takes KEY.PATH=VALUE; an index like servers[0] can be read\n \
190 by a ${{...}} reference but never written — put the value in a file instead"
191 ),
192 other => other.into(),
193 }
194}
195
196fn explain_rules(errors: RuleErrors) -> anyhow::Error {
201 const FLAGS: &str = "--append, --replace and --fail";
202 let mut help = String::new();
203 if errors
204 .errors()
205 .iter()
206 .any(|e| matches!(e, RuleError::Conflict { .. }))
207 {
208 help.push_str(&format!(
209 "\nhelp: a path may be named by only one of {FLAGS}"
210 ));
211 }
212 if errors
213 .errors()
214 .iter()
215 .any(|e| matches!(e, RuleError::Unreachable { .. }))
216 {
217 help.push_str(&format!(
218 "\nhelp: {FLAGS} take the whole value at their path, so a rule below one can never fire"
219 ));
220 }
221 anyhow!("{errors}{help}")
222}
223
224fn name_the_flag(err: MergeError) -> anyhow::Error {
226 let help = match err {
227 MergeError::Locked { .. } => {
228 "help: --fail pins a path to the first layer that sets it; drop the flag or the later value"
229 }
230 MergeError::AppendKind { .. } => "help: --append needs an array on both sides",
231 MergeError::TypeConflict { .. } => return err.into(),
232 };
233 anyhow!("{err}\n{help}")
234}
235
236fn explain_interp(err: InterpError) -> anyhow::Error {
241 let mut help = String::new();
242 match &err {
243 InterpError::Cycle(_) => {
244 help.push_str("\nhelp: a reference may not resolve, directly or indirectly, to itself")
245 }
246 InterpError::Problems(problems) => {
247 let has = |f: fn(&Problem) -> bool| problems.iter().any(f);
251 let syntax = has(|p| matches!(p, Problem::Syntax { .. }));
252 let unresolved = has(|p| matches!(p, Problem::Unresolved { .. }));
253 if syntax {
254 help.push_str(
255 "\nhelp: a reference is `${key.path}` (with `[n]` for array elements) or `${env:NAME}`; write `$$` for a literal `$`",
256 );
257 }
258 if unresolved {
259 help.push_str(
260 "\nhelp: `${key.path}` names a key in the merged document, `${env:NAME}` an environment variable",
261 );
262 }
263 if has(|p| matches!(p, Problem::NotStringifiable { .. })) {
264 help.push_str(
265 "\nhelp: an object or array reference must be the whole string, not embedded in one",
266 );
267 }
268 if syntax || unresolved {
269 help.push_str("\nhelp: drop --interpolate to pass `${...}` through untouched");
270 }
271 }
272 }
273 anyhow!("{err}{help}")
274}
275
276pub fn write_stdout(text: &str) -> anyhow::Result<()> {
279 match std::io::stdout().write_all(text.as_bytes()) {
280 Ok(()) => Ok(()),
281 Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
282 Err(e) => Err(e).context("writing to stdout"),
283 }
284}