use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use chrono::Local;
use pulldown_cmark::{Options, Parser, html};
use walkdir::{DirEntry, WalkDir};
const TASK_MARKER: &str = "<!-- Task -->";
const OUTPUT_FILE: &str = "tasks.html";
const EXCLUDED_DIRS: [&str; 3] = [".git", "target", "node_modules"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Task {
pub title: String,
pub header_level: u8,
pub body_markdown: String,
pub body_html: String,
pub source_path: PathBuf,
pub source_dir: PathBuf,
pub order_in_file: usize,
pub anchor_slug: String,
}
#[derive(Debug, Default)]
pub struct ScanReport {
pub tasks: Vec<Task>,
pub markdown_files_scanned: usize,
pub warnings: Vec<String>,
}
pub fn output_file_name() -> &'static str {
OUTPUT_FILE
}
pub fn scan_directory(root: &Path) -> ScanReport {
let mut report = ScanReport::default();
let walker = WalkDir::new(root)
.follow_links(false)
.into_iter()
.filter_entry(|entry| !is_excluded_dir(entry));
for entry in walker {
match entry {
Ok(entry) => {
if !entry.file_type().is_file() {
continue;
}
if !is_markdown_file(&entry) {
continue;
}
report.markdown_files_scanned += 1;
let path = entry.path();
let source_path = relativize(path, root);
match fs::read_to_string(path) {
Ok(content) => {
let mut tasks = extract_tasks(&content, &source_path);
report.tasks.append(&mut tasks);
}
Err(err) => {
report.warnings.push(format!(
"Failed to read {}: {}",
source_path.display(),
err
));
}
}
}
Err(err) => report.warnings.push(format!("Walk error: {err}")),
}
}
report.tasks.sort_by(|a, b| {
a.source_path
.cmp(&b.source_path)
.then(a.order_in_file.cmp(&b.order_in_file))
});
report
}
pub fn render_html(report: &ScanReport) -> String {
let generated_at = Local::now().format("%Y-%m-%d %H:%M:%S %Z").to_string();
let mut groups: BTreeMap<&Path, Vec<&Task>> = BTreeMap::new();
for task in &report.tasks {
groups
.entry(task.source_path.as_path())
.or_default()
.push(task);
}
let mut sections = String::new();
if groups.is_empty() {
sections.push_str(
"<section class=\"empty\" aria-live=\"polite\"><h2>No tasks found</h2><p>No markdown sections were marked with <code><!-- Task --></code>.</p></section>",
);
} else {
for (path, tasks) in groups {
sections.push_str("<section class=\"file-group\">");
sections.push_str(&format!(
"<h2 class=\"file-heading\">{}</h2>",
escape_html(&path.display().to_string())
));
sections.push_str("<div class=\"task-grid\">");
for task in tasks {
sections.push_str(&format!(
"<details class=\"task-card\" id=\"{}\"><summary><span class=\"task-title\">{}</span><span class=\"chip\">H{}</span></summary><div class=\"task-body\">{}</div></details>",
escape_html(&task.anchor_slug),
escape_html(&task.title),
task.header_level,
task.body_html
));
}
sections.push_str("</div></section>");
}
}
let collapse_disabled = if report.tasks.is_empty() {
"disabled"
} else {
""
};
format!(
"<!DOCTYPE html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\">
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
<title>Task Catalog</title>
<style>
:root {{
--bg: #0c111b;
--bg-accent: #113051;
--bg-accent-2: #153326;
--card: #141b27;
--hero-start: #1b4f8c;
--hero-end: #0f6f78;
--text: #e8ecf4;
--muted: #a9b5c9;
--line: #243247;
--primary: #73b3ff;
--control-bg: rgba(255, 255, 255, 0.12);
--control-border: rgba(255, 255, 255, 0.26);
--control-text: #f4f7ff;
--code-bg: #0a0f18;
--code-text: #f8fafc;
--empty-border: #344056;
--shadow: 0 12px 28px rgba(2, 6, 14, 0.45);
--radius: 16px;
}}
body[data-theme=\"light\"] {{
--bg: #f4f7fb;
--bg-accent: #e8f0ff;
--bg-accent-2: #dff4ec;
--card: #ffffff;
--hero-start: #0f5cc0;
--hero-end: #087f8c;
--text: #182230;
--muted: #536074;
--line: #d9e0ea;
--primary: #0f5cc0;
--control-bg: rgba(255, 255, 255, 0.18);
--control-border: rgba(255, 255, 255, 0.35);
--control-text: #ffffff;
--code-bg: #101827;
--code-text: #f8fafc;
--empty-border: #d9e0ea;
--shadow: 0 8px 24px rgba(15, 31, 56, 0.08);
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif;
color: var(--text);
background:
radial-gradient(circle at 15% 10%, var(--bg-accent), transparent 32%),
radial-gradient(circle at 88% 22%, var(--bg-accent-2), transparent 28%),
var(--bg);
line-height: 1.6;
}}
.page {{ max-width: 1080px; margin: 0 auto; padding: 28px 18px 56px; }}
.hero {{
background: linear-gradient(140deg, var(--hero-start), var(--hero-end));
color: #fff;
border-radius: calc(var(--radius) + 4px);
padding: 28px;
box-shadow: var(--shadow);
margin-bottom: 24px;
}}
.hero h1 {{ margin: 0 0 8px; font-size: clamp(1.7rem, 3vw, 2.4rem); }}
.hero p {{ margin: 0; opacity: 0.92; }}
.summary {{ display: flex; flex-wrap: wrap; gap: 10px; margin-top: 16px; }}
.summary .pill {{
background: var(--control-bg);
border: 1px solid var(--control-border);
border-radius: 999px;
padding: 6px 12px;
font-size: 0.92rem;
}}
.controls {{ margin-top: 14px; display: flex; flex-wrap: wrap; gap: 10px; }}
.control-btn {{
appearance: none;
border: 1px solid var(--control-border);
background: var(--control-bg);
color: var(--control-text);
border-radius: 999px;
padding: 7px 14px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
}}
.control-btn:disabled {{ opacity: 0.5; cursor: not-allowed; }}
.control-btn:focus-visible {{ outline: 2px solid #fff; outline-offset: 2px; }}
.file-group {{
margin-top: 24px;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 16px;
background: color-mix(in oklab, var(--card) 82%, transparent);
}}
.file-heading {{ margin: 0 0 12px; font-size: 1.1rem; color: var(--muted); }}
.task-grid {{
display: grid;
gap: 14px;
grid-template-columns: 1fr;
}}
.task-card {{
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 0;
transition: transform 120ms ease, box-shadow 120ms ease;
overflow: hidden;
}}
.task-card:hover {{ transform: translateY(-2px); }}
.task-card > summary {{
list-style: none;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 14px 16px;
font-weight: 650;
}}
.task-card > summary::-webkit-details-marker {{ display: none; }}
.task-card > summary::after {{
content: 'â–¸';
color: var(--muted);
margin-left: auto;
transition: transform 120ms ease;
}}
.task-card[open] > summary::after {{ transform: rotate(90deg); }}
.task-title {{ font-size: 1.02rem; }}
.chip {{
flex-shrink: 0;
font-size: 0.78rem;
color: var(--primary);
background: color-mix(in oklab, var(--primary) 18%, transparent);
border: 1px solid color-mix(in oklab, var(--primary) 42%, var(--line));
border-radius: 999px;
padding: 2px 8px;
}}
.task-body {{
overflow-wrap: anywhere;
border-top: 1px solid var(--line);
padding: 14px 16px 16px;
}}
.task-body :is(h1, h2, h3, h4, h5, h6) {{ margin-top: 1rem; margin-bottom: 0.5rem; }}
.task-body pre {{
background: var(--code-bg);
color: var(--code-text);
border-radius: 10px;
padding: 10px;
overflow-x: auto;
}}
.task-body code {{ font-family: 'JetBrains Mono', 'Cascadia Code', monospace; font-size: 0.92em; }}
.task-body a {{ color: var(--primary); }}
.empty {{
background: var(--card);
border: 1px dashed var(--empty-border);
border-radius: var(--radius);
padding: 20px;
}}
.empty h2 {{ margin-top: 0; }}
@media (max-width: 640px) {{
.hero {{ padding: 20px; }}
}}
</style>
</head>
<body data-theme=\"dark\">
<main class=\"page\">
<section class=\"hero\">
<h1>Task Catalog</h1>
<p>Collected task sections from markdown files marked with <code><!-- Task --></code>.</p>
<div class=\"summary\">
<span class=\"pill\">Tasks: {}</span>
<span class=\"pill\">Markdown files scanned: {}</span>
<span class=\"pill\">Generated: {}</span>
</div>
<div class=\"controls\">
<button id=\"toggle-collapse\" type=\"button\" class=\"control-btn\" {}>Expand all</button>
<button id=\"toggle-theme\" type=\"button\" class=\"control-btn\" aria-pressed=\"true\">Light mode</button>
</div>
</section>
{}
</main>
<script>
(function () {{
const body = document.body;
const cards = Array.from(document.querySelectorAll('.task-card'));
const collapseBtn = document.getElementById('toggle-collapse');
const themeBtn = document.getElementById('toggle-theme');
function applyTheme(theme) {{
body.setAttribute('data-theme', theme);
if (!themeBtn) {{
return;
}}
const dark = theme === 'dark';
themeBtn.textContent = dark ? 'Light mode' : 'Dark mode';
themeBtn.setAttribute('aria-pressed', String(dark));
}}
function updateCollapseButton() {{
if (!collapseBtn) {{
return;
}}
const anyClosed = cards.some((card) => !card.open);
collapseBtn.textContent = anyClosed ? 'Expand all' : 'Collapse all';
collapseBtn.setAttribute('aria-pressed', String(!anyClosed));
}}
const savedTheme = window.localStorage.getItem('taskc-theme');
applyTheme(savedTheme === 'light' ? 'light' : 'dark');
if (themeBtn) {{
themeBtn.addEventListener('click', function () {{
const current = body.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
applyTheme(next);
window.localStorage.setItem('taskc-theme', next);
}});
}}
if (collapseBtn) {{
collapseBtn.addEventListener('click', function () {{
const anyClosed = cards.some((card) => !card.open);
for (const card of cards) {{
card.open = anyClosed;
}}
updateCollapseButton();
}});
}}
for (const card of cards) {{
card.addEventListener('toggle', updateCollapseButton);
}}
updateCollapseButton();
}})();
</script>
</body>
</html>",
report.tasks.len(),
report.markdown_files_scanned,
escape_html(&generated_at),
collapse_disabled,
sections
)
}
pub fn write_output(root: &Path, html: &str) -> anyhow::Result<PathBuf> {
let output_path = root.join(OUTPUT_FILE);
fs::write(&output_path, html)?;
Ok(output_path)
}
fn extract_tasks(content: &str, source_path: &Path) -> Vec<Task> {
let lines: Vec<&str> = content.lines().collect();
let mut tasks = Vec::new();
let mut index = 0;
for (i, line) in lines.iter().enumerate() {
if line.trim() != TASK_MARKER {
continue;
}
let mut header_pos = i + 1;
while header_pos < lines.len() && lines[header_pos].trim().is_empty() {
header_pos += 1;
}
if header_pos >= lines.len() {
continue;
}
let Some((header_level, title)) = parse_atx_header(lines[header_pos]) else {
continue;
};
let mut body_end = lines.len();
let mut k = header_pos + 1;
while k < lines.len() {
if let Some((level, _)) = parse_atx_header(lines[k])
&& level <= header_level
{
body_end = k;
break;
}
k += 1;
}
index += 1;
let body_markdown = lines[header_pos + 1..body_end].join("\n");
let body_html = markdown_to_html(&body_markdown);
let source_dir = source_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
tasks.push(Task {
title: title.to_string(),
header_level,
body_markdown,
body_html,
source_path: source_path.to_path_buf(),
source_dir,
order_in_file: index,
anchor_slug: make_anchor_slug(title, index),
});
}
tasks
}
fn parse_atx_header(line: &str) -> Option<(u8, &str)> {
let trimmed = line.trim_start();
if !trimmed.starts_with('#') {
return None;
}
let mut level = 0u8;
for ch in trimmed.chars() {
if ch == '#' {
level += 1;
if level > 6 {
return None;
}
} else {
break;
}
}
let rest = trimmed[level as usize..].trim_start();
if rest.is_empty() {
return None;
}
Some((level, rest.trim_end_matches('#').trim_end()))
}
fn markdown_to_html(markdown: &str) -> String {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_TASKLISTS);
let parser = Parser::new_ext(markdown, options);
let mut output = String::new();
html::push_html(&mut output, parser);
output
}
fn is_excluded_dir(entry: &DirEntry) -> bool {
if entry.depth() == 0 || !entry.file_type().is_dir() {
return false;
}
let name = entry.file_name().to_string_lossy();
name.starts_with('.') || EXCLUDED_DIRS.iter().any(|dir| *dir == name)
}
fn is_markdown_file(entry: &DirEntry) -> bool {
entry
.path()
.extension()
.map(|ext| ext == "md")
.unwrap_or(false)
}
fn relativize(path: &Path, root: &Path) -> PathBuf {
path.strip_prefix(root)
.map(Path::to_path_buf)
.unwrap_or_else(|_| path.to_path_buf())
}
fn make_anchor_slug(title: &str, index: usize) -> String {
let mut slug = String::new();
let mut last_dash = false;
for ch in title.chars().flat_map(char::to_lowercase) {
if ch.is_ascii_alphanumeric() {
slug.push(ch);
last_dash = false;
} else if !last_dash {
slug.push('-');
last_dash = true;
}
}
let slug = slug.trim_matches('-');
if slug.is_empty() {
format!("task-{index}")
} else {
format!("{slug}-{index}")
}
}
fn escape_html(input: &str) -> String {
let mut escaped = String::with_capacity(input.len());
for ch in input.chars() {
match ch {
'&' => escaped.push_str("&"),
'<' => escaped.push_str("<"),
'>' => escaped.push_str(">"),
'"' => escaped.push_str("""),
'\'' => escaped.push_str("'"),
_ => escaped.push(ch),
}
}
escaped
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn parses_marker_followed_by_header() {
let content = "<!-- Task -->\n## Build parser\nDo this.\n";
let tasks = extract_tasks(content, Path::new("README.md"));
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].title, "Build parser");
assert_eq!(tasks[0].header_level, 2);
assert_eq!(tasks[0].body_markdown, "Do this.");
}
#[test]
fn ignores_marker_without_header() {
let content = "<!-- Task -->\nnot a header\n";
let tasks = extract_tasks(content, Path::new("README.md"));
assert!(tasks.is_empty());
}
#[test]
fn respects_same_or_higher_header_boundary() {
let content = "<!-- Task -->\n## Parent\nline a\n### Child\nline b\n## Next\nend\n";
let tasks = extract_tasks(content, Path::new("README.md"));
assert_eq!(tasks.len(), 1);
assert!(tasks[0].body_markdown.contains("### Child"));
assert!(!tasks[0].body_markdown.contains("## Next"));
}
#[test]
fn supports_multiple_tasks_in_one_file() {
let content = "<!-- Task -->\n# One\na\n<!-- Task -->\n## Two\nb\n";
let tasks = extract_tasks(content, Path::new("README.md"));
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0].order_in_file, 1);
assert_eq!(tasks[1].order_in_file, 2);
assert_eq!(tasks[0].anchor_slug, "one-1");
assert_eq!(tasks[1].anchor_slug, "two-2");
}
#[test]
fn scan_skips_excluded_directories() {
let tmp = TempDir::new().expect("temp dir");
fs::write(tmp.path().join("README.md"), "<!-- Task -->\n# Keep\nA\n")
.expect("write root readme");
fs::create_dir_all(tmp.path().join("target/nested")).expect("mkdir target");
fs::write(
tmp.path().join("target/nested/README.md"),
"<!-- Task -->\n# Skip\nB\n",
)
.expect("write target readme");
let report = scan_directory(tmp.path());
assert_eq!(report.markdown_files_scanned, 1);
assert_eq!(report.tasks.len(), 1);
assert_eq!(report.tasks[0].title, "Keep");
}
#[test]
fn integration_style_scan_and_render() {
let tmp = TempDir::new().expect("temp dir");
fs::create_dir_all(tmp.path().join("docs")).expect("mkdir docs");
fs::create_dir_all(tmp.path().join("node_modules/x")).expect("mkdir node_modules");
fs::write(
tmp.path().join("README.md"),
"Intro\n<!-- Task -->\n# Root task\n- item\n",
)
.expect("write root readme");
fs::write(
tmp.path().join("docs/README.md"),
"<!-- Task -->\n## Docs task\n`code`\n",
)
.expect("write docs readme");
fs::write(
tmp.path().join("node_modules/x/README.md"),
"<!-- Task -->\n# Ignored\n",
)
.expect("write ignored readme");
let report = scan_directory(tmp.path());
assert_eq!(report.markdown_files_scanned, 2);
assert_eq!(report.tasks.len(), 2);
let html = render_html(&report);
assert!(html.contains("Task Catalog"));
assert!(html.contains("Root task"));
assert!(html.contains("Docs task"));
assert!(!html.contains("Ignored"));
}
}