1pub mod cli;
4pub mod format;
5pub mod value;
6
7use std::io::{Read, Write};
8use std::path::Path;
9
10use anyhow::{Context, anyhow, bail};
11use knf_core::{
12 MergeError, MergeOptions, RuleError, RuleErrors, Rules, Strategy, Value, merge_with,
13};
14use knf_dotted::PathLeaf;
15
16use cli::Cli;
17use format::{Format, SourceName};
18
19const STDIN: &str = "-";
21
22pub fn run(cli: Cli) -> anyhow::Result<()> {
28 let opts = merge_options(&cli)?;
31
32 let mut layers: Vec<Value> = Vec::new();
33 let mut input_formats: Vec<Format> = Vec::new();
34
35 for path in &cli.files {
36 let (name, format, text) = read_input(path, cli.input_format)?;
37 let value = format::parse(format, &text, &name)?;
38 input_formats.push(format);
39 layers.push(value);
40 }
41
42 for path_leaf in &cli.set {
45 let json = serde_json::Value::from(PathLeaf::<serde_json::Value>::from(path_leaf.clone()));
46 layers.push(value::from_json(json));
47 }
48
49 let out_format = resolve_output_format(cli.format, &input_formats)?;
50
51 let merged = merge_with(layers, &opts).map_err(name_the_flag)?;
52 let text = format::emit(
53 merged,
54 out_format,
55 !cli.compact,
56 cli.null_placeholder.as_deref(),
57 )?;
58 write_stdout(&text)
59}
60
61fn read_input(
63 path: &Path,
64 override_format: Option<Format>,
65) -> anyhow::Result<(SourceName, Format, String)> {
66 if path.as_os_str() == STDIN {
67 let format = override_format.context(
68 "`-` reads stdin, which has no extension: pass --input-format json or --input-format toml",
69 )?;
70 let mut text = String::new();
71 std::io::stdin()
72 .read_to_string(&mut text)
73 .context("reading stdin")?;
74 return Ok((SourceName::Stdin, format, text));
75 }
76
77 if path.is_dir() {
78 bail!(
79 "`{}` is a directory; knf takes files as layers\n\
80 help: `knf {}/*.toml` merges its files as layers",
81 path.display(),
82 path.display(),
83 );
84 }
85
86 let format = match override_format {
87 Some(format) => format,
88 None => Format::from_path(path).with_context(|| {
89 format!(
90 "cannot infer a format from `{}`: pass --input-format json or --input-format toml",
91 path.display()
92 )
93 })?,
94 };
95 let text =
96 std::fs::read_to_string(path).with_context(|| format!("reading `{}`", path.display()))?;
97 Ok((SourceName::File(path.to_path_buf()), format, text))
98}
99
100pub fn resolve_output_format(
105 explicit: Option<Format>,
106 inputs: &[Format],
107) -> anyhow::Result<Format> {
108 if let Some(format) = explicit {
109 return Ok(format);
110 }
111 let mut distinct: Vec<Format> = Vec::new();
112 for format in inputs {
113 if !distinct.contains(format) {
114 distinct.push(*format);
115 }
116 }
117 match distinct.as_slice() {
118 [] => Ok(Format::Json),
120 [only] => Ok(*only),
121 mixed => {
122 let names: Vec<String> = mixed.iter().map(Format::to_string).collect();
123 bail!(
124 "inputs mix {} formats; -f is required to choose the output format\n\
125 help: pass -f json or -f toml",
126 names.join(" and "),
127 )
128 }
129 }
130}
131
132pub fn merge_options(cli: &Cli) -> anyhow::Result<MergeOptions> {
137 let flags = [
138 (&cli.append, Strategy::Append),
139 (&cli.replace, Strategy::Replace),
140 (&cli.fail, Strategy::Fail),
141 ];
142 let rules: Vec<(Vec<String>, Strategy)> = flags
143 .into_iter()
144 .flat_map(|(paths, strategy)| {
145 paths
146 .iter()
147 .map(move |path| (path.segments().to_vec(), strategy))
148 })
149 .collect();
150
151 Ok(MergeOptions {
152 strict: cli.strict,
153 rules: Rules::build(rules).map_err(explain_rules)?,
154 })
155}
156
157fn explain_rules(errors: RuleErrors) -> anyhow::Error {
162 const FLAGS: &str = "--append, --replace and --fail";
163 let mut help = String::new();
164 if errors
165 .errors()
166 .iter()
167 .any(|e| matches!(e, RuleError::Conflict { .. }))
168 {
169 help.push_str(&format!(
170 "\nhelp: a path may be named by only one of {FLAGS}"
171 ));
172 }
173 if errors
174 .errors()
175 .iter()
176 .any(|e| matches!(e, RuleError::Unreachable { .. }))
177 {
178 help.push_str(&format!(
179 "\nhelp: {FLAGS} take the whole value at their path, so a rule below one can never fire"
180 ));
181 }
182 anyhow!("{errors}{help}")
183}
184
185fn name_the_flag(err: MergeError) -> anyhow::Error {
187 let help = match err {
188 MergeError::Locked { .. } => {
189 "help: --fail pins a path to the first layer that sets it; drop the flag or the later value"
190 }
191 MergeError::AppendKind { .. } => "help: --append needs an array on both sides",
192 MergeError::TypeConflict { .. } => return err.into(),
193 };
194 anyhow!("{err}\n{help}")
195}
196
197pub fn write_stdout(text: &str) -> anyhow::Result<()> {
200 match std::io::stdout().write_all(text.as_bytes()) {
201 Ok(()) => Ok(()),
202 Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
203 Err(e) => Err(e).context("writing to stdout"),
204 }
205}