Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions internal/cli/add/add_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,59 @@ func TestAppendEntry(t *testing.T) {
})
}

// TestInsertDecisionNotInsideComment is a regression test for the bug where
// ctx add decision inserts the entry inside the <!-- DECISION FORMATS -->
// HTML comment block on a fresh DECISIONS.md, making it invisible when rendered.
func TestInsertDecisionNotInsideComment(t *testing.T) {
// This is the exact structure of a freshly scaffolded DECISIONS.md.
freshDecisions := "# Decisions\n\n" +
"<!-- INDEX:START -->\n" +
"<!-- INDEX:END -->\n\n" +
"<!-- DECISION FORMATS\n\n" +
"## Quick Format (Y-Statement)\n\n" +
"For lightweight decisions, a single statement suffices.\n\n" +
"## Full Format\n\n" +
"For significant decisions:\n\n" +
"## [YYYY-MM-DD] Decision Title\n\n" +
"**Status**: Accepted\n\n" +
"-->\n"

entry := "## [2026-02-18] My Important Decision\n\n" +
"**Status**: Accepted\n\n" +
"**Context**: What prompted it\n\n" +
"**Rationale**: Why this choice\n\n" +
"**Consequences**: What changes\n"

result := AppendEntry([]byte(freshDecisions), entry, "decision", "")
resultStr := string(result)

// The entry must appear in the output.
if !strings.Contains(resultStr, "My Important Decision") {
t.Fatalf("decision not found in result:\n%s", resultStr)
}

// The closing --> of the DECISION FORMATS comment block must appear BEFORE
// the new entry. Use LastIndex because the INDEX:START/END markers also
// contain --> on the same line; the last --> in the original template is
// the one that closes the multi-line DECISION FORMATS block.
//
// We search in the original template content (before the entry was added)
// to find where the DECISION FORMATS block closes, then verify the new
// entry appears after that position.
formatBlockCloseIdx := strings.LastIndex(freshDecisions, "-->")
entryIdx := strings.Index(resultStr, "My Important Decision")
if formatBlockCloseIdx == -1 {
t.Fatal("closing --> not found in template")
}
if entryIdx <= formatBlockCloseIdx {
t.Errorf(
"decision was inserted inside the HTML comment block: "+
"entry at index %d, DECISION FORMATS block closes at index %d\n\nFull result:\n%s",
entryIdx, formatBlockCloseIdx, resultStr,
)
}
}

// TestInsertTaskDefaultPlacement tests task insertion without --section.
func TestInsertTaskDefaultPlacement(t *testing.T) {
t.Run("inserts before first unchecked task", func(t *testing.T) {
Expand Down Expand Up @@ -501,3 +554,72 @@ func TestAddFromFile(t *testing.T) {
t.Error("content from file was not added to LEARNINGS.md")
}
}

// TestIsInsideHTMLComment unit-tests the isInsideHTMLComment helper directly.
func TestIsInsideHTMLComment(t *testing.T) {
cases := []struct {
name string
content string
idx int
want bool
}{
{
name: "position before any comment",
content: "hello <!-- comment --> world",
idx: 3, // inside "hello"
want: false,
},
{
name: "position inside comment",
content: "hello <!-- comment --> world",
idx: 10, // inside " comment "
want: true,
},
{
name: "position after comment close",
content: "hello <!-- comment --> world",
idx: 23, // inside " world"
want: false,
},
{
name: "inline single-line comment: position after close",
content: "<!-- INDEX:START -->\n## [real entry]",
idx: 20, // start of "## ["
want: false,
},
{
name: "multi-line comment: heading inside",
content: "<!-- FORMATS\n## [YYYY-MM-DD] Template\n-->\n## [real]",
idx: 13, // start of "## [YYYY"
want: true,
},
{
name: "multi-line comment: heading after close",
content: "<!-- FORMATS\n## [YYYY-MM-DD] Template\n-->\n## [real]",
idx: 41, // start of "## [real]"
want: false,
},
{
name: "unclosed comment treated as inside",
content: "<!-- unclosed\n## [heading]",
idx: 14,
want: true,
},
{
name: "no comment at all",
content: "# Decisions\n\n## [2026-01-01] Entry\n",
idx: 13, // start of "## ["
want: false,
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := isInsideHTMLComment(tc.content, tc.idx)
if got != tc.want {
t.Errorf("isInsideHTMLComment(%q, %d) = %v, want %v",
tc.content, tc.idx, got, tc.want)
}
})
}
}
114 changes: 83 additions & 31 deletions internal/cli/add/insert.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import (

// insertAfterHeader finds a header line and inserts content after it.
//
// Skips blank lines and ctx markers between the header and insertion point.
// Falls back to appending at the end if the header is not found.
// Skips blank lines and HTML comment blocks (<!-- ... -->) between the header
// and the insertion point, so new entries land after index tables, format
// guides, and other comment-wrapped metadata. Falls back to appending at the
// end if the header is not found.
//
// Parameters:
// - content: Existing file content
Expand All @@ -39,19 +41,20 @@ func insertAfterHeader(content, entry, header string) []byte {
insertPoint := idx + lineEnd
insertPoint = skipNewline(content, insertPoint)

// Skip blank lines and ctx markers
// Skip blank lines and any HTML comment blocks (<!-- ... -->).
// This handles INDEX markers, format-guide comments, and ctx markers alike.
for insertPoint < len(content) {
if n := skipNewline(content, insertPoint); n > insertPoint {
insertPoint = n
continue
}

// No context marker: we found the insertion point.
if !startsWithCtxMarker(content[insertPoint:]) {
// Not an HTML comment: we found the insertion point.
if !strings.HasPrefix(content[insertPoint:], config.CommentOpen) {
break
}

// Skip past the closing marker
// Skip past the closing --> of this comment block.
hasCommentEnd, endIdx := containsEndComment(content[insertPoint:])
if !hasCommentEnd {
break
Expand Down Expand Up @@ -150,11 +153,37 @@ func insertTaskAfterSection(entry, content, section string) []byte {
return []byte(content + config.NewlineLF + entry)
}

// isInsideHTMLComment reports whether the position idx in content falls
// inside an HTML comment block (<!-- ... -->).
//
// Parameters:
// - content: String to check
// - idx: Position to test
//
// Returns:
// - bool: True if idx is between a <!-- and its closing -->
func isInsideHTMLComment(content string, idx int) bool {
// Find the last <!-- before idx
openIdx := strings.LastIndex(content[:idx], config.CommentOpen)
if openIdx == -1 {
return false
}
// Check whether a --> closes that block before idx
closeIdx := strings.Index(content[openIdx:], config.CommentClose)
if closeIdx == -1 {
// Unclosed comment — treat as inside
return true
}
// The comment closes at openIdx+closeIdx; if that position is >= idx,
// the position is still inside the comment.
return openIdx+closeIdx+len(config.CommentClose) > idx
}

// insertDecision inserts a decision entry before existing entries.
//
// Finds the first "## [" marker and inserts before it, maintaining
// reverse-chronological order. Falls back to insertAfterHeader if no entries
// exist.
// Finds the first "## [" marker that is NOT inside an HTML comment block
// and inserts before it, maintaining reverse-chronological order.
// Falls back to insertAfterHeader if no real entries exist yet.
//
// Parameters:
// - content: Existing file content
Expand All @@ -164,27 +193,39 @@ func insertTaskAfterSection(entry, content, section string) []byte {
// Returns:
// - []byte: Modified content with entry inserted
func insertDecision(content, entry, header string) []byte {
// Find the first entry marker "## [" (timestamp-prefixed sections)
entryIdx := strings.Index(content, "## [")
if entryIdx != -1 {
// Insert before the first entry, with a separator after
return []byte(
content[:entryIdx] + entry +
config.NewlineLF + config.Separator +
config.NewlineLF + config.NewlineLF +
content[entryIdx:],
)
// Walk through all "## [" occurrences, skipping those inside HTML comments
// (e.g. the template example inside <!-- DECISION FORMATS ... -->).
search := content
offset := 0
for {
rel := strings.Index(search, "## [")
if rel == -1 {
break
}
entryIdx := offset + rel
if !isInsideHTMLComment(content, entryIdx) {
// Found a real entry — insert before it.
return []byte(
content[:entryIdx] + entry +
config.NewlineLF + config.Separator +
config.NewlineLF + config.NewlineLF +
content[entryIdx:],
)
}
// This match is inside a comment — skip past it and keep looking.
offset = entryIdx + len("## [")
search = content[offset:]
}

// No existing entries - find the header and insert after it
// No existing real entries - find the header and insert after it
return insertAfterHeader(content, entry, header)
}

// insertLearning inserts a learning entry before existing entries.
//
// Finds the first "## [" marker and inserts before it, maintaining
// reverse-chronological order. Falls back to insertAfterHeader if no entries
// exist.
// Finds the first "## [" marker that is NOT inside an HTML comment block
// and inserts before it, maintaining reverse-chronological order.
// Falls back to insertAfterHeader if no real entries exist yet.
//
// Parameters:
// - content: Existing file content
Expand All @@ -193,14 +234,25 @@ func insertDecision(content, entry, header string) []byte {
// Returns:
// - []byte: Modified content with entry inserted
func insertLearning(content, entry string) []byte {
// Find the first entry marker "## [" (timestamp-prefixed sections)
entryIdx := strings.Index(content, config.HeadingLearningStart)
if entryIdx != -1 {
return []byte(
content[:entryIdx] + entry + config.NewlineLF +
config.Separator + config.NewlineLF + config.NewlineLF +
content[entryIdx:],
)
// Walk through all "## [" occurrences, skipping those inside HTML comments.
search := content
offset := 0
for {
rel := strings.Index(search, config.HeadingLearningStart)
if rel == -1 {
break
}
entryIdx := offset + rel
if !isInsideHTMLComment(content, entryIdx) {
return []byte(
content[:entryIdx] + entry + config.NewlineLF +
config.Separator + config.NewlineLF + config.NewlineLF +
content[entryIdx:],
)
}
// This match is inside a comment — skip past it and keep looking.
offset = entryIdx + len(config.HeadingLearningStart)
search = content[offset:]
}

// No existing entries - find the header and insert after it
Expand Down
Loading