face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! Error types and per-record skip reporting for the face core surface.
//!
//! [`FaceError`] is the typed library error returned from fallible
//! operations. [`SkipReport`] / [`SkipReason`] describe non-fatal
//! per-record skip events surfaced to stderr and counted in
//! [`crate::Envelope`]'s `result.skipped` field (§11.1 of `docs/design.md`).

use std::path::PathBuf;
use thiserror::Error;

use crate::InputFormat;

/// Library-level error returned by the face core surface.
///
/// Variants cover I/O failure, input parse failure, detection ambiguity,
/// cluster-id parse failure, flag conflicts, and configuration errors.
/// The enum is `#[non_exhaustive]` so adding a new variant is not a
/// breaking change.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum FaceError {
    /// I/O failure reading or writing input.
    #[error("input I/O: {source}")]
    Io {
        /// Underlying I/O error.
        #[source]
        source: std::io::Error,
    },

    /// The input could not be parsed as the detected (or requested)
    /// format.
    #[error("input parse failed ({format:?}): {message}")]
    InputParse {
        /// Format we were attempting to parse.
        format: InputFormat,
        /// Human-readable explanation.
        message: String,
    },

    /// `--items` (or auto-detected items path) refers to a field that
    /// does not exist in the input.
    #[error("items path `{path}` not found in input")]
    UnknownItemsPath {
        /// The items path that could not be resolved.
        path: String,
    },

    /// The score path was supplied (or auto-detected) but no sampled
    /// item carried a numeric value at that path.
    #[error("score path `{path}` is not numeric in any sampled item")]
    UnknownScorePath {
        /// The score path that could not be resolved as numeric.
        path: String,
    },

    /// Auto-detection could not pick a single grouping field (§11.2).
    ///
    /// The Display impl renders a multi-line hint listing the string
    /// candidates and the available numeric fields so the user knows
    /// to pass `--by=FIELD` or `--score=FIELD`. When both lists are
    /// empty the message collapses to a single line.
    #[error("{}", render_ambiguous_detection(candidates, numeric_fields))]
    AmbiguousDetection {
        /// Candidate fields with their cardinality, ordered for display.
        candidates: Vec<DetectionCandidate>,
        /// Top-level numeric field names (no cardinality), surfaced so
        /// the user can pick one with `--score=FIELD`.
        numeric_fields: Vec<String>,
    },

    /// A cluster id segment failed to parse.
    #[error("invalid cluster id at segment {segment}: {reason}")]
    InvalidClusterId {
        /// Zero-based index of the offending segment.
        segment: usize,
        /// Human-readable reason.
        reason: String,
    },

    /// A cluster id refers to an axis name not present in the current
    /// envelope's `result.axes` list.
    #[error("cluster id refers to unknown axis `{axis}`")]
    UnknownAxis {
        /// The axis name that did not match any declared axis.
        axis: String,
    },

    /// Two flags that cannot be combined were both supplied (§11.3).
    #[error("conflicting flags: {message}")]
    ConflictingFlags {
        /// Human-readable explanation naming the conflict.
        message: String,
    },

    /// A configuration file could not be loaded or validated.
    #[error("config error in `{}`: {message}", path.display())]
    Config {
        /// Path to the offending config file.
        path: PathBuf,
        /// Human-readable explanation.
        message: String,
    },

    /// A requested capability is outside the current implementation,
    /// such as a future non-exhaustive strategy variant or invalid
    /// strategy parameters that cannot produce clusters.
    #[error("unsupported: {feature}")]
    Unsupported {
        /// Human-readable description of the missing capability.
        feature: String,
    },
}

impl From<std::io::Error> for FaceError {
    fn from(source: std::io::Error) -> Self {
        FaceError::Io { source }
    }
}

/// Render the §11.2 "ambiguous detection" message body, including a
/// multi-line hint when string candidates or numeric fields are
/// available. When both lists are empty, returns the bare leading line.
fn render_ambiguous_detection(
    candidates: &[DetectionCandidate],
    numeric_fields: &[String],
) -> String {
    let mut out = String::from("cannot auto-pick a grouping field");
    if candidates.is_empty() && numeric_fields.is_empty() {
        return out;
    }
    if !candidates.is_empty() {
        out.push_str("\n  string fields:  ");
        let parts: Vec<String> = candidates
            .iter()
            .map(|c| format!("{} ({} distinct)", c.field, c.cardinality))
            .collect();
        out.push_str(&parts.join(", "));
    }
    if !numeric_fields.is_empty() {
        out.push_str("\n  numeric fields: ");
        out.push_str(&numeric_fields.join(", "));
    }
    out.push_str(
        "\n  hint: pass --score=FIELD to rank by a numeric field, or --by=FIELD to group by a string field",
    );
    out
}

/// One candidate field surfaced when auto-detection cannot pick a
/// grouping field on its own.
///
/// Listed inside [`FaceError::AmbiguousDetection`] so callers can render
/// the §11.2 hint message.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct DetectionCandidate {
    /// Field path (e.g. `data.path.text`).
    pub field: String,
    /// Number of distinct values observed for this field.
    pub cardinality: usize,
}

impl DetectionCandidate {
    /// Construct a candidate from a field path and an observed
    /// distinct-value count.
    ///
    /// `DetectionCandidate` is `#[non_exhaustive]`; downstream crates
    /// (notably `face-cli`) build the candidate list when the CLI
    /// falls back into [`FaceError::AmbiguousDetection`], so this
    /// constructor is the intended public entry point.
    ///
    /// # Examples
    ///
    /// ```
    /// use face_core::DetectionCandidate;
    ///
    /// let c = DetectionCandidate::new("data.path.text", 47);
    /// assert_eq!(c.field, "data.path.text");
    /// assert_eq!(c.cardinality, 47);
    /// ```
    pub fn new(field: impl Into<String>, cardinality: usize) -> Self {
        Self {
            field: field.into(),
            cardinality,
        }
    }
}

/// One per-record skip event (§11.1).
///
/// Surfaced via stderr and counted in [`crate::Envelope`]'s
/// `result.skipped`. Skips are not errors — they are routine
/// best-effort signal preservation.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct SkipReport {
    /// Zero-based index of the input record that was skipped.
    pub record_index: usize,
    /// Why the record was skipped.
    pub reason: SkipReason,
}

/// Reason a single input record was skipped during processing.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum SkipReason {
    /// The record could not be parsed as JSON.
    InvalidJson {
        /// Column at which the parser failed.
        column: usize,
        /// Parser error message.
        message: String,
    },
    /// The record parsed but a required field was missing.
    MissingField {
        /// Field path that was missing.
        field: String,
    },
    /// The record had a value at the field path but the value's JSON
    /// kind was not usable for the strategy at hand. Distinct from
    /// [`SkipReason::MissingField`] — the field is present, just the
    /// wrong shape (e.g. an array where the strategy expects a scalar
    /// key).
    WrongType {
        /// Field path that resolved to a value of the wrong shape.
        field: String,
        /// Lowercase JSON kind name: `array`, `object`, `string`,
        /// `boolean`, `number`, `null`.
        kind: String,
    },
    /// The record had a value at the score path but it was not numeric.
    NonNumericScore {
        /// Score path that resolved to a non-numeric value.
        path: String,
        /// Short rendering of the offending value for the stderr line.
        value_summary: String,
    },
}