1use crate::cli::context::ProjectTypeHint;
17use anyhow::{Context as _, Result};
18use dialoguer::{Confirm, Input, MultiSelect, Select};
19
20pub trait ContextPrompter {
22 fn select(&self, prompt: &str, items: &[String], default: usize) -> Result<usize>;
24
25 fn multi_select(&self, prompt: &str, items: &[String], defaults: &[bool])
27 -> Result<Vec<usize>>;
28
29 fn confirm(&self, prompt: &str, default: bool) -> Result<bool>;
31
32 fn input(&self, prompt: &str, default: Option<&str>, allow_empty: bool) -> Result<String>;
34
35 fn edit(&self, prompt: &str, initial: &str) -> Result<String>;
37}
38
39pub struct DialoguerPrompter;
41
42impl ContextPrompter for DialoguerPrompter {
43 fn select(&self, prompt: &str, items: &[String], default: usize) -> Result<usize> {
44 Select::new()
45 .with_prompt(prompt)
46 .items(items)
47 .default(default)
48 .interact()
49 .with_context(|| format!("failed to get selection for: {}", prompt))
50 }
51
52 fn multi_select(
53 &self,
54 prompt: &str,
55 items: &[String],
56 defaults: &[bool],
57 ) -> Result<Vec<usize>> {
58 MultiSelect::new()
59 .with_prompt(prompt)
60 .items(items)
61 .defaults(defaults)
62 .interact()
63 .with_context(|| format!("failed to get multi-selection for: {}", prompt))
64 }
65
66 fn confirm(&self, prompt: &str, default: bool) -> Result<bool> {
67 Confirm::new()
68 .with_prompt(prompt)
69 .default(default)
70 .interact()
71 .with_context(|| format!("failed to get confirmation for: {}", prompt))
72 }
73
74 fn input(&self, prompt: &str, default: Option<&str>, allow_empty: bool) -> Result<String> {
75 let mut input = Input::new();
76 input = input.with_prompt(prompt).allow_empty(allow_empty);
77 if let Some(d) = default {
78 input = input.default(d.to_string());
79 }
80 input
81 .interact_text()
82 .with_context(|| format!("failed to get input for: {}", prompt))
83 }
84
85 fn edit(&self, prompt: &str, initial: &str) -> Result<String> {
86 dialoguer::Editor::new()
88 .edit(initial)
89 .with_context(|| format!("failed to edit content for: {}", prompt))?
90 .ok_or_else(|| anyhow::anyhow!("Editor was cancelled"))
91 }
92}
93
94#[derive(Debug)]
96pub struct ScriptedPrompter {
97 pub responses: Vec<ScriptedResponse>,
99 index: std::cell::Cell<usize>,
101}
102
103#[derive(Debug, Clone)]
105pub enum ScriptedResponse {
106 Select(usize),
108 MultiSelect(Vec<usize>),
110 Confirm(bool),
112 Input(String),
114 Edit(String),
116}
117
118impl ScriptedPrompter {
119 pub fn new(responses: Vec<ScriptedResponse>) -> Self {
121 Self {
122 responses,
123 index: std::cell::Cell::new(0),
124 }
125 }
126
127 fn next_response(&self) -> Result<ScriptedResponse> {
128 let idx = self.index.get();
129 if idx >= self.responses.len() {
130 anyhow::bail!(
131 "Scripted prompter ran out of responses (requested #{}, have {})",
132 idx + 1,
133 self.responses.len()
134 );
135 }
136 self.index.set(idx + 1);
137 Ok(self.responses[idx].clone())
138 }
139}
140
141impl ContextPrompter for ScriptedPrompter {
142 fn select(&self, prompt: &str, items: &[String], _default: usize) -> Result<usize> {
143 match self.next_response()? {
144 ScriptedResponse::Select(idx) => {
145 if idx >= items.len() {
146 anyhow::bail!(
147 "Scripted select index {} out of range for '{}' ({} items)",
148 idx,
149 prompt,
150 items.len()
151 );
152 }
153 Ok(idx)
154 }
155 other => anyhow::bail!("Expected Select response for '{}', got {:?}", prompt, other),
156 }
157 }
158
159 fn multi_select(
160 &self,
161 prompt: &str,
162 items: &[String],
163 _defaults: &[bool],
164 ) -> Result<Vec<usize>> {
165 match self.next_response()? {
166 ScriptedResponse::MultiSelect(indices) => {
167 for &idx in &indices {
168 if idx >= items.len() {
169 anyhow::bail!(
170 "Scripted multi-select index {} out of range for '{}' ({} items)",
171 idx,
172 prompt,
173 items.len()
174 );
175 }
176 }
177 Ok(indices)
178 }
179 other => anyhow::bail!(
180 "Expected MultiSelect response for '{}', got {:?}",
181 prompt,
182 other
183 ),
184 }
185 }
186
187 fn confirm(&self, prompt: &str, _default: bool) -> Result<bool> {
188 match self.next_response()? {
189 ScriptedResponse::Confirm(val) => Ok(val),
190 other => anyhow::bail!(
191 "Expected Confirm response for '{}', got {:?}",
192 prompt,
193 other
194 ),
195 }
196 }
197
198 fn input(&self, prompt: &str, _default: Option<&str>, _allow_empty: bool) -> Result<String> {
199 match self.next_response()? {
200 ScriptedResponse::Input(val) => Ok(val),
201 other => anyhow::bail!("Expected Input response for '{}', got {:?}", prompt, other),
202 }
203 }
204
205 fn edit(&self, prompt: &str, _initial: &str) -> Result<String> {
206 match self.next_response()? {
207 ScriptedResponse::Edit(val) => Ok(val),
208 other => anyhow::bail!("Expected Edit response for '{}', got {:?}", prompt, other),
209 }
210 }
211}
212
213#[derive(Debug, Clone)]
215pub struct ConfigHints {
216 pub project_description: Option<String>,
218 pub ci_command: String,
220 pub build_command: String,
222 pub test_command: String,
224 pub lint_command: String,
226 pub format_command: String,
228}
229
230impl Default for ConfigHints {
231 fn default() -> Self {
232 Self {
233 project_description: None,
234 ci_command: "make ci".to_string(),
235 build_command: "make build".to_string(),
236 test_command: "make test".to_string(),
237 lint_command: "make lint".to_string(),
238 format_command: "make format".to_string(),
239 }
240 }
241}
242
243#[derive(Debug, Clone)]
245pub struct InitWizardResult {
246 pub project_type: ProjectTypeHint,
248 pub output_path: Option<std::path::PathBuf>,
250 pub config_hints: ConfigHints,
252 pub confirm_write: bool,
254}
255
256pub fn run_init_wizard(
258 prompter: &dyn ContextPrompter,
259 detected_type: ProjectTypeHint,
260 default_output: &std::path::Path,
261) -> Result<InitWizardResult> {
262 let project_types = vec![
264 "Rust".to_string(),
265 "Python".to_string(),
266 "TypeScript".to_string(),
267 "Go".to_string(),
268 "Generic".to_string(),
269 ];
270
271 let default_idx = match detected_type {
272 ProjectTypeHint::Rust => 0,
273 ProjectTypeHint::Python => 1,
274 ProjectTypeHint::TypeScript => 2,
275 ProjectTypeHint::Go => 3,
276 ProjectTypeHint::Generic => 4,
277 };
278
279 let type_idx = prompter.select("Select project type", &project_types, default_idx)?;
280
281 let project_type = match type_idx {
282 0 => ProjectTypeHint::Rust,
283 1 => ProjectTypeHint::Python,
284 2 => ProjectTypeHint::TypeScript,
285 3 => ProjectTypeHint::Go,
286 _ => ProjectTypeHint::Generic,
287 };
288
289 let use_custom_path = prompter.confirm(
291 &format!("Use default output path ({})?", default_output.display()),
292 true,
293 )?;
294
295 let output_path = if use_custom_path {
296 None
297 } else {
298 let path_str: String = prompter.input(
299 "Enter output path",
300 Some(&default_output.to_string_lossy()),
301 false,
302 )?;
303 Some(std::path::PathBuf::from(path_str))
304 };
305
306 let customize = prompter.confirm("Customize build/test commands?", false)?;
308
309 let mut config_hints = ConfigHints::default();
310
311 if customize {
312 config_hints.ci_command =
313 prompter.input("CI command", Some(&config_hints.ci_command), false)?;
314 config_hints.build_command =
315 prompter.input("Build command", Some(&config_hints.build_command), false)?;
316 config_hints.test_command =
317 prompter.input("Test command", Some(&config_hints.test_command), false)?;
318 config_hints.lint_command =
319 prompter.input("Lint command", Some(&config_hints.lint_command), false)?;
320 config_hints.format_command =
321 prompter.input("Format command", Some(&config_hints.format_command), false)?;
322 }
323
324 let add_description = prompter.confirm("Add a project description?", false)?;
326
327 if add_description {
328 config_hints.project_description =
329 Some(prompter.input("Project description", None, true)?);
330 }
331
332 let confirm_write = prompter.confirm("Preview and confirm before writing?", true)?;
334
335 Ok(InitWizardResult {
336 project_type,
337 output_path,
338 config_hints,
339 confirm_write,
340 })
341}
342
343pub type UpdateWizardResult = Vec<(String, String)>;
345
346pub fn run_update_wizard(
350 prompter: &dyn ContextPrompter,
351 existing_sections: &[String],
352 _existing_content: &str,
353) -> Result<UpdateWizardResult> {
354 if existing_sections.is_empty() {
355 anyhow::bail!("No sections found in existing AGENTS.md");
356 }
357
358 let items: Vec<String> = existing_sections.iter().map(|s| s.to_string()).collect();
360 let defaults = vec![false; items.len()];
361
362 let selected_indices = prompter.multi_select(
363 "Select sections to update (Space to select, Enter to confirm)",
364 &items,
365 &defaults,
366 )?;
367
368 if selected_indices.is_empty() {
369 anyhow::bail!("No sections selected for update");
370 }
371
372 let mut updates = Vec::new();
373
374 for idx in selected_indices {
376 let section_name = &existing_sections[idx];
377
378 let input_method = prompter.select(
379 &format!("How would you like to add content to '{}'?", section_name),
380 &[
381 "Type in editor (multi-line)".to_string(),
382 "Type single line".to_string(),
383 ],
384 0,
385 )?;
386
387 let new_content = if input_method == 0 {
388 let initial = format!(
390 "\n\n<!-- Enter your new content for '{}' above this line -->\n",
391 section_name
392 );
393 prompter.edit(
394 &format!("Adding to '{}' - save and close when done", section_name),
395 &initial,
396 )?
397 } else {
398 prompter.input(&format!("New content for '{}'", section_name), None, true)?
400 };
401
402 let new_content = new_content
404 .replace(
405 &format!(
406 "<!-- Enter your new content for '{}' above this line -->\n",
407 section_name
408 ),
409 "",
410 )
411 .trim()
412 .to_string();
413
414 if !new_content.is_empty() {
415 updates.push((section_name.clone(), new_content));
416 }
417 }
418
419 if updates.is_empty() {
421 anyhow::bail!("No content was entered for any section");
422 }
423
424 let proceed = prompter.confirm(
425 &format!("Update {} section(s) with new content?", updates.len()),
426 true,
427 )?;
428
429 if !proceed {
430 anyhow::bail!("Update cancelled by user");
431 }
432
433 Ok(updates)
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439
440 #[test]
441 fn scripted_prompter_works() {
442 let prompter = ScriptedPrompter::new(vec![
443 ScriptedResponse::Select(1), ScriptedResponse::Confirm(true), ScriptedResponse::Confirm(false), ScriptedResponse::Confirm(false), ScriptedResponse::Confirm(false), ]);
449
450 let result = run_init_wizard(
451 &prompter,
452 ProjectTypeHint::Generic,
453 std::path::Path::new("AGENTS.md"),
454 );
455
456 assert!(result.is_ok());
457 let result = result.unwrap();
458 assert!(matches!(result.project_type, ProjectTypeHint::Python));
459 }
460
461 #[test]
462 fn scripted_prompter_out_of_responses() {
463 let prompter = ScriptedPrompter::new(vec![]);
464
465 let result = run_init_wizard(
466 &prompter,
467 ProjectTypeHint::Generic,
468 std::path::Path::new("AGENTS.md"),
469 );
470
471 assert!(result.is_err());
472 assert!(
473 result
474 .unwrap_err()
475 .to_string()
476 .contains("ran out of responses")
477 );
478 }
479
480 #[test]
481 fn scripted_prompter_type_mismatch() {
482 let prompter = ScriptedPrompter::new(vec![
483 ScriptedResponse::Confirm(true), ]);
485
486 let result = run_init_wizard(
487 &prompter,
488 ProjectTypeHint::Generic,
489 std::path::Path::new("AGENTS.md"),
490 );
491
492 assert!(result.is_err());
493 assert!(result.unwrap_err().to_string().contains("Expected Select"));
494 }
495
496 #[test]
497 fn update_wizard_no_sections() {
498 let prompter = ScriptedPrompter::new(vec![]);
499
500 let result = run_update_wizard(&prompter, &[], "");
501
502 assert!(result.is_err());
503 assert!(
504 result
505 .unwrap_err()
506 .to_string()
507 .contains("No sections found")
508 );
509 }
510
511 #[test]
512 fn update_wizard_selects_sections() {
513 let prompter = ScriptedPrompter::new(vec![
514 ScriptedResponse::MultiSelect(vec![0, 2]), ScriptedResponse::Select(0), ScriptedResponse::Edit("New content for section 1".to_string()),
517 ScriptedResponse::Select(1), ScriptedResponse::Input("New content for section 3".to_string()),
519 ScriptedResponse::Confirm(true), ]);
521
522 let sections = vec![
523 "Section 1".to_string(),
524 "Section 2".to_string(),
525 "Section 3".to_string(),
526 ];
527
528 let result = run_update_wizard(&prompter, §ions, "");
529
530 assert!(result.is_ok());
531 let updates = result.unwrap();
532 assert_eq!(updates.len(), 2);
533 assert_eq!(updates[0].0, "Section 1");
534 assert_eq!(updates[0].1, "New content for section 1");
535 assert_eq!(updates[1].0, "Section 3");
536 assert_eq!(updates[1].1, "New content for section 3");
537 }
538
539 #[test]
540 fn update_wizard_cancellation() {
541 let prompter = ScriptedPrompter::new(vec![
542 ScriptedResponse::MultiSelect(vec![0]),
543 ScriptedResponse::Select(1), ScriptedResponse::Input("Content".to_string()),
545 ScriptedResponse::Confirm(false), ]);
547
548 let sections = vec!["Section 1".to_string()];
549
550 let result = run_update_wizard(&prompter, §ions, "");
551
552 assert!(result.is_err());
553 assert!(result.unwrap_err().to_string().contains("cancelled"));
554 }
555
556 #[test]
557 fn config_hints_default() {
558 let hints = ConfigHints::default();
559 assert_eq!(hints.ci_command, "make ci");
560 assert_eq!(hints.build_command, "make build");
561 assert_eq!(hints.test_command, "make test");
562 assert_eq!(hints.lint_command, "make lint");
563 assert_eq!(hints.format_command, "make format");
564 assert!(hints.project_description.is_none());
565 }
566}