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 Blocked,
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::Blocked => "blocked",
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 "blocked" | "stuck" | "on_hold" | "paused" | "deferred" | "undone" | "reopen"
80 | "reopened" => Some(Self::Blocked),
81 _ => None,
82 }
83 }
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct PlanTask {
89 pub status: PlanStatus,
91 pub text: String,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct PlanStatusDigest {
98 pub overall: Option<PlanStatus>,
100 pub counts: BTreeMap<String, usize>,
102 pub tasks: Vec<PlanTask>,
104 pub open: usize,
106 pub done: usize,
108 pub cancelled: usize,
110}
111
112impl PlanStatusDigest {
113 pub fn summary_line(&self) -> String {
115 let overall = self
116 .overall
117 .map(|s| s.as_str())
118 .unwrap_or("unspecified");
119 format!(
120 "plan status={overall} · open {} · done {} · cancelled {}",
121 self.open, self.done, self.cancelled
122 )
123 }
124
125 pub fn fts_block(&self) -> String {
127 let mut parts = Vec::new();
128 parts.push("[plan-status]".to_string());
129 if let Some(o) = self.overall {
130 parts.push(format!("status:{}", o.as_str()));
131 parts.push(format!("plan_status:{}", o.as_str()));
132 }
133 parts.push(format!("plan_open:{}", self.open));
134 parts.push(format!("plan_done:{}", self.done));
135 parts.push(format!("plan_cancelled:{}", self.cancelled));
136 for (k, n) in &self.counts {
137 if *n > 0 {
138 parts.push(format!("count:{k}:{n}"));
139 for _ in 0..(*n).min(5) {
141 parts.push(format!("status:{k}"));
142 }
143 }
144 }
145 for t in self.tasks.iter().take(40) {
146 let slug = slug_token(&t.text);
147 if !slug.is_empty() {
148 parts.push(format!("task:{}:{}", t.status.as_str(), slug));
149 }
150 parts.push(format!("status:{}", t.status.as_str()));
151 }
152 parts.join(" ")
153 }
154
155 pub fn has_signal(&self) -> bool {
157 self.overall.is_some() || !self.tasks.is_empty() || self.counts.values().any(|n| *n > 0)
158 }
159}
160
161pub fn densify_plan(frontmatter_status: Option<&str>, body: &str) -> PlanStatusDigest {
163 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
164 let mut tasks: Vec<PlanTask> = Vec::new();
165 let mut overall = frontmatter_status.and_then(PlanStatus::parse);
166
167 if overall.is_none() {
169 if let Some(sec) = section_body(body, "status") {
170 overall = first_status_in_text(&sec);
171 }
172 }
173
174 let mut section_status: Option<PlanStatus> = None;
176 for line in body.lines() {
177 let t = line.trim();
178 if let Some(rest) = t.strip_prefix('#') {
179 let heading = rest.trim_start_matches('#').trim();
180 let h = heading.to_ascii_lowercase();
181 section_status = PlanStatus::parse(&h).or_else(|| {
183 h.split_whitespace()
185 .find_map(|w| PlanStatus::parse(w))
186 .or_else(|| {
187 for key in [
188 "in_progress",
189 "in progress",
190 "backlog",
191 "done",
192 "qa",
193 "review",
194 "cancelled",
195 "canceled",
196 "todo",
197 "blocked",
198 ] {
199 if h.contains(key) {
200 return PlanStatus::parse(key);
201 }
202 }
203 None
204 })
205 });
206 continue;
207 }
208
209 if let Some((st, text)) = parse_checkbox_line(t) {
210 let status = st.or(section_status).unwrap_or(PlanStatus::Backlog);
211 *counts.entry(status.as_str().to_string()).or_default() += 1;
212 if tasks.len() < 64 {
213 tasks.push(PlanTask {
214 status,
215 text: truncate(&text, 80),
216 });
217 }
218 continue;
219 }
220
221 if let Some(rest) = t.strip_prefix('-').or_else(|| t.strip_prefix('*')) {
223 let rest = rest.trim();
224 if let Some((st, text)) = parse_tagged_bullet(rest) {
225 *counts.entry(st.as_str().to_string()).or_default() += 1;
226 if tasks.len() < 64 {
227 tasks.push(PlanTask {
228 status: st,
229 text: truncate(&text, 80),
230 });
231 }
232 }
233 }
234 }
235
236 if overall.is_none() && !tasks.is_empty() {
238 overall = Some(infer_overall(&counts));
239 }
240
241 let done = *counts.get("done").unwrap_or(&0);
242 let cancelled = *counts.get("cancelled").unwrap_or(&0);
243 let open = counts
244 .iter()
245 .filter(|(k, _)| matches!(k.as_str(), "backlog" | "in_progress" | "qa" | "blocked"))
246 .map(|(_, n)| *n)
247 .sum();
248
249 PlanStatusDigest {
250 overall,
251 counts,
252 tasks,
253 open,
254 done,
255 cancelled,
256 }
257}
258
259fn infer_overall(counts: &BTreeMap<String, usize>) -> PlanStatus {
260 let get = |k: &str| *counts.get(k).unwrap_or(&0);
261 if get("in_progress") > 0 {
262 return PlanStatus::InProgress;
263 }
264 if get("qa") > 0 {
265 return PlanStatus::Qa;
266 }
267 if get("blocked") > 0 {
268 return PlanStatus::Blocked;
269 }
270 if get("backlog") > 0 {
271 return PlanStatus::Backlog;
272 }
273 if get("done") > 0 && get("cancelled") == 0 {
274 return PlanStatus::Done;
275 }
276 if get("cancelled") > 0 && get("done") == 0 && get("backlog") == 0 {
277 return PlanStatus::Cancelled;
278 }
279 PlanStatus::Backlog
280}
281
282fn parse_checkbox_line(t: &str) -> Option<(Option<PlanStatus>, String)> {
283 let t = t.strip_prefix('-').or_else(|| t.strip_prefix('*'))?.trim();
284 if !t.starts_with('[') {
286 return None;
287 }
288 let close = t.find(']')?;
289 let mark = t[1..close].trim();
290 let text = t[close + 1..].trim().to_string();
291 if text.is_empty() {
292 return None;
293 }
294 let st = match mark.to_ascii_lowercase().as_str() {
295 "" | " " => Some(PlanStatus::Backlog),
296 "x" => Some(PlanStatus::Done),
297 "/" | ">" | "o" => Some(PlanStatus::InProgress),
298 "~" | "-" => Some(PlanStatus::Cancelled),
299 "?" => Some(PlanStatus::Qa),
300 "!" => Some(PlanStatus::Blocked),
301 other => PlanStatus::parse(other),
302 };
303 Some((st, text))
304}
305
306fn parse_tagged_bullet(rest: &str) -> Option<(PlanStatus, String)> {
307 if let Some(inner) = rest.strip_prefix('[') {
309 if let Some(end) = inner.find(']') {
310 let tag = &inner[..end];
311 let text = inner[end + 1..].trim();
312 if let Some(st) = PlanStatus::parse(tag) {
313 if !text.is_empty() {
314 return Some((st, text.to_string()));
315 }
316 }
317 }
318 }
319 for sep in [':', '—', '-'] {
321 if let Some((left, right)) = rest.split_once(sep) {
322 if let Some(st) = PlanStatus::parse(left.trim()) {
323 let text = right.trim();
324 if !text.is_empty() && text.len() < 200 {
325 return Some((st, text.to_string()));
326 }
327 }
328 }
329 }
330 None
331}
332
333fn section_body(body: &str, name: &str) -> Option<String> {
334 let name = name.to_ascii_lowercase();
335 let mut lines = body.lines().peekable();
336 let mut out = String::new();
337 let mut in_sec = false;
338 while let Some(line) = lines.next() {
339 let t = line.trim();
340 if let Some(rest) = t.strip_prefix('#') {
341 let heading = rest.trim_start_matches('#').trim().to_ascii_lowercase();
342 if in_sec {
343 break;
344 }
345 if heading == name || heading.starts_with(&format!("{name} ")) {
346 in_sec = true;
347 continue;
348 }
349 }
350 if in_sec {
351 out.push_str(line);
352 out.push('\n');
353 }
354 }
355 if out.trim().is_empty() {
356 None
357 } else {
358 Some(out)
359 }
360}
361
362fn first_status_in_text(s: &str) -> Option<PlanStatus> {
363 for raw in s.split(|c: char| c.is_whitespace() || c == ',' || c == ';' || c == '|') {
364 if let Some(st) = PlanStatus::parse(raw) {
365 return Some(st);
366 }
367 }
368 s.lines()
370 .map(str::trim)
371 .find(|l| !l.is_empty() && !l.starts_with("<!--"))
372 .and_then(PlanStatus::parse)
373}
374
375fn slug_token(s: &str) -> String {
376 let mut out = String::new();
377 let mut prev = false;
378 for c in s.chars().take(48) {
379 if c.is_ascii_alphanumeric() {
380 out.push(c.to_ascii_lowercase());
381 prev = false;
382 } else if !prev && !out.is_empty() {
383 out.push('_');
384 prev = true;
385 }
386 }
387 while out.ends_with('_') {
388 out.pop();
389 }
390 out
391}
392
393fn truncate(s: &str, max: usize) -> String {
394 if s.chars().count() <= max {
395 s.to_string()
396 } else {
397 let t: String = s.chars().take(max.saturating_sub(1)).collect();
398 format!("{t}…")
399 }
400}
401
402pub fn enrich_plan_index_fields(
404 body: &str,
405 frontmatter_status: Option<&str>,
406 base_summary: &str,
407) -> (String, String) {
408 let dig = densify_plan(frontmatter_status, body);
409 if !dig.has_signal() {
410 return (body.to_string(), base_summary.to_string());
411 }
412 let fts = format!("{body}\n\n{}\n", dig.fts_block());
413 let summary = if base_summary.is_empty() {
414 dig.summary_line()
415 } else {
416 format!("{} · {}", dig.summary_line(), base_summary)
417 };
418 let summary = if summary.chars().count() > 240 {
420 truncate(&summary, 240)
421 } else {
422 summary
423 };
424 (fts, summary)
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 #[test]
432 fn parses_checkboxes_and_sections() {
433 let body = r#"
434## Status
435
436in_progress
437
438## Backlog
439
440- [ ] Design API
441- [ ] Write docs
442
443## In Progress
444
445- [/] Implement parser
446
447## Done
448
449- [x] Scaffold types
450
451## Cancelled
452
453- [~] Old approach
454"#;
455 let d = densify_plan(None, body);
456 assert_eq!(d.overall, Some(PlanStatus::InProgress));
457 assert_eq!(d.done, 1);
458 assert_eq!(d.cancelled, 1);
459 assert!(d.open >= 3);
460 let fts = d.fts_block();
461 assert!(fts.contains("status:in_progress"));
462 assert!(fts.contains("task:done:"));
463 assert!(fts.contains("plan_open:"));
464 }
465
466 #[test]
467 fn frontmatter_overall_wins() {
468 let d = densify_plan(Some("qa"), "- [ ] still open\n");
469 assert_eq!(d.overall, Some(PlanStatus::Qa));
470 }
471
472 #[test]
473 fn tagged_bullets() {
474 let body = "- done: ship 0.3\n- blocked: flaky CI\n";
475 let d = densify_plan(None, body);
476 assert!(d.tasks.iter().any(|t| t.status == PlanStatus::Done));
477 assert!(d.tasks.iter().any(|t| t.status == PlanStatus::Blocked));
478 }
479
480 #[test]
481 fn parse_aliases() {
482 assert_eq!(PlanStatus::parse("WIP"), Some(PlanStatus::InProgress));
483 assert_eq!(PlanStatus::parse("wontfix"), Some(PlanStatus::Cancelled));
484 assert_eq!(PlanStatus::parse("in-review"), Some(PlanStatus::Qa));
485 assert_eq!(PlanStatus::parse("blocked"), Some(PlanStatus::Blocked));
486 assert_eq!(PlanStatus::parse("undone"), Some(PlanStatus::Blocked));
488 }
489}