use super::types::{Answer, Question, Request, Response};
use serde_json::Value;
use std::collections::HashMap;
pub(super) struct PathScrub {
prefix: String,
aliases: HashMap<String, String>,
choices: HashMap<String, String>,
}
impl PathScrub {
pub(super) fn new(request: &Request) -> Self {
let mut texts = Vec::new();
collect_strings(&request.state, &mut texts);
for (name, question) in &request.questions {
texts.push(name.as_str());
match question {
Question::YesNo { instructions } => texts.push(instructions),
Question::Pick {
instructions,
options,
} => {
texts.push(instructions);
for (value, description) in options {
texts.push(value);
texts.push(description);
}
}
}
}
let mut prefix = String::from("pathref");
while texts.iter().any(|text| text.contains(prefix.as_str())) {
prefix.push('x');
}
Self {
prefix,
aliases: HashMap::new(),
choices: HashMap::new(),
}
}
pub(super) fn scrub_request(&mut self, request: &Request) -> Request {
let state = self.scrub_value(&request.state);
let questions = request
.questions
.iter()
.map(|(name, question)| (name.clone(), self.scrub_question(question)))
.collect();
Request { state, questions }
}
pub(super) fn unscrub_response(&self, mut response: Response) -> Response {
for answer in response.answers.values_mut() {
let Answer::Pick {
choice,
probabilities,
..
} = answer
else {
continue;
};
if let Some(real) = self.choices.get(choice.as_str()) {
choice.clone_from(real);
}
for (value, _) in probabilities.iter_mut() {
if let Some(real) = self.choices.get(value.as_str()) {
value.clone_from(real);
}
}
}
response
}
fn scrub_question(&mut self, question: &Question) -> Question {
match question {
Question::YesNo { instructions } => Question::YesNo {
instructions: self.scrub_field(instructions),
},
Question::Pick {
instructions,
options,
} => Question::Pick {
instructions: self.scrub_field(instructions),
options: options
.iter()
.map(|(value, description)| {
let sanitized = self.scrub_field(value);
if sanitized != *value {
self.choices.insert(sanitized.clone(), value.clone());
}
(sanitized, self.scrub_field(description))
})
.collect(),
},
}
}
fn scrub_value(&mut self, value: &Value) -> Value {
match value {
Value::String(text) => Value::String(self.scrub_field(text)),
Value::Array(items) => {
Value::Array(items.iter().map(|item| self.scrub_value(item)).collect())
}
Value::Object(map) => Value::Object(
map.iter()
.map(|(key, item)| (self.scrub_field(key), self.scrub_value(item)))
.collect(),
),
other => other.clone(),
}
}
fn scrub_text(&mut self, text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut last = 0;
for (start, end) in path_spans(text) {
out.push_str(&text[last..start]);
let alias = self.alias(&text[start..end]);
out.push_str(&alias);
last = end;
}
out.push_str(&text[last..]);
out
}
fn scrub_field(&mut self, text: &str) -> String {
if is_path_field(text) {
self.alias(text)
} else {
self.scrub_text(text)
}
}
fn alias(&mut self, path: &str) -> String {
if let Some(alias) = self.aliases.get(path) {
return alias.clone();
}
let alias = format!("{}-{}", self.prefix, self.aliases.len() + 1);
self.aliases.insert(path.to_string(), alias.clone());
alias
}
}
fn collect_strings<'a>(value: &'a Value, out: &mut Vec<&'a str>) {
match value {
Value::String(text) => out.push(text),
Value::Array(items) => items.iter().for_each(|item| collect_strings(item, out)),
Value::Object(map) => {
for (key, item) in map {
out.push(key);
collect_strings(item, out);
}
}
_ => {}
}
}
fn path_spans(text: &str) -> Vec<(usize, usize)> {
let mut spans = Vec::new();
let mut cursor = 0;
while cursor < text.len() {
let rest = &text[cursor..];
let preceded_by = text[..cursor].chars().next_back();
let span = if starts_token(preceded_by) {
span_at(rest)
} else {
None
};
match span {
Some((start, end)) => {
spans.push((cursor + start, cursor + end));
cursor += end;
}
None => cursor += rest.chars().next().map_or(1, char::len_utf8),
}
}
spans
}
fn starts_token(preceded_by: Option<char>) -> bool {
!preceded_by.is_some_and(|c| {
c.is_alphanumeric() || matches!(c, ':' | '/' | '\\' | '.' | '_' | '-' | '~')
})
}
fn span_at(rest: &str) -> Option<(usize, usize)> {
if let Some(quote) = rest.chars().next().filter(|&c| matches!(c, '"' | '\'')) {
let inner = &rest[1..];
let close = inner.find(quote)?;
let content = &inner[..close];
return (path_head(content).is_some() && !content.contains('\n')).then_some((1, 1 + close));
}
path_len(rest).map(|len| (0, len))
}
fn is_path_field(text: &str) -> bool {
path_head(text).is_some() && !text.contains('\n')
}
fn path_head(rest: &str) -> Option<usize> {
let bytes = rest.as_bytes();
if rest.starts_with("~/") {
Some(2)
} else if bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& matches!(bytes[2], b'\\' | b'/')
{
Some(3)
} else if bytes.len() >= 3
&& bytes[0] == bytes[1]
&& matches!(bytes[0], b'\\' | b'/')
&& bytes[2] != bytes[0]
{
Some(2)
} else if bytes.first() == Some(&b'/') {
Some(1)
} else {
None
}
}
fn path_len(rest: &str) -> Option<usize> {
let head = path_head(rest)?;
let bytes = rest.as_bytes();
let body = rest[head..]
.find(|c: char| !is_path_char(c))
.map_or(rest.len(), |i| head + i);
let mut end = body;
while end > head && matches!(bytes[end - 1], b'.' | b':') {
end -= 1;
}
rest[head..end]
.chars()
.any(|c| !matches!(c, '/' | '\\'))
.then_some(end)
}
fn is_path_char(c: char) -> bool {
!(c.is_whitespace()
|| matches!(
c,
'"' | '\''
| '`'
| '<'
| '>'
| '|'
| '('
| ')'
| '['
| ']'
| '{'
| '}'
| ','
| ';'
| '!'
| '?'
| '*'
))
}
#[cfg(test)]
#[path = "scrub_test.rs"]
mod scrub_test;