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//! | `undone` | undone, reopen, reopened, blocked, stuck |
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    /// Reopened, blocked, or otherwise not done after work started.
43    Undone,
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::Undone => "undone",
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            "undone" | "reopen" | "reopened" | "blocked" | "stuck" | "on_hold" | "paused"
79            | "deferred" => Some(Self::Undone),
80            _ => None,
81        }
82    }
83}
84
85/// One extracted checklist / task line.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct PlanTask {
88    /// Canonical status.
89    pub status: PlanStatus,
90    /// Short task text (truncated).
91    pub text: String,
92}
93
94/// Aggregated densification for a plan note body (+ optional frontmatter status).
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct PlanStatusDigest {
97    /// Overall plan status when detectable.
98    pub overall: Option<PlanStatus>,
99    /// Counts by status (from tasks + section bulk).
100    pub counts: BTreeMap<String, usize>,
101    /// Individual tasks (capped).
102    pub tasks: Vec<PlanTask>,
103    /// Open = backlog + in_progress + qa + undone.
104    pub open: usize,
105    /// Done count.
106    pub done: usize,
107    /// Cancelled count.
108    pub cancelled: usize,
109}
110
111impl PlanStatusDigest {
112    /// Dense one-line summary for `Node.summary` / agent skimming.
113    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    /// Tokens appended to FTS body (information-dense, stopword-resistant).
125    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                // Repeat status tokens for soft ranking weight (bounded).
139                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    /// True when we found any signal (worth densifying).
155    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
160/// Build a digest from optional frontmatter status string + markdown body.
161pub 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    // ## Status section: first non-empty line / first status token
167    if overall.is_none() {
168        if let Some(sec) = section_body(body, "status") {
169            overall = first_status_in_text(&sec);
170        }
171    }
172
173    // Headings as section-scoped status for following checkboxes
174    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            // "## In Progress" / "## Done" / "## Backlog" / "## QA"
181            section_status = PlanStatus::parse(&h).or_else(|| {
182                // heading may be longer: "In Progress (this week)"
183                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        // Bullet with leading status tag: "- in_progress: foo" or "- [done] foo"
221        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 still no overall, infer from task mix.
236    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    // - [ ] text  / - [x] text / - [/] / - [~]
284    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    // [done] text
307    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    // status: text  or status - text
319    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    // whole first line
368    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
401/// Enrich FTS body + summary for plan notes when status signal exists.
402pub 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    // Keep summary bounded for SQLite / agent skims
418    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}