use std::collections::HashMap;
use async_trait::async_trait;
use sha2::{Digest, Sha256};
use crate::options::OptionSchema;
use crate::payload::{
Bar, BarsData, Body, EntriesData, Entry, MarkdownTextBlockData, Payload, TextBlockData,
TextData,
};
use crate::render::Shape;
use crate::samples;
use super::super::git::{open_repo, payload, repo_cache_key};
use super::super::{FetchContext, FetchError, Fetcher, Safety};
use super::scan::for_each_tracked_file;
const SHAPES: &[Shape] = &[
Shape::Text,
Shape::TextBlock,
Shape::MarkdownTextBlock,
Shape::Entries,
Shape::Bars,
];
const DEFAULT_MARKERS: &[&str] = &["TODO", "FIXME"];
const DEFAULT_LIMIT: usize = 20;
const INTERNAL_HIT_CAP: usize = 500;
const SNIPPET_CHARS: usize = 100;
const OPTION_SCHEMAS: &[OptionSchema] = &[
OptionSchema {
name: "markers",
type_hint: "array of strings",
required: false,
default: Some("[\"TODO\", \"FIXME\"]"),
description: "Markers grepped for. Each must appear as a whole-word ASCII token followed by `:` (or `(name):`) — `// TODO: refactor` matches, `## TODO` heading and `\"TODO\"` literals don't.",
},
OptionSchema {
name: "limit",
type_hint: "integer",
required: false,
default: Some("20"),
description: "Cap on rendered hits (TextBlock) or rows (Entries / Bars). Total count surfaced by `Text` is unaffected.",
},
];
pub struct CodeTodos;
#[derive(Debug, Default, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Options {
#[serde(default)]
markers: Option<Vec<String>>,
#[serde(default)]
limit: Option<usize>,
}
#[async_trait]
impl Fetcher for CodeTodos {
fn name(&self) -> &str {
"code_todos"
}
fn safety(&self) -> Safety {
Safety::Safe
}
fn description(&self) -> &'static str {
"Greps tracked source files in the discovered git repo for `TODO:` / `FIXME:` style markers (trailing colon required, vendored / generated dirs skipped). `Text` summarises `\"N TODOs in M files\"`, `TextBlock` and `MarkdownTextBlock` list `path:line: snippet` (markdown variant uses inline-code formatting), and `Entries` / `Bars` rank files by hit count."
}
fn refresh_interval(&self) -> u64 {
60 * 5
}
fn shapes(&self) -> &[Shape] {
SHAPES
}
fn default_shape(&self) -> Shape {
Shape::Text
}
fn cache_key(&self, ctx: &FetchContext) -> String {
let base = repo_cache_key(self.name(), ctx);
let opts = ctx
.options
.as_ref()
.map(toml::Value::to_string)
.unwrap_or_default();
if opts.is_empty() {
return base;
}
let digest = Sha256::digest(opts.as_bytes());
let hex: String = digest.iter().take(4).map(|b| format!("{b:02x}")).collect();
format!("{base}-{hex}")
}
fn option_schemas(&self) -> &[OptionSchema] {
OPTION_SCHEMAS
}
fn sample_body(&self, shape: Shape) -> Option<Body> {
Some(match shape {
Shape::Text => samples::text("12 TODOs in 5 files"),
Shape::TextBlock => samples::text_block(&[
"src/render/mod.rs:120: TODO: handle empty body",
"src/fetcher/git.rs:88: FIXME: don't panic on detached head",
"src/main.rs:14: TODO: load config from $HOME",
]),
Shape::MarkdownTextBlock => samples::markdown(
"- `src/render/mod.rs:120` — TODO: handle empty body\n- `src/fetcher/git.rs:88` — FIXME: don't panic on detached head\n- `src/main.rs:14` — TODO: load config from $HOME",
),
Shape::Entries => samples::entries(&[
("src/render/mod.rs", "5"),
("src/fetcher/git.rs", "3"),
("src/main.rs", "2"),
]),
Shape::Bars => samples::bars(&[
("src/render/mod.rs", 5),
("src/fetcher/git.rs", 3),
("src/main.rs", 2),
]),
_ => return None,
})
}
async fn fetch(&self, ctx: &FetchContext) -> Result<Payload, FetchError> {
let opts: Options = parse_options(ctx.options.as_ref())?;
let markers = resolve_markers(opts.markers.as_deref());
let limit = opts.limit.filter(|n| *n > 0).unwrap_or(DEFAULT_LIMIT);
let scan = scan_cwd(&markers)?;
Ok(payload(render_body(
scan,
ctx.shape.unwrap_or(Shape::Text),
limit,
)))
}
}
fn parse_options(raw: Option<&toml::Value>) -> Result<Options, FetchError> {
match raw {
None => Ok(Options::default()),
Some(value) => value
.clone()
.try_into::<Options>()
.map_err(|e| FetchError::Failed(format!("invalid options: {e}"))),
}
}
fn resolve_markers(opt: Option<&[String]>) -> Vec<String> {
let custom: Vec<String> = opt
.map(|v| {
v.iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default();
if custom.is_empty() {
DEFAULT_MARKERS.iter().map(|s| (*s).into()).collect()
} else {
custom
}
}
#[derive(Debug, Default)]
struct ScanResult {
hits: Vec<Hit>,
total: usize,
files_with_hits: usize,
file_counts: Vec<(String, u64)>,
}
#[derive(Debug)]
struct Hit {
path: String,
line: usize,
text: String,
}
fn scan_cwd(markers: &[String]) -> Result<ScanResult, FetchError> {
let repo = open_repo()?;
scan_repo(&repo, markers)
}
fn scan_repo(repo: &gix::Repository, markers: &[String]) -> Result<ScanResult, FetchError> {
let needles: Vec<&str> = markers.iter().map(String::as_str).collect();
let mut hits = Vec::new();
let mut counts: HashMap<String, u64> = HashMap::new();
let mut total = 0usize;
for_each_tracked_file(repo, |path_str, bytes| {
let Ok(text) = std::str::from_utf8(bytes) else {
return;
};
for (idx, line) in text.lines().enumerate() {
if let Some(snippet) = match_marker(line, &needles) {
total += 1;
*counts.entry(path_str.to_string()).or_insert(0) += 1;
if hits.len() < INTERNAL_HIT_CAP {
hits.push(Hit {
path: path_str.to_string(),
line: idx + 1,
text: snippet,
});
}
}
}
})?;
let files_with_hits = counts.len();
let mut file_counts: Vec<_> = counts.into_iter().collect();
file_counts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
Ok(ScanResult {
hits,
total,
files_with_hits,
file_counts,
})
}
fn match_marker(line: &str, markers: &[&str]) -> Option<String> {
let bytes = line.as_bytes();
let mut best: Option<usize> = None;
for needle in markers {
if let Some(pos) = find_marker(bytes, needle.as_bytes())
&& best.map(|b| pos < b).unwrap_or(true)
{
best = Some(pos);
}
}
best.map(|pos| truncate(line[pos..].trim_end(), SNIPPET_CHARS))
}
fn find_marker(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || needle.len() > haystack.len() {
return None;
}
(0..=haystack.len() - needle.len()).find(|&i| {
let end = i + needle.len();
haystack[i..end] == *needle
&& (i == 0 || !is_word_byte(haystack[i - 1]))
&& (end < haystack.len() && !is_word_byte(haystack[end]))
&& trailing_colon(haystack, end)
})
}
fn trailing_colon(bytes: &[u8], mut i: usize) -> bool {
if bytes.get(i) == Some(&b'(') {
let mut depth = 1;
i += 1;
while i < bytes.len() && depth > 0 {
match bytes[i] {
b'(' => depth += 1,
b')' => depth -= 1,
_ => {}
}
i += 1;
}
if depth != 0 {
return false;
}
}
bytes.get(i) == Some(&b':') && bytes.get(i + 1) != Some(&b':')
}
fn is_word_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
fn truncate(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
s.to_string()
} else {
let head: String = s.chars().take(max_chars).collect();
format!("{head}…")
}
}
fn render_body(scan: ScanResult, shape: Shape, limit: usize) -> Body {
match shape {
Shape::TextBlock => Body::TextBlock(TextBlockData {
lines: scan
.hits
.into_iter()
.take(limit)
.map(|h| format!("{}:{}: {}", h.path, h.line, h.text))
.collect(),
}),
Shape::MarkdownTextBlock => Body::MarkdownTextBlock(MarkdownTextBlockData {
value: scan
.hits
.into_iter()
.take(limit)
.map(|h| format!("- `{}:{}` — {}", h.path, h.line, h.text))
.collect::<Vec<_>>()
.join("\n"),
}),
Shape::Entries => Body::Entries(EntriesData {
items: scan
.file_counts
.into_iter()
.take(limit)
.map(|(p, c)| Entry {
key: p,
value: Some(c.to_string()),
status: None,
})
.collect(),
}),
Shape::Bars => Body::Bars(BarsData {
bars: scan
.file_counts
.into_iter()
.take(limit)
.map(|(p, c)| Bar {
label: p,
value: c,
value_label: None,
})
.collect(),
}),
_ => text_summary(scan.total, scan.files_with_hits),
}
}
fn text_summary(total: usize, files: usize) -> Body {
let value = if total == 0 {
String::new()
} else {
format!(
"{total} TODO{} in {files} file{}",
if total == 1 { "" } else { "s" },
if files == 1 { "" } else { "s" },
)
};
Body::Text(TextData { value })
}
#[cfg(test)]
mod tests {
use std::future::Future;
use super::super::super::git::test_support::{commit_touching, make_repo};
use super::*;
fn defaults() -> Vec<String> {
DEFAULT_MARKERS.iter().map(|s| (*s).into()).collect()
}
fn run_async<T>(future: impl Future<Output = T>) -> T {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(future)
}
#[test]
fn empty_repo_returns_empty_scan() {
let (_tmp, repo) = make_repo();
let scan = scan_repo(&repo, &defaults()).unwrap();
assert_eq!(scan.total, 0);
assert!(scan.hits.is_empty());
}
#[test]
fn picks_up_marker_in_tracked_file() {
let (_tmp, repo) = make_repo();
commit_touching(&repo, "src/main.rs", "// TODO: refactor this");
let scan = scan_repo(&repo, &defaults()).unwrap();
assert_eq!(scan.total, 1);
let hit = scan.hits.first().expect("one hit");
assert_eq!(hit.path, "src/main.rs");
assert!(hit.text.starts_with("TODO"));
}
#[test]
fn marker_without_trailing_colon_is_ignored() {
let (_tmp, repo) = make_repo();
commit_touching(
&repo,
"README.md",
"## TODO\nstill TODO somewhere\nlet x = \"TODO\";",
);
let scan = scan_repo(&repo, &defaults()).unwrap();
assert_eq!(scan.total, 0);
}
#[test]
fn author_tagged_marker_is_recognised() {
let (_tmp, repo) = make_repo();
commit_touching(&repo, "src/lib.rs", "// TODO(yuji): wire this up");
let scan = scan_repo(&repo, &defaults()).unwrap();
assert_eq!(scan.total, 1);
assert!(scan.hits[0].text.starts_with("TODO(yuji):"));
}
#[test]
fn untracked_file_is_ignored() {
let (_tmp, repo) = make_repo();
commit_touching(&repo, "tracked.rs", "nothing");
let workdir = repo.workdir().unwrap();
std::fs::write(workdir.join("untracked.rs"), "// TODO: should be skipped\n").unwrap();
let scan = scan_repo(&repo, &defaults()).unwrap();
assert_eq!(scan.total, 0);
}
#[test]
fn ranks_files_by_hit_count() {
let (_tmp, repo) = make_repo();
commit_touching(&repo, "many.rs", "// TODO: a\n// TODO: b\n// TODO: c");
commit_touching(&repo, "few.rs", "// TODO: only one");
let scan = scan_repo(&repo, &defaults()).unwrap();
let labels: Vec<_> = scan.file_counts.iter().map(|(p, _)| p.clone()).collect();
assert_eq!(labels, vec!["many.rs", "few.rs"]);
assert_eq!(scan.file_counts[0].1, 3);
assert_eq!(scan.files_with_hits, 2);
}
#[test]
fn matcher_requires_word_boundary_and_trailing_colon() {
assert!(match_marker("let TODOLIST = 1;", &["TODO"]).is_none());
assert!(match_marker("## TODO", &["TODO"]).is_none());
assert!(match_marker("/*TODO*/", &["TODO"]).is_none());
assert!(match_marker("let x = \"TODO\";", &["TODO"]).is_none());
assert!(match_marker("// TODO: x", &["TODO"]).is_some());
assert!(match_marker("# TODO: x", &["TODO"]).is_some());
assert!(match_marker("// TODO(yuji): x", &["TODO"]).is_some());
assert!(match_marker("std::TODO::foo()", &["TODO"]).is_none());
}
#[test]
fn matcher_picks_first_marker_in_line() {
let snippet = match_marker(" // TODO: real text ", &["TODO", "FIXME"]).unwrap();
assert_eq!(snippet, "TODO: real text");
}
#[test]
fn binary_files_are_skipped() {
let (tmp, repo) = make_repo();
let workdir = tmp.path();
std::fs::write(workdir.join("blob.bin"), b"\x00\x01\x02TODO: x\x00").unwrap();
std::process::Command::new("git")
.args(["add", "blob.bin"])
.current_dir(workdir)
.status()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-q", "-m", "bin"])
.current_dir(workdir)
.status()
.unwrap();
let scan = scan_repo(&repo, &defaults()).unwrap();
assert_eq!(scan.total, 0);
}
#[test]
fn vendored_directories_are_excluded_even_when_committed() {
let (_tmp, repo) = make_repo();
commit_touching(&repo, "src/main.rs", "// TODO: app marker");
commit_touching(&repo, "node_modules/foo.js", "// TODO: node marker");
commit_touching(&repo, "vendor/lib/x.go", "// TODO: vendor marker");
commit_touching(&repo, "src/dist/x.js", "// TODO: dist marker");
let scan = scan_repo(&repo, &defaults()).unwrap();
assert_eq!(scan.total, 1);
assert_eq!(scan.hits[0].path, "src/main.rs");
}
#[test]
fn custom_markers_supersede_defaults() {
let (_tmp, repo) = make_repo();
commit_touching(&repo, "x.rs", "// TODO: ignored\n// XXX: caught");
let markers = vec!["XXX".to_string()];
let scan = scan_repo(&repo, &markers).unwrap();
assert_eq!(scan.total, 1);
assert!(scan.hits[0].text.starts_with("XXX:"));
}
#[test]
fn text_summary_reports_total_and_file_count() {
let body = render_body(
ScanResult {
hits: vec![],
total: 3,
files_with_hits: 2,
file_counts: vec![("a".into(), 2), ("b".into(), 1)],
},
Shape::Text,
10,
);
match body {
Body::Text(d) => assert_eq!(d.value, "3 TODOs in 2 files"),
_ => panic!(),
}
}
#[test]
fn text_summary_handles_singular_grammar() {
let body = render_body(
ScanResult {
hits: vec![],
total: 1,
files_with_hits: 1,
file_counts: vec![("a".into(), 1)],
},
Shape::Text,
10,
);
match body {
Body::Text(d) => assert_eq!(d.value, "1 TODO in 1 file"),
_ => panic!(),
}
}
#[test]
fn text_summary_is_empty_when_no_hits() {
let body = render_body(ScanResult::default(), Shape::Text, 10);
match body {
Body::Text(d) => assert!(d.value.is_empty()),
_ => panic!(),
}
}
#[test]
fn text_block_formats_path_line_snippet() {
let body = render_body(
ScanResult {
hits: vec![Hit {
path: "src/main.rs".into(),
line: 12,
text: "TODO: x".into(),
}],
total: 1,
files_with_hits: 1,
file_counts: vec![("src/main.rs".into(), 1)],
},
Shape::TextBlock,
10,
);
match body {
Body::TextBlock(d) => assert_eq!(d.lines, vec!["src/main.rs:12: TODO: x"]),
_ => panic!(),
}
}
#[test]
fn markdown_text_block_emits_path_line_snippet_as_list() {
let body = render_body(
ScanResult {
hits: vec![Hit {
path: "src/main.rs".into(),
line: 12,
text: "TODO: x".into(),
}],
total: 1,
files_with_hits: 1,
file_counts: vec![("src/main.rs".into(), 1)],
},
Shape::MarkdownTextBlock,
10,
);
match body {
Body::MarkdownTextBlock(d) => {
assert!(d.value.contains("- `src/main.rs:12` — TODO: x"));
}
_ => panic!(),
}
}
#[test]
fn limit_caps_rendered_rows() {
let scan = ScanResult {
hits: vec![],
total: 5,
files_with_hits: 5,
file_counts: (0..5).map(|i| (format!("f{i}.rs"), 1)).collect(),
};
let body = render_body(scan, Shape::Entries, 2);
match body {
Body::Entries(d) => assert_eq!(d.items.len(), 2),
_ => panic!(),
}
}
#[test]
fn truncate_appends_ellipsis_when_over_max() {
let s = "x".repeat(120);
let out = truncate(&s, 100);
assert!(out.ends_with('…'));
assert_eq!(out.chars().count(), 101);
}
#[test]
fn cache_key_changes_with_options() {
let mut ctx = FetchContext {
widget_id: "w".into(),
..Default::default()
};
let key_default = CodeTodos.cache_key(&ctx);
ctx.options = Some(toml::from_str(r#"markers = ["TODO"]"#).unwrap());
let key_a = CodeTodos.cache_key(&ctx);
ctx.options = Some(toml::from_str(r#"markers = ["FIXME"]"#).unwrap());
let key_b = CodeTodos.cache_key(&ctx);
assert_ne!(key_default, key_a);
assert_ne!(key_a, key_b);
}
#[test]
fn cache_key_is_stable_for_equivalent_options() {
let _lock = crate::paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let mut ctx = FetchContext {
widget_id: "w".into(),
..Default::default()
};
ctx.options = Some(toml::from_str(r#"markers = ["TODO", "FIXME"]"#).unwrap());
let a = CodeTodos.cache_key(&ctx);
ctx.options = Some(toml::from_str(r#"markers = ["TODO", "FIXME"]"#).unwrap());
let b = CodeTodos.cache_key(&ctx);
assert_eq!(a, b);
}
#[test]
fn resolve_markers_falls_back_when_empty() {
let empty: Vec<String> = Vec::new();
let blanks: Vec<String> = vec![" ".into(), "".into()];
let custom: Vec<String> = vec!["XXX".into()];
assert_eq!(resolve_markers(None), defaults());
assert_eq!(resolve_markers(Some(&empty)), defaults());
assert_eq!(resolve_markers(Some(&blanks)), defaults());
assert_eq!(resolve_markers(Some(&custom)), vec!["XXX".to_string()]);
}
#[test]
fn fetcher_contract_exposes_catalog_surface_and_samples() {
assert_eq!(CodeTodos.name(), "code_todos");
assert_eq!(CodeTodos.safety(), Safety::Safe);
assert_eq!(CodeTodos.shapes(), SHAPES);
assert_eq!(CodeTodos.default_shape(), Shape::Text);
let schema_names: Vec<_> = CodeTodos.option_schemas().iter().map(|s| s.name).collect();
assert_eq!(schema_names, vec!["markers", "limit"]);
let Body::Text(text) = CodeTodos.sample_body(Shape::Text).unwrap() else {
panic!();
};
assert_eq!(text.value, "12 TODOs in 5 files");
let Body::TextBlock(block) = CodeTodos.sample_body(Shape::TextBlock).unwrap() else {
panic!();
};
assert_eq!(block.lines.len(), 3);
let Body::MarkdownTextBlock(markdown) =
CodeTodos.sample_body(Shape::MarkdownTextBlock).unwrap()
else {
panic!();
};
assert!(markdown.value.contains("TODO: handle empty body"));
let Body::Entries(entries) = CodeTodos.sample_body(Shape::Entries).unwrap() else {
panic!();
};
assert_eq!(entries.items.len(), 3);
let Body::Bars(bars) = CodeTodos.sample_body(Shape::Bars).unwrap() else {
panic!();
};
assert_eq!(bars.bars.len(), 3);
assert!(CodeTodos.sample_body(Shape::Calendar).is_none());
}
#[test]
fn matcher_handles_empty_needles_nested_tags_and_unclosed_tags() {
assert!(find_marker(b"TODO", b"").is_none());
assert!(find_marker(b"TODO", b"TOODO").is_none());
assert!(match_marker("// TODO(owner: x", &["TODO"]).is_none());
assert!(match_marker("// TODO(owner(team)): x", &["TODO"]).is_some());
}
#[test]
fn truncate_preserves_short_snippets() {
assert_eq!(truncate("TODO: short", SNIPPET_CHARS), "TODO: short");
}
#[test]
fn bars_shape_formats_ranked_counts() {
let body = render_body(
ScanResult {
hits: vec![],
total: 3,
files_with_hits: 3,
file_counts: vec![("b.rs".into(), 2), ("a.rs".into(), 1), ("c.rs".into(), 1)],
},
Shape::Bars,
2,
);
match body {
Body::Bars(d) => assert_eq!(
d.bars,
vec![
Bar {
label: "b.rs".into(),
value: 2,
value_label: None
},
Bar {
label: "a.rs".into(),
value: 1,
value_label: None
},
]
),
_ => panic!(),
}
}
#[test]
fn fetch_rejects_invalid_options() {
let ctx = FetchContext {
options: Some(toml::from_str("bogus = 1").unwrap()),
..Default::default()
};
let err = run_async(CodeTodos.fetch(&ctx)).unwrap_err();
assert!(matches!(err, FetchError::Failed(msg) if msg.contains("invalid options")));
}
#[test]
fn fetch_scans_cwd_repo_for_default_and_bars_shapes() {
let _lock = crate::paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let (_tmp, repo) = crate::fetcher::git::test_support::make_repo();
crate::fetcher::git::test_support::commit_touching(
&repo,
"src/lib.rs",
"// TODO: implement",
);
let workdir = repo.workdir().unwrap().to_path_buf();
let prev_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&workdir).unwrap();
let default_ctx = FetchContext::default();
let default_payload = run_async(CodeTodos.fetch(&default_ctx));
let bars_ctx = FetchContext {
shape: Some(Shape::Bars),
options: Some(toml::from_str("markers = [\" TODO \"]\nlimit = 0").unwrap()),
..Default::default()
};
let bars_payload = run_async(CodeTodos.fetch(&bars_ctx));
let expected_default = scan_repo(&repo, &defaults())
.map(|s| payload(render_body(s, Shape::Text, DEFAULT_LIMIT)));
let markers = vec!["TODO".to_string()];
let expected_bars =
scan_repo(&repo, &markers).map(|s| payload(render_body(s, Shape::Bars, DEFAULT_LIMIT)));
std::env::set_current_dir(prev_cwd).unwrap();
assert_eq!(default_payload.unwrap(), expected_default.unwrap());
assert_eq!(bars_payload.unwrap(), expected_bars.unwrap());
}
}