use super::filter;
use crate::constants::{
agent_surface_field_synonym_groups, K_VOCABULARY_MAX_KEYS, K_VOCABULARY_MAX_SUGGESTIONS,
K_VOCABULARY_SAMPLE_ELEMENTS, VOCABULARY_SUGGESTION_MIN_SIMILARITY,
};
use serde_json::Value;
use std::collections::BTreeSet;
fn spellings(key: &str, command: Option<&str>) -> Vec<String> {
let (prefix, leaf) = match key.rfind('.') {
Some(idx) => (&key[..=idx], &key[idx + 1..]),
None => ("", key),
};
let mut out = vec![key.to_string()];
for group in agent_surface_field_synonym_groups(command) {
if !group.contains(&leaf) {
continue;
}
for spelling in group {
let candidate = format!("{prefix}{spelling}");
if *spelling != leaf && !out.contains(&candidate) {
out.push(candidate);
}
}
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyOrigin {
Element,
EnvelopeOnly,
Absent,
}
pub struct Scope<'a> {
elements: &'a [Value],
envelope: &'a Value,
command: Option<&'a str>,
}
impl<'a> Scope<'a> {
pub fn new(elements: &'a [Value], envelope: &'a Value) -> Self {
Self {
elements,
envelope,
command: None,
}
}
#[must_use]
pub fn with_command(mut self, command: Option<&'a str>) -> Self {
self.command = command;
self
}
pub fn is_empty(&self) -> bool {
self.elements.is_empty()
}
pub fn classify(&self, key: &str) -> KeyOrigin {
let candidates = spellings(key, self.command);
if self.elements.iter().any(|element| {
candidates
.iter()
.any(|candidate| filter::resolve(element, candidate).is_some())
}) {
return KeyOrigin::Element;
}
if candidates
.iter()
.any(|candidate| filter::resolve(self.envelope, candidate).is_some())
{
return KeyOrigin::EnvelopeOnly;
}
KeyOrigin::Absent
}
pub fn effective_key(&self, key: &str) -> Option<String> {
let candidates = spellings(key, self.command);
for candidate in &candidates {
if self
.elements
.iter()
.any(|element| filter::resolve(element, candidate).is_some())
{
return Some(candidate.clone());
}
}
candidates
.into_iter()
.find(|candidate| filter::resolve(self.envelope, candidate).is_some())
}
pub fn suggestions(&self, key: &str) -> Vec<String> {
let (vocabulary, _) = self.candidate_keys();
if vocabulary.is_empty() {
return Vec::new();
}
let declared: Vec<String> = spellings(key, self.command)
.into_iter()
.skip(1)
.filter(|candidate| {
let leaf = candidate.rsplit('.').next().unwrap_or(candidate);
vocabulary.contains(leaf)
})
.collect();
let comparator = rapidfuzz::distance::jaro_winkler::BatchComparator::new(key.chars());
let mut ranked: Vec<(f64, &str)> = vocabulary
.iter()
.map(|candidate| {
(
comparator.normalized_similarity(candidate.chars()),
*candidate,
)
})
.filter(|(score, _)| *score >= VOCABULARY_SUGGESTION_MIN_SIMILARITY)
.collect();
ranked.sort_by(|a, b| {
b.0.partial_cmp(&a.0)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.1.cmp(b.1))
});
let mut out = declared;
for (_, name) in ranked {
if out.len() >= K_VOCABULARY_MAX_SUGGESTIONS {
break;
}
if !out.iter().any(|already| already.as_str() == name) {
out.push(name.to_string());
}
}
out.truncate(K_VOCABULARY_MAX_SUGGESTIONS);
out
}
pub fn vocabulary_is_partial(&self) -> bool {
self.elements.len() > K_VOCABULARY_SAMPLE_ELEMENTS || self.candidate_keys().1
}
fn candidate_keys(&self) -> (BTreeSet<&'a str>, bool) {
let mut names = BTreeSet::new();
if self.elements.is_empty() {
let capped = self
.envelope
.as_object()
.is_some_and(|map| Self::absorb(map.keys(), &mut names));
return (names, capped);
}
for element in self.elements.iter().take(K_VOCABULARY_SAMPLE_ELEMENTS) {
let Some(map) = element.as_object() else {
continue;
};
if Self::absorb(map.keys(), &mut names) {
return (names, true);
}
}
(names, false)
}
fn absorb<I>(keys: I, names: &mut BTreeSet<&'a str>) -> bool
where
I: Iterator<Item = &'a String>,
{
for name in keys {
if names.len() >= K_VOCABULARY_MAX_KEYS {
return true;
}
names.insert(name.as_str());
}
false
}
}