1use serde::{Deserialize, Serialize};
26use std::collections::BTreeMap;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
30#[serde(rename_all = "snake_case")]
31pub enum PlanStatus {
32 Backlog,
34 InProgress,
36 Qa,
38 Done,
40 Cancelled,
42 Undone,
44}
45
46impl PlanStatus {
47 pub fn as_str(self) -> &'static str {
49 match self {
50 Self::Backlog => "backlog",
51 Self::InProgress => "in_progress",
52 Self::Qa => "qa",
53 Self::Done => "done",
54 Self::Cancelled => "cancelled",
55 Self::Undone => "undone",
56 }
57 }
58
59 pub fn parse(s: &str) -> Option<Self> {
61 let t = s
62 .trim()
63 .trim_matches(|c: char| c == '"' || c == '\'' || c == '`')
64 .to_ascii_lowercase()
65 .replace('-', "_")
66 .replace(' ', "_");
67 match t.as_str() {
68 "backlog" | "todo" | "to_do" | "open" | "pending" | "queued" | "queue" | "later"
69 | "icebox" => Some(Self::Backlog),
70 "in_progress" | "inprogress" | "wip" | "doing" | "active" | "started" | "working"
71 | "progress" => Some(Self::InProgress),
72 "qa" | "review" | "in_review" | "testing" | "test" | "verification" | "verify"
73 | "code_review" | "pr_review" => Some(Self::Qa),
74 "done" | "complete" | "completed" | "finished" | "closed" | "shipped" | "resolved"
75 | "fixed" | "merged" => Some(Self::Done),
76 "cancelled" | "canceled" | "wontfix" | "wont_fix" | "dropped" | "obsolete"
77 | "abandoned" | "declined" => Some(Self::Cancelled),
78 "undone" | "reopen" | "reopened" | "blocked" | "stuck" | "on_hold" | "paused"
79 | "deferred" => Some(Self::Undone),
80 _ => None,
81 }
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct PlanTask {
88 pub status: PlanStatus,
90 pub text: String,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct PlanStatusDigest {
97 pub overall: Option<PlanStatus>,
99 pub counts: BTreeMap<String, usize>,
101 pub tasks: Vec<PlanTask>,
103 pub open: usize,
105 pub done: usize,
107 pub cancelled: usize,
109}
110
111impl PlanStatusDigest {
112 pub fn summary_line(&self) -> String {
114 let overall = self
115 .overall
116 .map(|s| s.as_str())
117 .unwrap_or("unspecified");
118 format!(
119 "plan status={overall} · open {} · done {} · cancelled {}",
120 self.open, self.done, self.cancelled
121 )
122 }
123
124 pub fn fts_block(&self) -> String {
126 let mut parts = Vec::new();
127 parts.push("[plan-status]".to_string());
128 if let Some(o) = self.overall {
129 parts.push(format!("status:{}", o.as_str()));
130 parts.push(format!("plan_status:{}", o.as_str()));
131 }
132 parts.push(format!("plan_open:{}", self.open));
133 parts.push(format!("plan_done:{}", self.done));
134 parts.push(format!("plan_cancelled:{}", self.cancelled));
135 for (k, n) in &self.counts {
136 if *n > 0 {
137 parts.push(format!("count:{k}:{n}"));
138 for _ in 0..(*n).min(5) {
140 parts.push(format!("status:{k}"));
141 }
142 }
143 }
144 for t in self.tasks.iter().take(40) {
145 let slug = slug_token(&t.text);
146 if !slug.is_empty() {
147 parts.push(format!("task:{}:{}", t.status.as_str(), slug));
148 }
149 parts.push(format!("status:{}", t.status.as_str()));
150 }
151 parts.join(" ")
152 }
153
154 pub fn has_signal(&self) -> bool {
156 self.overall.is_some() || !self.tasks.is_empty() || self.counts.values().any(|n| *n > 0)
157 }
158}
159
160pub fn densify_plan(frontmatter_status: Option<&str>, body: &str) -> PlanStatusDigest {
162 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
163 let mut tasks: Vec<PlanTask> = Vec::new();
164 let mut overall = frontmatter_status.and_then(PlanStatus::parse);
165
166 if overall.is_none() {
168 if let Some(sec) = section_body(body, "status") {
169 overall = first_status_in_text(&sec);
170 }
171 }
172
173 let mut section_status: Option<PlanStatus> = None;
175 for line in body.lines() {
176 let t = line.trim();
177 if let Some(rest) = t.strip_prefix('#') {
178 let heading = rest.trim_start_matches('#').trim();
179 let h = heading.to_ascii_lowercase();
180 section_status = PlanStatus::parse(&h).or_else(|| {
182 h.split_whitespace()
184 .find_map(|w| PlanStatus::parse(w))
185 .or_else(|| {
186 for key in [
187 "in_progress",
188 "in progress",
189 "backlog",
190 "done",
191 "qa",
192 "review",
193 "cancelled",
194 "canceled",
195 "todo",
196 "blocked",
197 ] {
198 if h.contains(key) {
199 return PlanStatus::parse(key);
200 }
201 }
202 None
203 })
204 });
205 continue;
206 }
207
208 if let Some((st, text)) = parse_checkbox_line(t) {
209 let status = st.or(section_status).unwrap_or(PlanStatus::Backlog);
210 *counts.entry(status.as_str().to_string()).or_default() += 1;
211 if tasks.len() < 64 {
212 tasks.push(PlanTask {
213 status,
214 text: truncate(&text, 80),
215 });
216 }
217 continue;
218 }
219
220 if let Some(rest) = t.strip_prefix('-').or_else(|| t.strip_prefix('*')) {
222 let rest = rest.trim();
223 if let Some((st, text)) = parse_tagged_bullet(rest) {
224 *counts.entry(st.as_str().to_string()).or_default() += 1;
225 if tasks.len() < 64 {
226 tasks.push(PlanTask {
227 status: st,
228 text: truncate(&text, 80),
229 });
230 }
231 }
232 }
233 }
234
235 if overall.is_none() && !tasks.is_empty() {
237 overall = Some(infer_overall(&counts));
238 }
239
240 let done = *counts.get("done").unwrap_or(&0);
241 let cancelled = *counts.get("cancelled").unwrap_or(&0);
242 let open = counts
243 .iter()
244 .filter(|(k, _)| matches!(k.as_str(), "backlog" | "in_progress" | "qa" | "undone"))
245 .map(|(_, n)| *n)
246 .sum();
247
248 PlanStatusDigest {
249 overall,
250 counts,
251 tasks,
252 open,
253 done,
254 cancelled,
255 }
256}
257
258fn infer_overall(counts: &BTreeMap<String, usize>) -> PlanStatus {
259 let get = |k: &str| *counts.get(k).unwrap_or(&0);
260 if get("in_progress") > 0 {
261 return PlanStatus::InProgress;
262 }
263 if get("qa") > 0 {
264 return PlanStatus::Qa;
265 }
266 if get("undone") > 0 {
267 return PlanStatus::Undone;
268 }
269 if get("backlog") > 0 {
270 return PlanStatus::Backlog;
271 }
272 if get("done") > 0 && get("cancelled") == 0 {
273 return PlanStatus::Done;
274 }
275 if get("cancelled") > 0 && get("done") == 0 && get("backlog") == 0 {
276 return PlanStatus::Cancelled;
277 }
278 PlanStatus::Backlog
279}
280
281fn parse_checkbox_line(t: &str) -> Option<(Option<PlanStatus>, String)> {
282 let t = t.strip_prefix('-').or_else(|| t.strip_prefix('*'))?.trim();
283 if !t.starts_with('[') {
285 return None;
286 }
287 let close = t.find(']')?;
288 let mark = t[1..close].trim();
289 let text = t[close + 1..].trim().to_string();
290 if text.is_empty() {
291 return None;
292 }
293 let st = match mark.to_ascii_lowercase().as_str() {
294 "" | " " => Some(PlanStatus::Backlog),
295 "x" => Some(PlanStatus::Done),
296 "/" | ">" | "o" => Some(PlanStatus::InProgress),
297 "~" | "-" => Some(PlanStatus::Cancelled),
298 "?" => Some(PlanStatus::Qa),
299 "!" => Some(PlanStatus::Undone),
300 other => PlanStatus::parse(other),
301 };
302 Some((st, text))
303}
304
305fn parse_tagged_bullet(rest: &str) -> Option<(PlanStatus, String)> {
306 if let Some(inner) = rest.strip_prefix('[') {
308 if let Some(end) = inner.find(']') {
309 let tag = &inner[..end];
310 let text = inner[end + 1..].trim();
311 if let Some(st) = PlanStatus::parse(tag) {
312 if !text.is_empty() {
313 return Some((st, text.to_string()));
314 }
315 }
316 }
317 }
318 for sep in [':', '—', '-'] {
320 if let Some((left, right)) = rest.split_once(sep) {
321 if let Some(st) = PlanStatus::parse(left.trim()) {
322 let text = right.trim();
323 if !text.is_empty() && text.len() < 200 {
324 return Some((st, text.to_string()));
325 }
326 }
327 }
328 }
329 None
330}
331
332fn section_body(body: &str, name: &str) -> Option<String> {
333 let name = name.to_ascii_lowercase();
334 let mut lines = body.lines().peekable();
335 let mut out = String::new();
336 let mut in_sec = false;
337 while let Some(line) = lines.next() {
338 let t = line.trim();
339 if let Some(rest) = t.strip_prefix('#') {
340 let heading = rest.trim_start_matches('#').trim().to_ascii_lowercase();
341 if in_sec {
342 break;
343 }
344 if heading == name || heading.starts_with(&format!("{name} ")) {
345 in_sec = true;
346 continue;
347 }
348 }
349 if in_sec {
350 out.push_str(line);
351 out.push('\n');
352 }
353 }
354 if out.trim().is_empty() {
355 None
356 } else {
357 Some(out)
358 }
359}
360
361fn first_status_in_text(s: &str) -> Option<PlanStatus> {
362 for raw in s.split(|c: char| c.is_whitespace() || c == ',' || c == ';' || c == '|') {
363 if let Some(st) = PlanStatus::parse(raw) {
364 return Some(st);
365 }
366 }
367 s.lines()
369 .map(str::trim)
370 .find(|l| !l.is_empty() && !l.starts_with("<!--"))
371 .and_then(PlanStatus::parse)
372}
373
374fn slug_token(s: &str) -> String {
375 let mut out = String::new();
376 let mut prev = false;
377 for c in s.chars().take(48) {
378 if c.is_ascii_alphanumeric() {
379 out.push(c.to_ascii_lowercase());
380 prev = false;
381 } else if !prev && !out.is_empty() {
382 out.push('_');
383 prev = true;
384 }
385 }
386 while out.ends_with('_') {
387 out.pop();
388 }
389 out
390}
391
392fn truncate(s: &str, max: usize) -> String {
393 if s.chars().count() <= max {
394 s.to_string()
395 } else {
396 let t: String = s.chars().take(max.saturating_sub(1)).collect();
397 format!("{t}…")
398 }
399}
400
401pub fn enrich_plan_index_fields(
403 body: &str,
404 frontmatter_status: Option<&str>,
405 base_summary: &str,
406) -> (String, String) {
407 let dig = densify_plan(frontmatter_status, body);
408 if !dig.has_signal() {
409 return (body.to_string(), base_summary.to_string());
410 }
411 let fts = format!("{body}\n\n{}\n", dig.fts_block());
412 let summary = if base_summary.is_empty() {
413 dig.summary_line()
414 } else {
415 format!("{} · {}", dig.summary_line(), base_summary)
416 };
417 let summary = if summary.chars().count() > 240 {
419 truncate(&summary, 240)
420 } else {
421 summary
422 };
423 (fts, summary)
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429
430 #[test]
431 fn parses_checkboxes_and_sections() {
432 let body = r#"
433## Status
434
435in_progress
436
437## Backlog
438
439- [ ] Design API
440- [ ] Write docs
441
442## In Progress
443
444- [/] Implement parser
445
446## Done
447
448- [x] Scaffold types
449
450## Cancelled
451
452- [~] Old approach
453"#;
454 let d = densify_plan(None, body);
455 assert_eq!(d.overall, Some(PlanStatus::InProgress));
456 assert_eq!(d.done, 1);
457 assert_eq!(d.cancelled, 1);
458 assert!(d.open >= 3);
459 let fts = d.fts_block();
460 assert!(fts.contains("status:in_progress"));
461 assert!(fts.contains("task:done:"));
462 assert!(fts.contains("plan_open:"));
463 }
464
465 #[test]
466 fn frontmatter_overall_wins() {
467 let d = densify_plan(Some("qa"), "- [ ] still open\n");
468 assert_eq!(d.overall, Some(PlanStatus::Qa));
469 }
470
471 #[test]
472 fn tagged_bullets() {
473 let body = "- done: ship 0.3\n- blocked: flaky CI\n";
474 let d = densify_plan(None, body);
475 assert!(d.tasks.iter().any(|t| t.status == PlanStatus::Done));
476 assert!(d.tasks.iter().any(|t| t.status == PlanStatus::Undone));
477 }
478
479 #[test]
480 fn parse_aliases() {
481 assert_eq!(PlanStatus::parse("WIP"), Some(PlanStatus::InProgress));
482 assert_eq!(PlanStatus::parse("wontfix"), Some(PlanStatus::Cancelled));
483 assert_eq!(PlanStatus::parse("in-review"), Some(PlanStatus::Qa));
484 }
485}