#![allow(clippy::missing_errors_doc)]
use anyhow::{Context, Result, bail};
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use crate::frontmatter;
use crate::heading::parse_atx_heading;
use crate::scanner::{self, FileVisitor, ScanAction};
use crate::types::{TaskCount, TaskInfo};
pub fn detect_task_checkbox(line: &str) -> Option<(char, bool)> {
let trimmed = line.trim_start();
let rest = trimmed
.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* "))
.or_else(|| trimmed.strip_prefix("+ "))?;
let inner = rest.strip_prefix('[')?;
let mut chars = inner.chars();
let marker = chars.next()?;
let close = chars.next()?;
if close != ']' {
return None;
}
let done = marker == 'x' || marker == 'X';
Some((marker, done))
}
fn extract_task_text(line: &str) -> &str {
let trimmed = line.trim_start();
let rest = trimmed
.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* "))
.or_else(|| trimmed.strip_prefix("+ "))
.unwrap_or("");
if rest.len() < 3 {
return "";
}
let Some(after_bracket) = rest.strip_prefix('[') else {
return "";
};
let mut chars = after_bracket.char_indices();
let _ = chars.next(); let Some((close_idx, close_char)) = chars.next() else {
return "";
};
if close_char != ']' {
return "";
}
let after_close = &after_bracket[close_idx + 1..];
after_close.strip_prefix(' ').unwrap_or(after_close)
}
#[cfg(test)]
pub(crate) struct TaskCollector {
tasks: Vec<TaskInfo>,
}
#[cfg(test)]
impl Default for TaskCollector {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
impl TaskCollector {
pub(crate) fn new() -> Self {
Self { tasks: Vec::new() }
}
pub(crate) fn into_tasks(self) -> Vec<TaskInfo> {
self.tasks
}
}
#[cfg(test)]
impl FileVisitor for TaskCollector {
fn on_body_line(&mut self, raw: &str, _cleaned: &str, line_num: usize) -> ScanAction {
if let Some((status_char, done)) = detect_task_checkbox(raw) {
self.tasks.push(TaskInfo {
line: line_num,
status: status_char,
text: extract_task_text(raw).to_owned(),
done,
});
}
ScanAction::Continue
}
}
pub struct TaskCounter {
total: usize,
done: usize,
}
impl Default for TaskCounter {
fn default() -> Self {
Self::new()
}
}
impl TaskCounter {
#[must_use]
pub fn new() -> Self {
Self { total: 0, done: 0 }
}
#[must_use]
pub fn into_count(self) -> TaskCount {
TaskCount {
total: self.total,
done: self.done,
}
}
}
impl FileVisitor for TaskCounter {
fn on_body_line(&mut self, raw: &str, _cleaned: &str, _line_num: usize) -> ScanAction {
if let Some((_status, is_done)) = detect_task_checkbox(raw) {
self.total += 1;
if is_done {
self.done += 1;
}
}
ScanAction::Continue
}
}
pub(crate) struct TaskExtractor {
current_section: String,
tasks: Vec<crate::types::FindTaskInfo>,
}
impl Default for TaskExtractor {
fn default() -> Self {
Self::new()
}
}
impl TaskExtractor {
#[must_use]
pub fn new() -> Self {
Self {
current_section: String::new(),
tasks: Vec::new(),
}
}
#[must_use]
pub fn into_tasks(self) -> Vec<crate::types::FindTaskInfo> {
self.tasks
}
}
impl FileVisitor for TaskExtractor {
fn on_body_line(&mut self, raw: &str, _cleaned: &str, line_num: usize) -> ScanAction {
if raw.starts_with('#')
&& let Some((level, text)) = parse_atx_heading(raw)
{
self.current_section = format!("{} {}", "#".repeat(level as usize), text);
}
if let Some((status_char, done)) = detect_task_checkbox(raw) {
self.tasks.push(crate::types::FindTaskInfo {
line: line_num,
section: self.current_section.clone(),
status: status_char,
text: extract_task_text(raw).to_owned(),
done,
});
}
ScanAction::Continue
}
}
#[cfg(test)]
pub(crate) fn extract_tasks(path: &Path) -> Result<Vec<TaskInfo>> {
let mut collector = TaskCollector::new();
scanner::scan_file_multi(path, &mut [&mut collector])?;
Ok(collector.into_tasks())
}
#[cfg(test)]
pub(crate) fn count_tasks(path: &Path) -> Result<TaskCount> {
let mut counter = TaskCounter::new();
scanner::scan_file_multi(path, &mut [&mut counter])?;
Ok(counter.into_count())
}
pub fn read_task(path: &Path, line: usize) -> Result<Option<TaskInfo>> {
let file = File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
let mut reader = BufReader::new(file);
let mut line_num: usize = 0;
let mut buf = String::new();
buf.clear();
let n = reader
.read_line(&mut buf)
.context("failed to read first line")?;
if n == 0 {
return Ok(None);
}
line_num += 1;
let first_trimmed = buf.trim_end_matches(['\n', '\r']).to_owned();
let fm_lines = frontmatter::skip_frontmatter(&mut reader, &first_trimmed)?;
if fm_lines > 0 {
line_num = fm_lines;
if line <= fm_lines {
return Ok(None);
}
}
let mut fence = scanner::FenceTracker::new();
let mut in_comment = false;
if fm_lines == 0 {
if line_num == line {
if fence.process_line(&first_trimmed) {
} else if scanner::is_comment_fence(&first_trimmed) {
in_comment = true;
} else {
let info =
detect_task_checkbox(&first_trimmed).map(|(status_char, done)| TaskInfo {
line: line_num,
status: status_char,
text: extract_task_text(&first_trimmed).to_owned(),
done,
});
return Ok(info);
}
} else if fence.process_line(&first_trimmed) {
} else if scanner::is_comment_fence(&first_trimmed) {
in_comment = true;
}
}
loop {
buf.clear();
let n = reader.read_line(&mut buf).context("failed to read line")?;
if n == 0 {
break;
}
line_num += 1;
let line_str = buf.trim_end_matches(['\n', '\r']);
if fence.in_fence() {
fence.process_line(line_str);
if line_num == line {
return Ok(None); }
if line_num > line {
break;
}
continue;
}
if in_comment {
if scanner::is_comment_fence(line_str) {
in_comment = false;
}
if line_num == line {
return Ok(None); }
if line_num > line {
break;
}
continue;
}
if fence.process_line(line_str) {
if line_num == line {
return Ok(None); }
if line_num > line {
break;
}
continue;
}
if scanner::is_comment_fence(line_str) {
if line_num == line {
return Ok(None); }
in_comment = true;
if line_num > line {
break;
}
continue;
}
if line_num == line {
let info = detect_task_checkbox(line_str).map(|(status_char, done)| TaskInfo {
line: line_num,
status: status_char,
text: extract_task_text(line_str).to_owned(),
done,
});
return Ok(info);
}
if line_num > line {
break;
}
}
Ok(None)
}
fn mutate_task_line(line: &str, line_num: usize, new_status: char) -> Option<(String, TaskInfo)> {
let trimmed = line.trim_start();
let leading = line.len() - trimmed.len();
let marker_len =
if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
2usize
} else {
return None;
};
let after_marker = &trimmed[marker_len..];
if !after_marker.starts_with('[') {
return None;
}
let inner = &after_marker[1..]; let mut chars = inner.char_indices();
let (_, _status_char) = chars.next()?; let (close_idx, close_char) = chars.next()?;
if close_char != ']' {
return None;
}
let bracket_open_pos = leading + marker_len; let status_pos = bracket_open_pos + 1; let close_pos = bracket_open_pos + 1 + close_idx;
let status_byte_len = inner[..close_idx].len(); let mut modified = line.to_owned();
modified.replace_range(
status_pos..status_pos + status_byte_len,
&new_status.to_string(),
);
let done = new_status == 'x' || new_status == 'X';
let text = extract_task_text(line).to_owned();
let _ = close_pos;
let info = TaskInfo {
line: line_num,
status: new_status,
text,
done,
};
Some((modified, info))
}
pub fn toggle_task(path: &Path, line: usize) -> Result<TaskInfo> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
let lines: Vec<&str> = content.split('\n').collect();
let line_count = if lines.last() == Some(&"") {
lines.len() - 1
} else {
lines.len()
};
if line == 0 || line > line_count {
bail!("line {line} is out of range (file has {line_count} lines)");
}
let target = lines[line - 1];
let (current_status, _done) = detect_task_checkbox(target)
.ok_or_else(|| anyhow::anyhow!("line {line} is not a task checkbox"))?;
let new_status = if current_status == 'x' || current_status == 'X' {
' '
} else {
'x'
};
let (modified_line, info) = mutate_task_line(target, line, new_status)
.ok_or_else(|| anyhow::anyhow!("failed to mutate task on line {line}"))?;
let new_content = build_new_content(&content, &lines, &[(line, modified_line)]);
crate::fs_util::atomic_write(path, new_content.as_bytes())
.with_context(|| format!("failed to write {}", path.display()))?;
Ok(info)
}
pub fn set_task_status(path: &Path, line: usize, status: char) -> Result<TaskInfo> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
let lines: Vec<&str> = content.split('\n').collect();
let line_count = if lines.last() == Some(&"") {
lines.len() - 1
} else {
lines.len()
};
if line == 0 || line > line_count {
bail!("line {line} is out of range (file has {line_count} lines)");
}
let target = lines[line - 1];
detect_task_checkbox(target)
.ok_or_else(|| anyhow::anyhow!("line {line} is not a task checkbox"))?;
let (modified_line, info) = mutate_task_line(target, line, status)
.ok_or_else(|| anyhow::anyhow!("failed to mutate task on line {line}"))?;
let new_content = build_new_content(&content, &lines, &[(line, modified_line)]);
crate::fs_util::atomic_write(path, new_content.as_bytes())
.with_context(|| format!("failed to write {}", path.display()))?;
Ok(info)
}
pub fn find_task_lines(path: &Path) -> Result<Vec<crate::types::FindTaskInfo>> {
let mut extractor = TaskExtractor::new();
scanner::scan_file_multi(path, &mut [&mut extractor])?;
Ok(extractor.into_tasks())
}
pub fn toggle_tasks(path: &Path, lines: &[usize]) -> Result<Vec<TaskInfo>> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
let file_lines: Vec<&str> = content.split('\n').collect();
let line_count = if file_lines.last() == Some(&"") {
file_lines.len() - 1
} else {
file_lines.len()
};
let mut deduped = lines.to_vec();
deduped.sort_unstable();
deduped.dedup();
let mut results = Vec::with_capacity(deduped.len());
let mut replacements: Vec<(usize, String)> = Vec::with_capacity(deduped.len());
for &line in &deduped {
if line == 0 || line > line_count {
bail!("line {line} is out of range (file has {line_count} lines)");
}
let target = file_lines[line - 1];
let (current_status, _done) = detect_task_checkbox(target)
.ok_or_else(|| anyhow::anyhow!("line {line} is not a task checkbox"))?;
let new_status = if current_status == 'x' || current_status == 'X' {
' '
} else {
'x'
};
let (modified_line, info) = mutate_task_line(target, line, new_status)
.ok_or_else(|| anyhow::anyhow!("failed to mutate task on line {line}"))?;
replacements.push((line, modified_line));
results.push(info);
}
let new_content = build_new_content(&content, &file_lines, &replacements);
crate::fs_util::atomic_write(path, new_content.as_bytes())
.with_context(|| format!("failed to write {}", path.display()))?;
Ok(results)
}
pub fn set_tasks_status(path: &Path, lines: &[usize], status: char) -> Result<Vec<TaskInfo>> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
let file_lines: Vec<&str> = content.split('\n').collect();
let line_count = if file_lines.last() == Some(&"") {
file_lines.len() - 1
} else {
file_lines.len()
};
let mut deduped = lines.to_vec();
deduped.sort_unstable();
deduped.dedup();
let mut results = Vec::with_capacity(deduped.len());
let mut replacements: Vec<(usize, String)> = Vec::with_capacity(deduped.len());
for &line in &deduped {
if line == 0 || line > line_count {
bail!("line {line} is out of range (file has {line_count} lines)");
}
let target = file_lines[line - 1];
detect_task_checkbox(target)
.ok_or_else(|| anyhow::anyhow!("line {line} is not a task checkbox"))?;
let (modified_line, info) = mutate_task_line(target, line, status)
.ok_or_else(|| anyhow::anyhow!("failed to mutate task on line {line}"))?;
replacements.push((line, modified_line));
results.push(info);
}
let new_content = build_new_content(&content, &file_lines, &replacements);
crate::fs_util::atomic_write(path, new_content.as_bytes())
.with_context(|| format!("failed to write {}", path.display()))?;
Ok(results)
}
fn build_new_content(original: &str, lines: &[&str], replacements: &[(usize, String)]) -> String {
let repl_map: std::collections::HashMap<usize, &str> = replacements
.iter()
.map(|(ln, s)| (*ln, s.as_str()))
.collect();
let mut buf = String::with_capacity(original.len());
for (i, &l) in lines.iter().enumerate() {
if i > 0 {
buf.push('\n');
}
if let Some(repl) = repl_map.get(&(i + 1)) {
buf.push_str(repl);
} else {
buf.push_str(l);
}
}
buf
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
macro_rules! md {
($s:expr) => {
$s.strip_prefix('\n').unwrap_or($s)
};
}
#[test]
fn detect_open_task() {
let (ch, done) = detect_task_checkbox("- [ ] Do something").unwrap();
assert_eq!(ch, ' ');
assert!(!done);
}
#[test]
fn detect_done_lowercase_x() {
let (ch, done) = detect_task_checkbox("- [x] Done").unwrap();
assert_eq!(ch, 'x');
assert!(done);
}
#[test]
fn detect_done_uppercase_x() {
let (ch, done) = detect_task_checkbox("- [X] Done").unwrap();
assert_eq!(ch, 'X');
assert!(done);
}
#[test]
fn detect_custom_status_dash() {
let (ch, done) = detect_task_checkbox("- [-] Cancelled").unwrap();
assert_eq!(ch, '-');
assert!(!done);
}
#[test]
fn detect_custom_status_question() {
let (ch, done) = detect_task_checkbox("- [?] In review").unwrap();
assert_eq!(ch, '?');
assert!(!done);
}
#[test]
fn detect_custom_status_exclamation() {
let (ch, done) = detect_task_checkbox("- [!] Urgent").unwrap();
assert_eq!(ch, '!');
assert!(!done);
}
#[test]
fn detect_star_bullet() {
let (ch, done) = detect_task_checkbox("* [ ] Star bullet").unwrap();
assert_eq!(ch, ' ');
assert!(!done);
}
#[test]
fn detect_plus_bullet() {
let (ch, done) = detect_task_checkbox("+ [ ] Plus bullet").unwrap();
assert_eq!(ch, ' ');
assert!(!done);
}
#[test]
fn detect_indented_task() {
let (ch, done) = detect_task_checkbox(" - [ ] Indented").unwrap();
assert_eq!(ch, ' ');
assert!(!done);
}
#[test]
fn detect_non_task_regular_bullet() {
assert!(detect_task_checkbox("- Just a bullet").is_none());
}
#[test]
fn detect_non_task_plain_text() {
assert!(detect_task_checkbox("Regular text").is_none());
}
#[test]
fn detect_non_task_heading() {
assert!(detect_task_checkbox("# Heading").is_none());
}
#[test]
fn detect_non_task_empty_line() {
assert!(detect_task_checkbox("").is_none());
}
#[test]
fn extract_tasks_from_file_with_mixed_content() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("tasks.md");
fs::write(
&path,
md!(r"
---
title: Test
---
# Section
- [ ] Open task
- [x] Done task
Some regular text.
```
- [ ] This is inside code block
```
- [?] Custom status
"),
)
.unwrap();
let tasks = extract_tasks(&path).unwrap();
assert_eq!(tasks.len(), 3);
assert_eq!(tasks[0].status, ' ');
assert_eq!(tasks[0].text, "Open task");
assert!(!tasks[0].done);
assert_eq!(tasks[1].status, 'x');
assert_eq!(tasks[1].text, "Done task");
assert!(tasks[1].done);
assert_eq!(tasks[2].status, '?');
assert_eq!(tasks[2].text, "Custom status");
assert!(!tasks[2].done);
}
#[test]
fn extract_tasks_skips_code_blocks() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("tasks.md");
fs::write(
&path,
md!(r"
- [ ] Before code
```
- [ ] Inside code block — ignored
```
- [x] After code
"),
)
.unwrap();
let tasks = extract_tasks(&path).unwrap();
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0].text, "Before code");
assert_eq!(tasks[1].text, "After code");
}
#[test]
fn extract_tasks_empty_file() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("empty.md");
fs::write(&path, "").unwrap();
let tasks = extract_tasks(&path).unwrap();
assert!(tasks.is_empty());
}
#[test]
fn extract_tasks_no_tasks() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("note.md");
fs::write(&path, "# Heading\n\nJust text.\n").unwrap();
let tasks = extract_tasks(&path).unwrap();
assert!(tasks.is_empty());
}
#[test]
fn extract_tasks_line_numbers_accurate_with_frontmatter() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("note.md");
fs::write(
&path,
md!(r"
---
title: Test
---
- [ ] First task
- [x] Second task
"),
)
.unwrap();
let tasks = extract_tasks(&path).unwrap();
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0].line, 4);
assert_eq!(tasks[1].line, 5);
}
#[test]
fn count_tasks_accuracy() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("tasks.md");
fs::write(
&path,
md!(r"
- [ ] Task 1
- [x] Task 2
- [X] Task 3
- [-] Task 4
Regular text.
"),
)
.unwrap();
let count = count_tasks(&path).unwrap();
assert_eq!(count.total, 4);
assert_eq!(count.done, 2);
}
#[test]
fn count_tasks_empty_file() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("empty.md");
fs::write(&path, "").unwrap();
let count = count_tasks(&path).unwrap();
assert_eq!(count.total, 0);
assert_eq!(count.done, 0);
}
#[test]
fn read_task_finds_task_at_line() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("tasks.md");
fs::write(&path, "Regular line\n- [ ] The task\nAnother line\n").unwrap();
let task = read_task(&path, 2).unwrap();
assert!(task.is_some());
let task = task.unwrap();
assert_eq!(task.line, 2);
assert_eq!(task.status, ' ');
assert_eq!(task.text, "The task");
assert!(!task.done);
}
#[test]
fn read_task_returns_none_for_non_task_line() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("tasks.md");
fs::write(&path, "Regular line\n- [ ] The task\n").unwrap();
let task = read_task(&path, 1).unwrap();
assert!(task.is_none());
}
#[test]
fn read_task_returns_none_out_of_range() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("tasks.md");
fs::write(&path, "- [ ] Task\n").unwrap();
let task = read_task(&path, 99).unwrap();
assert!(task.is_none());
}
#[test]
fn read_task_returns_none_inside_code_block() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("tasks.md");
fs::write(&path, "```\n- [ ] Inside code\n```\n- [ ] Real task\n").unwrap();
let task = read_task(&path, 2).unwrap();
assert!(task.is_none());
let task = read_task(&path, 4).unwrap();
assert!(task.is_some());
}
#[test]
fn toggle_task_open_to_done() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("tasks.md");
fs::write(&path, "- [ ] Open task\n").unwrap();
let info = toggle_task(&path, 1).unwrap();
assert_eq!(info.status, 'x');
assert!(info.done);
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("- [x] Open task"));
}
#[test]
fn toggle_task_done_to_open_lowercase() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("tasks.md");
fs::write(&path, "- [x] Done task\n").unwrap();
let info = toggle_task(&path, 1).unwrap();
assert_eq!(info.status, ' ');
assert!(!info.done);
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("- [ ] Done task"));
}
#[test]
fn toggle_task_done_to_open_uppercase() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("tasks.md");
fs::write(&path, "- [X] Done task\n").unwrap();
let info = toggle_task(&path, 1).unwrap();
assert_eq!(info.status, ' ');
assert!(!info.done);
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("- [ ] Done task"));
}
#[test]
fn toggle_task_custom_to_done() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("tasks.md");
fs::write(&path, "- [-] Cancelled task\n").unwrap();
let info = toggle_task(&path, 1).unwrap();
assert_eq!(info.status, 'x');
assert!(info.done);
}
#[test]
fn toggle_task_error_on_non_task_line() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("tasks.md");
fs::write(&path, "Regular line\n").unwrap();
let result = toggle_task(&path, 1);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not a task"));
}
#[test]
fn toggle_task_preserves_other_lines() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("tasks.md");
fs::write(&path, "Line 1\n- [ ] Task\nLine 3\n").unwrap();
toggle_task(&path, 2).unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("Line 1"));
assert!(content.contains("- [x] Task"));
assert!(content.contains("Line 3"));
}
#[test]
fn set_task_status_custom_char() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("tasks.md");
fs::write(&path, "- [ ] Open task\n").unwrap();
let info = set_task_status(&path, 1, '?').unwrap();
assert_eq!(info.status, '?');
assert!(!info.done);
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("- [?] Open task"));
}
#[test]
fn set_task_status_to_done() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("tasks.md");
fs::write(&path, "- [ ] Open task\n").unwrap();
let info = set_task_status(&path, 1, 'x').unwrap();
assert_eq!(info.status, 'x');
assert!(info.done);
}
#[test]
fn set_task_status_error_on_non_task() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("tasks.md");
fs::write(&path, "# Heading\n").unwrap();
let result = set_task_status(&path, 1, 'x');
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not a task"));
}
#[test]
fn extract_tasks_skips_comment_blocks() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("tasks.md");
fs::write(
&path,
md!(r"
- [ ] Before comment
%%
- [ ] Inside comment — ignored
- [x] Also inside — ignored
%%
- [x] After comment
"),
)
.unwrap();
let tasks = extract_tasks(&path).unwrap();
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0].text, "Before comment");
assert_eq!(tasks[1].text, "After comment");
}
#[test]
fn count_tasks_skips_comment_blocks() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("tasks.md");
fs::write(
&path,
md!(r"
- [ ] Visible 1
%%
- [ ] Hidden
- [x] Hidden done
%%
- [x] Visible 2
"),
)
.unwrap();
let count = count_tasks(&path).unwrap();
assert_eq!(count.total, 2);
assert_eq!(count.done, 1);
}
#[test]
fn read_task_returns_none_inside_comment() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("tasks.md");
fs::write(&path, "%%\n- [ ] Inside comment\n%%\n- [ ] Real task\n").unwrap();
let task = read_task(&path, 2).unwrap();
assert!(task.is_none());
let task = read_task(&path, 4).unwrap();
assert!(task.is_some());
assert_eq!(task.unwrap().text, "Real task");
}
#[test]
fn read_task_returns_none_when_first_line_is_comment_fence() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("tasks.md");
fs::write(&path, "%%\n- [ ] Inside comment\n%%\n").unwrap();
let task = read_task(&path, 1).unwrap();
assert!(task.is_none());
}
}