Skip to main content

oven_cli/cli/
ticket.rs

1use std::path::Path;
2
3use anyhow::{Context, Result};
4
5use super::{GlobalOpts, TicketArgs, TicketCommands};
6use crate::issues::local::{parse_label_array, rewrite_frontmatter_labels};
7
8#[allow(clippy::unused_async)]
9pub async fn run(args: TicketArgs, _global: &GlobalOpts) -> Result<()> {
10    let project_dir = std::env::current_dir().context("getting current directory")?;
11    let issues_dir = project_dir.join(".oven").join("issues");
12
13    match args.command {
14        TicketCommands::Create(create_args) => {
15            std::fs::create_dir_all(&issues_dir).context("creating issues directory")?;
16            let id = next_ticket_id(&issues_dir)?;
17            let labels = if create_args.ready { vec!["o-ready".to_string()] } else { Vec::new() };
18            let body = create_args.body.unwrap_or_default();
19            let content = format_ticket(
20                id,
21                &create_args.title,
22                "open",
23                &labels,
24                &body,
25                create_args.repo.as_deref(),
26            );
27            let path = issues_dir.join(format!("{id}.md"));
28            std::fs::write(&path, content).context("writing ticket")?;
29            println!("created ticket #{id}: {}", create_args.title);
30        }
31        TicketCommands::List(list_args) => {
32            if !issues_dir.exists() {
33                println!("no tickets found");
34                return Ok(());
35            }
36            let tickets = read_all_tickets(&issues_dir)?;
37            let filtered: Vec<_> = tickets
38                .iter()
39                .filter(|t| {
40                    list_args.label.as_ref().is_none_or(|l| t.labels.contains(l))
41                        && list_args.status.as_ref().is_none_or(|s| t.status == *s)
42                })
43                .collect();
44
45            if filtered.is_empty() {
46                println!("no tickets found");
47            } else {
48                println!("{:<5} {:<8} {:<40} Labels", "ID", "Status", "Title");
49                println!("{}", "-".repeat(70));
50                for t in &filtered {
51                    println!(
52                        "{:<5} {:<8} {:<40} {}",
53                        t.id,
54                        t.status,
55                        truncate(&t.title, 38),
56                        t.labels.join(", ")
57                    );
58                }
59            }
60        }
61        TicketCommands::View(view_args) => {
62            let path = issues_dir.join(format!("{}.md", view_args.id));
63            let content = std::fs::read_to_string(&path)
64                .with_context(|| format!("ticket #{} not found", view_args.id))?;
65            println!("{content}");
66        }
67        TicketCommands::Close(close_args) => {
68            let path = issues_dir.join(format!("{}.md", close_args.id));
69            let content = std::fs::read_to_string(&path)
70                .with_context(|| format!("ticket #{} not found", close_args.id))?;
71            let updated = replace_frontmatter_status(&content, "open", "closed");
72            std::fs::write(&path, updated).context("updating ticket")?;
73            println!("closed ticket #{}", close_args.id);
74        }
75        TicketCommands::Label(label_args) => {
76            let path = issues_dir.join(format!("{}.md", label_args.id));
77            let content = std::fs::read_to_string(&path)
78                .with_context(|| format!("ticket #{} not found", label_args.id))?;
79            let mut ticket =
80                parse_ticket_frontmatter(&content).context("failed to parse ticket frontmatter")?;
81            if label_args.remove {
82                ticket.labels.retain(|l| l != &label_args.label);
83            } else if !ticket.labels.contains(&label_args.label) {
84                ticket.labels.push(label_args.label.clone());
85            }
86            let updated = rewrite_frontmatter_labels(&content, &ticket.labels);
87            std::fs::write(&path, updated).context("updating ticket")?;
88            println!("updated ticket #{}", label_args.id);
89        }
90        TicketCommands::Edit(edit_args) => {
91            let path = issues_dir.join(format!("{}.md", edit_args.id));
92            if !path.exists() {
93                anyhow::bail!("ticket #{} not found", edit_args.id);
94            }
95            let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".to_string());
96            let mut parts = editor.split_whitespace();
97            let bin = parts.next().unwrap_or("vim");
98            let extra_args: Vec<&str> = parts.collect();
99            std::process::Command::new(bin)
100                .args(&extra_args)
101                .arg(&path)
102                .status()
103                .with_context(|| format!("opening {editor}"))?;
104        }
105    }
106
107    Ok(())
108}
109
110struct Ticket {
111    id: u32,
112    title: String,
113    status: String,
114    labels: Vec<String>,
115}
116
117fn format_ticket(
118    id: u32,
119    title: &str,
120    status: &str,
121    labels: &[String],
122    body: &str,
123    target_repo: Option<&str>,
124) -> String {
125    let labels_str = if labels.is_empty() {
126        "[]".to_string()
127    } else {
128        format!("[{}]", labels.iter().map(|l| format!("\"{l}\"")).collect::<Vec<_>>().join(", "))
129    };
130    let now = chrono::Utc::now().to_rfc3339();
131    let target_line = target_repo.map_or_else(String::new, |r| format!("target_repo: {r}\n"));
132    format!(
133        "---\nid: {id}\ntitle: {title}\nstatus: {status}\nlabels: {labels_str}\n{target_line}created_at: {now}\n---\n\n{body}\n"
134    )
135}
136
137fn next_ticket_id(issues_dir: &Path) -> Result<u32> {
138    let mut max_id = 0u32;
139    if issues_dir.exists() {
140        for entry in std::fs::read_dir(issues_dir).context("reading issues directory")? {
141            let entry = entry?;
142            if let Some(stem) = entry.path().file_stem().and_then(|s| s.to_str()) {
143                if let Ok(id) = stem.parse::<u32>() {
144                    max_id = max_id.max(id);
145                }
146            }
147        }
148    }
149    Ok(max_id + 1)
150}
151
152fn read_all_tickets(issues_dir: &Path) -> Result<Vec<Ticket>> {
153    let mut tickets = Vec::new();
154
155    for entry in std::fs::read_dir(issues_dir).context("reading issues directory")? {
156        let entry = entry?;
157        let path = entry.path();
158        if path.extension().and_then(|e| e.to_str()) != Some("md") {
159            continue;
160        }
161        let content = std::fs::read_to_string(&path)?;
162        if let Some(ticket) = parse_ticket_frontmatter(&content) {
163            tickets.push(ticket);
164        }
165    }
166
167    tickets.sort_by_key(|t| t.id);
168    Ok(tickets)
169}
170
171fn parse_ticket_frontmatter(content: &str) -> Option<Ticket> {
172    let content = content.strip_prefix("---\n")?;
173    let end = content.find("---")?;
174    let frontmatter = &content[..end];
175
176    let mut id = 0u32;
177    let mut title = String::new();
178    let mut status = String::new();
179    let mut labels = Vec::new();
180
181    for line in frontmatter.lines() {
182        if let Some(val) = line.strip_prefix("id: ") {
183            id = val.trim().parse().unwrap_or(0);
184        } else if let Some(val) = line.strip_prefix("title: ") {
185            title = val.trim().to_string();
186        } else if let Some(val) = line.strip_prefix("status: ") {
187            status = val.trim().to_string();
188        } else if let Some(val) = line.strip_prefix("labels: ") {
189            labels = parse_label_array(val);
190        }
191    }
192
193    if id > 0 { Some(Ticket { id, title, status, labels }) } else { None }
194}
195
196/// Replace `status: <from>` with `status: <to>` only within the frontmatter block,
197/// leaving the body untouched so example text like "status: open" isn't corrupted.
198fn replace_frontmatter_status(content: &str, from: &str, to: &str) -> String {
199    let old = format!("status: {from}");
200    let new = format!("status: {to}");
201
202    // Find the frontmatter region (between first and second "---")
203    if let Some(rest) = content.strip_prefix("---\n") {
204        if let Some(end) = rest.find("\n---") {
205            let frontmatter = &rest[..end];
206            let after = &rest[end..];
207            let replaced = frontmatter.replace(&old, &new);
208            return format!("---\n{replaced}{after}");
209        }
210    }
211    // Fallback: no valid frontmatter, do nothing
212    content.to_string()
213}
214
215fn truncate(s: &str, max_len: usize) -> String {
216    if s.len() <= max_len {
217        return s.to_string();
218    }
219    let target = max_len.saturating_sub(3);
220    let mut end = target;
221    while end > 0 && !s.is_char_boundary(end) {
222        end -= 1;
223    }
224    format!("{}...", &s[..end])
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn format_ticket_with_labels() {
233        let content = format_ticket(
234            1,
235            "Add retry logic",
236            "open",
237            &["o-ready".to_string()],
238            "Implement retry.",
239            None,
240        );
241        assert!(content.contains("id: 1"));
242        assert!(content.contains("title: Add retry logic"));
243        assert!(content.contains("status: open"));
244        assert!(content.contains("\"o-ready\""));
245        assert!(content.contains("Implement retry."));
246        assert!(!content.contains("target_repo:"));
247    }
248
249    #[test]
250    fn format_ticket_no_labels() {
251        let content = format_ticket(1, "Test", "open", &[], "body", None);
252        assert!(content.contains("labels: []"));
253    }
254
255    #[test]
256    fn format_ticket_with_target_repo() {
257        let content = format_ticket(1, "Multi", "open", &[], "body", Some("api"));
258        assert!(content.contains("target_repo: api"));
259    }
260
261    #[test]
262    fn next_ticket_id_starts_at_1() {
263        let dir = tempfile::tempdir().unwrap();
264        let id = next_ticket_id(dir.path()).unwrap();
265        assert_eq!(id, 1);
266    }
267
268    #[test]
269    fn next_ticket_id_increments() {
270        let dir = tempfile::tempdir().unwrap();
271        std::fs::write(dir.path().join("1.md"), "---\nid: 1\n---").unwrap();
272        std::fs::write(dir.path().join("3.md"), "---\nid: 3\n---").unwrap();
273        let id = next_ticket_id(dir.path()).unwrap();
274        assert_eq!(id, 4);
275    }
276
277    #[test]
278    fn parse_ticket_frontmatter_valid() {
279        let content =
280            "---\nid: 42\ntitle: Fix bug\nstatus: open\nlabels: [\"o-ready\"]\n---\n\nbody";
281        let ticket = parse_ticket_frontmatter(content).unwrap();
282        assert_eq!(ticket.id, 42);
283        assert_eq!(ticket.title, "Fix bug");
284        assert_eq!(ticket.status, "open");
285        assert_eq!(ticket.labels, vec!["o-ready"]);
286    }
287
288    #[test]
289    fn parse_ticket_frontmatter_no_labels() {
290        let content = "---\nid: 1\ntitle: Test\nstatus: open\nlabels: []\n---\n\n";
291        let ticket = parse_ticket_frontmatter(content).unwrap();
292        assert_eq!(ticket.id, 1);
293        assert!(ticket.labels.is_empty());
294    }
295
296    #[test]
297    fn parse_ticket_frontmatter_invalid() {
298        assert!(parse_ticket_frontmatter("no frontmatter").is_none());
299    }
300
301    #[test]
302    fn close_ticket_updates_status() {
303        let content = "---\nid: 1\ntitle: Test\nstatus: open\nlabels: []\n---\n\nbody\n";
304        let updated = replace_frontmatter_status(content, "open", "closed");
305        assert!(updated.contains("status: closed"));
306        assert!(!updated.contains("\nstatus: open"));
307    }
308
309    #[test]
310    fn close_ticket_does_not_corrupt_body() {
311        let content = "---\nid: 1\ntitle: Test\nstatus: open\nlabels: []\n---\n\nThe status: open field is an example.\n";
312        let updated = replace_frontmatter_status(content, "open", "closed");
313        assert!(updated.contains("status: closed"));
314        // Body text should be untouched
315        assert!(updated.contains("The status: open field is an example."));
316    }
317
318    #[test]
319    fn label_add_and_remove() {
320        let content = "---\nid: 1\ntitle: Test\nstatus: open\nlabels: [\"o-ready\"]\n---\n\nbody";
321        let mut ticket = parse_ticket_frontmatter(content).unwrap();
322        assert_eq!(ticket.labels, vec!["o-ready"]);
323
324        // Add a label
325        if !ticket.labels.contains(&"o-cooking".to_string()) {
326            ticket.labels.push("o-cooking".to_string());
327        }
328        let updated = rewrite_frontmatter_labels(content, &ticket.labels);
329        assert!(updated.contains("\"o-ready\""));
330        assert!(updated.contains("\"o-cooking\""));
331
332        // Remove a label
333        ticket.labels.retain(|l| l != "o-ready");
334        let updated2 = rewrite_frontmatter_labels(content, &ticket.labels);
335        assert!(!updated2.contains("\"o-ready\""));
336        assert!(updated2.contains("\"o-cooking\""));
337    }
338
339    #[test]
340    fn list_filters_by_status() {
341        let dir = tempfile::tempdir().unwrap();
342        std::fs::write(
343            dir.path().join("1.md"),
344            "---\nid: 1\ntitle: Open\nstatus: open\nlabels: []\n---\n\n",
345        )
346        .unwrap();
347        std::fs::write(
348            dir.path().join("2.md"),
349            "---\nid: 2\ntitle: Closed\nstatus: closed\nlabels: []\n---\n\n",
350        )
351        .unwrap();
352
353        let tickets = read_all_tickets(dir.path()).unwrap();
354        let open: Vec<_> = tickets.iter().filter(|t| t.status == "open").collect();
355        assert_eq!(open.len(), 1);
356        assert_eq!(open[0].id, 1);
357
358        let closed: Vec<_> = tickets.iter().filter(|t| t.status == "closed").collect();
359        assert_eq!(closed.len(), 1);
360        assert_eq!(closed[0].id, 2);
361    }
362
363    #[test]
364    fn editor_split_simple() {
365        let editor = "vim";
366        let mut parts = editor.split_whitespace();
367        assert_eq!(parts.next(), Some("vim"));
368        assert_eq!(parts.next(), None);
369    }
370
371    #[test]
372    fn editor_split_with_args() {
373        let editor = "code --wait";
374        let mut parts = editor.split_whitespace();
375        assert_eq!(parts.next(), Some("code"));
376        let args: Vec<&str> = parts.collect();
377        assert_eq!(args, vec!["--wait"]);
378    }
379
380    #[test]
381    fn editor_split_multiple_args() {
382        let editor = "emacs -nw --no-splash";
383        let mut parts = editor.split_whitespace();
384        assert_eq!(parts.next(), Some("emacs"));
385        let args: Vec<&str> = parts.collect();
386        assert_eq!(args, vec!["-nw", "--no-splash"]);
387    }
388
389    #[test]
390    fn read_all_tickets_sorts_by_id() {
391        let dir = tempfile::tempdir().unwrap();
392        std::fs::write(
393            dir.path().join("3.md"),
394            "---\nid: 3\ntitle: Third\nstatus: open\nlabels: []\n---\n\n",
395        )
396        .unwrap();
397        std::fs::write(
398            dir.path().join("1.md"),
399            "---\nid: 1\ntitle: First\nstatus: open\nlabels: []\n---\n\n",
400        )
401        .unwrap();
402
403        let tickets = read_all_tickets(dir.path()).unwrap();
404        assert_eq!(tickets.len(), 2);
405        assert_eq!(tickets[0].id, 1);
406        assert_eq!(tickets[1].id, 3);
407    }
408}