noya_cli/lib.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! Shared CLI surface for the `noyafmt` and `noyavalidate` binaries.
5//!
6//! The same [`clap::Command`] builders the binaries use to parse
7//! their argv at runtime are also consumed by the build script
8//! (`build.rs`) and the `cargo xtask` runner — so the binaries,
9//! the man pages, and the shell completions can never drift.
10//!
11//! # Surface
12//!
13//! - [`NoyafmtCli`] / [`NoyavalidateCli`] — the parsed-args structs
14//! produced by `clap`'s derive macros. `main()` in each binary
15//! matches against fields of these.
16//! - [`noyafmt_command`] / [`noyavalidate_command`] — the
17//! underlying [`clap::Command`] tree. Used by `clap_complete` and
18//! `clap_mangen` to generate completions and man pages
19//! respectively.
20//!
21//! # Cargo features
22//!
23//! This crate exposes no optional features of its own — both
24//! binaries always ship with the same dispatch surface. The
25//! transitive `noyalib` dependency is consumed with its **default
26//! feature set** (`std` + the always-on parser / serializer /
27//! Value / CST). To opt into optional `noyalib` features
28//! (`schema`, `parallel`, `miette`, …), pin the version directly
29//! and select features at the consuming binary's `Cargo.toml`;
30//! the `noyalib` feature matrix is canonicalised in
31//! [`crates/noyalib/src/lib.rs`](https://docs.rs/noyalib).
32//!
33//! # MSRV
34//!
35//! **Rust 1.85.0** stable. The `clap_builder` 4.6 dep pulls
36//! edition-2024 helpers and floors the MSRV at 1.85; the core
37//! `noyalib` library still builds on **1.75**. CI verifies both
38//! floors via the `Per-crate MSRV` workflow job. The bump
39//! policy is documented in the workspace
40//! [`POLICIES.md`](https://github.com/sebastienrousseau/noyalib/blob/main/doc/POLICIES.md#1-msrv-minimum-supported-rust-version).
41//!
42//! # Panics
43//!
44//! Public functions in this crate do not panic. The two
45//! binaries (`noyafmt`, `noyavalidate`) handle argv-parse
46//! failures via clap's error path — surfaced as exit code `2`,
47//! never as a panic.
48//!
49//! # Errors
50//!
51//! Binary exit codes follow Unix convention:
52//! `0` on success, `1` on a YAML/schema problem, `2` on
53//! argv-parse error. Library-side errors flow through
54//! [`noyalib::Error`] (not re-exported here — call sites
55//! that want library access should depend on `noyalib`
56//! directly).
57//!
58//! # Concurrency
59//!
60//! `NoyafmtCli` / `NoyavalidateCli` are `Send + Sync` (plain
61//! POD parsed-args structs). The `clap::Command` builders
62//! return owned values; cheap to clone. No interior mutability.
63//!
64//! # Platform support
65//!
66//! Tier-1 (CI-verified each PR): `aarch64-apple-darwin`,
67//! `x86_64-unknown-linux-gnu`, `x86_64-pc-windows-msvc`. Both
68//! binaries write via an *atomic file replacement* pattern
69//! (write to a sibling temp file → `sync_all` → `rename`), so
70//! concurrent readers always see either the pre-edit or the
71//! post-edit contents — never a half-written truncation.
72//!
73//! # Performance
74//!
75//! Each YAML file in argv flows through the underlying
76//! `noyalib::cst::parse_document` call (formatter) or
77//! `noyalib::from_str::<Value>` (validator) — both run in
78//! `O(n)` over input bytes. Argv-batch processing is sequential
79//! by design (deterministic exit code on the first failure);
80//! pipelines that need parallelism should fan out via `xargs -P`
81//! at the shell layer rather than burying threading in the CLI.
82//! End-to-end overhead per file: parse + serialise dominates;
83//! argv parsing and file I/O are negligible (<1 ms) for files
84//! up to a few MiB.
85//!
86//! # Security
87//!
88//! `#![forbid(unsafe_code)]` (workspace lint). No FFI. No
89//! network I/O. The binaries only read files passed on argv;
90//! they do not read environment variables. Resource-limit
91//! gates are inherited from `noyalib`'s `ParserConfig`
92//! defaults; pass `--strict` to opt into the tighter
93//! `ParserConfig::strict()` preset. Full posture:
94//! [`SECURITY.md`](https://github.com/sebastienrousseau/noyalib/blob/main/SECURITY.md).
95//!
96//! # API stability and SemVer
97//!
98//! Pre-1.0 (`0.0.x`): the argv contract (long flags, exit
99//! codes, stdin/stdout shape) is **stable** within a 0.0.x
100//! line — bug fixes only. Adding a new flag is allowed within
101//! a 0.0.x bump; removing or renaming a flag, or repurposing
102//! an exit code, is held to a 0.x bump (e.g. 0.0.x → 0.1.0).
103//! The Rust library surface (`NoyafmtCli`, `NoyavalidateCli`,
104//! `noyafmt_command`, `noyavalidate_command`) is also covered by
105//! the workspace SemVer policy in
106//! [`POLICIES.md`](https://github.com/sebastienrousseau/noyalib/blob/main/doc/POLICIES.md#2-semver--api-stability).
107//! `cargo-semver-checks` runs in CI on every PR and blocks
108//! accidental SemVer-incompatible changes.
109//!
110//! # Documentation
111//!
112//! - **Engineering policies** — workspace
113//! [`POLICIES.md`](https://github.com/sebastienrousseau/noyalib/blob/main/doc/POLICIES.md)
114//! covers MSRV, SemVer, security, performance, concurrency,
115//! platform support, feature flags.
116//! - **CLI flag reference**:
117//! [`doc/cli-reference.md`](https://github.com/sebastienrousseau/noyalib/blob/main/crates/noya-cli/doc/cli-reference.md).
118//! - **Recipes** (pre-commit, CI gate, schema validation, k8s,
119//! Helm, Compose, GitHub Actions): the
120//! [`examples/`](https://github.com/sebastienrousseau/noyalib/tree/main/crates/noya-cli/examples)
121//! directory.
122
123use clap::{CommandFactory, Parser};
124use std::path::PathBuf;
125
126/// CLI surface for `noyafmt` — the YAML formatter.
127///
128/// Mirrors the `rustfmt` / `prettier` ergonomics so it slots into
129/// existing developer workflows: `--check` for CI gates, `--write`
130/// for in-place rewrites, stdin/stdout for editor integration.
131#[derive(Debug, Parser)]
132#[command(
133 name = "noyafmt",
134 about = "Format YAML files via the noyalib CST formatter",
135 long_about = "noyafmt — auto-format YAML via the noyalib CST.\n\n\
136 Reads YAML from FILE arguments (or stdin via --stdin) and\n\
137 rewrites them through noyalib's lossless CST formatter.\n\
138 Comments, anchor positions, and document structure are\n\
139 preserved byte-for-byte; only whitespace and quoting are\n\
140 normalised.",
141 version = env!("CARGO_PKG_VERSION"),
142 after_help = "EXAMPLES:\n \
143 noyafmt config.yaml # print formatted source to stdout\n \
144 noyafmt --write config.yaml # rewrite in place\n \
145 noyafmt --check ci/*.yaml # CI gate\n \
146 cat foo.yaml | noyafmt --stdin",
147)]
148pub struct NoyafmtCli {
149 /// Verify each FILE is formatted; print the list of files that
150 /// need formatting and exit 1 if any do. Non-destructive.
151 /// Suitable as a pre-commit / CI gate.
152 #[arg(long, conflicts_with = "write")]
153 pub check: bool,
154
155 /// Rewrite each FILE in place. Default is to print the formatted
156 /// source to stdout.
157 #[arg(long)]
158 pub write: bool,
159
160 /// Read from stdin, write to stdout. Mutually exclusive with
161 /// FILE arguments.
162 #[arg(long, conflicts_with = "files")]
163 pub stdin: bool,
164
165 /// Indentation width in spaces.
166 #[arg(long, value_name = "N", default_value_t = 2)]
167 pub indent: usize,
168
169 /// YAML files to format. Pass `--stdin` to read from stdin
170 /// instead.
171 #[arg(value_name = "FILE")]
172 pub files: Vec<PathBuf>,
173}
174
175/// CLI surface for `noyavalidate` — the YAML validator.
176///
177/// Validates YAML syntax, optionally enforces a JSON Schema 2020-12
178/// contract, and can normalise the input through the lossless CST
179/// formatter via `--fix`.
180#[derive(Debug, Parser)]
181#[command(
182 name = "noyavalidate",
183 about = "Validate YAML syntax and (optionally) a JSON Schema",
184 long_about = "noyavalidate — check YAML syntax (and optional JSON Schema).\n\n\
185 Reads one or more YAML documents from a file (or stdin),\n\
186 reports syntax errors via the miette fancy renderer, and —\n\
187 when --schema PATH is given — validates each parsed\n\
188 document against a JSON Schema 2020-12 contract (the\n\
189 schema may itself be written in YAML or JSON).\n\n\
190 --fix rewrites the input in-place through the lossless\n\
191 CST formatter, normalising whitespace and quoting without\n\
192 changing semantics. When the input is stdin, the\n\
193 formatted output is written to stdout instead.",
194 version = env!("CARGO_PKG_VERSION"),
195 after_help = "EXIT CODES:\n \
196 0 All documents valid (and fixed if --fix)\n \
197 1 Parse error or schema violation\n \
198 2 Usage error\n \
199 3 I/O error",
200)]
201pub struct NoyavalidateCli {
202 /// Validate each document against the JSON Schema 2020-12 at
203 /// PATH (the schema may itself be YAML or JSON).
204 #[arg(short = 's', long, value_name = "PATH")]
205 pub schema: Option<PathBuf>,
206
207 /// Rewrite FILE in place via the CST formatter (lossless:
208 /// byte-faithful for everything except normalised whitespace
209 /// and line endings). With stdin input, the formatted bytes go
210 /// to stdout.
211 #[arg(long)]
212 pub fix: bool,
213
214 /// Suppress success output.
215 #[arg(short, long)]
216 pub quiet: bool,
217
218 /// YAML file to validate. Use `-` or omit for stdin.
219 #[arg(value_name = "FILE")]
220 pub file: Option<PathBuf>,
221}
222
223/// Build the [`clap::Command`] for `noyafmt`.
224///
225/// Used by the build script and `cargo xtask` to drive
226/// `clap_complete` and `clap_mangen` against the same Command tree
227/// the binary uses at runtime.
228#[must_use]
229pub fn noyafmt_command() -> clap::Command {
230 NoyafmtCli::command()
231}
232
233/// Build the [`clap::Command`] for `noyavalidate`.
234///
235/// Used by the build script and `cargo xtask` to drive
236/// `clap_complete` and `clap_mangen` against the same Command tree
237/// the binary uses at runtime.
238#[must_use]
239pub fn noyavalidate_command() -> clap::Command {
240 NoyavalidateCli::command()
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 // ── noyafmt parsing ───────────────────────────────────────────
248 #[test]
249 fn noyafmt_help_flag_renders() {
250 let r = NoyafmtCli::try_parse_from(["noyafmt", "--help"]);
251 let err = r.unwrap_err();
252 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
253 }
254
255 #[test]
256 fn noyafmt_version_flag_renders() {
257 let r = NoyafmtCli::try_parse_from(["noyafmt", "--version"]);
258 let err = r.unwrap_err();
259 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
260 }
261
262 #[test]
263 fn noyafmt_check_with_files() {
264 let cli = NoyafmtCli::try_parse_from(["noyafmt", "--check", "a.yaml", "b.yaml"]).unwrap();
265 assert!(cli.check);
266 assert!(!cli.write);
267 assert_eq!(cli.files.len(), 2);
268 }
269
270 #[test]
271 fn noyafmt_write_with_file() {
272 let cli = NoyafmtCli::try_parse_from(["noyafmt", "--write", "x.yaml"]).unwrap();
273 assert!(cli.write);
274 assert_eq!(cli.files.len(), 1);
275 }
276
277 #[test]
278 fn noyafmt_stdin_alone() {
279 let cli = NoyafmtCli::try_parse_from(["noyafmt", "--stdin"]).unwrap();
280 assert!(cli.stdin);
281 assert!(cli.files.is_empty());
282 }
283
284 #[test]
285 fn noyafmt_indent_separate_value() {
286 let cli = NoyafmtCli::try_parse_from(["noyafmt", "--indent", "4", "--stdin"]).unwrap();
287 assert_eq!(cli.indent, 4);
288 }
289
290 #[test]
291 fn noyafmt_indent_eq_value() {
292 let cli = NoyafmtCli::try_parse_from(["noyafmt", "--indent=8", "--stdin"]).unwrap();
293 assert_eq!(cli.indent, 8);
294 }
295
296 #[test]
297 fn noyafmt_indent_default_is_two() {
298 let cli = NoyafmtCli::try_parse_from(["noyafmt", "--stdin"]).unwrap();
299 assert_eq!(cli.indent, 2);
300 }
301
302 #[test]
303 fn noyafmt_indent_non_numeric_errors() {
304 let r = NoyafmtCli::try_parse_from(["noyafmt", "--indent", "abc", "--stdin"]);
305 assert!(r.is_err());
306 }
307
308 #[test]
309 fn noyafmt_unknown_option_errors() {
310 let r = NoyafmtCli::try_parse_from(["noyafmt", "--frobnicate"]);
311 assert!(r.is_err());
312 }
313
314 #[test]
315 fn noyafmt_check_and_write_rejected() {
316 let r = NoyafmtCli::try_parse_from(["noyafmt", "--check", "--write", "f.yaml"]);
317 let err = r.unwrap_err();
318 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
319 }
320
321 #[test]
322 fn noyafmt_stdin_with_files_rejected() {
323 let r = NoyafmtCli::try_parse_from(["noyafmt", "--stdin", "f.yaml"]);
324 let err = r.unwrap_err();
325 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
326 }
327
328 // ── noyavalidate parsing ──────────────────────────────────────
329 #[test]
330 fn noyavalidate_help_flag_renders() {
331 let r = NoyavalidateCli::try_parse_from(["noyavalidate", "--help"]);
332 assert_eq!(r.unwrap_err().kind(), clap::error::ErrorKind::DisplayHelp);
333 }
334
335 #[test]
336 fn noyavalidate_schema_short_form() {
337 let cli =
338 NoyavalidateCli::try_parse_from(["noyavalidate", "-s", "s.json", "in.yaml"]).unwrap();
339 assert_eq!(cli.schema.unwrap().to_string_lossy(), "s.json");
340 assert_eq!(cli.file.unwrap().to_string_lossy(), "in.yaml");
341 }
342
343 #[test]
344 fn noyavalidate_schema_long_form() {
345 let cli =
346 NoyavalidateCli::try_parse_from(["noyavalidate", "--schema=schema.yaml", "x.yaml"])
347 .unwrap();
348 assert_eq!(cli.schema.unwrap().to_string_lossy(), "schema.yaml");
349 }
350
351 #[test]
352 fn noyavalidate_fix_quiet_flags() {
353 let cli = NoyavalidateCli::try_parse_from(["noyavalidate", "--fix", "--quiet", "in.yaml"])
354 .unwrap();
355 assert!(cli.fix);
356 assert!(cli.quiet);
357 }
358
359 #[test]
360 fn noyavalidate_no_args_means_stdin() {
361 let cli = NoyavalidateCli::try_parse_from(["noyavalidate"]).unwrap();
362 assert!(cli.file.is_none());
363 }
364
365 // ── Command introspection (used by build.rs / xtask) ──────────
366 #[test]
367 fn commands_render_help_without_panic() {
368 let mut a = noyafmt_command();
369 let mut b = noyavalidate_command();
370 let _ = a.render_help();
371 let _ = b.render_help();
372 }
373}