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 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 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 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}