import {describe, expect, it} from "bun:test"; import { appendRolledOver, buildRolloverPlan, formatDate, parseDateFromName, RolloverPlanSettings, } from "../src/rolloverCore"; const settings = (overrides: Partial = {}): RolloverPlanSettings => ({ completedSymbol: "x", inProgressSymbol: "/", movedSymbol: ">", rolloverSourceAction: "mark-moved", rolloverContext: "topmost-element", enableLogbook: true, logbookHeader: "**Logbook**", ...overrides, }); describe("formatDate", () => { it("formats with default YYYY-MM-DD", () => { expect(formatDate(new Date(2026, 3, 5), "YYYY-MM-DD")).toBe("2026-04-05"); }); it("zero-pads month and day", () => { expect(formatDate(new Date(2026, 0, 1), "YYYY-MM-DD")).toBe("2026-01-01"); }); it("supports custom separators", () => { expect(formatDate(new Date(2026, 11, 31), "DD/MM/YYYY")).toBe("31/12/2026"); }); it("supports literal text", () => { expect(formatDate(new Date(2026, 3, 5), "daily-YYYY-MM-DD")).toBe("daily-2026-04-05"); }); }); describe("parseDateFromName", () => { it("parses YYYY-MM-DD", () => { const d = parseDateFromName("2026-04-05", "YYYY-MM-DD"); expect(d).not.toBeNull(); expect(d.getFullYear()).toBe(2026); expect(d.getMonth()).toBe(3); expect(d.getDate()).toBe(5); }); it("returns null for non-matching name", () => { expect(parseDateFromName("not-a-date", "YYYY-MM-DD")).toBeNull(); }); it("returns null when format is wrong", () => { expect(parseDateFromName("04/05/2026", "YYYY-MM-DD")).toBeNull(); }); it("rejects out-of-range dates", () => { expect(parseDateFromName("2026-13-01", "YYYY-MM-DD")).toBeNull(); expect(parseDateFromName("2026-02-30", "YYYY-MM-DD")).toBeNull(); }); it("supports custom format", () => { const d = parseDateFromName("31/12/2026", "DD/MM/YYYY"); expect(d).not.toBeNull(); expect(d.getFullYear()).toBe(2026); expect(d.getMonth()).toBe(11); expect(d.getDate()).toBe(31); }); it("escapes regex metacharacters in format", () => { const d = parseDateFromName("2026.04.05", "YYYY.MM.DD"); expect(d).not.toBeNull(); expect(d.getFullYear()).toBe(2026); }); it("round-trips with formatDate", () => { const original = new Date(2026, 6, 14); const formatted = formatDate(original, "YYYY-MM-DD"); const parsed = parseDateFromName(formatted, "YYYY-MM-DD"); expect(parsed).not.toBeNull(); expect(parsed.getTime()).toBe(original.getTime()); }); }); describe("buildRolloverPlan – no migration", () => { it("leaves content unchanged when there are no tasks", () => { const input = "# Title\n\nJust some prose.\n"; const plan = buildRolloverPlan(input, settings()); expect(plan.newContent).toBe(input); expect(plan.migratedBlocks).toEqual([]); }); it("does not migrate when only completed tasks exist", () => { const input = "- [x] done\n- [x] also done\n"; const plan = buildRolloverPlan(input, settings()); expect(plan.migratedBlocks).toEqual([]); }); it("does not migrate when only discarded tasks exist", () => { const input = "- [-] discarded\n"; const plan = buildRolloverPlan(input, settings()); expect(plan.migratedBlocks).toEqual([]); }); it("does not migrate already-moved tasks", () => { const input = "- [>] moved already\n"; const plan = buildRolloverPlan(input, settings()); expect(plan.migratedBlocks).toEqual([]); }); }); describe("buildRolloverPlan – pending detection", () => { it("treats [ ] (open) as pending", () => { const plan = buildRolloverPlan("- [ ] open\n", settings({rolloverContext: "none"})); expect(plan.migratedBlocks).toEqual([["- [ ] open"]]); }); it("treats configured in-progress symbol as pending", () => { const plan = buildRolloverPlan("- [/] in progress\n", settings({rolloverContext: "none"})); expect(plan.migratedBlocks).toEqual([["- [/] in progress"]]); }); it("respects a custom in-progress symbol", () => { const custom = settings({inProgressSymbol: "?", rolloverContext: "none"}); const plan = buildRolloverPlan("- [?] custom\n- [/] not pending now\n", custom); expect(plan.migratedBlocks).toEqual([["- [?] custom"]]); }); it("ignores discarded [-] tasks", () => { const plan = buildRolloverPlan( "- [ ] open\n- [-] discarded\n", settings({rolloverContext: "none"}), ); expect(plan.migratedBlocks).toEqual([["- [ ] open"]]); }); }); describe("buildRolloverPlan – source action", () => { it("marks pending tasks as moved (default)", () => { const plan = buildRolloverPlan( "- [ ] open\n- [x] done\n", settings({rolloverContext: "none"}), ); expect(plan.newContent).toBe("- [>] open\n- [x] done\n"); }); it("removes pending tasks when action is remove", () => { const plan = buildRolloverPlan( "- [ ] open\n- [x] done\n", settings({rolloverSourceAction: "remove", rolloverContext: "none"}), ); expect(plan.newContent).toBe("- [x] done\n"); }); it("uses a custom moved symbol", () => { const plan = buildRolloverPlan( "- [ ] open\n", settings({movedSymbol: "~", rolloverContext: "none"}), ); expect(plan.newContent).toBe("- [~] open\n"); }); it("does not touch non-pending tasks in the source", () => { const input = "- This\n - [ ] pending\n - [x] done\n - [-] discarded\n"; const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-list"})); expect(plan.newContent).toBe("- This\n - [>] pending\n - [x] done\n - [-] discarded\n"); }); }); describe("buildRolloverPlan – surrounding context", () => { const input = ["# H1", "## H2", "- Group", " - [ ] task", ""].join("\n"); it("none migrates only the task subtree", () => { const plan = buildRolloverPlan(input, settings({rolloverContext: "none"})); expect(plan.migratedBlocks).toEqual([["- [ ] task"]]); }); it("topmost-task behaves like none", () => { const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-task"})); expect(plan.migratedBlocks).toEqual([["- [ ] task"]]); }); it("topmost-list returns the topmost non-task list ancestor subtree", () => { const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-list"})); expect(plan.migratedBlocks).toEqual([["- Group", " - [ ] task"]]); }); it("topmost-list falls back to root task when no list ancestor", () => { const plain = "## H2\n- [ ] task\n"; const plan = buildRolloverPlan(plain, settings({rolloverContext: "topmost-list"})); expect(plan.migratedBlocks).toEqual([["- [ ] task"]]); }); it("topmost-heading returns the outermost heading section", () => { const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-heading"})); expect(plan.migratedBlocks).toEqual([["# H1", "## H2", "- Group", " - [ ] task"]]); }); it("topmost-heading falls back to root task when no heading", () => { const plain = "- Group\n - [ ] task\n"; const plan = buildRolloverPlan(plain, settings({rolloverContext: "topmost-heading"})); expect(plan.migratedBlocks).toEqual([["- [ ] task"]]); }); it("nearest-heading returns the closest heading section above the task", () => { const plan = buildRolloverPlan(input, settings({rolloverContext: "nearest-heading"})); expect(plan.migratedBlocks).toEqual([["## H2", "- Group", " - [ ] task"]]); }); it("nearest-heading falls back to root task when no heading", () => { const plain = "- Group\n - [ ] task\n"; const plan = buildRolloverPlan(plain, settings({rolloverContext: "nearest-heading"})); expect(plan.migratedBlocks).toEqual([["- [ ] task"]]); }); it("topmost-element prefers heading over list ancestor", () => { const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-element"})); expect(plan.migratedBlocks).toEqual([["# H1", "## H2", "- Group", " - [ ] task"]]); }); it("topmost-element uses list when no heading is present", () => { const noHeading = "- Group\n - [ ] task\n"; const plan = buildRolloverPlan(noHeading, settings({rolloverContext: "topmost-element"})); expect(plan.migratedBlocks).toEqual([["- Group", " - [ ] task"]]); }); it("topmost-element falls back to task subtree when neither heading nor list", () => { const bare = "- [ ] task\n"; const plan = buildRolloverPlan(bare, settings({rolloverContext: "topmost-element"})); expect(plan.migratedBlocks).toEqual([["- [ ] task"]]); }); }); describe("buildRolloverPlan – tree pruning", () => { it("user example: prunes branches without pending tasks", () => { const input = [ "- This", " - [ ] tasks pending", " - Heading", " - [ ] Pending", " - Head 2", " - [x] Completed", " - Head 3", " - Comment", " - Comment 2", " - [x] Completed", "", ].join("\n"); const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-list"})); expect(plan.migratedBlocks).toEqual([ ["- This", " - [ ] tasks pending", " - Heading", " - [ ] Pending"], ]); }); it("keeps non-task containers that have pending descendants", () => { const input = "- Group\n - [ ] open\n"; const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-list"})); expect(plan.migratedBlocks).toEqual([["- Group", " - [ ] open"]]); }); it("drops non-task containers with only completed children", () => { const input = [ "- Outer", " - Empty group", " - [x] done", " - Active group", " - [ ] open", "", ].join("\n"); const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-list"})); expect(plan.migratedBlocks).toEqual([["- Outer", " - Active group", " - [ ] open"]]); }); it("keeps a heading when one of its tasks is pending", () => { const input = "## Plan\n- [x] done\n- [ ] open\n"; const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-heading"})); expect(plan.migratedBlocks).toEqual([["## Plan", "- [ ] open"]]); }); it("drops completed tasks even within the migrated block", () => { const input = "## Plan\n- [ ] open\n- [x] done\n- [-] discarded\n- [ ] also open\n"; const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-heading"})); expect(plan.migratedBlocks).toEqual([["## Plan", "- [ ] open", "- [ ] also open"]]); }); }); describe("buildRolloverPlan – multiple anchors", () => { it("emits separate blocks for separate root tasks with no surrounding context", () => { const input = "- [ ] first\n\n- [ ] second\n"; const plan = buildRolloverPlan(input, settings({rolloverContext: "none"})); expect(plan.migratedBlocks).toEqual([["- [ ] first"], ["- [ ] second"]]); }); it("merges overlapping anchors (two pending under same heading)", () => { const input = "## Plan\n- [ ] one\n- [ ] two\n"; const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-heading"})); expect(plan.migratedBlocks).toEqual([["## Plan", "- [ ] one", "- [ ] two"]]); }); }); describe("buildRolloverPlan – dedent", () => { it("dedents migrated content by the anchor's indent", () => { const input = " - Outer\n - [ ] task\n"; const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-list"})); expect(plan.migratedBlocks).toEqual([["- Outer", " - [ ] task"]]); }); }); describe("buildRolloverPlan – migration footer", () => { it("appends the migration footer to the source when target is provided", () => { const plan = buildRolloverPlan( "- [ ] open\n", settings({rolloverContext: "none"}), "2026-04-29", ); expect(plan.newContent).toBe( "- [>] open\n\nTasks were automatically migrated to [[2026-04-29]]\n", ); }); it("does not append a footer when no target is provided", () => { const plan = buildRolloverPlan("- [ ] open\n", settings({rolloverContext: "none"})); expect(plan.newContent).toBe("- [>] open\n"); }); it("does not append a footer when nothing was migrated", () => { const plan = buildRolloverPlan( "- [x] done\n", settings({rolloverContext: "none"}), "2026-04-29", ); expect(plan.newContent).toBe("- [x] done\n"); }); it("does not duplicate an existing footer for the same target", () => { const input = "- [ ] open\n\nTasks were automatically migrated to [[2026-04-29]]\n"; const plan = buildRolloverPlan(input, settings({rolloverContext: "none"}), "2026-04-29"); expect(plan.newContent).toBe( "- [>] open\n\nTasks were automatically migrated to [[2026-04-29]]\n", ); }); it("appends a new footer when targeting a different note", () => { const input = "- [ ] open\n\nTasks were automatically migrated to [[2026-04-28]]\n"; const plan = buildRolloverPlan(input, settings({rolloverContext: "none"}), "2026-04-29"); expect(plan.newContent).toBe( "- [>] open\n\nTasks were automatically migrated to [[2026-04-28]]\n\nTasks were automatically migrated to [[2026-04-29]]\n", ); }); it("works alongside the remove source action", () => { const plan = buildRolloverPlan( "- [ ] open\n", settings({rolloverContext: "none", rolloverSourceAction: "remove"}), "2026-04-29", ); expect(plan.newContent).toBe("Tasks were automatically migrated to [[2026-04-29]]\n"); }); }); describe("buildRolloverPlan – code blocks and frontmatter", () => { it("does not migrate task-like lines inside a fenced code block", () => { const input = ["```markdown", "- [ ] not real", "```", ""].join("\n"); const plan = buildRolloverPlan(input, settings()); expect(plan.migratedBlocks).toEqual([]); expect(plan.newContent).toBe(input); }); it("does not migrate task-like lines inside a tilde-fenced code block", () => { const input = ["~~~", "- [ ] not real", "~~~", ""].join("\n"); const plan = buildRolloverPlan(input, settings()); expect(plan.migratedBlocks).toEqual([]); expect(plan.newContent).toBe(input); }); it("treats code-block content as inert when migrating a heading section", () => { const input = ["## Plan", "- [ ] real", "```", "- [ ] code task", "```", ""].join("\n"); const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-heading"})); expect(plan.migratedBlocks).toEqual([["## Plan", "- [ ] real"]]); }); it("does not treat a heading inside a code block as a heading ancestor", () => { const input = ["```", "# Fake heading", "```", "- [ ] real"].join("\n"); const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-heading"})); expect(plan.migratedBlocks).toEqual([["- [ ] real"]]); }); it("does not mark code-block tasks as moved in the source", () => { const input = ["```", "- [ ] not real", "```", "- [ ] real"].join("\n"); const plan = buildRolloverPlan(input, settings({rolloverContext: "none"})); expect(plan.newContent).toBe(["```", "- [ ] not real", "```", "- [>] real"].join("\n")); }); it("ignores task-like lines inside YAML frontmatter", () => { const input = ["---", "- [ ] not a task", "---", "- [x] done"].join("\n"); const plan = buildRolloverPlan(input, settings()); expect(plan.migratedBlocks).toEqual([]); expect(plan.newContent).toBe(input); }); }); describe("appendRolledOver", () => { const block = (lines: string[]) => lines; it("returns destination unchanged when no blocks", () => { expect(appendRolledOver("hello\n", [], "## Past tasks")).toBe("hello\n"); }); it("inserts header and content into an empty destination", () => { const result = appendRolledOver("", [block(["- [ ] x"])], "## Past tasks"); expect(result).toBe("## Past tasks\n\n- [ ] x\n"); }); it("appends header and content when not present", () => { const result = appendRolledOver("# Day\n", [block(["- [ ] x"])], "## Past tasks"); expect(result).toBe("# Day\n\n## Past tasks\n\n- [ ] x\n"); }); it("appends content under existing header", () => { const dest = "# Day\n\n## Past tasks\n\n- [ ] earlier\n"; const result = appendRolledOver(dest, [block(["- [ ] new"])], "## Past tasks"); expect(result).toBe("# Day\n\n## Past tasks\n\n- [ ] earlier\n- [ ] new\n"); }); it("joins multiple blocks", () => { const result = appendRolledOver( "", [block(["- [ ] a"]), block(["- [ ] b"])], "## Past tasks", ); expect(result).toBe("## Past tasks\n\n- [ ] a\n- [ ] b\n"); }); it("normalizes destination missing trailing newline", () => { const result = appendRolledOver("# Day", [block(["- [ ] x"])], "## Past tasks"); expect(result).toBe("# Day\n\n## Past tasks\n\n- [ ] x\n"); }); }); describe("buildRolloverPlan – logbook migration", () => { const nested = [ "- [ ] My task", " - **Logbook**", " - 2026-05-31: did stuff", " - 2026-06-01: more", ].join("\n"); it("migrates a nested logbook along with its task", () => { const plan = buildRolloverPlan(nested, settings({rolloverContext: "topmost-task"})); expect(plan.migratedBlocks).toEqual([ [ "- [ ] My task", " - **Logbook**", " - 2026-05-31: did stuff", " - 2026-06-01: more", ], ]); }); it("migrates the logbook regardless of surrounding-context setting", () => { for (const ctx of ["none", "topmost-list", "topmost-element"] as const) { const plan = buildRolloverPlan(nested, settings({rolloverContext: ctx})); expect(plan.migratedBlocks[0]).toContain(" - **Logbook**"); expect(plan.migratedBlocks[0]).toContain(" - 2026-05-31: did stuff"); } }); it("dedents a deeply nested logbook with the task", () => { const input = ["## Work", "- [ ] task", " - **Logbook**", " - 2026-05-31: note"].join( "\n", ); const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-heading"})); expect(plan.migratedBlocks).toEqual([ ["## Work", "- [ ] task", " - **Logbook**", " - 2026-05-31: note"], ]); }); it("leaves the logbook in the source under the moved task (mark-moved)", () => { const plan = buildRolloverPlan(nested, settings({rolloverContext: "topmost-task"})); expect(plan.newContent).toBe( [ "- [>] My task", " - **Logbook**", " - 2026-05-31: did stuff", " - 2026-06-01: more", ].join("\n"), ); }); it("does not migrate a completed task's logbook", () => { const input = [ "- [x] done", " - **Logbook**", " - 2026-05-30: history", "- [ ] todo", " - **Logbook**", " - 2026-05-31: note", ].join("\n"); const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-task"})); expect(plan.migratedBlocks).toEqual([ ["- [ ] todo", " - **Logbook**", " - 2026-05-31: note"], ]); }); it("keeps each task's own logbook when a subtask has one too", () => { const input = [ "- [ ] parent", " - **Logbook**", " - 2026-05-31: parent log", " - [ ] child", " - **Logbook**", " - 2026-05-31: child log", ].join("\n"); const plan = buildRolloverPlan(input, settings({rolloverContext: "topmost-task"})); expect(plan.migratedBlocks).toEqual([ [ "- [ ] parent", " - **Logbook**", " - 2026-05-31: parent log", " - [ ] child", " - **Logbook**", " - 2026-05-31: child log", ], ]); }); it("honors a custom logbook header", () => { const input = ["- [ ] task", " - Journal", " - 2026-05-31: note"].join("\n"); const plan = buildRolloverPlan( input, settings({rolloverContext: "topmost-task", logbookHeader: "Journal"}), ); expect(plan.migratedBlocks).toEqual([ ["- [ ] task", " - Journal", " - 2026-05-31: note"], ]); }); it("drops the logbook when the feature is disabled", () => { const plan = buildRolloverPlan( nested, settings({rolloverContext: "topmost-task", enableLogbook: false}), ); expect(plan.migratedBlocks).toEqual([["- [ ] My task"]]); }); });