Skip to main content

bn/commands/
interactive.rs

1use std::path::Path;
2
3use anyhow::Result;
4use dialoguer::theme::ColorfulTheme;
5use dialoguer::{Confirm, Editor, FuzzySelect, Input, Select};
6
7use crate::bean::Status;
8use crate::commands::create::CreateArgs;
9use crate::index::Index;
10use crate::project::suggest_verify_command;
11
12/// Pre-filled values from CLI flags that were already provided.
13/// Any `Some` field skips the corresponding prompt.
14#[derive(Default)]
15pub struct Prefill {
16    pub title: Option<String>,
17    pub description: Option<String>,
18    pub acceptance: Option<String>,
19    pub notes: Option<String>,
20    pub design: Option<String>,
21    pub verify: Option<String>,
22    pub parent: Option<String>,
23    pub priority: Option<u8>,
24    pub labels: Option<String>,
25    pub assignee: Option<String>,
26    pub deps: Option<String>,
27    pub produces: Option<String>,
28    pub requires: Option<String>,
29    pub pass_ok: Option<bool>,
30}
31
32/// Run the interactive bean creation wizard.
33///
34/// Prompts the user step-by-step for bean fields. Any field already
35/// provided in `prefill` is skipped (shown as pre-accepted).
36///
37/// Flow:
38/// 1. Title (required)
39/// 2. Parent (fuzzy-search from existing beans, or none)
40/// 3. Verify command (with smart default from project type)
41/// 4. Acceptance criteria
42/// 5. Priority (P0-P4, default P2)
43/// 6. Description (open $EDITOR)
44/// 7. Produces / Requires (for dependency tracking)
45/// 8. Labels
46/// 9. Summary + confirm
47///
48/// Returns a fully populated `CreateArgs`.
49pub fn interactive_create(beans_dir: &Path, prefill: Prefill) -> Result<CreateArgs> {
50    let theme = ColorfulTheme::default();
51    let project_dir = beans_dir
52        .parent()
53        .ok_or_else(|| anyhow::anyhow!("Cannot determine project root"))?;
54
55    println!("Creating a new bean\n");
56
57    // ── 1. Title (required) ──────────────────────────────────────────
58    let title = if let Some(t) = prefill.title {
59        println!("  Title: {}", t);
60        t
61    } else {
62        Input::with_theme(&theme)
63            .with_prompt("Title")
64            .interact_text()?
65    };
66
67    // ── 2. Parent (fuzzy-select from existing open beans) ────────────
68    let parent = if let Some(p) = prefill.parent {
69        println!("  Parent: {}", p);
70        Some(p)
71    } else {
72        select_parent(beans_dir, &theme)?
73    };
74
75    // ── 3. Verify command ────────────────────────────────────────────
76    let verify = if let Some(v) = prefill.verify {
77        println!("  Verify: {}", v);
78        Some(v)
79    } else {
80        let suggested = suggest_verify_command(project_dir);
81        let mut input = Input::<String>::with_theme(&theme)
82            .with_prompt("Verify command (empty to skip)")
83            .allow_empty(true);
84        if let Some(s) = suggested {
85            input = input.default(s.to_string()).show_default(true);
86        }
87        let v: String = input.interact_text()?;
88        if v.is_empty() {
89            None
90        } else {
91            Some(v)
92        }
93    };
94
95    // ── 4. Acceptance criteria ───────────────────────────────────────
96    let acceptance = if let Some(a) = prefill.acceptance {
97        println!("  Acceptance: {}", a);
98        Some(a)
99    } else {
100        let a: String = Input::with_theme(&theme)
101            .with_prompt("Acceptance criteria (empty to skip)")
102            .allow_empty(true)
103            .interact_text()?;
104        if a.is_empty() {
105            None
106        } else {
107            Some(a)
108        }
109    };
110
111    // ── 5. Priority ──────────────────────────────────────────────────
112    let priority = if let Some(p) = prefill.priority {
113        println!("  Priority: P{}", p);
114        p
115    } else {
116        let items = &[
117            "P0 (critical)",
118            "P1 (high)",
119            "P2 (normal)",
120            "P3 (low)",
121            "P4 (backlog)",
122        ];
123        let idx = Select::with_theme(&theme)
124            .with_prompt("Priority")
125            .items(items)
126            .default(2)
127            .interact()?;
128        idx as u8
129    };
130
131    // ── 6. Description ($EDITOR) ─────────────────────────────────────
132    let description = if let Some(d) = prefill.description {
133        println!("  Description: (provided)");
134        Some(d)
135    } else {
136        let wants = Confirm::with_theme(&theme)
137            .with_prompt("Open editor for description?")
138            .default(false)
139            .interact()?;
140
141        if wants {
142            let template = build_description_template(beans_dir, parent.as_deref(), &title);
143            Editor::new().edit(&template)?
144        } else {
145            None
146        }
147    };
148
149    // ── 7. Produces / Requires ───────────────────────────────────────
150    let produces = if let Some(p) = prefill.produces {
151        println!("  Produces: {}", p);
152        Some(p)
153    } else {
154        let p: String = Input::with_theme(&theme)
155            .with_prompt("Produces (comma-separated, empty to skip)")
156            .allow_empty(true)
157            .interact_text()?;
158        if p.is_empty() {
159            None
160        } else {
161            Some(p)
162        }
163    };
164
165    let requires = if let Some(r) = prefill.requires {
166        println!("  Requires: {}", r);
167        Some(r)
168    } else {
169        let r: String = Input::with_theme(&theme)
170            .with_prompt("Requires (comma-separated, empty to skip)")
171            .allow_empty(true)
172            .interact_text()?;
173        if r.is_empty() {
174            None
175        } else {
176            Some(r)
177        }
178    };
179
180    // ── 8. Labels ────────────────────────────────────────────────────
181    let labels = if let Some(l) = prefill.labels {
182        println!("  Labels: {}", l);
183        Some(l)
184    } else {
185        let wants = Confirm::with_theme(&theme)
186            .with_prompt("Add labels?")
187            .default(false)
188            .interact()?;
189        if wants {
190            let l: String = Input::with_theme(&theme)
191                .with_prompt("Labels (comma-separated)")
192                .interact_text()?;
193            if l.is_empty() {
194                None
195            } else {
196                Some(l)
197            }
198        } else {
199            None
200        }
201    };
202
203    // ── 9. Summary + confirm ─────────────────────────────────────────
204    println!();
205    println!("─── Bean Summary ───────────────────────");
206    println!("  Title:      {}", title);
207    if let Some(ref p) = parent {
208        println!("  Parent:     {}", p);
209    }
210    if let Some(ref v) = verify {
211        println!("  Verify:     {}", v);
212    }
213    if let Some(ref a) = acceptance {
214        println!("  Acceptance: {}", truncate(a, 60));
215    }
216    println!("  Priority:   P{}", priority);
217    if description.is_some() {
218        println!("  Description: (provided)");
219    }
220    if let Some(ref p) = produces {
221        println!("  Produces:   {}", p);
222    }
223    if let Some(ref r) = requires {
224        println!("  Requires:   {}", r);
225    }
226    if let Some(ref l) = labels {
227        println!("  Labels:     {}", l);
228    }
229    println!("────────────────────────────────────────");
230
231    let confirmed = Confirm::with_theme(&theme)
232        .with_prompt("Create this bean?")
233        .default(true)
234        .interact()?;
235
236    if !confirmed {
237        anyhow::bail!("Cancelled");
238    }
239
240    // For interactive human usage, default to pass_ok=true.
241    // Fail-first is an agent workflow concept — humans creating beans
242    // interactively usually want to just create the bean.
243    let pass_ok = prefill.pass_ok.unwrap_or(true);
244
245    Ok(CreateArgs {
246        title,
247        description,
248        acceptance,
249        notes: prefill.notes,
250        design: prefill.design,
251        verify,
252        priority: Some(priority),
253        labels,
254        assignee: prefill.assignee,
255        deps: prefill.deps,
256        parent,
257        produces,
258        requires,
259        paths: None,
260        on_fail: None,
261        pass_ok,
262        claim: false,
263        by: None,
264        verify_timeout: None,
265    })
266}
267
268/// Build a description template for $EDITOR.
269/// If a parent is selected, embed its title and any existing description context.
270fn build_description_template(beans_dir: &Path, parent_id: Option<&str>, title: &str) -> String {
271    let mut template = format!("# {}\n\n", title);
272
273    // If parent exists, pull context from it
274    if let Some(pid) = parent_id {
275        if let Ok(parent_bean) = load_bean_by_id(beans_dir, pid) {
276            template.push_str(&format!(
277                "<!-- Parent: {} — {} -->\n\n",
278                pid, parent_bean.title
279            ));
280            if let Some(ref desc) = parent_bean.description {
281                // Extract file references from parent for hints
282                let files: Vec<&str> = desc
283                    .lines()
284                    .filter(|l| {
285                        l.starts_with("- ")
286                            && (l.contains('/')
287                                || l.contains(".rs")
288                                || l.contains(".ts")
289                                || l.contains(".py"))
290                    })
291                    .collect();
292                if !files.is_empty() {
293                    template.push_str("## Files (from parent)\n");
294                    for f in files {
295                        template.push_str(&format!("{}\n", f));
296                    }
297                    template.push('\n');
298                }
299            }
300        }
301    }
302
303    template.push_str("## Task\n\n\n");
304    template.push_str("## Files\n");
305    template.push_str("- \n\n");
306    template.push_str("## Context\n\n\n");
307    template.push_str("## Acceptance\n");
308    template.push_str("- [ ] \n");
309
310    template
311}
312
313/// Load a bean by ID (scans beans dir for matching file).
314fn load_bean_by_id(beans_dir: &Path, id: &str) -> Result<crate::bean::Bean> {
315    use std::fs;
316    let prefix = format!("{}-", id);
317    let exact_yaml = format!("{}.yaml", id);
318
319    for entry in fs::read_dir(beans_dir)? {
320        let entry = entry?;
321        let name = entry.file_name();
322        let name = name.to_string_lossy();
323        if name.starts_with(&prefix) && name.ends_with(".md") {
324            return crate::bean::Bean::from_file(entry.path());
325        }
326        if *name == exact_yaml {
327            return crate::bean::Bean::from_file(entry.path());
328        }
329    }
330    anyhow::bail!("Bean {} not found", id)
331}
332
333/// Present a fuzzy-searchable selection list of open beans to pick as parent.
334/// Returns `None` if the user picks "(none)" or there are no beans.
335fn select_parent(beans_dir: &Path, theme: &ColorfulTheme) -> Result<Option<String>> {
336    let index = match Index::load(beans_dir) {
337        Ok(idx) => idx,
338        Err(_) => return Ok(None),
339    };
340
341    // Only show open/in-progress beans as potential parents
342    let candidates: Vec<_> = index
343        .beans
344        .iter()
345        .filter(|b| b.status == Status::Open || b.status == Status::InProgress)
346        .collect();
347
348    if candidates.is_empty() {
349        return Ok(None);
350    }
351
352    // Build display items: "(none)" + each bean
353    let mut items: Vec<String> = vec!["(none — top-level bean)".to_string()];
354    for b in &candidates {
355        items.push(format!("{} — {}", b.id, b.title));
356    }
357
358    let selection = FuzzySelect::with_theme(theme)
359        .with_prompt("Parent (type to filter)")
360        .items(&items)
361        .default(0)
362        .interact()?;
363
364    if selection == 0 {
365        Ok(None)
366    } else {
367        Ok(Some(candidates[selection - 1].id.clone()))
368    }
369}
370
371/// Truncate a string for display, adding ellipsis if needed.
372fn truncate(s: &str, max: usize) -> String {
373    // Take first line only
374    let line = s.lines().next().unwrap_or(s);
375    if line.len() <= max {
376        line.to_string()
377    } else {
378        format!("{}…", &line[..max])
379    }
380}