face 0.1.0

Fold And Cluster Entries — a Unix-style CLI for grouping, paging, and summarizing structured command output.
Documentation
//! End-to-end pipeline for the `face` binary.
//!
//! [`run`] is the library entry point: it accepts a parsed [`cli::Cli`]
//! and an [`Io`] with explicit stdin/stdout/stderr handles, runs the §3
//! pipeline (sniff → parse → detect → strategy → cluster → render), and
//! emits output. Stream injection via `Io` lets tests drive the
//! pipeline against in-memory buffers without touching the real OS
//! handles.
//!
//! # Pipeline shape (§4–§8 of `docs/design.md`)
//!
//! 1. Validate flag combinations (§11.3).
//! 2. Sniff the input format (§4.1) — or honor `--format-in`.
//! 3. Parse stdin into a `ParsedInput` (§4.2).
//! 4. `--schema` short-circuits and prints the input shape.
//! 5. Detect score field (§4.3) and preset (§4.4) when not pinned.
//! 6. Plan axes / strategies via [`face_core::auto_strategy`] (§4.5).
//! 7. `--explain` short-circuits and prints the full reasoning.
//! 8. Build the cluster tree via [`face_core::build_tree`] (§5.5).
//! 9. Drill into a cluster if `--cluster` was supplied (§6).
//! 10. Compose the [`Envelope`] and render via the chosen format (§7, §8).
//! 11. With `--verbose`, write provenance and per-record skip lines to stderr (§11.1).

pub mod cli;

mod config;
mod pipeline;

use std::io::{Read, Write};

use face_core::FaceError;

/// Explicit I/O handles for [`run`].
///
/// The binary's `main` wraps `stdin().lock()`, `stdout().lock()`, and
/// `stderr().lock()`; tests use byte slices and `Vec<u8>` writers.
/// Holding the three together avoids threading three generics through
/// the call sites.
#[non_exhaustive]
pub struct Io<R: Read, W: Write, E: Write> {
    /// Standard input source (the records to cluster).
    pub stdin: R,
    /// Standard output sink (the rendered envelope).
    pub stdout: W,
    /// Standard error sink (`--verbose` provenance + skip warnings).
    pub stderr: E,
}

impl<R: Read, W: Write, E: Write> Io<R, W, E> {
    /// Construct explicit handles for one [`run`] invocation.
    pub fn new(stdin: R, stdout: W, stderr: E) -> Self {
        Self {
            stdin,
            stdout,
            stderr,
        }
    }
}

/// Library entry point with explicit I/O.
///
/// The binary's `main` wraps the locked OS handles into [`Io`] and
/// passes them in here; tests use in-memory slices and buffers.
///
/// # Errors
///
/// Surfaces every [`FaceError`] variant raised inside the pipeline.
/// Conflicting flags surface as [`FaceError::ConflictingFlags`];
/// detection ambiguity as [`FaceError::AmbiguousDetection`].
///
/// # Examples
///
/// ```no_run
/// use face_cli::{Io, cli};
/// use clap::Parser;
///
/// let parsed = cli::Cli::parse_from(["face"]);
/// let mut io = Io::new(std::io::empty(), Vec::<u8>::new(), Vec::<u8>::new());
/// let _ = face_cli::run(parsed, &mut io);
/// ```
pub fn run<R: Read, W: Write, E: Write>(
    cli: cli::Cli,
    io: &mut Io<R, W, E>,
) -> Result<(), FaceError> {
    pipeline::run(cli, io)
}

#[cfg(test)]
mod tests {
    use super::*;
    use clap::Parser;
    use face_core::OutputFormat;
    use std::io::Cursor;

    fn parse_args<I, S>(args: I) -> cli::Cli
    where
        I: IntoIterator<Item = S>,
        S: Into<std::ffi::OsString> + Clone,
    {
        cli::Cli::parse_from(args)
    }

    fn run_with_stdin(cli: cli::Cli, stdin: &[u8]) -> (Result<(), FaceError>, Vec<u8>, Vec<u8>) {
        let mut io = Io::new(
            Cursor::new(stdin.to_vec()),
            Vec::<u8>::new(),
            Vec::<u8>::new(),
        );
        let res = run(cli, &mut io);
        (res, io.stdout, io.stderr)
    }

    #[test]
    fn run_help_and_exit() {
        // `--help` is handled by clap before `run` is reached, so we
        // only confirm clap accepts it without erroring through our code.
        let err = cli::Cli::try_parse_from(["face", "--help"]).unwrap_err();
        // `--help` is reported as a `DisplayHelp` "error", which is
        // clap's signal to print and exit 0. Confirm the kind so a
        // future refactor can't silently turn it into a real error.
        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
    }

    #[test]
    fn run_simple_jsonl_grouping() {
        // `--color=never` pins the renderer to ANSI-free output. By
        // design `face` keys color off the host process's stdout (so
        // `face | less -R` preserves color), not the writer the pipeline
        // was handed — when this test is run from a real terminal that
        // means color would be on and the `starts_with` assertion would
        // see a leading `\u{1b}[1m` escape.
        let cli = parse_args(["face", "--color=never"]);
        let stdin = b"{\"kind\":\"bug\"}\n{\"kind\":\"bug\"}\n{\"kind\":\"feat\"}\n";
        let (res, stdout, _stderr) = run_with_stdin(cli, stdin);
        res.expect("run ok");
        let out = String::from_utf8(stdout).expect("utf-8 output");
        assert!(
            out.contains("bug"),
            "human output should include `bug`: {out:?}"
        );
        assert!(
            out.contains("feat"),
            "human output should include `feat`: {out:?}"
        );
        assert!(out.starts_with("face: 3 items"), "header line: {out:?}");
    }

    #[test]
    fn run_explicit_format_json() {
        let cli = parse_args(["face", "--format=json"]);
        let stdin = b"{\"kind\":\"bug\"}\n{\"kind\":\"bug\"}\n{\"kind\":\"feat\"}\n";
        let (res, stdout, _stderr) = run_with_stdin(cli, stdin);
        res.expect("run ok");
        // Output should parse as a face envelope.
        let env: face_core::Envelope =
            serde_json::from_slice(&stdout).expect("envelope round-trip");
        assert_eq!(env.result.input_total, 3);
        assert!(!env.clusters.is_empty());
    }

    #[test]
    fn run_explain_short_circuits() {
        let cli = parse_args(["face", "--explain"]);
        let stdin = b"{\"kind\":\"bug\"}\n{\"kind\":\"feat\"}\n";
        let (res, stdout, _stderr) = run_with_stdin(cli, stdin);
        res.expect("run ok");
        let out = String::from_utf8(stdout).expect("utf-8 output");
        // Explain block has the "input:", "strategy:", and "output:"
        // section headers per §4.6.
        assert!(out.contains("input:"), "{out:?}");
        assert!(out.contains("strategy:"), "{out:?}");
        assert!(out.contains("output:"), "{out:?}");
    }

    #[test]
    fn json_short_flag_overrides_format() {
        let cli = parse_args(["face", "-j"]);
        let stdin = b"{\"kind\":\"bug\"}\n{\"kind\":\"feat\"}\n";
        let (res, stdout, _stderr) = run_with_stdin(cli, stdin);
        res.expect("run ok");
        let env: face_core::Envelope =
            serde_json::from_slice(&stdout).expect("envelope round-trip");
        assert_eq!(env.result.input_total, 2);
        // Confirm the alias actually flips the output format.
        let _ = OutputFormat::Json;
    }

    #[test]
    fn run_schema_short_circuits() {
        let cli = parse_args(["face", "--schema"]);
        let stdin = b"{\"kind\":\"bug\"}\n{\"kind\":\"feat\"}\n";
        let (res, stdout, _stderr) = run_with_stdin(cli, stdin);
        res.expect("run ok");
        let out = String::from_utf8(stdout).expect("utf-8 output");
        assert!(out.contains("format:"), "{out:?}");
        assert!(out.contains("records:"), "{out:?}");
        assert!(out.contains("kind"), "schema lists fields: {out:?}");
    }

    #[test]
    fn explain_and_schema_are_mutually_exclusive() {
        let cli = parse_args(["face", "--explain", "--schema"]);
        let (res, _, _) = run_with_stdin(cli, b"[]");
        match res {
            Err(FaceError::ConflictingFlags { .. }) => {}
            other => panic!("expected ConflictingFlags, got {other:?}"),
        }
    }
}