Skip to main content

knf/
cli.rs

1//! clap derive structs.
2
3use std::path::PathBuf;
4
5use clap::Parser;
6use knf_dotted::{PathLeaf, RefPath};
7
8use crate::format::Format;
9
10#[derive(Parser, Debug)]
11#[command(
12    name = "knf",
13    version,
14    about = "Merge layered configuration files and print the result",
15    long_about = "\
16Merge layered configuration files and print the result.
17
18Files are layers, merged left to right in argument order. JSON and TOML may be
19mixed freely. Exactly one document goes to stdout.
20
21  knf base.toml prod.toml
22  knf base.json - --input-format json          # stdin as a layer
23  knf defaults.json --set server.port=8080 -f toml
24  knf base.toml prod.toml --append plugins     # concatenate one array
25
26Objects merge key by key. Arrays, scalars and null all replace wholesale —
27null is an ordinary value that overwrites, not a delete instruction.
28
29--append, --replace and --fail change that at the paths they name, and only
30there. A path may be named by at most one of them, and since all three consume
31the whole value at their path, no rule may sit below another. Rules are a set:
32their order never affects the output."
33)]
34pub struct Cli {
35    /// Files to merge as layers; `-` reads stdin
36    #[arg(value_name = "FILE")]
37    pub files: Vec<PathBuf>,
38
39    /// Treat every input as this format; required for `-`
40    #[arg(
41        long,
42        value_name = "FORMAT",
43        long_help = "\
44Treat every input as this format, overriding extension inference.
45
46Required for `-`, which has no extension. Note that it applies to all inputs,
47not only stdin, so it cannot be used to mix a stdin layer of one format with
48files of another."
49    )]
50    pub input_format: Option<Format>,
51
52    /// Inline terminal layer, applied after all files
53    #[arg(
54        long = "set",
55        value_name = "KEY.PATH=VALUE",
56        long_help = "\
57Inline terminal layer, applied after all files. Repeatable; multiple --set apply
58left to right.
59
60The value is parsed as JSON, falling back to a string when that fails:
61
62  port=8080       -> 8080     (number)
63  debug=true      -> true     (bool)
64  name=foo        -> \"foo\"    (not valid JSON, so a string)
65  proxy=null      -> null     (an error under -f toml, like any other null)
66  tags=[\"a\",\"b\"]  -> array
67  tags=[a,b]      -> \"[a,b]\"  (not valid JSON, so a string)
68
69Sharp edge: version=1.0 is the number 1.0, not the string \"1.0\". Force a string
70by quoting into JSON: --set version='\"1.0\"'.
71
72Dotted paths nest, so keys containing a literal dot are not addressable from
73--set; use a file. Brackets name array elements only in ${...} references, so
74--set 'a[0]=1' is an error rather than a write into an array or to a key
75literally spelled a[0]; only a file can carry either."
76    )]
77    pub set: Vec<PathLeaf<String>>,
78
79    /// Concatenate arrays at this path instead of replacing them
80    #[arg(
81        long,
82        value_name = "KEY.PATH",
83        help_heading = "Merge rules",
84        long_help = "\
85Concatenate arrays at this path instead of replacing them. Repeatable.
86
87Both sides must be arrays; anything else is an error. The path is only combined
88where the merge already has a value for it, so a lone layer's array is inserted
89as-is rather than doubled:
90
91  knf base.toml prod.toml --append plugins    # base's plugins ++ prod's
92
93Dotted paths address nested keys, so a key containing a literal dot cannot be
94named. An index like xs[0] cannot appear either: a rule names keys, never an
95array element."
96    )]
97    pub append: Vec<RefPath>,
98
99    /// Replace the value at this path wholesale, without merging into it
100    #[arg(
101        long,
102        value_name = "KEY.PATH",
103        help_heading = "Merge rules",
104        long_help = "\
105Replace the value at this path wholesale, without merging into it. Repeatable.
106
107Object over object stops recursing, so the later layer's table is taken whole
108and keys it omits are dropped:
109
110  knf base.toml prod.toml --replace db        # db is prod's db, entirely
111
112This applies to --set layers too, which are ordinary layers: --replace db
113--set db.host=x leaves db with nothing but host.
114
115Dotted paths address nested keys, so a key containing a literal dot cannot be
116named. An index like xs[0] cannot appear either: a rule names keys, never an
117array element."
118    )]
119    pub replace: Vec<RefPath>,
120
121    /// Error if a later layer sets this path again
122    #[arg(
123        long,
124        value_name = "KEY.PATH",
125        help_heading = "Merge rules",
126        long_help = "\
127Error if a later layer sets this path again. Repeatable.
128
129The first layer to define the path pins it; the path may still be absent from
130every layer. Use it to protect a value that later layers must not override:
131
132  knf base.toml prod.toml --fail db.host
133
134Dotted paths address nested keys, so a key containing a literal dot cannot be
135named. An index like xs[0] cannot appear either: a rule names keys, never an
136array element."
137    )]
138    pub fail: Vec<RefPath>,
139
140    /// Output format; required when inputs are mixed
141    #[arg(short = 'f', long, value_name = "FORMAT")]
142    pub format: Option<Format>,
143
144    /// Write this string in place of null when emitting TOML
145    #[arg(
146        long,
147        value_name = "STRING",
148        long_help = "\
149Write this string in place of null when emitting TOML.
150
151TOML has no null, so a null reaching TOML output is an error by default. This
152substitutes a value of your choosing instead:
153
154  knf base.toml override.json -f toml --null-as=none
155
156It applies to TOML output only. JSON can hold a null, so under -f json the flag
157has nothing to rescue and is ignored rather than corrupting a document that was
158never in trouble.
159
160The substitution writes a value that appeared in none of the inputs, which is
161why it is opt-in and why the string is yours to pick. It also applies inside
162arrays, where a null cannot simply be dropped without shifting every index
163after it."
164    )]
165    pub null_as: Option<String>,
166
167    /// Resolve ${key.path} and ${env:VAR} references in the merged document
168    #[arg(
169        long,
170        long_help = "\
171Resolve ${key.path} and ${env:VAR} references in the merged document.
172
173Opt-in, and off by default. knf sits upstream of tools whose own syntax is
174${...} — compose files, GitHub Actions workflows, Helm charts, systemd units —
175so eating those without being asked would be silent corruption. Off, the output
176is exactly what it is today.
177
178The pass runs once, on the merged document, never per layer:
179
180  root     = \"/srv\"
181  data_dir = \"${root}/data\"    -> \"/srv/data\"
182  port     = \"${env:PORT}\"     -> 8080  (a number, not a string)
183  url      = \"x:${env:PORT}\"   -> \"x:8080\"
184  literal  = \"$${NOT_A_REF}\"   -> \"${NOT_A_REF}\"
185
186A reference that is the *whole* string takes the referent's value and type, so
187${port} can yield a number, an array or a table. A reference *embedded* in text
188stringifies; an object or array has no format-independent spelling there, so it
189is an error rather than a guess. An environment variable is spliced as raw text
190when embedded and typed like --set's right-hand side when it is the whole
191string.
192
193$$ is a literal $. A $ followed by anything else is ordinary text, so `USD $5`
194needs no escaping.
195
196Document references resolve transitively and in any order; environment values
197are terminal and are never re-scanned. Cycles are an error.
198
199`env:` is a reserved prefix, matched literally: ${a:b} is the ordinary key
200`a:b`, and only keys that literally begin `env:` are unaddressable.
201
202A reference may read an array element — ${servers[0].host} — with all the same
203rules: whole-string it takes the element's value and type, embedded it
204stringifies. Brackets are part of the grammar, so a key literally spelled
205`a[0]` cannot be addressed by a reference or written by --set, exactly as a
206key containing a literal dot never could; only a file can carry one.
207
208An unset variable or a missing key is an error naming every offender, never
209passed through as literal text."
210    )]
211    pub interpolate: bool,
212
213    /// Error when a layer changes the type of an existing key
214    #[arg(long)]
215    pub strict: bool,
216
217    /// Disable pretty-printing
218    #[arg(long)]
219    pub compact: bool,
220}