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
196fn replace_frontmatter_status(content: &str, from: &str, to: &str) -> String {
199 let old = format!("status: {from}");
200 let new = format!("status: {to}");
201
202 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 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 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 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 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}