Skip to main content

rustbrain_core/
plan_status.rs

1//! Algorithmic **plan / task status** densification for FTS and agent summaries.
2//!
3//! Plans are ordinary Markdown. Rustbrain does **not** require a kanban product.
4//! On index, it extracts a compact status vocabulary so agents can query and pack
5//! status without reading whole roadmaps:
6//!
7//! | Canonical | Accepted surface forms |
8//! |-----------|------------------------|
9//! | `backlog` | backlog, todo, open, pending, queued |
10//! | `in_progress` | in_progress, in-progress, inprogress, wip, doing, active |
11//! | `qa` | qa, review, in-review, testing, verification |
12//! | `done` | done, complete, completed, finished, closed, shipped (task-level) |
13//! | `cancelled` | cancelled, canceled, wontfix, dropped, obsolete |
14//! | `blocked` | blocked, stuck, on_hold, paused, deferred, undone, reopen, reopened |
15//!
16//! Sources (first wins for *overall* status when explicit):
17//! 1. YAML frontmatter `status:` / `state:`
18//! 2. `## Status` section body (first status token)
19//! 3. Section headings (`## In Progress`, `## Done`, …)
20//! 4. Checkbox lines (`- [ ]`, `- [x]`, `- [/]`, `- [~]`)
21//!
22//! Dense FTS tokens use stable prefixes (`status:…`, `task:status:slug`) so they
23//! survive stopword stripping and stay machine-readable.
24
25use serde::{Deserialize, Serialize};
26use std::collections::BTreeMap;
27
28/// Canonical task / plan lifecycle status.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
30#[serde(rename_all = "snake_case")]
31pub enum PlanStatus {
32    /// Not started / queue.
33    Backlog,
34    /// Actively being worked.
35    InProgress,
36    /// Review / QA / testing.
37    Qa,
38    /// Finished successfully.
39    Done,
40    /// Explicitly abandoned.
41    Cancelled,
42    /// Blocked, stuck, on hold, or reopened (not actively progressing).
43    Blocked,
44}
45
46impl PlanStatus {
47    /// Stable token used in FTS and summaries.
48    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    /// Parse free-form status text into a canonical value.
60    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            // Canonical `blocked`; `undone` kept as alias for older notes.
79            "blocked" | "stuck" | "on_hold" | "paused" | "deferred" | "undone" | "reopen"
80            | "reopened" => Some(Self::Blocked),
81            _ => None,
82        }
83    }
84}
85
86/// One extracted checklist / task line.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct PlanTask {
89    /// Canonical status.
90    pub status: PlanStatus,
91    /// Short task text (truncated).
92    pub text: String,
93}
94
95/// Aggregated densification for a plan note body (+ optional frontmatter status).
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct PlanStatusDigest {
98    /// Overall plan status when detectable.
99    pub overall: Option<PlanStatus>,
100    /// Counts by status (from tasks + section bulk).
101    pub counts: BTreeMap<String, usize>,
102    /// Individual tasks (capped).
103    pub tasks: Vec<PlanTask>,
104    /// Open = backlog + in_progress + qa + blocked.
105    pub open: usize,
106    /// Done count.
107    pub done: usize,
108    /// Cancelled count.
109    pub cancelled: usize,
110}
111
112impl PlanStatusDigest {
113    /// Dense one-line summary for `Node.summary` / agent skimming.
114    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    /// Tokens appended to FTS body (information-dense, stopword-resistant).
126    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                // Repeat status tokens for soft ranking weight (bounded).
140                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    /// True when we found any signal (worth densifying).
156    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
161/// Build a digest from optional frontmatter status string + markdown body.
162pub 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    // ## Status section: first non-empty line / first status token
168    if overall.is_none() {
169        if let Some(sec) = section_body(body, "status") {
170            overall = first_status_in_text(&sec);
171        }
172    }
173
174    // Headings as section-scoped status for following checkboxes
175    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            // "## In Progress" / "## Done" / "## Backlog" / "## QA"
182            section_status = PlanStatus::parse(&h).or_else(|| {
183                // heading may be longer: "In Progress (this week)"
184                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        // Bullet with leading status tag: "- in_progress: foo" or "- [done] foo"
222        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 still no overall, infer from task mix.
237    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    // - [ ] text  / - [x] text / - [/] / - [~]
285    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    // [done] text
308    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    // status: text  or status - text
320    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    // whole first line
369    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
402/// Enrich FTS body + summary for plan notes when status signal exists.
403pub 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    // Keep summary bounded for SQLite / agent skims
419    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        // Legacy alias
487        assert_eq!(PlanStatus::parse("undone"), Some(PlanStatus::Blocked));
488    }
489}