frame 0.1.7

A markdown task tracker with a terminal UI for humans and a CLI for agents
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
use crate::model::task::{Metadata, Task, TaskState};
use crate::model::task_id::TaskId;
use crate::parse::{count_indent, has_continuation_at_indent};

/// Maximum nesting depth (3 levels: top, sub, sub-sub)
const MAX_DEPTH: usize = 3;

/// Parse task lines starting from `start_idx` at the given `indent` level.
/// Returns parsed tasks and the line index where parsing stopped.
pub fn parse_tasks(
    lines: &[String],
    start_idx: usize,
    indent: usize,
    depth: usize,
) -> (Vec<Task>, usize) {
    let mut tasks = Vec::new();
    let mut idx = start_idx;

    // Lines seen at this level that belong to no task yet. They are handed to
    // the next task as its `leading_lines`; see the loop's `else` arm.
    let mut pending: Vec<String> = Vec::new();
    let mut pending_start = start_idx;

    while idx < lines.len() {
        let line = &lines[idx];

        // Check if this line is a task at the expected indent level
        if let Some(task_indent) = task_indent(line) {
            if task_indent == indent {
                let (mut task, next_idx) = parse_single_task(lines, idx, indent, depth);
                task.leading_lines = std::mem::take(&mut pending);
                tasks.push(task);
                idx = next_idx;
            } else if task_indent < indent {
                // Dedented — we're done with this nesting level
                break;
            } else {
                // More indented than expected, and not claimed by the task above:
                // `parse_single_task` takes its own metadata and subtasks before
                // returning, so anything still here is orphaned — a hand edit that
                // removed a parent, a merge that kept a subtask whose parent went
                // away, or nesting past MAX_DEPTH.
                //
                // This used to `idx += 1`, which consumed the line without
                // recording it anywhere: the task was absent from the model, so
                // the next write deleted it from the file and `fr check` could not
                // see it either. Parse it at *this* level instead — its own lines
                // are read at their real indent, but it is recorded at our depth,
                // so a rewrite re-emits it somewhere that parses back the same
                // way. Over-deep nesting is flattened rather than dropped.
                let (mut task, next_idx) = parse_single_task(lines, idx, task_indent, depth);
                task.leading_lines = std::mem::take(&mut pending);
                tasks.push(task);
                idx = next_idx;
            }
        } else {
            // Not a task line. Blank lines and orphaned deeper-indent content
            // can appear between tasks (e.g., after multi-line notes with
            // trailing blank lines, or orphaned subtasks from previous parse
            // errors).
            //
            // This used to `idx += 1` past all of it. Blank lines are formatting
            // and losing them is cosmetic, but a *non-blank* line consumed here
            // was recorded nowhere: absent from the model, invisible to
            // `fr check`, and deleted by the next write of the file. `fr clean`
            // turned that into routine damage — one task's missing `resolved:`
            // date rewrites the whole track, so the deletion landed in a track
            // the user never touched.
            //
            // Hold non-blank lines instead and hand them to the next task at
            // this level as `leading_lines`, which re-emits them verbatim in
            // place. There is always such a task: `has_more_tasks_at_indent` is
            // the condition for consuming the line at all.
            if (line.trim().is_empty() || count_indent(line) > indent)
                && has_more_tasks_at_indent(lines, idx + 1, indent)
            {
                if !line.trim().is_empty() {
                    if pending.is_empty() {
                        pending_start = idx;
                    }
                    pending.push(line.clone());
                } else if !pending.is_empty() {
                    // A blank inside a stranded run: keep it, so two stranded
                    // paragraphs don't get glued together.
                    pending.push(String::new());
                }
                idx += 1;
                continue;
            }
            break;
        }
    }

    if !pending.is_empty() {
        // Unreachable while the consume condition above requires a following
        // task, but the invariant this function owes its caller is that it
        // never consumes a line without recording it. Rewinding to the first
        // held line keeps that true unconditionally: the caller re-reads them
        // and the track parser keeps them as literal text.
        return (tasks, pending_start);
    }

    (tasks, idx)
}

/// Parse a single task and all its metadata and subtasks.
/// Returns the task and the next line index to process.
fn parse_single_task(
    lines: &[String],
    start_idx: usize,
    indent: usize,
    depth: usize,
) -> (Task, usize) {
    let line = &lines[start_idx];
    let (state, id, title, tags) = parse_task_line(line, indent);

    let mut task = Task {
        state,
        id,
        title,
        tags,
        metadata: Vec::new(),
        subtasks: Vec::new(),
        depth,
        leading_lines: Vec::new(),
        source_lines: None,
        source_text: None,
        dirty: false,
    };

    let mut idx = start_idx + 1;
    let meta_indent = indent + 2;

    // Parse metadata lines (before subtasks)
    while idx < lines.len() {
        let line = &lines[idx];

        // If we hit a subtask line at the expected indent, stop collecting metadata
        if let Some(ti) = task_indent(line)
            && ti <= meta_indent
        {
            break;
        }

        // Check for metadata line at meta_indent
        if is_metadata_line(line, meta_indent) {
            let (meta, next_idx) = parse_metadata(lines, idx, meta_indent);
            task.metadata.push(meta);
            idx = next_idx;
            continue;
        }

        // Deeper-indented content that is not metadata and not a subtask. This
        // used to `idx += 1; continue`, which folded the line into this task's
        // `source_text` — it survived a verbatim write but vanished the moment
        // the task went dirty, because the canonical path rebuilds the task from
        // its fields and the line is in none of them.
        //
        // Stop instead, and hand the line back to `parse_tasks`, which keeps it:
        // as a task if it looks like one, otherwise as literal text on the track.
        // The cost is that metadata *after* stray content is no longer collected
        // onto this task — it becomes literal text too. Trading a silent deletion
        // for a visible mis-grouping is the right way round, and both shapes are
        // malformed input either way.
        let line_indent = count_indent(line);
        if line_indent > indent && !line.trim().is_empty() {
            break;
        }

        // Blank line — check if more metadata or subtasks follow.
        // This handles multi-line notes with trailing blank lines before subtasks,
        // and empty notes (- note:\n\n) followed by more metadata.
        if line.trim().is_empty() {
            let mut peek = idx + 1;
            while peek < lines.len() && lines[peek].trim().is_empty() {
                peek += 1;
            }
            if peek < lines.len()
                && (is_metadata_line(&lines[peek], meta_indent)
                    || task_indent(&lines[peek]).is_some_and(|ti| ti == meta_indent))
            {
                idx += 1;
                continue;
            }
        }

        // Unrecognized content or end of task — stop
        break;
    }

    // Record the task's OWN source text (task line + metadata, NOT subtask lines).
    // This enables selective rewrite: editing a subtask doesn't reformat the parent.
    let own_end_idx = idx;
    task.source_text = Some(lines[start_idx..own_end_idx].to_vec());

    // Now parse subtasks (they get their own independent source_text)
    if idx < lines.len()
        && let Some(ti) = task_indent(&lines[idx])
        && ti == meta_indent
        && depth + 1 < MAX_DEPTH
    {
        let (subtasks, next_idx) = parse_tasks(lines, idx, meta_indent, depth + 1);
        task.subtasks = subtasks;
        idx = next_idx;
    }

    task.source_lines = Some(start_idx..idx);

    (task, idx)
}

/// Parse the task line itself: `- [x] \`ID\` Title text #tag1 #tag2`
///
/// **Callers must have accepted `line` via [`task_indent`] first.** That is what
/// makes the byte slice below safe: `task_indent` requires `content` to start
/// with `- [` and to have `]` at byte 4, which forces the state character to be
/// a single byte, so byte 5 is a character boundary. Nothing in the type system
/// says so, and every caller today reaches here through `task_indent`.
///
/// The coupling is called out because the same shape was a real abort: `eff5ec0`
/// fixed `strip_block_indent` slicing `line[4..]` into the middle of a `§`,
/// which panicked `fr list` outright on a file containing one. That one had no
/// guard at all; this one has a guard nothing points at.
fn parse_task_line(line: &str, indent: usize) -> (TaskState, Option<TaskId>, String, Vec<String>) {
    let content = &line[indent..];

    // Parse checkbox: `- [X] `
    let state_char = content
        .strip_prefix("- [")
        .and_then(|rest| rest.chars().next())
        .unwrap_or(' ');
    let state = TaskState::from_checkbox_char(state_char).unwrap_or(TaskState::Todo);

    // Skip past `- [X] `
    debug_assert!(
        content.is_char_boundary(5),
        "parse_task_line requires a line task_indent accepted; \
         byte 5 is not a boundary in {content:?}"
    );
    let after_checkbox = &content[5..]; // "- [X] " is 5 chars: "- [" + char + "]"
    let after_checkbox = after_checkbox.strip_prefix(' ').unwrap_or(after_checkbox);

    // Parse optional ID: `\`PREFIX-NNN\``
    let (id, after_id) = if let Some(after_tick) = after_checkbox.strip_prefix('`') {
        if let Some(end_tick) = after_tick.find('`') {
            let id_text = &after_tick[..end_tick];
            let rest = &after_tick[end_tick + 1..];
            let rest = rest.strip_prefix(' ').unwrap_or(rest);
            (Some(TaskId::parse(id_text)), rest)
        } else {
            (None, after_checkbox)
        }
    } else {
        (None, after_checkbox)
    };

    // Parse tags from end of line, then title is everything before tags
    let (title, tags) = parse_title_and_tags(after_id);

    (state, id, title, tags)
}

/// Split a string into title and tags. Tags are `#word` tokens at the end.
pub fn parse_title_and_tags(s: &str) -> (String, Vec<String>) {
    let s = s.trim_end();
    if s.is_empty() {
        return (String::new(), Vec::new());
    }

    // Collect tags from the end
    let mut tags = Vec::new();
    let mut remaining = s;

    loop {
        let trimmed = remaining.trim_end();
        if trimmed.is_empty() {
            break;
        }

        // Find the last word
        if let Some(last_space) = trimmed.rfind(' ') {
            let last_word = &trimmed[last_space + 1..];
            if let Some(tag) = last_word.strip_prefix('#')
                && !tag.is_empty()
                && !tag.contains('#')
            {
                tags.push(tag.to_string());
                remaining = &trimmed[..last_space];
                continue;
            }
        } else {
            // Single word — check if it's a tag
            if let Some(tag) = trimmed.strip_prefix('#')
                && !tag.is_empty()
                && !tag.contains('#')
            {
                tags.push(tag.to_string());
                remaining = "";
                continue;
            }
        }
        break;
    }

    tags.reverse();
    (remaining.trim_end().to_string(), tags)
}

/// Check if a line is a task line (starts with `- [` at some indent)
/// Returns the indent level if it is.
fn task_indent(line: &str) -> Option<usize> {
    let indent = count_indent(line);
    let content = &line[indent..];
    if content.starts_with("- [") && content.len() >= 5 && content.as_bytes().get(4) == Some(&b']')
    {
        Some(indent)
    } else {
        None
    }
}

/// Look ahead through blank lines and deeper-indent content to check if
/// there are more tasks at the given indent level. Used by parse_tasks to
/// skip gaps caused by multi-line notes with trailing blank lines.
fn has_more_tasks_at_indent(lines: &[String], start: usize, indent: usize) -> bool {
    for line in lines.iter().skip(start) {
        if line.trim().is_empty() {
            continue;
        }
        if count_indent(line) > indent {
            continue; // skip deeper-indent content (orphaned subtasks/metadata)
        }
        // Found non-blank line at or below our indent — check if it's a task
        return task_indent(line).is_some_and(|ti| ti == indent);
    }
    false
}

/// Check if a line is a metadata line at the given indent: `  - key: value`
fn is_metadata_line(line: &str, indent: usize) -> bool {
    let line_indent = count_indent(line);
    if line_indent != indent {
        return false;
    }
    let content = line[indent..].trim_start();
    if !content.starts_with("- ") {
        return false;
    }
    let after_dash = &content[2..];
    // Must have a recognized key followed by ':'
    matches!(
        after_dash.split_once(':'),
        Some((key, _)) if is_metadata_key(key)
    )
}

fn is_metadata_key(key: &str) -> bool {
    matches!(
        key.trim(),
        "dep" | "ref" | "spec" | "note" | "added" | "resolved"
    )
}

/// Parse a metadata entry starting at `idx`. Returns the metadata and next line.
fn parse_metadata(lines: &[String], idx: usize, indent: usize) -> (Metadata, usize) {
    let line = &lines[idx];
    let content = line[indent..].trim_start();
    let after_dash = &content[2..]; // skip "- "

    let (key, value_part) = after_dash.split_once(':').unwrap();
    let key = key.trim();
    let value = value_part.trim();

    match key {
        "dep" => {
            let deps: Vec<String> = value
                .split(',')
                .map(|s| s.trim().to_string())
                .filter(|s| !s.is_empty())
                .collect();
            (Metadata::Dep(deps), idx + 1)
        }
        "ref" => {
            let refs: Vec<String> = value
                .split(',')
                .map(|s| s.trim().to_string())
                .filter(|s| !s.is_empty())
                .collect();
            (Metadata::Ref(refs), idx + 1)
        }
        "spec" => (Metadata::Spec(value.to_string()), idx + 1),
        "added" => (Metadata::Added(value.to_string()), idx + 1),
        "resolved" => (Metadata::Resolved(value.to_string()), idx + 1),
        "note" => {
            if !value.is_empty() {
                // Single-line note: `- note: some text`
                (Metadata::Note(value.to_string()), idx + 1)
            } else {
                // Block note: collect indented lines
                let block_indent = indent + 2;
                let (note_text, next_idx) = parse_note_block(lines, idx + 1, block_indent);
                (Metadata::Note(note_text), next_idx)
            }
        }
        _ => {
            // Unknown metadata — treat as a note
            (Metadata::Note(format!("{}: {}", key, value)), idx + 1)
        }
    }
}

/// Parse a multiline note block. Lines are at `block_indent` or deeper.
/// Returns the note text and next line.
///
/// The block's extent is determined by **indentation alone** — deliberately, and
/// never by code-fence state. [`serialize_task`](crate::parse::serialize_tasks)
/// re-indents every note line to `block_indent`, so any line less indented than
/// that was never note content. Tracking fences here instead once let an
/// unbalanced fence in a note body absorb the rest of the track file — sibling
/// tasks and `## Done` included — and a later rewrite then demoted or dropped
/// them. Blank lines inside a fenced block are already handled by
/// `has_continuation_at_indent`, so fence awareness buys nothing and costs
/// round-trip safety.
///
/// The one shape this cannot represent is a fenced block containing flush-left
/// lines: the note ends at the first such line. The serializer would indent
/// those lines anyway (corrupting the code), so that content was never
/// round-trippable — this just makes the boundary explicit.
fn parse_note_block(lines: &[String], start_idx: usize, block_indent: usize) -> (String, usize) {
    let mut note_lines = Vec::new();
    let mut idx = start_idx;

    while idx < lines.len() {
        let line = &lines[idx];

        if line.trim().is_empty() {
            // Blank line inside note — include it
            // But check if the next non-blank line is still part of the note
            if has_continuation_at_indent(lines, idx + 1, block_indent) {
                note_lines.push(String::new());
                idx += 1;
                continue;
            } else {
                break;
            }
        }

        if count_indent(line) < block_indent {
            // Dedented — no longer part of the note. This bound is absolute.
            break;
        }

        note_lines.push(strip_block_indent(line, block_indent));
        idx += 1;
    }

    // Trim trailing empty lines
    while note_lines.last().is_some_and(|l| l.is_empty()) {
        note_lines.pop();
    }

    (note_lines.join("\n"), idx)
}

/// Strip block indent from a line, preserving relative indentation.
///
/// Guards on *indent*, not byte length: a shorter-indented line must be trimmed,
/// not sliced. Slicing on length alone ate real characters (`## Done` → `one`)
/// and could panic mid-UTF-8 (`line[4..]` inside a `§`). With the indent guard,
/// the first `block_indent` bytes are known to be ASCII spaces, so the slice is
/// char-boundary-safe.
fn strip_block_indent(line: &str, block_indent: usize) -> String {
    if count_indent(line) >= block_indent {
        line[block_indent..].to_string()
    } else if line.trim().is_empty() {
        String::new()
    } else {
        line.trim_start().to_string()
    }
}

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

    fn lines(s: &str) -> Vec<String> {
        s.lines().map(|l| l.to_string()).collect()
    }

    #[test]
    fn test_parse_minimal_task() {
        let input = lines("- [ ] Fix parser crash on empty blocks");
        let (tasks, _) = parse_tasks(&input, 0, 0, 0);
        assert_eq!(tasks.len(), 1);
        assert_eq!(tasks[0].state, TaskState::Todo);
        assert_eq!(tasks[0].id, None);
        assert_eq!(tasks[0].title, "Fix parser crash on empty blocks");
        assert!(tasks[0].tags.is_empty());
    }

    #[test]
    fn test_parse_task_with_id_and_tags() {
        let input = lines("- [ ] `EFF-003` Implement effect handler desugaring #core #cc");
        let (tasks, _) = parse_tasks(&input, 0, 0, 0);
        assert_eq!(tasks.len(), 1);
        assert_eq!(tasks[0].id.as_deref(), Some("EFF-003"));
        assert_eq!(tasks[0].title, "Implement effect handler desugaring");
        assert_eq!(tasks[0].tags, vec!["core", "cc"]);
    }

    #[test]
    fn test_parse_task_states() {
        for (ch, expected) in [
            (' ', TaskState::Todo),
            ('>', TaskState::Active),
            ('-', TaskState::Blocked),
            ('x', TaskState::Done),
            ('~', TaskState::Parked),
        ] {
            let input = lines(&format!("- [{}] Test task", ch));
            let (tasks, _) = parse_tasks(&input, 0, 0, 0);
            assert_eq!(tasks[0].state, expected);
        }
    }

    #[test]
    fn test_parse_task_with_metadata() {
        let input = lines(
            "- [>] `EFF-014` Implement effect inference #core\n\
             \x20\x20- added: 2025-05-10\n\
             \x20\x20- dep: EFF-003\n\
             \x20\x20- spec: doc/spec/effects.md#closure-effects\n\
             \x20\x20- ref: doc/design/effect-handlers-v2.md",
        );
        let (tasks, _) = parse_tasks(&input, 0, 0, 0);
        assert_eq!(tasks[0].metadata.len(), 4);
        assert!(matches!(&tasks[0].metadata[0], Metadata::Added(d) if d == "2025-05-10"));
        assert!(matches!(&tasks[0].metadata[1], Metadata::Dep(d) if d == &["EFF-003"]));
        assert!(
            matches!(&tasks[0].metadata[2], Metadata::Spec(s) if s == "doc/spec/effects.md#closure-effects")
        );
        assert!(
            matches!(&tasks[0].metadata[3], Metadata::Ref(r) if r == &["doc/design/effect-handlers-v2.md"])
        );
    }

    #[test]
    fn test_parse_subtasks() {
        let input = lines(
            "- [>] `EFF-014` Implement effect inference #core\n\
             \x20\x20- added: 2025-05-10\n\
             \x20\x20- [ ] `EFF-014.1` Add effect variables\n\
             \x20\x20- [>] `EFF-014.2` Unify effect rows #cc\n\
             \x20\x20- [ ] `EFF-014.3` Test with nested closures",
        );
        let (tasks, _) = parse_tasks(&input, 0, 0, 0);
        assert_eq!(tasks[0].subtasks.len(), 3);
        assert_eq!(tasks[0].subtasks[0].id.as_deref(), Some("EFF-014.1"));
        assert_eq!(tasks[0].subtasks[1].tags, vec!["cc"]);
        assert_eq!(tasks[0].subtasks[2].state, TaskState::Todo);
    }

    #[test]
    fn test_parse_note_block() {
        let input = lines(
            "- [ ] `EFF-014` Test task\n\
             \x20\x20- note:\n\
             \x20\x20\x20\x20Found while working on EFF-002.\n\
             \x20\x20\x20\x20\n\
             \x20\x20\x20\x20The desugaring needs to handle three cases:\n\
             \x20\x20\x20\x20 1. Simple perform\n\
             \x20\x20\x20\x20 2. Single-shot resumption",
        );
        let (tasks, _) = parse_tasks(&input, 0, 0, 0);
        assert_eq!(tasks[0].metadata.len(), 1);
        if let Metadata::Note(note) = &tasks[0].metadata[0] {
            assert!(note.contains("Found while working"));
            assert!(note.contains("three cases"));
        } else {
            panic!("Expected Note metadata");
        }
    }

    #[test]
    fn test_parse_note_with_code_fence() {
        let input = lines(
            "- [ ] `EFF-014` Test task\n\
             \x20\x20- note:\n\
             \x20\x20\x20\x20See the Koka paper:\n\
             \x20\x20\x20\x20```lace\n\
             \x20\x20\x20\x20handle(e) { ... } with {\n\
             \x20\x20\x20\x20  op(x, resume) -> resume(x + 1)\n\
             \x20\x20\x20\x20}\n\
             \x20\x20\x20\x20```",
        );
        let (tasks, _) = parse_tasks(&input, 0, 0, 0);
        if let Metadata::Note(note) = &tasks[0].metadata[0] {
            assert!(note.contains("```lace"));
            assert!(note.contains("handle(e)"));
            assert!(note.contains("```"));
        } else {
            panic!("Expected Note metadata");
        }
    }

    /// A note whose fences don't pair up must not let the note absorb anything
    /// past its indentation. Regression: an unbalanced fence used to swallow the
    /// rest of the track file — sibling tasks and `## Done` alike — because the
    /// in-fence branch had no indent bound.
    #[test]
    fn test_note_with_unbalanced_fence_stops_at_dedent() {
        let input = lines(
            "- [ ] `DOC-029` Fence hazard\n\
             \x20\x20- note:\n\
             \x20\x20\x20\x20§13.4 mentions three fence kinds:\n\
             \x20\x20\x20\x20\x20\x20```lace\n\
             \x20\x20\x20\x20\x20\x20```rust\n\
             \x20\x20\x20\x20\x20\x20```\n\
             \x20\x20\x20\x20Check the spec.\n\
             - [ ] `DOC-030` Sibling task\n\
             \x20\x20- added: 2026-07-29",
        );
        let (tasks, next_idx) = parse_tasks(&input, 0, 0, 0);

        // Both top-level tasks survive; the note did not eat the sibling.
        assert_eq!(tasks.len(), 2);
        assert_eq!(tasks[0].id.as_deref(), Some("DOC-029"));
        assert_eq!(tasks[1].id.as_deref(), Some("DOC-030"));
        assert_eq!(next_idx, input.len());

        let Metadata::Note(note) = &tasks[0].metadata[0] else {
            panic!("Expected Note metadata");
        };
        // The note keeps its own lines verbatim, relative indent intact...
        assert!(note.contains("  ```lace"));
        assert!(note.contains("Check the spec."));
        // ...and nothing from beyond its indentation.
        assert!(!note.contains("DOC-030"));
    }

    /// A single unclosed fence is the minimal form of the same hazard, and the
    /// one most likely to arrive by paste.
    #[test]
    fn test_note_with_single_unclosed_fence_stops_at_section_header() {
        let input = lines(
            "- [ ] `DOC-029` Fence hazard\n\
             \x20\x20- note:\n\
             \x20\x20\x20\x20Uses one bare fence:\n\
             \x20\x20\x20\x20```\n\
             \n\
             ## Done\n\
             \n\
             - [x] `DOC-025` Done ticket",
        );
        let (tasks, next_idx) = parse_tasks(&input, 0, 0, 0);

        assert_eq!(tasks.len(), 1);
        let Metadata::Note(note) = &tasks[0].metadata[0] else {
            panic!("Expected Note metadata");
        };
        assert_eq!(note, "Uses one bare fence:\n```");
        // Parsing stopped before `## Done` so the track parser still sees it.
        assert!(next_idx < input.len());
        assert!(input[next_idx..].iter().any(|l| l == "## Done"));
    }

    /// `strip_block_indent` slices bytes. It must only do so once the line is
    /// known to carry `block_indent` ASCII spaces — otherwise a dedented line
    /// with a multi-byte char straddling that offset panics mid-UTF-8.
    #[test]
    fn test_note_with_unbalanced_fence_and_multibyte_dedent_does_not_panic() {
        let input = lines(
            "- [ ] `DOC-029` Fence hazard\n\
             \x20\x20- note:\n\
             \x20\x20\x20\x20Unclosed:\n\
             \x20\x20\x20\x20```\n\
             \n\
             x§§y\n\
             \n\
             ## Done",
        );
        // Panicked at `line[4..]` (inside '§') before the indent guard.
        let (tasks, _) = parse_tasks(&input, 0, 0, 0);
        assert_eq!(tasks.len(), 1);
        let Metadata::Note(note) = &tasks[0].metadata[0] else {
            panic!("Expected Note metadata");
        };
        // The dedented line is not note content, so it is not mangled into one.
        assert!(!note.contains('§'));
    }

    #[test]
    fn test_strip_block_indent_shorter_indent_is_trimmed_not_sliced() {
        // Length >= block_indent but indent < block_indent: trim, never slice.
        assert_eq!(strip_block_indent("## Done", 4), "## Done");
        assert_eq!(strip_block_indent("  - [x] `X-1` T", 4), "- [x] `X-1` T");
        // Multi-byte char straddling the byte offset must not panic.
        assert_eq!(strip_block_indent("x§§y", 4), "x§§y");
        // At or past block_indent: slice, preserving relative indent.
        assert_eq!(strip_block_indent("    code", 4), "code");
        assert_eq!(strip_block_indent("      code", 4), "  code");
    }

    #[test]
    fn test_parse_multiple_deps() {
        let input = lines(
            "- [-] `EFF-012` Effect-aware DCE #core\n\
             \x20\x20- dep: EFF-014, INFRA-003",
        );
        let (tasks, _) = parse_tasks(&input, 0, 0, 0);
        if let Metadata::Dep(deps) = &tasks[0].metadata[0] {
            assert_eq!(deps, &["EFF-014", "INFRA-003"]);
        } else {
            panic!("Expected Dep metadata");
        }
    }

    #[test]
    fn test_three_level_nesting() {
        let input = lines(
            "- [>] `EFF-014` Top level\n\
             \x20\x20- [>] `EFF-014.2` Second level #cc\n\
             \x20\x20\x20\x20- [ ] `EFF-014.2.1` Third level\n\
             \x20\x20\x20\x20- [ ] `EFF-014.2.2` Third level 2",
        );
        let (tasks, _) = parse_tasks(&input, 0, 0, 0);
        assert_eq!(tasks[0].subtasks.len(), 1);
        assert_eq!(tasks[0].subtasks[0].subtasks.len(), 2);
        assert_eq!(
            tasks[0].subtasks[0].subtasks[0].id.as_deref(),
            Some("EFF-014.2.1")
        );
    }

    #[test]
    fn test_blank_lines_between_note_and_subtasks() {
        // Multi-line note with trailing blank lines before subtasks
        let input = lines(
            "- [ ] `T-001` Parent task\n\
             \x20\x20- note:\n\
             \x20\x20\x20\x20Some note content\n\
             \n\
             \n\
             \x20\x20- [ ] `T-001.1` First subtask\n\
             \x20\x20- [ ] `T-001.2` Second subtask",
        );
        let (tasks, _) = parse_tasks(&input, 0, 0, 0);
        assert_eq!(tasks.len(), 1);
        assert_eq!(tasks[0].subtasks.len(), 2);
        assert_eq!(tasks[0].subtasks[0].id.as_deref(), Some("T-001.1"));
        assert_eq!(tasks[0].subtasks[1].id.as_deref(), Some("T-001.2"));
        if let Metadata::Note(note) = &tasks[0].metadata[0] {
            assert!(note.contains("Some note content"));
        } else {
            panic!("Expected Note metadata");
        }
    }

    #[test]
    fn test_blank_line_between_empty_note_and_metadata() {
        // Empty note (- note:\n\n) followed by more metadata
        let input = lines(
            "- [ ] `T-001` Task\n\
             \x20\x20- note:\n\
             \n\
             \x20\x20- spec: some-file.md\n\
             \x20\x20- dep: T-002",
        );
        let (tasks, _) = parse_tasks(&input, 0, 0, 0);
        assert_eq!(tasks[0].metadata.len(), 3); // note, spec, dep
        assert!(matches!(&tasks[0].metadata[0], Metadata::Note(n) if n.is_empty()));
        assert!(matches!(&tasks[0].metadata[1], Metadata::Spec(s) if s == "some-file.md"));
        assert!(matches!(&tasks[0].metadata[2], Metadata::Dep(d) if d == &["T-002"]));
    }

    #[test]
    fn test_blank_lines_between_sibling_tasks() {
        // Blank lines between two top-level tasks should not lose the second task
        let input = lines(
            "- [ ] `T-001` First task\n\
             \x20\x20- added: 2025-01-01\n\
             \n\
             - [ ] `T-002` Second task",
        );
        let (tasks, _) = parse_tasks(&input, 0, 0, 0);
        assert_eq!(tasks.len(), 2);
        assert_eq!(tasks[0].id.as_deref(), Some("T-001"));
        assert_eq!(tasks[1].id.as_deref(), Some("T-002"));
    }

    #[test]
    fn test_blank_lines_before_section_header_stops() {
        // Blank lines followed by non-task content (like a section header)
        // should still stop parsing
        let input = lines(
            "- [ ] `T-001` First task\n\
             \n\
             ## Done",
        );
        let (tasks, next_idx) = parse_tasks(&input, 0, 0, 0);
        assert_eq!(tasks.len(), 1);
        assert_eq!(next_idx, 1); // stopped at blank line, not past it
    }

    #[test]
    fn test_parse_title_and_tags_edge_cases() {
        // Title with no tags
        let (title, tags) = parse_title_and_tags("Fix parser crash");
        assert_eq!(title, "Fix parser crash");
        assert!(tags.is_empty());

        // Only tags (no title text)
        let (title, tags) = parse_title_and_tags("#core #cc");
        assert!(title.is_empty());
        assert_eq!(tags, vec!["core", "cc"]);

        // Tag-like content in the middle of the title is still title
        let (title, tags) = parse_title_and_tags("Fix #3 parser crash #bug");
        assert_eq!(title, "Fix #3 parser crash");
        assert_eq!(tags, vec!["bug"]);
    }
}