Skip to main content

ralph/commands/context/
wizard.rs

1//! Interactive wizard for AGENTS.md context initialization and updates.
2//!
3//! Responsibilities:
4//! - Collect user preferences for context initialization (project type, config hints).
5//! - Guide users through updating existing AGENTS.md sections interactively.
6//! - Provide testable prompt abstractions via the `ContextPrompter` trait.
7//!
8//! Not handled here:
9//! - File I/O (handled by the main context module).
10//! - Markdown merging logic (handled by the merge module).
11//!
12//! Invariants/assumptions:
13//! - Wizard is only run in interactive TTY environments (enforced by CLI layer).
14//! - User inputs are validated before returning wizard results.
15
16use crate::cli::context::ProjectTypeHint;
17use anyhow::{Context as _, Result};
18use dialoguer::{Confirm, Input, MultiSelect, Select};
19
20/// Trait for prompting user input, allowing testable implementations.
21pub trait ContextPrompter {
22    /// Select a single item from a list. Returns the index of the selected item.
23    fn select(&self, prompt: &str, items: &[String], default: usize) -> Result<usize>;
24
25    /// Select multiple items from a list. Returns indices of selected items.
26    fn multi_select(&self, prompt: &str, items: &[String], defaults: &[bool])
27    -> Result<Vec<usize>>;
28
29    /// Confirm a yes/no question.
30    fn confirm(&self, prompt: &str, default: bool) -> Result<bool>;
31
32    /// Get text input from user.
33    fn input(&self, prompt: &str, default: Option<&str>, allow_empty: bool) -> Result<String>;
34
35    /// Edit multi-line text in an editor.
36    fn edit(&self, prompt: &str, initial: &str) -> Result<String>;
37}
38
39/// Dialoguer-based prompter for interactive terminal use.
40pub 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        // Use dialoguer's Editor for multi-line input
87        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/// Scripted prompter for testing with predetermined responses.
95#[derive(Debug)]
96pub struct ScriptedPrompter {
97    /// Queue of responses for different prompt types
98    pub responses: Vec<ScriptedResponse>,
99    /// Current response index
100    index: std::cell::Cell<usize>,
101}
102
103/// Types of scripted responses.
104#[derive(Debug, Clone)]
105pub enum ScriptedResponse {
106    /// Single selection (index)
107    Select(usize),
108    /// Multiple selection (indices)
109    MultiSelect(Vec<usize>),
110    /// Confirmation (yes/no)
111    Confirm(bool),
112    /// Text input
113    Input(String),
114    /// Editor result
115    Edit(String),
116}
117
118impl ScriptedPrompter {
119    /// Create a new scripted prompter with the given responses.
120    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/// Configuration hints collected during init wizard.
214#[derive(Debug, Clone)]
215pub struct ConfigHints {
216    /// Project description to replace placeholder.
217    pub project_description: Option<String>,
218    /// CI command (default: make ci).
219    pub ci_command: String,
220    /// Build command (default: make build).
221    pub build_command: String,
222    /// Test command (default: make test).
223    pub test_command: String,
224    /// Lint command (default: make lint).
225    pub lint_command: String,
226    /// Format command (default: make format).
227    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/// Result of the init wizard.
244#[derive(Debug, Clone)]
245pub struct InitWizardResult {
246    /// Selected project type.
247    pub project_type: ProjectTypeHint,
248    /// Optional output path override.
249    pub output_path: Option<std::path::PathBuf>,
250    /// Config hints for customizing the generated content.
251    pub config_hints: ConfigHints,
252    /// Whether to confirm before writing.
253    pub confirm_write: bool,
254}
255
256/// Run the interactive init wizard.
257pub fn run_init_wizard(
258    prompter: &dyn ContextPrompter,
259    detected_type: ProjectTypeHint,
260    default_output: &std::path::Path,
261) -> Result<InitWizardResult> {
262    // Project type selection
263    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    // Output path
290    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    // Config hints
307    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    // Project description
325    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    // Confirm before write
333    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
343/// Result of the update wizard: section name -> new content.
344pub type UpdateWizardResult = Vec<(String, String)>;
345
346/// Run the interactive update wizard.
347///
348/// Presents existing sections for selection, then prompts for new content for each.
349pub 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 user select sections to update
359    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 each selected section, prompt for new content
375    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            // Use editor
389            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            // Single line input
399            prompter.input(&format!("New content for '{}'", section_name), None, true)?
400        };
401
402        // Clean up the content (remove the placeholder comment if present)
403        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    // Confirm before applying
420    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),      // Select Python
444            ScriptedResponse::Confirm(true),  // Use default path
445            ScriptedResponse::Confirm(false), // Don't customize commands
446            ScriptedResponse::Confirm(false), // Don't add description
447            ScriptedResponse::Confirm(false), // Don't confirm before write
448        ]);
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), // Wrong type - should be Select
484        ]);
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]), // Select sections 0 and 2
515            ScriptedResponse::Select(0),               // Use editor for first section
516            ScriptedResponse::Edit("New content for section 1".to_string()),
517            ScriptedResponse::Select(1), // Use single line for second section
518            ScriptedResponse::Input("New content for section 3".to_string()),
519            ScriptedResponse::Confirm(true), // Proceed with update
520        ]);
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, &sections, "");
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), // Single line
544            ScriptedResponse::Input("Content".to_string()),
545            ScriptedResponse::Confirm(false), // Cancel
546        ]);
547
548        let sections = vec!["Section 1".to_string()];
549
550        let result = run_update_wizard(&prompter, &sections, "");
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}