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 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}