Skip to main content

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}