Skip to main content

hen/parser/
context.rs

1use std::{
2    collections::HashMap,
3    fmt,
4    io::IsTerminal,
5    sync::{OnceLock, RwLock},
6};
7
8use pest::Parser;
9use pest_derive::Parser;
10
11#[derive(Parser)]
12#[grammar = "src/parser/context.pest"]
13struct VarPlaceholderParser;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum PromptMode {
17    Interactive,
18    NonInteractive,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct PromptInputError {
23    prompt: String,
24    default: Option<String>,
25}
26
27impl PromptInputError {
28    fn missing(prompt: String, default: Option<String>) -> Self {
29        Self { prompt, default }
30    }
31
32    pub fn prompt(&self) -> &str {
33        &self.prompt
34    }
35
36    pub fn default(&self) -> Option<&str> {
37        self.default.as_deref()
38    }
39}
40
41impl fmt::Display for PromptInputError {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        match &self.default {
44            Some(default) => write!(
45                f,
46                "Missing value for prompt '{}' (default: {})",
47                self.prompt, default
48            ),
49            None => write!(f, "Missing value for prompt '{}'", self.prompt),
50        }
51    }
52}
53
54impl std::error::Error for PromptInputError {}
55
56pub fn inject_from_prompt(input: &str) -> String {
57    try_inject_from_prompt(input).expect("prompt resolution failed")
58}
59
60pub fn try_inject_from_prompt(input: &str) -> Result<String, PromptInputError> {
61    let mut output = String::new();
62
63    let mut pairs = VarPlaceholderParser::parse(Rule::text, input).unwrap();
64
65    let pair = pairs.next().unwrap();
66
67    for inner_pair in pair.into_inner() {
68        match inner_pair.as_rule() {
69            Rule::word => {
70                output.push_str(inner_pair.as_str());
71            }
72            Rule::input => {
73                let (key, default) = parse_input_pair(inner_pair.as_str());
74
75                if let Some(value) = prompt_inputs().read().unwrap().get(&key).cloned() {
76                    output.push_str(value.as_str());
77                    continue;
78                }
79
80                if !can_prompt_for_inputs() {
81                    return Err(PromptInputError::missing(key, default));
82                }
83
84                let prompt = match &default {
85                    Some(def) => format!("Provide a value for \"{}\" (default: {})", key, def),
86                    None => format!("Provide a value for \"{}\"", key),
87                };
88
89                let mut dialog = dialoguer::Input::new().with_prompt(prompt);
90                if let Some(def) = &default {
91                    dialog = dialog.default(def.to_string());
92                }
93
94                let input: String = dialog.interact().unwrap();
95
96                // store the resolved value so repeated prompts reuse it
97                prompt_inputs()
98                    .write()
99                    .unwrap()
100                    .insert(key.clone(), input.clone());
101
102                output.push_str(input.as_str());
103            }
104            Rule::var => {
105                // retain the variable placeholder
106                // unresolved variables may be encountered in the context of a preamble.
107                output.push_str(format!("{{{{{}}}}}", inner_pair.as_str()).as_str());
108            }
109            _ => {
110                unreachable!("unexpected rule: {:?}", inner_pair.as_rule());
111            }
112        }
113    }
114    Ok(output)
115}
116
117pub(crate) fn try_inject_from_prompt_inputs_or_defaults(
118    input: &str,
119) -> Result<String, PromptInputError> {
120    let mut output = String::new();
121
122    let mut pairs = VarPlaceholderParser::parse(Rule::text, input).unwrap();
123    let pair = pairs.next().unwrap();
124
125    for inner_pair in pair.into_inner() {
126        match inner_pair.as_rule() {
127            Rule::word => {
128                output.push_str(inner_pair.as_str());
129            }
130            Rule::input => {
131                let (key, default) = parse_input_pair(inner_pair.as_str());
132
133                if let Some(value) = prompt_inputs().read().unwrap().get(&key).cloned() {
134                    output.push_str(value.as_str());
135                    continue;
136                }
137
138                if let Some(default) = default {
139                    output.push_str(default.as_str());
140                    continue;
141                }
142
143                return Err(PromptInputError::missing(key, None));
144            }
145            Rule::var => {
146                output.push_str(format!("{{{{{}}}}}", inner_pair.as_str()).as_str());
147            }
148            _ => {
149                unreachable!("unexpected rule: {:?}", inner_pair.as_rule());
150            }
151        }
152    }
153
154    Ok(output)
155}
156
157pub fn inject_from_variable(input: &str, context: &HashMap<String, String>) -> String {
158    let mut output = String::new();
159
160    let mut pairs = VarPlaceholderParser::parse(Rule::text, input).unwrap();
161
162    let pair = pairs.next().unwrap();
163
164    for inner_pair in pair.into_inner() {
165        match inner_pair.as_rule() {
166            Rule::word => {
167                output.push_str(inner_pair.as_str());
168            }
169            Rule::var => {
170                let key = inner_pair.as_str().to_string();
171                if let Some(value) = context.get(&key) {
172                    output.push_str(value);
173                } else {
174                    output.push_str(format!("{{{{{}}}}}", key).as_str());
175                }
176            }
177            Rule::input => {
178                // retain the input placeholder
179                output.push_str(format!("[[{}]]", inner_pair.as_str()).as_str());
180            }
181            _ => {
182                unreachable!("unexpected rule: {:?}", inner_pair.as_rule());
183            }
184        }
185    }
186    output
187}
188
189pub fn resolve_with_context(input: &str, context: &HashMap<String, String>) -> String {
190    let mut current = inject_from_variable(input, context);
191    loop {
192        let next = inject_from_variable(current.as_str(), context);
193        if next == current {
194            return current;
195        }
196        current = next;
197    }
198}
199
200pub fn extract_placeholders(input: &str) -> Vec<String> {
201    let mut names = Vec::new();
202
203    let mut pairs = VarPlaceholderParser::parse(Rule::text, input).unwrap();
204    let pair = pairs.next().unwrap();
205
206    for inner_pair in pair.into_inner() {
207        if inner_pair.as_rule() == Rule::var {
208            names.push(inner_pair.as_str().to_string());
209        }
210    }
211
212    names
213}
214
215pub fn set_prompt_inputs(inputs: HashMap<String, String>) {
216    let mut map = prompt_inputs().write().unwrap();
217    map.clear();
218    map.extend(inputs);
219}
220
221pub fn set_prompt_mode(mode: PromptMode) {
222    *prompt_mode_lock().write().unwrap() = mode;
223}
224
225pub fn prompt_mode() -> PromptMode {
226    *prompt_mode_lock().read().unwrap()
227}
228
229pub fn can_prompt_for_inputs() -> bool {
230    prompt_mode() == PromptMode::Interactive && prompt_terminals_available()
231}
232
233pub fn extract_prompt_placeholders(input: &str) -> Vec<(String, Option<String>)> {
234    let mut prompts = Vec::new();
235    let mut remainder = input;
236
237    while let Some(start) = remainder.find("[[") {
238        let after_start = &remainder[start + 2..];
239        let Some(end) = after_start.find("]]") else {
240            break;
241        };
242
243        let raw = after_start[..end].trim();
244        if !raw.is_empty() {
245            prompts.push(parse_input_pair(raw));
246        }
247
248        remainder = &after_start[end + 2..];
249    }
250
251    prompts
252}
253
254pub fn has_prompt_input(key: &str) -> bool {
255    prompt_inputs().read().unwrap().contains_key(key)
256}
257
258fn prompt_inputs() -> &'static RwLock<HashMap<String, String>> {
259    static PROMPT_INPUTS: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
260    PROMPT_INPUTS.get_or_init(|| RwLock::new(HashMap::new()))
261}
262
263fn prompt_mode_lock() -> &'static RwLock<PromptMode> {
264    static PROMPT_MODE: OnceLock<RwLock<PromptMode>> = OnceLock::new();
265    PROMPT_MODE.get_or_init(|| RwLock::new(PromptMode::Interactive))
266}
267
268fn prompt_terminals_available() -> bool {
269    #[cfg(test)]
270    if let Some(available) = *prompt_terminal_override().read().unwrap() {
271        return available;
272    }
273
274    std::io::stdin().is_terminal() && std::io::stderr().is_terminal()
275}
276
277#[cfg(test)]
278fn prompt_terminal_override() -> &'static RwLock<Option<bool>> {
279    static PROMPT_TERMINAL_OVERRIDE: OnceLock<RwLock<Option<bool>>> = OnceLock::new();
280    PROMPT_TERMINAL_OVERRIDE.get_or_init(|| RwLock::new(None))
281}
282
283#[cfg(test)]
284pub(crate) fn set_prompt_terminal_override(available: Option<bool>) {
285    *prompt_terminal_override().write().unwrap() = available;
286}
287
288#[doc(hidden)]
289#[allow(dead_code)]
290pub fn prompt_state_test_guard() -> std::sync::MutexGuard<'static, ()> {
291    static TEST_GUARD: OnceLock<std::sync::Mutex<()>> = OnceLock::new();
292    TEST_GUARD
293        .get_or_init(|| std::sync::Mutex::new(()))
294        .lock()
295        .unwrap_or_else(|poisoned| poisoned.into_inner())
296}
297
298fn parse_input_pair(raw: &str) -> (String, Option<String>) {
299    let mut parts = raw.splitn(2, '=');
300    let key = parts.next().unwrap().trim().to_string();
301    let default = parts.next().map(|value| value.trim().to_string());
302    (key, default)
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    fn test_guard() -> std::sync::MutexGuard<'static, ()> {
310        prompt_state_test_guard()
311    }
312
313    #[test]
314    fn should_replace_variables() {
315        let _guard = test_guard();
316        let input = "this is a test with a {{variable}}";
317        let mut context = HashMap::new();
318        context.insert("variable".to_string(), "value".to_string());
319
320        let output = inject_from_variable(input, &context);
321
322        assert_eq!(output, "this is a test with a value");
323    }
324
325    #[test]
326    fn should_use_provided_prompt_inputs() {
327        let _guard = test_guard();
328        let mut inputs = HashMap::new();
329        inputs.insert("foo".to_string(), "bar".to_string());
330        set_prompt_mode(PromptMode::Interactive);
331        set_prompt_inputs(inputs);
332
333        let output = inject_from_prompt("value [[ foo ]]");
334
335        assert_eq!(output, "value bar");
336
337        set_prompt_inputs(HashMap::new());
338        set_prompt_mode(PromptMode::Interactive);
339    }
340
341    #[test]
342    fn should_extract_prompt_placeholders_from_multiline_source() {
343        let prompts = extract_prompt_placeholders(
344            "GET https://example.com/[[ token ]]\n? region = [[ region = us-east-1 ]]",
345        );
346
347        assert_eq!(
348            prompts,
349            vec![
350                ("token".to_string(), None),
351                ("region".to_string(), Some("us-east-1".to_string()))
352            ]
353        );
354    }
355
356    #[test]
357    fn should_extract_prompt_placeholders_with_url_defaults() {
358        let prompts = extract_prompt_placeholders(
359            "GET [[ ws_origin = wss://example.com ]]/chat",
360        );
361
362        assert_eq!(
363            prompts,
364            vec![(
365                "ws_origin".to_string(),
366                Some("wss://example.com".to_string())
367            )]
368        );
369    }
370
371    #[test]
372    fn should_fail_on_missing_prompt_when_noninteractive() {
373        let _guard = test_guard();
374        set_prompt_mode(PromptMode::NonInteractive);
375        set_prompt_inputs(HashMap::new());
376
377        let err = try_inject_from_prompt("value [[ foo = bar ]]").unwrap_err();
378
379        assert_eq!(err.prompt(), "foo");
380        assert_eq!(err.default(), Some("bar"));
381
382        set_prompt_mode(PromptMode::Interactive);
383    }
384
385    #[test]
386    fn should_fail_on_missing_prompt_with_url_default_when_noninteractive() {
387        let _guard = test_guard();
388        set_prompt_mode(PromptMode::NonInteractive);
389        set_prompt_inputs(HashMap::new());
390
391        let err =
392            try_inject_from_prompt("value [[ ws_origin = wss://example.com ]]").unwrap_err();
393
394        assert_eq!(err.prompt(), "ws_origin");
395        assert_eq!(err.default(), Some("wss://example.com"));
396
397        set_prompt_mode(PromptMode::Interactive);
398    }
399
400    #[test]
401    fn should_use_defaults_without_prompting_in_inputs_or_defaults_mode() {
402        let _guard = test_guard();
403        set_prompt_mode(PromptMode::Interactive);
404        set_prompt_inputs(HashMap::new());
405
406        let output = try_inject_from_prompt_inputs_or_defaults(
407            "value [[ ws_origin = wss://example.com ]]",
408        )
409        .unwrap();
410
411        assert_eq!(output, "value wss://example.com");
412
413        set_prompt_inputs(HashMap::new());
414        set_prompt_mode(PromptMode::Interactive);
415    }
416}