@@ -12,7 +12,7 @@ use std::path::{Path, PathBuf};
1212
1313use super :: placement:: { InsertAnchor , PlacementInfo } ;
1414use super :: validation:: ValidationContext ;
15- use crate :: parser:: header;
15+ use crate :: parser:: { checkbox , header} ;
1616
1717/// Markdown emitter for task creation
1818///
@@ -161,6 +161,17 @@ impl MarkdownEmitter {
161161 // Copy lines before insertion point
162162 new_lines. extend_from_slice ( & lines[ ..insert_idx] ) ;
163163
164+ // Separate the new task from a preceding task's body. Landing directly
165+ // under the last line of someone else's prose reads as a continuation
166+ // of it, which is the confusion this insertion point exists to avoid.
167+ // Tasks with no body keep butting up against each other as before.
168+ let follows_body = insert_idx
169+ . checked_sub ( 1 )
170+ . is_some_and ( |prev| Self :: continues_task_block ( lines[ prev] ) ) ;
171+ if follows_body {
172+ new_lines. push ( "" ) ;
173+ }
174+
164175 // Insert task line
165176 new_lines. push ( task_line) ;
166177
@@ -181,7 +192,11 @@ impl MarkdownEmitter {
181192 updated. push ( '\n' ) ;
182193 Self :: write_file_atomic ( file_path, & updated) ?;
183194
184- Ok ( insert_idx + 1 )
195+ Ok ( if follows_body {
196+ insert_idx + 2
197+ } else {
198+ insert_idx + 1
199+ } )
185200 }
186201
187202 /// Translate a [`InsertAnchor`] into a 0-indexed position in `lines`
@@ -195,6 +210,10 @@ impl MarkdownEmitter {
195210 fn resolve_insert_index ( content : & str , lines : & [ & str ] , anchor : InsertAnchor ) -> usize {
196211 match anchor {
197212 InsertAnchor :: Line ( line_number) => line_number. saturating_sub ( 1 ) . min ( lines. len ( ) ) ,
213+ InsertAnchor :: AfterTaskBlock ( line_number) => {
214+ let start = line_number. saturating_sub ( 1 ) . min ( lines. len ( ) ) ;
215+ Self :: skip_task_body ( lines, start)
216+ }
198217 InsertAnchor :: EndOfTasksSection => {
199218 // No `## Tasks` heading at all: append at the end of the file
200219 // rather than guessing where the section would have been.
@@ -217,6 +236,65 @@ impl MarkdownEmitter {
217236 }
218237 }
219238
239+ /// Advance past the body lines that belong to the preceding task
240+ ///
241+ /// A parsed task records its checkbox line and its annotation lines and
242+ /// nothing else, so the free-text body a task may carry underneath —
243+ /// prose, numbered steps, acceptance criteria, indented contextual note
244+ /// bullets — is invisible to the placement resolver. Anchoring on parsed
245+ /// data alone therefore lands "after this task" between the task's title
246+ /// and its own body, which silently reassigns that body to the newly
247+ /// inserted task (GitHub issue #48). Only the source text says where the
248+ /// block actually ends.
249+ ///
250+ /// A line continues the block when it is indented and is not itself a
251+ /// checkbox; a checkbox at any depth starts a new task, and an
252+ /// unindented line has left the block. Blank lines are consumed only when
253+ /// indented content resumes after them, so a body split into paragraphs
254+ /// stays whole while a blank line that genuinely ends the block still
255+ /// stops the scan.
256+ ///
257+ /// `start` is a 0-indexed position in `lines`; the return value is the
258+ /// 0-indexed position to insert at.
259+ fn skip_task_body ( lines : & [ & str ] , start : usize ) -> usize {
260+ let mut idx = start;
261+
262+ while idx < lines. len ( ) {
263+ if lines[ idx] . trim ( ) . is_empty ( ) {
264+ // A blank line only belongs to the block if the block resumes
265+ // after it. Otherwise the block ended at this blank line.
266+ let resumes = lines[ idx..]
267+ . iter ( )
268+ . position ( |line| !line. trim ( ) . is_empty ( ) )
269+ . map ( |offset| idx + offset)
270+ . filter ( |& next| Self :: continues_task_block ( lines[ next] ) ) ;
271+
272+ match resumes {
273+ Some ( next) => idx = next,
274+ None => break ,
275+ }
276+ } else if Self :: continues_task_block ( lines[ idx] ) {
277+ idx += 1 ;
278+ } else {
279+ break ;
280+ }
281+ }
282+
283+ idx
284+ }
285+
286+ /// Does this line continue the block that a task's checkbox line opened?
287+ ///
288+ /// Indented non-checkbox content belongs to the task above it: body prose,
289+ /// folded annotation values, contextual note bullets. A checkbox at any
290+ /// depth starts a new task, and an unindented line has left the block
291+ /// entirely.
292+ fn continues_task_block ( line : & str ) -> bool {
293+ !line. trim ( ) . is_empty ( )
294+ && line. starts_with ( char:: is_whitespace)
295+ && checkbox:: CheckboxLine :: parse ( line, 1 ) . is_none ( )
296+ }
297+
220298 /// Create a new task file with proper structure
221299 ///
222300 /// Generates a complete task file with header, metadata, and the first task.
@@ -986,6 +1064,98 @@ mod tests {
9861064 assert_eq ! ( insert_index( content, InsertAnchor :: Line ( 99 ) ) , 3 ) ;
9871065 }
9881066
1067+ #[ test]
1068+ fn test_after_task_block_steps_over_the_previous_tasks_body ( ) {
1069+ // GitHub issue #48: the parser records the checkbox line and nothing
1070+ // about the body under it, so the resolver anchors on line 6 — between
1071+ // the task's title and its own body. The body must stay with its task.
1072+ let content = "# T\n \n ## Tasks\n \n - [x] Second task\n Body prose.\n 1. A step.\n Acceptance: still the second task's.\n " ;
1073+ assert_eq ! ( insert_index( content, InsertAnchor :: AfterTaskBlock ( 6 ) ) , 8 ) ;
1074+ }
1075+
1076+ #[ test]
1077+ fn test_after_task_block_keeps_a_body_split_into_paragraphs_whole ( ) {
1078+ // A blank line inside a body is not the end of the block: indented
1079+ // content resumes after it.
1080+ let content =
1081+ "# T\n \n ## Tasks\n \n - [x] One\n First paragraph.\n \n Second paragraph.\n \n ## Notes\n " ;
1082+ assert_eq ! ( insert_index( content, InsertAnchor :: AfterTaskBlock ( 6 ) ) , 8 ) ;
1083+ }
1084+
1085+ #[ test]
1086+ fn test_after_task_block_stops_at_a_blank_line_that_ends_the_block ( ) {
1087+ // Nothing indented follows the blank, so the block really did end.
1088+ let content = "# T\n \n ## Tasks\n \n - [x] One\n Body.\n \n Unindented prose.\n " ;
1089+ assert_eq ! ( insert_index( content, InsertAnchor :: AfterTaskBlock ( 6 ) ) , 6 ) ;
1090+ }
1091+
1092+ #[ test]
1093+ fn test_after_task_block_stops_at_the_next_checkbox ( ) {
1094+ // An indented checkbox is a child task, not body text.
1095+ let content = "# T\n \n ## Tasks\n \n - [x] One\n - [ ] Child\n - [ ] Two\n " ;
1096+ assert_eq ! ( insert_index( content, InsertAnchor :: AfterTaskBlock ( 6 ) ) , 5 ) ;
1097+ }
1098+
1099+ #[ test]
1100+ fn test_after_task_block_leaves_bodyless_tasks_where_they_were ( ) {
1101+ // The common case must resolve exactly like `Line` did before.
1102+ let content = "# T\n \n ## Tasks\n \n - [ ] One\n - [ ] Two\n " ;
1103+ assert_eq ! ( insert_index( content, InsertAnchor :: AfterTaskBlock ( 6 ) ) , 5 ) ;
1104+ assert_eq ! ( insert_index( content, InsertAnchor :: AfterTaskBlock ( 7 ) ) , 6 ) ;
1105+ }
1106+
1107+ #[ test]
1108+ fn test_after_task_block_absorbs_indented_note_bullets ( ) {
1109+ // Contextual note bullets belong to the task above them.
1110+ let content = "# T\n \n ## Tasks\n \n - [x] One\n - a note\n - another note\n " ;
1111+ assert_eq ! ( insert_index( content, InsertAnchor :: AfterTaskBlock ( 6 ) ) , 7 ) ;
1112+ }
1113+
1114+ /// Insert `task_line` into `content` at `anchor`, returning the new file
1115+ /// text and the 1-indexed line the task landed on.
1116+ fn insert_at ( content : & str , anchor : InsertAnchor , task_line : & str ) -> ( String , usize ) {
1117+ let temp_dir =
1118+ std:: env:: temp_dir ( ) . join ( format ! ( "lash-test-insert-{:p}" , content. as_ptr( ) ) ) ;
1119+ let _ = fs:: remove_dir_all ( & temp_dir) ;
1120+ fs:: create_dir_all ( & temp_dir) . unwrap ( ) ;
1121+ let file_path = temp_dir. join ( "tasks.md" ) ;
1122+ fs:: write ( & file_path, content) . unwrap ( ) ;
1123+
1124+ let placement = PlacementInfo {
1125+ anchor,
1126+ order_index : 0 ,
1127+ indent_level : 0 ,
1128+ } ;
1129+ let line = MarkdownEmitter :: insert_into_existing ( & file_path, task_line, & [ ] , & placement)
1130+ . expect ( "insert must succeed" ) ;
1131+ let updated = fs:: read_to_string ( & file_path) . unwrap ( ) ;
1132+ let _ = fs:: remove_dir_all ( & temp_dir) ;
1133+ ( updated, line)
1134+ }
1135+
1136+ #[ test]
1137+ fn test_insert_after_a_body_separates_the_new_task_from_it ( ) {
1138+ // GitHub issue #48's expected output: the new task sits below the whole
1139+ // block, with a blank line so it does not read as more of that prose.
1140+ let content = "# T\n \n ## Tasks\n \n - [x] One\n Body prose.\n Acceptance: mine.\n " ;
1141+ let ( updated, line) = insert_at ( content, InsertAnchor :: AfterTaskBlock ( 6 ) , "- [ ] New task" ) ;
1142+
1143+ assert_eq ! (
1144+ updated,
1145+ "# T\n \n ## Tasks\n \n - [x] One\n Body prose.\n Acceptance: mine.\n \n - [ ] New task\n "
1146+ ) ;
1147+ assert_eq ! ( line, 9 , "reported line must be where the task really is" ) ;
1148+ }
1149+
1150+ #[ test]
1151+ fn test_insert_after_a_bodyless_task_adds_no_separator ( ) {
1152+ let content = "# T\n \n ## Tasks\n \n - [ ] One\n " ;
1153+ let ( updated, line) = insert_at ( content, InsertAnchor :: AfterTaskBlock ( 6 ) , "- [ ] New task" ) ;
1154+
1155+ assert_eq ! ( updated, "# T\n \n ## Tasks\n \n - [ ] One\n - [ ] New task\n " ) ;
1156+ assert_eq ! ( line, 6 ) ;
1157+ }
1158+
9891159 /// Emit `note` as annotation lines, then read it back the way the parser
9901160 /// would when it encounters those lines under a task.
9911161 fn round_trip_agent_note ( indent : usize , note : & str ) -> Option < String > {
0 commit comments