Skip to main content

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}