bn/commands/
interactive.rs1use 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#[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
32pub 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 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 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 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 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 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 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 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 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 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 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 feature: false,
266 })
267}
268
269fn build_description_template(beans_dir: &Path, parent_id: Option<&str>, title: &str) -> String {
272 let mut template = format!("# {}\n\n", title);
273
274 if let Some(pid) = parent_id {
276 if let Ok(parent_bean) = load_bean_by_id(beans_dir, pid) {
277 template.push_str(&format!(
278 "<!-- Parent: {} — {} -->\n\n",
279 pid, parent_bean.title
280 ));
281 if let Some(ref desc) = parent_bean.description {
282 let files: Vec<&str> = desc
284 .lines()
285 .filter(|l| {
286 l.starts_with("- ")
287 && (l.contains('/')
288 || l.contains(".rs")
289 || l.contains(".ts")
290 || l.contains(".py"))
291 })
292 .collect();
293 if !files.is_empty() {
294 template.push_str("## Files (from parent)\n");
295 for f in files {
296 template.push_str(&format!("{}\n", f));
297 }
298 template.push('\n');
299 }
300 }
301 }
302 }
303
304 template.push_str("## Task\n\n\n");
305 template.push_str("## Files\n");
306 template.push_str("- \n\n");
307 template.push_str("## Context\n\n\n");
308 template.push_str("## Acceptance\n");
309 template.push_str("- [ ] \n");
310
311 template
312}
313
314fn load_bean_by_id(beans_dir: &Path, id: &str) -> Result<crate::bean::Bean> {
316 use std::fs;
317 let prefix = format!("{}-", id);
318 let exact_yaml = format!("{}.yaml", id);
319
320 for entry in fs::read_dir(beans_dir)? {
321 let entry = entry?;
322 let name = entry.file_name();
323 let name = name.to_string_lossy();
324 if name.starts_with(&prefix) && name.ends_with(".md") {
325 return crate::bean::Bean::from_file(entry.path());
326 }
327 if *name == exact_yaml {
328 return crate::bean::Bean::from_file(entry.path());
329 }
330 }
331 anyhow::bail!("Bean {} not found", id)
332}
333
334fn select_parent(beans_dir: &Path, theme: &ColorfulTheme) -> Result<Option<String>> {
337 let index = match Index::load(beans_dir) {
338 Ok(idx) => idx,
339 Err(_) => return Ok(None),
340 };
341
342 let candidates: Vec<_> = index
344 .beans
345 .iter()
346 .filter(|b| b.status == Status::Open || b.status == Status::InProgress)
347 .collect();
348
349 if candidates.is_empty() {
350 return Ok(None);
351 }
352
353 let mut items: Vec<String> = vec!["(none — top-level bean)".to_string()];
355 for b in &candidates {
356 items.push(format!("{} — {}", b.id, b.title));
357 }
358
359 let selection = FuzzySelect::with_theme(theme)
360 .with_prompt("Parent (type to filter)")
361 .items(&items)
362 .default(0)
363 .interact()?;
364
365 if selection == 0 {
366 Ok(None)
367 } else {
368 Ok(Some(candidates[selection - 1].id.clone()))
369 }
370}
371
372fn truncate(s: &str, max: usize) -> String {
374 let line = s.lines().next().unwrap_or(s);
376 if line.len() <= max {
377 line.to_string()
378 } else {
379 format!("{}…", &line[..max])
380 }
381}