1use std::path::Path;
2
3use anyhow::Result;
4use dialoguer::theme::ColorfulTheme;
5use dialoguer::{Confirm, Editor, FuzzySelect, Input, Select};
6
7use crate::commands::create::CreateArgs;
8use crate::index::Index;
9use crate::project::suggest_verify_command;
10use crate::unit::Status;
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(mana_dir: &Path, prefill: Prefill) -> Result<CreateArgs> {
50 let theme = ColorfulTheme::default();
51 let project_dir = mana_dir
52 .parent()
53 .ok_or_else(|| anyhow::anyhow!("Cannot determine project root"))?;
54
55 println!("Creating a new unit\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(mana_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(mana_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!("─── Unit 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 unit?")
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 decisions: Vec::new(),
267 force: false,
268 })
269}
270
271fn build_description_template(mana_dir: &Path, parent_id: Option<&str>, title: &str) -> String {
274 let mut template = format!("# {}\n\n", title);
275
276 if let Some(pid) = parent_id {
278 if let Ok(parent_unit) = load_unit_by_id(mana_dir, pid) {
279 template.push_str(&format!(
280 "<!-- Parent: {} — {} -->\n\n",
281 pid, parent_unit.title
282 ));
283 if let Some(ref desc) = parent_unit.description {
284 let files: Vec<&str> = desc
286 .lines()
287 .filter(|l| {
288 l.starts_with("- ")
289 && (l.contains('/')
290 || l.contains(".rs")
291 || l.contains(".ts")
292 || l.contains(".py"))
293 })
294 .collect();
295 if !files.is_empty() {
296 template.push_str("## Files (from parent)\n");
297 for f in files {
298 template.push_str(&format!("{}\n", f));
299 }
300 template.push('\n');
301 }
302 }
303 }
304 }
305
306 template.push_str("## Task\n\n\n");
307 template.push_str("## Files\n");
308 template.push_str("- \n\n");
309 template.push_str("## Context\n\n\n");
310 template.push_str("## Acceptance\n");
311 template.push_str("- [ ] \n");
312
313 template
314}
315
316fn load_unit_by_id(mana_dir: &Path, id: &str) -> Result<crate::unit::Unit> {
318 use std::fs;
319 let prefix = format!("{}-", id);
320 let exact_yaml = format!("{}.yaml", id);
321
322 for entry in fs::read_dir(mana_dir)? {
323 let entry = entry?;
324 let name = entry.file_name();
325 let name = name.to_string_lossy();
326 if name.starts_with(&prefix) && name.ends_with(".md") {
327 return crate::unit::Unit::from_file(entry.path());
328 }
329 if *name == exact_yaml {
330 return crate::unit::Unit::from_file(entry.path());
331 }
332 }
333 anyhow::bail!("Unit {} not found", id)
334}
335
336fn select_parent(mana_dir: &Path, theme: &ColorfulTheme) -> Result<Option<String>> {
339 let index = match Index::load(mana_dir) {
340 Ok(idx) => idx,
341 Err(_) => return Ok(None),
342 };
343
344 let candidates: Vec<_> = index
346 .units
347 .iter()
348 .filter(|b| b.status == Status::Open || b.status == Status::InProgress)
349 .collect();
350
351 if candidates.is_empty() {
352 return Ok(None);
353 }
354
355 let mut items: Vec<String> = vec!["(none — top-level unit)".to_string()];
357 for b in &candidates {
358 items.push(format!("{} — {}", b.id, b.title));
359 }
360
361 let selection = FuzzySelect::with_theme(theme)
362 .with_prompt("Parent (type to filter)")
363 .items(&items)
364 .default(0)
365 .interact()?;
366
367 if selection == 0 {
368 Ok(None)
369 } else {
370 Ok(Some(candidates[selection - 1].id.clone()))
371 }
372}
373
374fn truncate(s: &str, max: usize) -> String {
376 let line = s.lines().next().unwrap_or(s);
378 if line.len() <= max {
379 line.to_string()
380 } else {
381 format!("{}…", &line[..max])
382 }
383}