Skip to main content

mana/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::commands::create::CreateArgs;
8use crate::index::Index;
9use crate::project::suggest_verify_command;
10use crate::unit::Status;
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 unit creation wizard.
33///
34/// Prompts the user step-by-step for unit 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 units, 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(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    // ── 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 units) ────────────
68    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    // ── 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(mana_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!("─── 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    // For interactive human usage, default to pass_ok=true.
241    // Fail-first is an agent workflow concept — humans creating units
242    // interactively usually want to just create the unit.
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        feature: false,
266        decisions: Vec::new(),
267        force: false,
268    })
269}
270
271/// Build a description template for $EDITOR.
272/// If a parent is selected, embed its title and any existing description context.
273fn build_description_template(mana_dir: &Path, parent_id: Option<&str>, title: &str) -> String {
274    let mut template = format!("# {}\n\n", title);
275
276    // If parent exists, pull context from it
277    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                // Extract file references from parent for hints
285                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
316/// Load a unit by ID (scans units dir for matching file).
317fn 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
336/// Present a fuzzy-searchable selection list of open units to pick as parent.
337/// Returns `None` if the user picks "(none)" or there are no units.
338fn 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    // Only show open/in-progress units as potential parents
345    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    // Build display items: "(none)" + each unit
356    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
374/// Truncate a string for display, adding ellipsis if needed.
375fn truncate(s: &str, max: usize) -> String {
376    // Take first line only
377    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}