face_cli/lib.rs
1//! End-to-end pipeline for the `face` binary.
2//!
3//! [`run`] is the library entry point: it accepts a parsed [`cli::Cli`]
4//! and an [`Io`] with explicit stdin/stdout/stderr handles, runs the §3
5//! pipeline (sniff → parse → detect → strategy → cluster → render), and
6//! emits output. Stream injection via `Io` lets tests drive the
7//! pipeline against in-memory buffers without touching the real OS
8//! handles.
9//!
10//! # Pipeline shape (§4–§8 of `docs/design.md`)
11//!
12//! 1. Validate flag combinations (§11.3).
13//! 2. Sniff the input format (§4.1) — or honor `--format-in`.
14//! 3. Parse stdin into a `ParsedInput` (§4.2).
15//! 4. `--schema` short-circuits and prints the input shape.
16//! 5. Detect score field (§4.3) and preset (§4.4) when not pinned.
17//! 6. Plan axes / strategies via [`face_core::auto_strategy`] (§4.5).
18//! 7. `--explain` short-circuits and prints the full reasoning.
19//! 8. Build the cluster tree via [`face_core::build_tree`] (§5.5).
20//! 9. Drill into a cluster if `--cluster` was supplied (§6).
21//! 10. Compose the [`Envelope`] and render via the chosen format (§7, §8).
22//! 11. With `--verbose`, write provenance and per-record skip lines to stderr (§11.1).
23
24pub mod cli;
25
26mod config;
27mod pipeline;
28
29use std::io::{Read, Write};
30
31use face_core::FaceError;
32
33/// Explicit I/O handles for [`run`].
34///
35/// The binary's `main` wraps `stdin().lock()`, `stdout().lock()`, and
36/// `stderr().lock()`; tests use byte slices and `Vec<u8>` writers.
37/// Holding the three together avoids threading three generics through
38/// the call sites.
39#[non_exhaustive]
40pub struct Io<R: Read, W: Write, E: Write> {
41 /// Standard input source (the records to cluster).
42 pub stdin: R,
43 /// Standard output sink (the rendered envelope).
44 pub stdout: W,
45 /// Standard error sink (`--verbose` provenance + skip warnings).
46 pub stderr: E,
47}
48
49impl<R: Read, W: Write, E: Write> Io<R, W, E> {
50 /// Construct explicit handles for one [`run`] invocation.
51 pub fn new(stdin: R, stdout: W, stderr: E) -> Self {
52 Self {
53 stdin,
54 stdout,
55 stderr,
56 }
57 }
58}
59
60/// Library entry point with explicit I/O.
61///
62/// The binary's `main` wraps the locked OS handles into [`Io`] and
63/// passes them in here; tests use in-memory slices and buffers.
64///
65/// # Errors
66///
67/// Surfaces every [`FaceError`] variant raised inside the pipeline.
68/// Conflicting flags surface as [`FaceError::ConflictingFlags`];
69/// detection ambiguity as [`FaceError::AmbiguousDetection`].
70///
71/// # Examples
72///
73/// ```no_run
74/// use face_cli::{Io, cli};
75/// use clap::Parser;
76///
77/// let parsed = cli::Cli::parse_from(["face"]);
78/// let mut io = Io::new(std::io::empty(), Vec::<u8>::new(), Vec::<u8>::new());
79/// let _ = face_cli::run(parsed, &mut io);
80/// ```
81pub fn run<R: Read, W: Write, E: Write>(
82 cli: cli::Cli,
83 io: &mut Io<R, W, E>,
84) -> Result<(), FaceError> {
85 pipeline::run(cli, io)
86}
87
88#[cfg(test)]
89mod tests {
90 use super::*;
91 use clap::Parser;
92 use face_core::OutputFormat;
93 use std::io::Cursor;
94
95 fn parse_args<I, S>(args: I) -> cli::Cli
96 where
97 I: IntoIterator<Item = S>,
98 S: Into<std::ffi::OsString> + Clone,
99 {
100 cli::Cli::parse_from(args)
101 }
102
103 fn run_with_stdin(cli: cli::Cli, stdin: &[u8]) -> (Result<(), FaceError>, Vec<u8>, Vec<u8>) {
104 let mut io = Io::new(
105 Cursor::new(stdin.to_vec()),
106 Vec::<u8>::new(),
107 Vec::<u8>::new(),
108 );
109 let res = run(cli, &mut io);
110 (res, io.stdout, io.stderr)
111 }
112
113 #[test]
114 fn run_help_and_exit() {
115 // `--help` is handled by clap before `run` is reached, so we
116 // only confirm clap accepts it without erroring through our code.
117 let err = cli::Cli::try_parse_from(["face", "--help"]).unwrap_err();
118 // `--help` is reported as a `DisplayHelp` "error", which is
119 // clap's signal to print and exit 0. Confirm the kind so a
120 // future refactor can't silently turn it into a real error.
121 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
122 }
123
124 #[test]
125 fn run_simple_jsonl_grouping() {
126 // `--color=never` pins the renderer to ANSI-free output. By
127 // design `face` keys color off the host process's stdout (so
128 // `face | less -R` preserves color), not the writer the pipeline
129 // was handed — when this test is run from a real terminal that
130 // means color would be on and the `starts_with` assertion would
131 // see a leading `\u{1b}[1m` escape.
132 let cli = parse_args(["face", "--color=never"]);
133 let stdin = b"{\"kind\":\"bug\"}\n{\"kind\":\"bug\"}\n{\"kind\":\"feat\"}\n";
134 let (res, stdout, _stderr) = run_with_stdin(cli, stdin);
135 res.expect("run ok");
136 let out = String::from_utf8(stdout).expect("utf-8 output");
137 assert!(
138 out.contains("bug"),
139 "human output should include `bug`: {out:?}"
140 );
141 assert!(
142 out.contains("feat"),
143 "human output should include `feat`: {out:?}"
144 );
145 assert!(out.starts_with("face: 3 items"), "header line: {out:?}");
146 }
147
148 #[test]
149 fn run_explicit_format_json() {
150 let cli = parse_args(["face", "--format=json"]);
151 let stdin = b"{\"kind\":\"bug\"}\n{\"kind\":\"bug\"}\n{\"kind\":\"feat\"}\n";
152 let (res, stdout, _stderr) = run_with_stdin(cli, stdin);
153 res.expect("run ok");
154 // Output should parse as a face envelope.
155 let env: face_core::Envelope =
156 serde_json::from_slice(&stdout).expect("envelope round-trip");
157 assert_eq!(env.result.input_total, 3);
158 assert!(!env.clusters.is_empty());
159 }
160
161 #[test]
162 fn run_explain_short_circuits() {
163 let cli = parse_args(["face", "--explain"]);
164 let stdin = b"{\"kind\":\"bug\"}\n{\"kind\":\"feat\"}\n";
165 let (res, stdout, _stderr) = run_with_stdin(cli, stdin);
166 res.expect("run ok");
167 let out = String::from_utf8(stdout).expect("utf-8 output");
168 // Explain block has the "input:", "strategy:", and "output:"
169 // section headers per §4.6.
170 assert!(out.contains("input:"), "{out:?}");
171 assert!(out.contains("strategy:"), "{out:?}");
172 assert!(out.contains("output:"), "{out:?}");
173 }
174
175 #[test]
176 fn json_short_flag_overrides_format() {
177 let cli = parse_args(["face", "-j"]);
178 let stdin = b"{\"kind\":\"bug\"}\n{\"kind\":\"feat\"}\n";
179 let (res, stdout, _stderr) = run_with_stdin(cli, stdin);
180 res.expect("run ok");
181 let env: face_core::Envelope =
182 serde_json::from_slice(&stdout).expect("envelope round-trip");
183 assert_eq!(env.result.input_total, 2);
184 // Confirm the alias actually flips the output format.
185 let _ = OutputFormat::Json;
186 }
187
188 #[test]
189 fn run_schema_short_circuits() {
190 let cli = parse_args(["face", "--schema"]);
191 let stdin = b"{\"kind\":\"bug\"}\n{\"kind\":\"feat\"}\n";
192 let (res, stdout, _stderr) = run_with_stdin(cli, stdin);
193 res.expect("run ok");
194 let out = String::from_utf8(stdout).expect("utf-8 output");
195 assert!(out.contains("format:"), "{out:?}");
196 assert!(out.contains("records:"), "{out:?}");
197 assert!(out.contains("kind"), "schema lists fields: {out:?}");
198 }
199
200 #[test]
201 fn explain_and_schema_are_mutually_exclusive() {
202 let cli = parse_args(["face", "--explain", "--schema"]);
203 let (res, _, _) = run_with_stdin(cli, b"[]");
204 match res {
205 Err(FaceError::ConflictingFlags { .. }) => {}
206 other => panic!("expected ConflictingFlags, got {other:?}"),
207 }
208 }
209}