use std::io::{BufRead, BufReader, IsTerminal, Read, Write};
use dialoguer::{Input, MultiSelect, Select, theme::ColorfulTheme};
use serde_json::Value;
use terminal_size::{Width, terminal_size};
use face_core::detect::{detect_preset, invert as score_invert};
use face_core::input::{parse, parse_with_columns_and_options, sniff_format};
use face_core::{
Axis, AxisPlan, Cluster, ClusterId, DetectionReport, Envelope, FaceError, InputFormat,
ItemsOptions, OutputFormat, Page, ParseOptions, ParsedInput, Preset, Record, RenderOptions,
ResultBlock, ScoreOptions, SimilarAlgorithm, SkipReason, SkipReport, Strategy,
StrategyDetectionOptions, VecDiagnostics, auto_strategy_with_options, build_tree,
detect_score_with_options, pick_grouping_field_with_options, render, sample_scores, to_flat,
validate_score_path,
};
use crate::Io;
use crate::cli::Cli;
use crate::config::{self, RuntimeSettings, SettingSource, UserPreset};
pub(crate) fn run<R: Read, W: Write, E: Write>(
cli: Cli,
io: &mut Io<R, W, E>,
) -> Result<(), FaceError> {
validate_flag_combinations(&cli)?;
let settings = config::load()?;
let options = RunOptions::resolve(&cli, settings);
let mut reader = BufReader::new(&mut io.stdin);
let format = resolve_input_format(&cli, &mut reader)?;
if matches!(format, InputFormat::FaceEnvelope) {
let envelope = read_face_envelope(&mut reader)?;
drop(reader);
return run_face_envelope_input(&cli, &options, envelope, io);
}
let items_path_override = cli.items.as_deref();
let parse_options = options.parse_options();
let parsed = parse_with_columns_and_options(
&mut reader,
format,
items_path_override,
cli.columns.as_deref(),
&parse_options,
)?;
run_parsed_input(&cli, &options, format, parsed, io)
}
#[derive(Debug, Clone)]
struct RunOptions {
output_format: OutputFormat,
output_format_source: SettingSource,
page: u32,
per_page: u32,
color: String,
color_source: SettingSource,
default_bands: u8,
interactive_save_threshold: usize,
settings: RuntimeSettings,
}
impl RunOptions {
fn resolve(cli: &Cli, settings: RuntimeSettings) -> Self {
let (output_format, output_format_source) = if cli.json {
(OutputFormat::Json, SettingSource::Cli)
} else if let Some(format) = cli.format {
(format, SettingSource::Cli)
} else {
(
settings.output.default_format,
settings.sources.default_format,
)
};
let (color, color_source) = if let Some(color) = &cli.color {
(color.clone(), SettingSource::Cli)
} else {
(settings.output.color.clone(), settings.sources.color)
};
let page = cli.page.unwrap_or(1);
let per_page = cli.per_page.unwrap_or(settings.output.per_page);
let default_bands = cli.bands.unwrap_or(settings.strategy.default_bands);
let interactive_save_threshold = settings.interactive.save_threshold;
Self {
output_format,
output_format_source,
page,
per_page,
color,
color_source,
default_bands,
interactive_save_threshold,
settings,
}
}
fn parse_options(&self) -> ParseOptions {
let mut items = ItemsOptions::default();
items.candidates = self.settings.detect.items_candidates.clone();
let mut options = ParseOptions::default();
options.items = items;
options
}
fn score_options(&self) -> ScoreOptions {
let mut options = ScoreOptions::default();
options.candidates = self.settings.detect.score_candidates.clone();
options
}
fn strategy_options(&self) -> StrategyDetectionOptions {
let mut options = StrategyDetectionOptions::default();
options.enum_max_cardinality = self.settings.detect.enum_max_cardinality;
options.enum_coverage_threshold = self.settings.detect.enum_coverage_threshold;
options.path_like_slash_ratio = self.settings.detect.path_like_slash_ratio;
options.group_preference = self.settings.detect.group_preference.clone();
options.default_bands = self.default_bands;
options
}
}
fn run_parsed_input<R: Read, W: Write, E: Write>(
cli: &Cli,
options: &RunOptions,
format: InputFormat,
parsed: ParsedInput,
io: &mut Io<R, W, E>,
) -> Result<(), FaceError> {
if cli.schema {
emit_schema(&mut io.stdout, format, &parsed)?;
return Ok(());
}
let preset_selection = resolve_preset_selection(cli, &options.settings)?;
let score_decision = resolve_score(cli, options, preset_selection.as_ref(), &parsed.items)?;
let preset_decision = resolve_preset(
preset_selection,
&parsed.items,
score_decision.path.as_deref(),
);
let invert = resolve_invert(cli, preset_decision.custom.as_ref());
let scale = resolve_scale(cli, preset_decision.custom.as_ref());
let inverted_items = if invert {
if let Some(score_path) = score_decision.path.as_deref() {
invert_score_in_items(&parsed.items, score_path, scale)
} else {
parsed.items.clone()
}
} else {
parsed.items.clone()
};
let plan = build_axis_plan(
cli,
options,
&inverted_items,
score_decision.path.as_deref(),
)?;
if cli.explain {
emit_explain(
&mut io.stdout,
format,
&parsed,
score_decision.path.as_deref(),
score_decision.fallback_reason.as_deref(),
preset_decision.name.as_deref(),
&plan,
options,
)?;
return Ok(());
}
let records = Record::from_items(inverted_items, score_decision.path.as_deref());
let mut diag = VecDiagnostics::default();
let clusters = build_tree(&plan, records, &mut diag)?;
let total_skipped = (parsed.skips.len() + diag.skips.len()) as u64;
let interactive = cli.interactive && interactive_tty_available();
let page = if interactive {
let Some(cluster_ids) = select_interactive_clusters(&clusters)? else {
return Ok(());
};
build_page_for_cluster_ids(
options.page,
options.per_page,
&cluster_ids,
&clusters,
&parsed.items,
&plan,
)?
} else {
build_page(cli, options, &clusters, &parsed.items, &plan)?
};
let axes = collect_axes(&plan);
let mut detection = DetectionReport::default();
detection.format = format;
detection.items_path = parsed.items_path.clone();
detection.score_path = score_decision.path.clone();
detection.preset = preset_decision.name.clone();
detection.fallback_reason = score_decision.fallback_reason.clone();
let total_input = parsed.items.len() as u64 + parsed.skips.len() as u64;
let mut result_block = ResultBlock::default();
result_block.input_total = total_input;
result_block.skipped = total_skipped;
result_block.axes = axes;
result_block.detection = detection;
let mut envelope = Envelope::default();
envelope.result = result_block;
envelope.meta = parsed.meta.clone().unwrap_or_default();
envelope.clusters = clusters;
envelope.page = page;
envelope.cache.items = parsed.items.clone();
if cli.verbose {
write_provenance(&mut io.stderr, &envelope, format)?;
for skip in &parsed.skips {
write_skip_warning(&mut io.stderr, skip)?;
}
for skip in diag.skips() {
write_skip_warning(&mut io.stderr, skip)?;
}
}
let render_format = resolve_pipeline_render_format(
cli,
options,
&envelope,
interactive,
host_stdout_is_terminal(),
);
let opts = resolve_render_options(render_format, &options.color);
let mut rendered = render(&envelope, render_format, &opts)?;
if interactive {
rendered = maybe_save_interactive_output(rendered, options)?;
if rendered.is_empty() {
return Ok(());
}
}
if !rendered.ends_with('\n') {
rendered.push('\n');
}
io.stdout.write_all(rendered.as_bytes())?;
Ok(())
}
fn read_face_envelope<R: BufRead>(reader: &mut R) -> Result<Envelope, FaceError> {
serde_json::from_reader(reader).map_err(|e| FaceError::InputParse {
format: InputFormat::FaceEnvelope,
message: e.to_string(),
})
}
fn run_face_envelope_input<R: Read, W: Write, E: Write>(
cli: &Cli,
options: &RunOptions,
mut envelope: Envelope,
io: &mut Io<R, W, E>,
) -> Result<(), FaceError> {
if cli.schema {
emit_face_envelope_schema(&mut io.stdout, &envelope)?;
return Ok(());
}
if envelope_regroup_requested(cli) {
let parsed = parse_cluster_records_from_envelope(&envelope)?;
return run_parsed_input(cli, options, InputFormat::FaceEnvelope, parsed, io);
}
if cli.explain {
emit_face_envelope_explain(&mut io.stdout, &envelope, options)?;
return Ok(());
}
let interactive = cli.interactive && interactive_tty_available();
if interactive {
let Some(cluster_ids) = select_interactive_clusters(&envelope.clusters)? else {
return Ok(());
};
focus_envelope_on_cluster_ids(options, &mut envelope, &cluster_ids)?;
if cli.verbose {
write_provenance(&mut io.stderr, &envelope, InputFormat::FaceEnvelope)?;
}
let opts = resolve_render_options(OutputFormat::Human, &options.color);
let mut rendered = render(&envelope, OutputFormat::Human, &opts)?;
rendered = maybe_save_interactive_output(rendered, options)?;
if rendered.is_empty() {
return Ok(());
}
if !rendered.ends_with('\n') {
rendered.push('\n');
}
io.stdout.write_all(rendered.as_bytes())?;
return Ok(());
}
if !cli.cluster.is_empty() {
focus_envelope_on_cluster(cli, options, &mut envelope)?;
}
if cli.verbose {
write_provenance(&mut io.stderr, &envelope, InputFormat::FaceEnvelope)?;
}
let render_format =
resolve_pipeline_render_format(cli, options, &envelope, false, host_stdout_is_terminal());
emit_rendered_envelope(&mut io.stdout, &envelope, render_format, &options.color)?;
Ok(())
}
fn envelope_regroup_requested(cli: &Cli) -> bool {
cli.by.is_some()
|| !cli.within.is_empty()
|| cli.score.is_some()
|| cli.preset.is_some()
|| cli.invert
|| cli.strategy.is_some()
|| cli.bands.is_some()
|| cli.top.is_some()
}
fn parse_cluster_records_from_envelope(envelope: &Envelope) -> Result<ParsedInput, FaceError> {
let using_cache = !envelope.cache.items.is_empty();
let items = if using_cache {
envelope.cache.items.clone()
} else {
to_flat(&envelope.clusters)
.into_iter()
.map(serde_json::to_value)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| FaceError::InputParse {
format: InputFormat::FaceEnvelope,
message: e.to_string(),
})?
};
let bytes = serde_json::to_vec(&Value::Array(items)).map_err(|e| FaceError::InputParse {
format: InputFormat::FaceEnvelope,
message: e.to_string(),
})?;
let mut reader = BufReader::new(bytes.as_slice());
let mut parsed = parse(&mut reader, InputFormat::Json, None)?;
if using_cache {
parsed.items_path = envelope.result.detection.items_path.clone();
if !envelope.meta.is_empty() {
parsed.meta = Some(envelope.meta.clone());
}
}
Ok(parsed)
}
fn focus_envelope_on_cluster(
cli: &Cli,
options: &RunOptions,
envelope: &mut Envelope,
) -> Result<(), FaceError> {
let cluster_id = resolve_cluster_arg(&cli.cluster, &envelope.clusters)?;
focus_envelope_on_cluster_id(options, envelope, cluster_id)
}
fn focus_envelope_on_cluster_id(
options: &RunOptions,
envelope: &mut Envelope,
cluster_id: ClusterId,
) -> Result<(), FaceError> {
let focused = locate_cluster(&envelope.clusters, &cluster_id)
.cloned()
.ok_or_else(|| FaceError::InvalidClusterId {
segment: cluster_id.depth().saturating_sub(1),
reason: "cluster id not present in saved envelope".to_string(),
})?;
let cached_page =
cached_page_for_cluster_id(envelope, &cluster_id, options.page, options.per_page)?;
let fallback_page_items = if cached_page.is_none() {
page_items_from_saved_envelope(envelope, &cluster_id, options.page, options.per_page)
} else {
Vec::new()
};
envelope.result.input_total = focused.total;
envelope.clusters = if focused.clusters.is_empty() {
vec![focused.clone()]
} else {
focused.clusters.clone()
};
envelope.page = cached_page.unwrap_or_else(|| {
let mut page = Page::default();
page.cluster_id = Some(cluster_id);
page.page = options.page;
page.per_page = options.per_page;
page.total_items = focused.total;
page.items = fallback_page_items;
page
});
Ok(())
}
fn focus_envelope_on_cluster_ids(
options: &RunOptions,
envelope: &mut Envelope,
cluster_ids: &[ClusterId],
) -> Result<(), FaceError> {
if cluster_ids.len() == 1 {
return focus_envelope_on_cluster_id(options, envelope, cluster_ids[0].clone());
}
let focused = cluster_ids
.iter()
.map(|cluster_id| {
locate_cluster(&envelope.clusters, cluster_id)
.cloned()
.ok_or_else(|| FaceError::InvalidClusterId {
segment: cluster_id.depth().saturating_sub(1),
reason: "cluster id not present in saved envelope".to_string(),
})
})
.collect::<Result<Vec<_>, _>>()?;
envelope.result.input_total = focused.iter().map(|cluster| cluster.total).sum();
let page = cached_page_for_cluster_ids(envelope, cluster_ids, options.page, options.per_page)?;
envelope.clusters = focused;
envelope.page = page.unwrap_or_else(|| {
let mut page = Page::default();
page.cluster_id = None;
page.page = options.page;
page.per_page = options.per_page;
page.total_items = 0;
page.items = Vec::new();
page
});
Ok(())
}
fn cached_page_for_cluster_id(
envelope: &Envelope,
cluster_id: &ClusterId,
page: u32,
per_page: u32,
) -> Result<Option<Page>, FaceError> {
if envelope.cache.items.is_empty() {
return Ok(None);
}
let Some(plan) = axis_plan_from_axes(&envelope.result.axes) else {
return Ok(None);
};
build_page_for_cluster_id(
page,
per_page,
cluster_id.clone(),
&envelope.clusters,
&envelope.cache.items,
&plan,
)
.map(Some)
}
fn cached_page_for_cluster_ids(
envelope: &Envelope,
cluster_ids: &[ClusterId],
page: u32,
per_page: u32,
) -> Result<Option<Page>, FaceError> {
if envelope.cache.items.is_empty() {
return Ok(None);
}
let Some(plan) = axis_plan_from_axes(&envelope.result.axes) else {
return Ok(None);
};
build_page_for_cluster_ids(
page,
per_page,
cluster_ids,
&envelope.clusters,
&envelope.cache.items,
&plan,
)
.map(Some)
}
fn axis_plan_from_axes(axes: &[Axis]) -> Option<AxisPlan> {
let mut iter = axes.iter().rev();
let mut current = AxisPlan::leaf(iter.next()?.clone());
for axis in iter {
current = AxisPlan::with(axis.clone(), current);
}
Some(current)
}
fn page_items_from_saved_envelope(
envelope: &Envelope,
cluster_id: &ClusterId,
page: u32,
per_page: u32,
) -> Vec<Value> {
if envelope.page.cluster_id.as_ref() != Some(cluster_id) {
return Vec::new();
}
let per_page = per_page.max(1) as usize;
let page = page.max(1) as usize;
let start = (page - 1) * per_page;
let end = (start + per_page).min(envelope.page.items.len());
if start >= envelope.page.items.len() {
Vec::new()
} else {
envelope.page.items[start..end].to_vec()
}
}
fn emit_rendered_envelope<W: Write>(
stdout: &mut W,
envelope: &Envelope,
output_format: OutputFormat,
color: &str,
) -> Result<(), FaceError> {
let opts = resolve_render_options(output_format, color);
let mut rendered = render(envelope, output_format, &opts)?;
if !rendered.ends_with('\n') {
rendered.push('\n');
}
stdout.write_all(rendered.as_bytes())?;
Ok(())
}
fn resolve_pipeline_render_format(
cli: &Cli,
options: &RunOptions,
envelope: &Envelope,
interactive: bool,
stdout_is_terminal: bool,
) -> OutputFormat {
if interactive {
return OutputFormat::Human;
}
if should_auto_emit_jsonl_items(cli, options, envelope, stdout_is_terminal) {
OutputFormat::JsonlItems
} else {
options.output_format
}
}
fn should_auto_emit_jsonl_items(
cli: &Cli,
options: &RunOptions,
envelope: &Envelope,
stdout_is_terminal: bool,
) -> bool {
!stdout_is_terminal
&& !cli.cluster.is_empty()
&& envelope.page.cluster_id.is_some()
&& matches!(options.output_format, OutputFormat::Human)
&& options.output_format_source != SettingSource::Cli
}
fn validate_flag_combinations(cli: &Cli) -> Result<(), FaceError> {
if cli.explain && cli.schema {
return Err(FaceError::ConflictingFlags {
message: "--explain cannot be combined with --schema".to_string(),
});
}
if cli.interactive && (cli.json || cli.format.is_some()) {
return Err(FaceError::ConflictingFlags {
message: "--interactive cannot be combined with --format".to_string(),
});
}
if cli.columns.is_some() {
let format_in = cli.format_in.as_deref().map(str::to_ascii_lowercase);
let is_non_delim = matches!(
format_in.as_deref(),
Some("json") | Some("jsonl" | "ndjson")
);
if is_non_delim {
return Err(FaceError::ConflictingFlags {
message: "--columns requires --format-in=csv or --format-in=tsv".to_string(),
});
}
}
Ok(())
}
fn resolve_input_format<R: BufRead>(cli: &Cli, reader: &mut R) -> Result<InputFormat, FaceError> {
if let Some(forced) = cli.format_in.as_deref() {
return parse_format_in(forced);
}
let outcome = sniff_format(reader)?;
Ok(outcome.format)
}
fn parse_format_in(name: &str) -> Result<InputFormat, FaceError> {
match name.to_ascii_lowercase().as_str() {
"json" => Ok(InputFormat::Json),
"jsonl" | "ndjson" => Ok(InputFormat::Jsonl),
"csv" => Ok(InputFormat::Csv),
"tsv" => Ok(InputFormat::Tsv),
other => Err(FaceError::ConflictingFlags {
message: format!("--format-in={other} is not a recognized format"),
}),
}
}
fn emit_schema<W: Write>(
stdout: &mut W,
format: InputFormat,
parsed: &ParsedInput,
) -> Result<(), FaceError> {
let mut out = String::new();
out.push_str(&format!("format: {}\n", input_format_name(format)));
out.push_str(&format!("records: {}\n", parsed.items.len()));
out.push_str(&format!("items_path: {}\n", parsed.items_path));
let field_summaries = summarize_top_level_fields(&parsed.items);
if !field_summaries.is_empty() {
out.push_str("fields:\n");
for (name, kind, distinct) in &field_summaries {
out.push_str(&format!(" {name:<24} {kind} ({distinct} distinct)\n"));
}
}
out.push_str("score_candidates: (run --explain for full report)\n");
stdout.write_all(out.as_bytes())?;
Ok(())
}
fn emit_face_envelope_schema<W: Write>(
stdout: &mut W,
envelope: &Envelope,
) -> Result<(), FaceError> {
let mut out = String::new();
out.push_str("format: face-envelope\n");
out.push_str(&format!("records: {}\n", envelope.result.input_total));
out.push_str("items_path: .clusters\n");
out.push_str(&format!(
"clusters: {}\n",
count_clusters(&envelope.clusters)
));
out.push_str(&format!("axes: {}\n", envelope.result.axes.len()));
stdout.write_all(out.as_bytes())?;
Ok(())
}
fn summarize_top_level_fields(items: &[Value]) -> Vec<(String, &'static str, usize)> {
use std::collections::BTreeMap;
let mut per_field: BTreeMap<String, FieldShape> = BTreeMap::new();
for item in items {
collect_field_shapes(item, "", &mut per_field);
}
per_field
.into_iter()
.map(|(name, shape)| (name, shape.dominant_kind(), shape.distinct.len()))
.collect()
}
fn collect_field_shapes(
value: &Value,
prefix: &str,
per_field: &mut std::collections::BTreeMap<String, FieldShape>,
) {
match value {
Value::Object(map) => {
for (key, value) in map {
let path = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
collect_field_shapes(value, &path, per_field);
}
}
_ if !prefix.is_empty() => {
per_field
.entry(prefix.to_string())
.or_default()
.observe(value);
}
_ => {}
}
}
#[derive(Default)]
struct FieldShape {
string_count: usize,
number_count: usize,
bool_count: usize,
other_count: usize,
distinct: std::collections::BTreeSet<String>,
}
impl FieldShape {
fn observe(&mut self, v: &Value) {
match v {
Value::String(s) => {
self.string_count += 1;
self.distinct.insert(s.clone());
}
Value::Number(n) => {
self.number_count += 1;
self.distinct.insert(n.to_string());
}
Value::Bool(b) => {
self.bool_count += 1;
self.distinct.insert(b.to_string());
}
_ => self.other_count += 1,
}
}
fn dominant_kind(&self) -> &'static str {
let mut best = ("mixed", 0usize);
if self.string_count > best.1 {
best = ("string", self.string_count);
}
if self.number_count > best.1 {
best = ("number", self.number_count);
}
if self.bool_count > best.1 {
best = ("bool", self.bool_count);
}
if self.other_count > best.1 {
best = ("other", self.other_count);
}
best.0
}
}
#[derive(Debug, Default, Clone)]
struct ScoreDecision {
path: Option<String>,
fallback_reason: Option<String>,
}
fn resolve_score(
cli: &Cli,
options: &RunOptions,
preset: Option<&PresetDecision>,
items: &[Value],
) -> Result<ScoreDecision, FaceError> {
if let Some(user_path) = cli.score.as_deref() {
validate_score_path(items, user_path)?;
return Ok(ScoreDecision {
path: Some(user_path.to_string()),
fallback_reason: None,
});
}
if let Some(user_path) = preset.and_then(|preset| preset.custom.as_ref()?.score.as_deref()) {
validate_score_path(items, user_path)?;
return Ok(ScoreDecision {
path: Some(user_path.to_string()),
fallback_reason: None,
});
}
let score_options = options.score_options();
let det = detect_score_with_options(items, &score_options)?;
Ok(ScoreDecision {
path: det.path,
fallback_reason: det.fallback_reason,
})
}
#[derive(Debug, Default, Clone)]
struct PresetDecision {
name: Option<String>,
custom: Option<UserPreset>,
}
fn resolve_preset_selection(
cli: &Cli,
settings: &RuntimeSettings,
) -> Result<Option<PresetDecision>, FaceError> {
let Some(name) = cli.preset.as_deref() else {
return Ok(None);
};
if let Some(preset) = Preset::from_name(name) {
return Ok(Some(PresetDecision {
name: Some(preset.name().to_string()),
custom: None,
}));
}
if let Some(preset) = settings.presets.get(name) {
return Ok(Some(PresetDecision {
name: Some(name.to_string()),
custom: Some(preset.clone()),
}));
}
Err(FaceError::ConflictingFlags {
message: format!(
"--preset={name} is not a known preset (try `confidence`, `concept-search`, `bm25`, `severity`, or a configured preset)",
),
})
}
fn resolve_preset(
explicit: Option<PresetDecision>,
items: &[Value],
score_path: Option<&str>,
) -> PresetDecision {
if let Some(decision) = explicit {
return decision;
}
let Some(path) = score_path else {
return PresetDecision::default();
};
let samples = sample_scores(items, path, face_core::detect::preset::preset_sample_size());
match detect_preset(&samples) {
Some(preset) => PresetDecision {
name: Some(preset.name().to_string()),
custom: None,
},
None => PresetDecision::default(),
}
}
fn resolve_invert(cli: &Cli, custom_preset: Option<&UserPreset>) -> bool {
cli.invert
|| custom_preset
.and_then(|preset| preset.invert)
.unwrap_or(false)
}
fn resolve_scale(cli: &Cli, custom_preset: Option<&UserPreset>) -> Option<f64> {
cli.scale
.or_else(|| custom_preset.and_then(|preset| preset.scale))
}
fn invert_score_in_items(items: &[Value], score_path: &str, scale: Option<f64>) -> Vec<Value> {
items
.iter()
.map(|item| invert_one(item.clone(), score_path, scale))
.collect()
}
fn invert_one(mut item: Value, score_path: &str, scale: Option<f64>) -> Value {
let path = score_path.strip_prefix('.').unwrap_or(score_path);
if path.is_empty() {
return item;
}
let segments: Vec<&str> = path.split('.').collect();
if let Some((leaf, parents)) = segments.split_last() {
let mut cursor = &mut item;
for seg in parents {
let Value::Object(map) = cursor else {
return item;
};
match map.get_mut(*seg) {
Some(next) => cursor = next,
None => return item,
}
}
if let Value::Object(map) = cursor
&& let Some(slot) = map.get_mut(*leaf)
&& let Some(n) = slot.as_f64()
{
let new = score_invert(n, scale);
*slot = serde_json::json!(new);
}
}
item
}
fn build_axis_plan(
cli: &Cli,
options: &RunOptions,
items: &[Value],
score_path: Option<&str>,
) -> Result<AxisPlan, FaceError> {
let mut axis_fields: Vec<String> = Vec::new();
let mut auto_top_axis = false;
let score_axis = score_path.is_some()
&& cli.by.is_none()
&& options.settings.strategy.prefer_score_when_present;
if let Some(by) = &cli.by {
axis_fields.extend(by.iter().cloned());
} else if let Some(score) = score_path.filter(|_| score_axis) {
axis_fields.push(strip_leading_dot(score).to_string());
} else {
let strategy_options = options.strategy_options();
let picked =
pick_grouping_field_with_options(items, &strategy_options).ok_or_else(|| {
FaceError::AmbiguousDetection {
candidates: detection_candidates(items),
numeric_fields: numeric_fields_for_hint(items),
}
})?;
axis_fields.push(picked);
auto_top_axis = true;
}
axis_fields.extend(cli.within.iter().cloned());
if axis_fields.is_empty() {
return Err(FaceError::AmbiguousDetection {
candidates: detection_candidates(items),
numeric_fields: numeric_fields_for_hint(items),
});
}
let global_strategy = match cli.strategy.as_deref() {
Some(s) => Some(parse_strategy_arg(
s,
cli.bands,
cli.top,
options.default_bands,
)?),
None => None,
};
let strategy_options = options.strategy_options();
let mut current: Option<AxisPlan> = None;
for (idx, field) in axis_fields.iter().enumerate().rev() {
let strategy = if let Some(s) = global_strategy.clone() {
s
} else if idx == 0 && score_axis {
Strategy::Bands {
count: options.default_bands,
}
} else {
let auto = auto_strategy_with_options(field, items, &strategy_options)?;
apply_strategy_overrides(auto, cli.bands, cli.top)
};
let auto_flag = global_strategy.is_none() && (idx > 0 || cli.by.is_none() || auto_top_axis);
let axis = make_axis(field.clone(), strategy, auto_flag);
let plan = match current {
None => AxisPlan::leaf(axis),
Some(child) => AxisPlan::with(axis, child),
};
current = Some(plan);
}
current.ok_or_else(|| FaceError::AmbiguousDetection {
candidates: detection_candidates(items),
numeric_fields: numeric_fields_for_hint(items),
})
}
fn parse_strategy_arg(
raw: &str,
bands: Option<u8>,
top: Option<u32>,
default_bands: u8,
) -> Result<Strategy, FaceError> {
let (name, payload) = match raw.split_once('=') {
Some((n, p)) => (n, Some(p)),
None => (raw, None),
};
let name = name.to_ascii_lowercase();
match name.as_str() {
"exact" => Ok(Strategy::Exact),
"prefix" => {
let depth = match payload {
Some(p) => Some(parse_depth(p)?),
None => None,
};
Ok(Strategy::Prefix { depth })
}
"top" => {
let n = payload
.map(parse_count_u32)
.transpose()?
.or(top)
.unwrap_or(10);
Ok(Strategy::Top { n })
}
"bands" => {
let count = payload
.map(parse_count_u8)
.transpose()?
.or(bands)
.unwrap_or(default_bands);
Ok(Strategy::Bands { count })
}
"quantiles" => {
let count = payload
.map(parse_count_u8)
.transpose()?
.or(bands)
.unwrap_or(4);
Ok(Strategy::Quantiles { count })
}
"natural" => {
let count = payload
.map(parse_count_u8)
.transpose()?
.or(bands)
.unwrap_or(default_bands);
Ok(Strategy::Natural { count })
}
"similar" => {
let algorithm = match payload {
Some(name) => SimilarAlgorithm::from_name(name).ok_or_else(|| {
FaceError::ConflictingFlags {
message: format!(
"--strategy=similar={name} is not recognized (use token, ngram, edit, lsh, or simhash)",
),
}
})?,
None => SimilarAlgorithm::Token,
};
Ok(Strategy::Similar { algorithm })
}
other => Err(FaceError::ConflictingFlags {
message: format!(
"--strategy={other} is not recognized (use exact, prefix, top, bands, quantiles, natural, or similar=token)",
),
}),
}
}
fn parse_count_u32(s: &str) -> Result<u32, FaceError> {
s.parse::<u32>().map_err(|_| FaceError::ConflictingFlags {
message: format!("expected integer count in --strategy payload, got `{s}`"),
})
}
fn parse_count_u8(s: &str) -> Result<u8, FaceError> {
s.parse::<u8>().map_err(|_| FaceError::ConflictingFlags {
message: format!("expected small integer count in --strategy payload, got `{s}`"),
})
}
fn parse_depth(s: &str) -> Result<u8, FaceError> {
s.parse::<u8>().map_err(|_| FaceError::ConflictingFlags {
message: format!("expected small integer depth in --strategy=prefix payload, got `{s}`"),
})
}
fn apply_strategy_overrides(s: Strategy, bands: Option<u8>, top: Option<u32>) -> Strategy {
match s {
Strategy::Bands { count } => Strategy::Bands {
count: bands.unwrap_or(count),
},
Strategy::Top { n } => Strategy::Top {
n: top.unwrap_or(n),
},
other => other,
}
}
fn make_axis(field: String, strategy: Strategy, auto: bool) -> Axis {
let mut value = serde_json::Map::new();
value.insert("field".into(), Value::String(field));
let strat_value = serde_json::to_value(&strategy).expect("Strategy serializes to JSON");
let strat_map = strat_value
.as_object()
.expect("Strategy serializes as a JSON object");
for (k, v) in strat_map {
value.insert(k.clone(), v.clone());
}
value.insert("auto".into(), Value::Bool(auto));
serde_json::from_value(Value::Object(value)).expect("Axis round-trips through wire form")
}
fn strip_leading_dot(s: &str) -> &str {
s.strip_prefix('.').unwrap_or(s)
}
#[allow(clippy::too_many_arguments)] fn emit_explain<W: Write>(
stdout: &mut W,
format: InputFormat,
parsed: &ParsedInput,
score_path: Option<&str>,
score_fallback: Option<&str>,
preset_name: Option<&str>,
plan: &AxisPlan,
options: &RunOptions,
) -> Result<(), FaceError> {
let mut out = String::new();
out.push_str("input:\n");
out.push_str(&format!(
" format {} (sniffed: {} records)\n",
input_format_name(format),
parsed.items.len()
));
out.push_str(&format!(
" items {} ({})\n",
parsed.items_path,
items_path_reason(format, &parsed.items_path),
));
match score_path {
Some(p) => {
let preset_label = preset_name.unwrap_or("no preset matched");
out.push_str(&format!(" score {p} ({preset_label})\n"));
}
None => {
let reason = score_fallback.unwrap_or("(none detected)");
out.push_str(&format!(" score (none; {reason})\n"));
}
}
out.push_str("strategy:\n");
write_axis_plan_explain(&mut out, plan, 1);
out.push_str("output:\n");
out.push_str(&format!(
" format {} ({})\n",
output_format_wire_name(options.output_format),
options.output_format_source.label()
));
out.push_str(&format!(
" color {} ({})\n",
options.color,
options.color_source.label()
));
if let Some(path) = &options.settings.config_path {
out.push_str(&format!(" config {}\n", path.display()));
}
stdout.write_all(out.as_bytes())?;
Ok(())
}
fn emit_face_envelope_explain<W: Write>(
stdout: &mut W,
envelope: &Envelope,
options: &RunOptions,
) -> Result<(), FaceError> {
let mut out = String::new();
out.push_str("input:\n");
out.push_str(&format!(
" format face-envelope (sniffed: saved face envelope, {} clusters)\n",
count_clusters(&envelope.clusters)
));
out.push_str(" items .clusters (input is a saved cluster tree)\n");
match envelope.result.detection.score_path.as_deref() {
Some(p) => out.push_str(&format!(" score {p} (saved)\n")),
None => out.push_str(" score (none detected in saved envelope)\n"),
}
out.push_str("strategy:\n");
if envelope.result.axes.is_empty() {
out.push_str(" axis 1 (none; saved envelope has no axes)\n");
} else {
for (idx, axis) in envelope.result.axes.iter().enumerate() {
let label = format!("axis {}", idx + 1);
let auto_marker = if axis.auto { "auto" } else { "manual" };
out.push_str(&format!(
" {label:<14}{} (saved {auto_marker}: {})\n",
axis.field,
axis.strategy.name(),
));
}
}
out.push_str("output:\n");
out.push_str(&format!(
" format {} ({})\n",
output_format_wire_name(options.output_format),
options.output_format_source.label()
));
out.push_str(&format!(
" color {} ({})\n",
options.color,
options.color_source.label()
));
if let Some(path) = &options.settings.config_path {
out.push_str(&format!(" config {}\n", path.display()));
}
stdout.write_all(out.as_bytes())?;
Ok(())
}
fn write_axis_plan_explain(out: &mut String, plan: &AxisPlan, level: usize) {
let label = format!("axis {level}");
let strategy_name = plan.axis.strategy.name();
let auto_marker = if plan.axis.auto { "auto" } else { "manual" };
out.push_str(&format!(
" {label:<14}{} ({auto_marker}: {strategy_name})\n",
plan.axis.field,
));
for sub in &plan.within {
write_axis_plan_explain(out, sub, level + 1);
}
}
fn items_path_reason(format: InputFormat, items_path: &str) -> &'static str {
match (format, items_path) {
(InputFormat::Jsonl, ".") => "input is a flat record stream",
(InputFormat::Json, ".") => "input is a top-level array",
(_, ".") => "input is a flat record stream",
_ => "detected items array",
}
}
fn build_page(
cli: &Cli,
options: &RunOptions,
clusters: &[Cluster],
items: &[Value],
plan: &AxisPlan,
) -> Result<Page, FaceError> {
if cli.cluster.is_empty() {
let mut page = Page::default();
page.cluster_id = None;
page.page = options.page;
page.per_page = options.per_page;
page.total_items = 0;
page.items = Vec::new();
return Ok(page);
}
let cluster_id = resolve_cluster_arg(&cli.cluster, clusters)?;
build_page_for_cluster_id(
options.page,
options.per_page,
cluster_id,
clusters,
items,
plan,
)
}
fn build_page_for_cluster_id(
page_number: u32,
per_page_number: u32,
cluster_id: ClusterId,
clusters: &[Cluster],
items: &[Value],
plan: &AxisPlan,
) -> Result<Page, FaceError> {
locate_cluster(clusters, &cluster_id).ok_or_else(|| FaceError::InvalidClusterId {
segment: cluster_id.depth().saturating_sub(1),
reason: "cluster id not present in current tree".to_string(),
})?;
let matching = items
.iter()
.filter(|item| item_matches_cluster_id(item, &cluster_id, clusters, plan))
.cloned()
.collect::<Vec<_>>();
let total_items = matching.len() as u64;
let per_page = per_page_number.max(1) as usize;
let page = page_number.max(1) as usize;
let start = (page - 1) * per_page;
let end = (start + per_page).min(matching.len());
let page_items = if start >= matching.len() {
Vec::new()
} else {
matching[start..end].to_vec()
};
let mut page = Page::default();
page.cluster_id = Some(cluster_id);
page.page = page_number;
page.per_page = per_page_number;
page.total_items = total_items;
page.items = page_items;
Ok(page)
}
fn build_page_for_cluster_ids(
page_number: u32,
per_page_number: u32,
cluster_ids: &[ClusterId],
clusters: &[Cluster],
items: &[Value],
plan: &AxisPlan,
) -> Result<Page, FaceError> {
if cluster_ids.len() == 1 {
return build_page_for_cluster_id(
page_number,
per_page_number,
cluster_ids[0].clone(),
clusters,
items,
plan,
);
}
for cluster_id in cluster_ids {
locate_cluster(clusters, cluster_id).ok_or_else(|| FaceError::InvalidClusterId {
segment: cluster_id.depth().saturating_sub(1),
reason: "cluster id not present in current tree".to_string(),
})?;
}
let matching = items
.iter()
.filter(|item| {
cluster_ids
.iter()
.any(|id| item_matches_cluster_id(item, id, clusters, plan))
})
.cloned()
.collect::<Vec<_>>();
let total_items = matching.len() as u64;
let per_page = per_page_number.max(1) as usize;
let page = page_number.max(1) as usize;
let start = (page - 1) * per_page;
let end = (start + per_page).min(matching.len());
let page_items = if start >= matching.len() {
Vec::new()
} else {
matching[start..end].to_vec()
};
let mut page = Page::default();
page.cluster_id = None;
page.page = page_number;
page.per_page = per_page_number;
page.total_items = total_items;
page.items = page_items;
Ok(page)
}
fn interactive_tty_available() -> bool {
interactive_tty_available_from(host_stderr_is_terminal())
}
fn interactive_tty_available_from(prompt_is_terminal: bool) -> bool {
prompt_is_terminal
}
fn select_interactive_clusters(clusters: &[Cluster]) -> Result<Option<Vec<ClusterId>>, FaceError> {
if clusters.is_empty() {
return Ok(None);
}
if count_clusters(clusters) == 1 {
return Ok(clusters.first().map(|cluster| vec![cluster.id.clone()]));
}
let theme = ColorfulTheme::default();
let mut stack: Vec<&[Cluster]> = vec![clusters];
loop {
let current = stack.last().copied().unwrap_or(clusters);
if current.iter().all(|cluster| cluster.clusters.is_empty()) {
let labels = current
.iter()
.map(|cluster| format!("{} {} {}", cluster.label, cluster.total, cluster.id))
.collect::<Vec<_>>();
let selected = MultiSelect::with_theme(&theme)
.items(&labels)
.interact_opt()
.map_err(dialoguer_io_error)?;
let Some(indices) = selected else {
if stack.len() == 1 {
return Ok(None);
}
stack.pop();
continue;
};
if indices.is_empty() {
return Ok(None);
}
let cluster_ids = indices
.into_iter()
.filter_map(|index| current.get(index).map(|cluster| cluster.id.clone()))
.collect::<Vec<_>>();
return Ok(Some(cluster_ids));
}
let labels = current
.iter()
.map(|cluster| format!("{} {} {}", cluster.label, cluster.total, cluster.id))
.collect::<Vec<_>>();
let selection = Select::with_theme(&theme)
.items(&labels)
.default(0)
.interact_opt()
.map_err(dialoguer_io_error)?;
let Some(index) = selection else {
if stack.len() == 1 {
return Ok(None);
}
stack.pop();
continue;
};
let Some(cluster) = current.get(index) else {
return Ok(None);
};
if cluster.clusters.is_empty() {
return Ok(Some(vec![cluster.id.clone()]));
}
stack.push(&cluster.clusters);
}
}
fn maybe_save_interactive_output(
rendered: String,
options: &RunOptions,
) -> Result<String, FaceError> {
let line_count = rendered.lines().count();
if line_count <= options.interactive_save_threshold {
return Ok(rendered);
}
let suggested = interactive_output_filename();
let save_label = format!("Save to file ({suggested})");
let choices = ["Print to terminal", save_label.as_str(), "Cancel"];
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt(format!(
"{line_count} lines of output. Print, save, or cancel?"
))
.items(choices)
.default(0)
.interact_opt()
.map_err(dialoguer_io_error)?;
match selection {
Some(0) => Ok(rendered),
Some(1) => {
let path = Input::<String>::with_theme(&ColorfulTheme::default())
.with_prompt("Save as")
.default(suggested)
.interact_text()
.map_err(dialoguer_io_error)?;
std::fs::write(&path, rendered).map_err(|source| FaceError::Io { source })?;
Ok(format!("saved to {path}\n"))
}
_ => Ok(String::new()),
}
}
fn interactive_output_filename() -> String {
let seconds = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0);
format!("face-output-{seconds}.txt")
}
fn dialoguer_io_error(error: dialoguer::Error) -> FaceError {
FaceError::Io {
source: std::io::Error::other(error),
}
}
fn resolve_cluster_arg(raw: &[String], clusters: &[Cluster]) -> Result<ClusterId, FaceError> {
if let Some(index) = parse_cluster_ordinal(raw)? {
let cluster = clusters
.get(index - 1)
.ok_or_else(|| FaceError::InvalidClusterId {
segment: 0,
reason: format!(
"cluster ordinal {index} out of range; current tree has {} top-level clusters",
clusters.len()
),
})?;
return Ok(cluster.id.clone());
}
parse_cluster_arg(raw)
}
fn parse_cluster_ordinal(raw: &[String]) -> Result<Option<usize>, FaceError> {
if raw.len() != 1 {
return Ok(None);
}
let value = raw[0].trim();
if value.is_empty() || !value.chars().all(|ch| ch.is_ascii_digit()) {
return Ok(None);
}
let index = value
.parse::<usize>()
.map_err(|err| FaceError::InvalidClusterId {
segment: 0,
reason: format!("invalid cluster ordinal `{value}`: {err}"),
})?;
if index == 0 {
return Err(FaceError::InvalidClusterId {
segment: 0,
reason: "cluster ordinal must be 1-based".to_string(),
});
}
Ok(Some(index))
}
fn parse_cluster_arg(raw: &[String]) -> Result<ClusterId, FaceError> {
if raw.is_empty() {
return Err(FaceError::InvalidClusterId {
segment: 0,
reason: "no --cluster value supplied".to_string(),
});
}
let any_structured = raw.iter().any(|s| s.contains('='));
if raw.len() > 1 || any_structured {
return ClusterId::parse_structured(raw).map_err(|e| FaceError::InvalidClusterId {
segment: 0,
reason: e.to_string(),
});
}
ClusterId::parse_canonical(&raw[0]).map_err(|e| FaceError::InvalidClusterId {
segment: 0,
reason: e.to_string(),
})
}
fn locate_cluster<'a>(clusters: &'a [Cluster], id: &ClusterId) -> Option<&'a Cluster> {
if id.is_root() {
return None;
}
let segs = id.segments();
let mut current: Option<&Cluster> = clusters
.iter()
.find(|c| c.id.segments().first() == segs.first());
for seg in segs.iter().skip(1) {
current = current?
.clusters
.iter()
.find(|c| c.id.segments().last() == Some(seg));
}
current
}
fn item_matches_cluster_id(
item: &Value,
id: &ClusterId,
clusters: &[Cluster],
plan: &AxisPlan,
) -> bool {
item_matches_cluster_segments(item, id.segments(), clusters, plan)
}
fn item_matches_cluster_segments(
item: &Value,
segments: &[face_core::ClusterIdSegment],
siblings: &[Cluster],
plan: &AxisPlan,
) -> bool {
let Some((segment, tail)) = segments.split_first() else {
return true;
};
let Some(cluster) = siblings
.iter()
.find(|cluster| cluster.id.segments().last() == Some(segment))
else {
return false;
};
if !item_belongs_to_cluster(item, cluster, siblings, &plan.axis) {
return false;
}
let Some(next_segment) = tail.first() else {
return true;
};
let Some(child_plan) = plan
.within
.iter()
.find(|child| child.axis.field == next_segment.axis)
else {
return false;
};
item_matches_cluster_segments(item, tail, &cluster.clusters, child_plan)
}
fn item_belongs_to_cluster(
item: &Value,
cluster: &Cluster,
siblings: &[Cluster],
axis: &Axis,
) -> bool {
let Ok(resolved) = face_core::path::resolve(item, &axis.field) else {
return false;
};
match &axis.strategy {
Strategy::Exact => scalar_string(resolved)
.as_deref()
.is_some_and(|observed| observed == cluster_value(cluster)),
Strategy::Top { .. } => top_membership(resolved, cluster, siblings),
Strategy::Prefix { .. } => prefix_membership(resolved, cluster),
Strategy::Bands { .. } => numeric_range_membership(resolved, cluster, siblings, true),
Strategy::Quantiles { .. } | Strategy::Natural { .. } => {
numeric_range_membership(resolved, cluster, siblings, false)
}
Strategy::Similar { algorithm } => similar_membership(resolved, cluster, *algorithm),
_ => false,
}
}
fn top_membership(resolved: &Value, cluster: &Cluster, siblings: &[Cluster]) -> bool {
let Some(observed) = scalar_string(resolved) else {
return false;
};
if cluster_value(cluster) != "(other)" {
return observed == cluster_value(cluster);
}
siblings
.iter()
.filter(|sibling| cluster_value(sibling) != "(other)")
.all(|sibling| observed != cluster_value(sibling))
}
fn prefix_membership(resolved: &Value, cluster: &Cluster) -> bool {
let Some(path) = resolved.as_str() else {
return false;
};
let prefix = cluster_value(cluster);
path == prefix
|| path
.strip_prefix(prefix)
.is_some_and(|rest| rest.starts_with('/'))
}
fn numeric_range_membership(
resolved: &Value,
cluster: &Cluster,
siblings: &[Cluster],
half_open_upper: bool,
) -> bool {
let Some(value) = resolved.as_f64() else {
return false;
};
let Some((min, max)) = numeric_bounds(&cluster.value) else {
return false;
};
if value < min {
return false;
}
if half_open_upper && !has_highest_numeric_upper_bound(cluster, siblings) {
value < max
} else {
value <= max
}
}
fn numeric_bounds(value: &Value) -> Option<(f64, f64)> {
let object = value.as_object()?;
let min = object.get("min")?.as_f64()?;
let max = object.get("max")?.as_f64()?;
Some((min, max))
}
fn similar_membership(resolved: &Value, cluster: &Cluster, algorithm: SimilarAlgorithm) -> bool {
let Some(candidate) = resolved.as_str() else {
return false;
};
let Some(representative) = cluster
.value
.as_object()
.and_then(|value| value.get("representative"))
.and_then(Value::as_str)
else {
return false;
};
algorithm.matches(candidate, representative)
}
fn has_highest_numeric_upper_bound(cluster: &Cluster, siblings: &[Cluster]) -> bool {
let Some((_, cluster_max)) = numeric_bounds(&cluster.value) else {
return false;
};
siblings
.iter()
.filter_map(|sibling| numeric_bounds(&sibling.value).map(|(_, max)| max))
.max_by(f64::total_cmp)
.is_some_and(|highest| cluster_max == highest)
}
fn cluster_value(cluster: &Cluster) -> &str {
cluster
.id
.segments()
.last()
.map(|segment| segment.value.as_str())
.unwrap_or(cluster.label.as_str())
}
fn scalar_string(value: &Value) -> Option<String> {
match value {
Value::String(s) => Some(s.clone()),
Value::Bool(b) => Some(b.to_string()),
Value::Number(n) => Some(n.to_string()),
Value::Null => Some("null".to_string()),
_ => None,
}
}
fn collect_axes(plan: &AxisPlan) -> Vec<Axis> {
let mut out = Vec::new();
let mut cursor = Some(plan);
while let Some(p) = cursor {
out.push(p.axis.clone());
cursor = p.within.first();
}
out
}
fn write_provenance<W: Write>(
stderr: &mut W,
envelope: &Envelope,
format: InputFormat,
) -> Result<(), FaceError> {
if matches!(format, InputFormat::FaceEnvelope) {
let total = envelope.result.input_total;
let clusters = count_clusters(&envelope.clusters);
let cluster_axis = envelope
.result
.axes
.first()
.map(|a| a.field.as_str())
.unwrap_or("saved");
let axis_count = envelope.result.axes.len();
let axis_word = if axis_count == 1 { "axis" } else { "axes" };
let line = format!(
"\u{2192} from face envelope ({total} items, {clusters} {cluster_axis} clusters, {axis_count} {axis_word})\n",
);
stderr.write_all(line.as_bytes())?;
return Ok(());
}
let total = envelope.result.input_total;
let by_field = envelope
.result
.axes
.first()
.map(|a| a.field.as_str())
.unwrap_or("?");
let mode = envelope
.result
.axes
.first()
.map(|a| if a.auto { "auto" } else { "manual" })
.unwrap_or("auto");
let format_name = input_format_name(format);
let mut line = format!("\u{2192} {total} items by {by_field} ({mode}, from {format_name})");
if envelope.result.skipped > 0 {
line.push_str(&format!(", {} skipped", envelope.result.skipped));
}
line.push('\n');
stderr.write_all(line.as_bytes())?;
Ok(())
}
fn write_skip_warning<W: Write>(stderr: &mut W, skip: &SkipReport) -> Result<(), FaceError> {
let line = match &skip.reason {
SkipReason::InvalidJson { column, message } => format!(
"face: skipped record {} (invalid JSON: {message} at column {column})\n",
skip.record_index,
),
SkipReason::MissingField { field } => format!(
"face: skipped record {} (missing field: {field})\n",
skip.record_index,
),
SkipReason::WrongType { field, kind } => format!(
"face: skipped record {} (field \"{field}\" is {kind})\n",
skip.record_index,
),
SkipReason::NonNumericScore {
path,
value_summary,
} => format!(
"face: skipped record {} (non-numeric score at {path}: {value_summary})\n",
skip.record_index,
),
other => format!("face: skipped record {} ({other:?})\n", skip.record_index),
};
stderr.write_all(line.as_bytes())?;
Ok(())
}
fn resolve_render_options(output_format: OutputFormat, color_policy: &str) -> RenderOptions {
let want_color = match color_policy {
"always" => true,
"never" => false,
_ => host_stdout_is_terminal() && !no_color_env_set() || clicolor_force_set(),
};
let mut opts = RenderOptions::default();
opts.color = want_color;
opts.width = if matches!(output_format, OutputFormat::Human) {
terminal_width_columns()
} else {
None
};
opts
}
fn terminal_width_columns() -> Option<usize> {
terminal_size()
.map(|(Width(width), _)| usize::from(width))
.filter(|width| *width > 0)
}
fn no_color_env_set() -> bool {
std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty())
}
fn clicolor_force_set() -> bool {
std::env::var_os("CLICOLOR_FORCE").is_some_and(|v| !v.is_empty())
}
fn host_stdout_is_terminal() -> bool {
std::io::stdout().is_terminal()
}
fn host_stderr_is_terminal() -> bool {
std::io::stderr().is_terminal()
}
fn input_format_name(format: InputFormat) -> &'static str {
match format {
InputFormat::Json => "json",
InputFormat::Jsonl => "jsonl",
InputFormat::Csv => "csv",
InputFormat::Tsv => "tsv",
InputFormat::FaceEnvelope => "face-envelope",
_ => "unknown",
}
}
fn output_format_wire_name(format: OutputFormat) -> &'static str {
match format {
OutputFormat::Human => "human",
OutputFormat::Json => "json",
OutputFormat::JsonFlat => "json-flat",
OutputFormat::JsonlItems => "jsonl-items",
OutputFormat::Tsv => "tsv",
OutputFormat::Csv => "csv",
OutputFormat::Markdown => "markdown",
_ => "unknown",
}
}
fn count_clusters(clusters: &[Cluster]) -> usize {
clusters
.iter()
.map(|cluster| 1 + count_clusters(&cluster.clusters))
.sum()
}
fn detection_candidates(items: &[Value]) -> Vec<face_core::DetectionCandidate> {
use std::collections::BTreeMap;
let mut counts: BTreeMap<String, std::collections::BTreeSet<String>> = BTreeMap::new();
for item in items {
if let Value::Object(map) = item {
for (k, v) in map {
if let Value::String(s) = v {
counts.entry(k.clone()).or_default().insert(s.clone());
}
}
}
}
counts
.into_iter()
.map(|(field, distinct)| face_core::DetectionCandidate::new(field, distinct.len()))
.collect()
}
fn numeric_fields_for_hint(items: &[Value]) -> Vec<String> {
use std::collections::BTreeMap;
const SAMPLE_LIMIT: usize = 50;
struct FieldState {
still_numeric: bool,
has_number: bool,
}
let mut state: BTreeMap<String, FieldState> = BTreeMap::new();
for item in items.iter().take(SAMPLE_LIMIT) {
let Value::Object(map) = item else {
continue;
};
for (k, v) in map {
let entry = state.entry(k.clone()).or_insert_with(|| FieldState {
still_numeric: true,
has_number: false,
});
match v {
Value::Number(_) => entry.has_number = true,
Value::Null => {}
_ => entry.still_numeric = false,
}
}
}
state
.into_iter()
.filter(|(_, s)| s.still_numeric && s.has_number)
.map(|(name, _)| name)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
use serde_json::json;
#[test]
fn config_defaults_feed_run_options() {
let cli = Cli::try_parse_from(["face"]).unwrap();
let mut settings = RuntimeSettings::default();
settings.output.default_format = OutputFormat::Json;
settings.output.per_page = 7;
settings.output.color = "never".to_string();
settings.strategy.default_bands = 3;
settings.sources.default_format = SettingSource::Config;
settings.sources.per_page = SettingSource::Config;
settings.sources.color = SettingSource::Config;
settings.sources.default_bands = SettingSource::Config;
let options = RunOptions::resolve(&cli, settings);
assert_eq!(options.output_format, OutputFormat::Json);
assert_eq!(options.output_format_source, SettingSource::Config);
assert_eq!(options.per_page, 7);
assert_eq!(options.color, "never");
assert_eq!(options.default_bands, 3);
}
#[test]
fn cli_values_override_config_defaults() {
let cli = Cli::try_parse_from([
"face",
"--format=tsv",
"--per-page=11",
"--color=always",
"--bands=4",
])
.unwrap();
let mut settings = RuntimeSettings::default();
settings.output.default_format = OutputFormat::Json;
settings.output.per_page = 7;
settings.output.color = "never".to_string();
settings.strategy.default_bands = 3;
let options = RunOptions::resolve(&cli, settings);
assert_eq!(options.output_format, OutputFormat::Tsv);
assert_eq!(options.output_format_source, SettingSource::Cli);
assert_eq!(options.per_page, 11);
assert_eq!(options.color, "always");
assert_eq!(options.default_bands, 4);
}
#[test]
fn configured_preset_can_supply_score_invert_and_scale() {
let cli = Cli::try_parse_from(["face", "--preset=distance-search"]).unwrap();
let mut settings = RuntimeSettings::default();
settings.presets.insert(
"distance-search".to_string(),
UserPreset {
score: Some(".distance".to_string()),
invert: Some(true),
scale: Some(1.0),
},
);
let items = vec![json!({"distance": 0.25, "kind": "hit"})];
let selection = resolve_preset_selection(&cli, &settings)
.unwrap()
.expect("configured preset selected");
let options = RunOptions::resolve(&cli, settings);
let score = resolve_score(&cli, &options, Some(&selection), &items).unwrap();
assert_eq!(selection.name.as_deref(), Some("distance-search"));
assert_eq!(score.path.as_deref(), Some(".distance"));
assert!(resolve_invert(&cli, selection.custom.as_ref()));
assert_eq!(resolve_scale(&cli, selection.custom.as_ref()), Some(1.0));
}
#[test]
fn interactive_tty_gate_uses_prompt_terminal() {
assert!(interactive_tty_available_from(true));
assert!(!interactive_tty_available_from(false));
}
#[test]
fn implicit_human_drill_pipe_renders_jsonl_items() {
let cli = Cli::try_parse_from(["face", "--cluster", "1"]).unwrap();
let options = RunOptions::resolve(&cli, RuntimeSettings::default());
let mut envelope = Envelope::default();
envelope.page.cluster_id = Some(ClusterId::parse_canonical("kind:bug").unwrap());
envelope.page.items = vec![json!({"kind": "bug"})];
assert_eq!(
resolve_pipeline_render_format(
&cli, &options, &envelope, false, false,
),
OutputFormat::JsonlItems,
);
assert_eq!(
resolve_pipeline_render_format(
&cli, &options, &envelope, false, true,
),
OutputFormat::Human,
);
}
#[test]
fn numeric_fields_for_hint_lists_pure_number_fields() {
let items = vec![
json!({"name": "a", "score": 0.5, "count": 3}),
json!({"name": "b", "score": 0.25, "count": 7}),
];
assert_eq!(
numeric_fields_for_hint(&items),
vec!["count".to_string(), "score".to_string()],
);
}
#[test]
fn numeric_fields_for_hint_skips_all_null_fields() {
let items = vec![
json!({"name": "a", "ratio": null}),
json!({"name": "b", "ratio": null}),
];
assert!(numeric_fields_for_hint(&items).is_empty());
}
#[test]
fn numeric_fields_for_hint_treats_null_as_compatible_with_number() {
let items = vec![
json!({"name": "a", "score": 0.5}),
json!({"name": "b", "score": null}),
json!({"name": "c", "score": 0.75}),
];
assert_eq!(numeric_fields_for_hint(&items), vec!["score".to_string()],);
}
#[test]
fn numeric_fields_for_hint_disqualifies_mixed_type_fields() {
let items = vec![json!({"value": 1}), json!({"value": "not a number"})];
assert!(numeric_fields_for_hint(&items).is_empty());
}
#[test]
fn explicit_output_format_is_not_auto_switched_for_pipe() {
let mut envelope = Envelope::default();
envelope.page.cluster_id = Some(ClusterId::parse_canonical("kind:bug").unwrap());
envelope.page.items = vec![json!({"kind": "bug"})];
let human_cli = Cli::try_parse_from(["face", "--cluster", "1", "--format=human"]).unwrap();
let human_options = RunOptions::resolve(&human_cli, RuntimeSettings::default());
assert_eq!(
resolve_pipeline_render_format(
&human_cli,
&human_options,
&envelope,
false,
false,
),
OutputFormat::Human,
);
let json_cli = Cli::try_parse_from(["face", "--cluster", "1", "--json"]).unwrap();
let json_options = RunOptions::resolve(&json_cli, RuntimeSettings::default());
assert_eq!(
resolve_pipeline_render_format(
&json_cli,
&json_options,
&envelope,
false,
false,
),
OutputFormat::Json,
);
}
}