Skip to main content

face_core/
error.rs

1//! Error types and per-record skip reporting for the face core surface.
2//!
3//! [`FaceError`] is the typed library error returned from fallible
4//! operations. [`SkipReport`] / [`SkipReason`] describe non-fatal
5//! per-record skip events surfaced to stderr and counted in
6//! [`crate::Envelope`]'s `result.skipped` field (§11.1 of `docs/design.md`).
7
8use std::path::PathBuf;
9use thiserror::Error;
10
11use crate::InputFormat;
12
13/// Library-level error returned by the face core surface.
14///
15/// Variants cover I/O failure, input parse failure, detection ambiguity,
16/// cluster-id parse failure, flag conflicts, and configuration errors.
17/// The enum is `#[non_exhaustive]` so adding a new variant is not a
18/// breaking change.
19#[derive(Debug, Error)]
20#[non_exhaustive]
21pub enum FaceError {
22    /// I/O failure reading or writing input.
23    #[error("input I/O: {source}")]
24    Io {
25        /// Underlying I/O error.
26        #[source]
27        source: std::io::Error,
28    },
29
30    /// The input could not be parsed as the detected (or requested)
31    /// format.
32    #[error("input parse failed ({format:?}): {message}")]
33    InputParse {
34        /// Format we were attempting to parse.
35        format: InputFormat,
36        /// Human-readable explanation.
37        message: String,
38    },
39
40    /// `--items` (or auto-detected items path) refers to a field that
41    /// does not exist in the input.
42    #[error("items path `{path}` not found in input")]
43    UnknownItemsPath {
44        /// The items path that could not be resolved.
45        path: String,
46    },
47
48    /// The score path was supplied (or auto-detected) but no sampled
49    /// item carried a numeric value at that path.
50    #[error("score path `{path}` is not numeric in any sampled item")]
51    UnknownScorePath {
52        /// The score path that could not be resolved as numeric.
53        path: String,
54    },
55
56    /// Auto-detection could not pick a single grouping field (§11.2).
57    ///
58    /// The Display impl renders a multi-line hint listing the string
59    /// candidates and the available numeric fields so the user knows
60    /// to pass `--by=FIELD` or `--score=FIELD`. When both lists are
61    /// empty the message collapses to a single line.
62    #[error("{}", render_ambiguous_detection(candidates, numeric_fields))]
63    AmbiguousDetection {
64        /// Candidate fields with their cardinality, ordered for display.
65        candidates: Vec<DetectionCandidate>,
66        /// Top-level numeric field names (no cardinality), surfaced so
67        /// the user can pick one with `--score=FIELD`.
68        numeric_fields: Vec<String>,
69    },
70
71    /// A cluster id segment failed to parse.
72    #[error("invalid cluster id at segment {segment}: {reason}")]
73    InvalidClusterId {
74        /// Zero-based index of the offending segment.
75        segment: usize,
76        /// Human-readable reason.
77        reason: String,
78    },
79
80    /// A cluster id refers to an axis name not present in the current
81    /// envelope's `result.axes` list.
82    #[error("cluster id refers to unknown axis `{axis}`")]
83    UnknownAxis {
84        /// The axis name that did not match any declared axis.
85        axis: String,
86    },
87
88    /// Two flags that cannot be combined were both supplied (§11.3).
89    #[error("conflicting flags: {message}")]
90    ConflictingFlags {
91        /// Human-readable explanation naming the conflict.
92        message: String,
93    },
94
95    /// A configuration file could not be loaded or validated.
96    #[error("config error in `{}`: {message}", path.display())]
97    Config {
98        /// Path to the offending config file.
99        path: PathBuf,
100        /// Human-readable explanation.
101        message: String,
102    },
103
104    /// A requested capability is outside the current implementation,
105    /// such as a future non-exhaustive strategy variant or invalid
106    /// strategy parameters that cannot produce clusters.
107    #[error("unsupported: {feature}")]
108    Unsupported {
109        /// Human-readable description of the missing capability.
110        feature: String,
111    },
112}
113
114impl From<std::io::Error> for FaceError {
115    fn from(source: std::io::Error) -> Self {
116        FaceError::Io { source }
117    }
118}
119
120/// Render the §11.2 "ambiguous detection" message body, including a
121/// multi-line hint when string candidates or numeric fields are
122/// available. When both lists are empty, returns the bare leading line.
123fn render_ambiguous_detection(
124    candidates: &[DetectionCandidate],
125    numeric_fields: &[String],
126) -> String {
127    let mut out = String::from("cannot auto-pick a grouping field");
128    if candidates.is_empty() && numeric_fields.is_empty() {
129        return out;
130    }
131    if !candidates.is_empty() {
132        out.push_str("\n  string fields:  ");
133        let parts: Vec<String> = candidates
134            .iter()
135            .map(|c| format!("{} ({} distinct)", c.field, c.cardinality))
136            .collect();
137        out.push_str(&parts.join(", "));
138    }
139    if !numeric_fields.is_empty() {
140        out.push_str("\n  numeric fields: ");
141        out.push_str(&numeric_fields.join(", "));
142    }
143    out.push_str(
144        "\n  hint: pass --score=FIELD to rank by a numeric field, or --by=FIELD to group by a string field",
145    );
146    out
147}
148
149/// One candidate field surfaced when auto-detection cannot pick a
150/// grouping field on its own.
151///
152/// Listed inside [`FaceError::AmbiguousDetection`] so callers can render
153/// the §11.2 hint message.
154#[derive(Debug, Clone)]
155#[non_exhaustive]
156pub struct DetectionCandidate {
157    /// Field path (e.g. `data.path.text`).
158    pub field: String,
159    /// Number of distinct values observed for this field.
160    pub cardinality: usize,
161}
162
163impl DetectionCandidate {
164    /// Construct a candidate from a field path and an observed
165    /// distinct-value count.
166    ///
167    /// `DetectionCandidate` is `#[non_exhaustive]`; downstream crates
168    /// (notably `face-cli`) build the candidate list when the CLI
169    /// falls back into [`FaceError::AmbiguousDetection`], so this
170    /// constructor is the intended public entry point.
171    ///
172    /// # Examples
173    ///
174    /// ```
175    /// use face_core::DetectionCandidate;
176    ///
177    /// let c = DetectionCandidate::new("data.path.text", 47);
178    /// assert_eq!(c.field, "data.path.text");
179    /// assert_eq!(c.cardinality, 47);
180    /// ```
181    pub fn new(field: impl Into<String>, cardinality: usize) -> Self {
182        Self {
183            field: field.into(),
184            cardinality,
185        }
186    }
187}
188
189/// One per-record skip event (§11.1).
190///
191/// Surfaced via stderr and counted in [`crate::Envelope`]'s
192/// `result.skipped`. Skips are not errors — they are routine
193/// best-effort signal preservation.
194#[derive(Debug, Clone)]
195#[non_exhaustive]
196pub struct SkipReport {
197    /// Zero-based index of the input record that was skipped.
198    pub record_index: usize,
199    /// Why the record was skipped.
200    pub reason: SkipReason,
201}
202
203/// Reason a single input record was skipped during processing.
204#[derive(Debug, Clone)]
205#[non_exhaustive]
206pub enum SkipReason {
207    /// The record could not be parsed as JSON.
208    InvalidJson {
209        /// Column at which the parser failed.
210        column: usize,
211        /// Parser error message.
212        message: String,
213    },
214    /// The record parsed but a required field was missing.
215    MissingField {
216        /// Field path that was missing.
217        field: String,
218    },
219    /// The record had a value at the field path but the value's JSON
220    /// kind was not usable for the strategy at hand. Distinct from
221    /// [`SkipReason::MissingField`] — the field is present, just the
222    /// wrong shape (e.g. an array where the strategy expects a scalar
223    /// key).
224    WrongType {
225        /// Field path that resolved to a value of the wrong shape.
226        field: String,
227        /// Lowercase JSON kind name: `array`, `object`, `string`,
228        /// `boolean`, `number`, `null`.
229        kind: String,
230    },
231    /// The record had a value at the score path but it was not numeric.
232    NonNumericScore {
233        /// Score path that resolved to a non-numeric value.
234        path: String,
235        /// Short rendering of the offending value for the stderr line.
236        value_summary: String,
237    },
238}