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 })
266}
267
268fn build_description_template(beans_dir: &Path, parent_id: Option<&str>, title: &str) -> String {
271 let mut template = format!("# {}\n\n", title);
272
273 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 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
313fn 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
333fn 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 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 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
371fn truncate(s: &str, max: usize) -> String {
373 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}