codescout 0.15.0

High-performance coding agent toolkit MCP server
Documentation
//! `plan` artifact preview: heading map + checklist progress.

use crate::librarian::catalog::artifact::ArtifactRow;
use crate::librarian::preview::headings;
use serde_json::{json, Value};

const MAX_HEADINGS: usize = 20;
const OPEN_NEXT_LIMIT: usize = 3;
const TASK_TEXT_MAX: usize = 100;

pub fn extract(_row: &ArtifactRow, body: &str) -> Value {
    let mut hs = headings::parse(body);
    hs.truncate(MAX_HEADINGS);

    let mut total = 0u64;
    let mut done = 0u64;
    let mut open_next: Vec<String> = Vec::new();
    let mut in_fence = false;

    for line in body.lines() {
        let trimmed_start = line.trim_start();
        if trimmed_start.starts_with("```") {
            in_fence = !in_fence;
            continue;
        }
        if in_fence {
            continue;
        }
        let Some(rest) = trimmed_start.strip_prefix("- [") else {
            continue;
        };
        let (marker, after) = match rest.split_once("] ") {
            Some(parts) => parts,
            None => continue,
        };
        let marker = marker.trim();
        match marker {
            " " | "" => {
                total += 1;
                if open_next.len() < OPEN_NEXT_LIMIT {
                    open_next.push(truncate_task_text(after));
                }
            }
            "x" | "X" => {
                total += 1;
                done += 1;
            }
            _ => {}
        }
    }

    let line_count = if body.is_empty() {
        0
    } else {
        body.lines().count()
    };

    json!({
        "shape": "plan",
        "headings": hs,
        "tasks": {
            "total": total,
            "done": done,
            "open_next": open_next,
        },
        "line_count": line_count,
    })
}

fn truncate_task_text(s: &str) -> String {
    let s = s.trim();
    if s.chars().count() <= TASK_TEXT_MAX {
        return s.to_string();
    }
    s.chars().take(TASK_TEXT_MAX).collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    fn mk_row() -> ArtifactRow {
        ArtifactRow {
            id: "p".into(),
            abs_path: std::path::PathBuf::from("/test/r/p.md"),
            kind: "plan".into(),
            status: "draft".into(),
            title: None,
            owners: vec![],
            tags: vec![],
            topic: None,
            time_scope: None,
            source: None,
            created_at: 0,
            updated_at: 0,
            file_mtime: 0,
            file_sha256: String::new(),
            confidence: 1.0,
        }
    }

    #[test]
    fn counts_tasks_total_and_done() {
        let body = "\
- [ ] First
- [x] Second
- [X] Third (upper X)
- [ ] Fourth
";
        let v = extract(&mk_row(), body);
        assert_eq!(v["shape"], "plan");
        assert_eq!(v["tasks"]["total"], 4);
        assert_eq!(v["tasks"]["done"], 2);
    }

    #[test]
    fn open_next_returns_first_three_unchecked() {
        let body = "\
- [ ] Alpha
- [x] Beta (done)
- [ ] Gamma
- [ ] Delta
- [ ] Epsilon
";
        let v = extract(&mk_row(), body);
        let open = v["tasks"]["open_next"].as_array().unwrap();
        assert_eq!(open.len(), 3);
        assert_eq!(open[0], "Alpha");
        assert_eq!(open[1], "Gamma");
        assert_eq!(open[2], "Delta");
    }

    #[test]
    fn empty_when_no_tasks() {
        let body = "Just prose, no checklist.\n";
        let v = extract(&mk_row(), body);
        assert_eq!(v["tasks"]["total"], 0);
        assert_eq!(v["tasks"]["done"], 0);
        assert_eq!(v["tasks"]["open_next"].as_array().unwrap().len(), 0);
    }

    #[test]
    fn ignores_task_syntax_inside_fenced_code() {
        let body = "\
- [ ] Real task
```
- [ ] Fake task inside code
- [x] Another fake
```
- [ ] Second real task
";
        let v = extract(&mk_row(), body);
        assert_eq!(v["tasks"]["total"], 2);
        assert_eq!(v["tasks"]["done"], 0);
    }

    #[test]
    fn task_text_truncated_to_limit() {
        let long_task = "x".repeat(150);
        let body = format!("- [ ] {long_task}\n");
        let v = extract(&mk_row(), &body);
        let text = v["tasks"]["open_next"][0].as_str().unwrap();
        assert!(text.chars().count() <= TASK_TEXT_MAX);
    }

    #[test]
    fn headings_included_and_capped() {
        let mut body = String::new();
        for i in 0..25 {
            body.push_str(&format!("## H{i}\n"));
        }
        let v = extract(&mk_row(), &body);
        assert_eq!(v["headings"].as_array().unwrap().len(), 20);
    }

    #[test]
    fn nested_indented_tasks_are_counted() {
        let body = "\
- [ ] Parent
  - [x] Nested done
  - [ ] Nested open
";
        let v = extract(&mk_row(), body);
        assert_eq!(v["tasks"]["total"], 3);
        assert_eq!(v["tasks"]["done"], 1);
    }
}