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}