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