Skip to content
Merged
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
137 changes: 137 additions & 0 deletions crates/lash-cli/tests/add_command_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,143 @@ Prose that must stay under its heading.
);
}

// ---------------------------------------------------------------------
// A task's body must stay with its task (GitHub issue #48)
//
// The parser records a task's checkbox line and its annotation lines and
// nothing else, so free-text body under a task — prose, numbered steps,
// acceptance criteria — was invisible when the insertion point was
// computed. `lash add` landed the new task between the previous task's
// title and its own body, silently reassigning that body to the new task.
// `lash lint` passes on the result and `lash show` does not print bodies,
// so nothing surfaced the damage.
// ---------------------------------------------------------------------

#[test]
fn test_add_does_not_steal_the_previous_tasks_body() {
let project = TestProject::builder()
.with_index("test-project", "Test Project")
.with_file(
"polish.md",
r#"# Repro

@id: polish

## Tasks

- [x] First task @id:rep-first
A one-line body belonging to the first task.

- [x] Second task @id:rep-second
This body belongs to the SECOND task and must stay attached to it.
1. First numbered point.
2. Second numbered point.
Acceptance: this paragraph still reads as part of the second task.
"#,
)
.build();
index(&project);

let stdout = add_task(&project, "New task", "polish.md");

let content = fs::read_to_string(project.file_path("polish.md")).unwrap();

let task = task_line_number(&content, "- [ ] New task");
assert!(
task > task_line_number(&content, "Acceptance: this paragraph"),
"the new task split the second task from its body:\n{content}"
);
assert!(
content.contains(
"- [x] Second task @id:rep-second\n This body belongs to the SECOND task and must stay attached to it."
),
"the second task lost its body:\n{content}"
);

// The reported line must be where the task actually is, not where the
// resolver first guessed.
let reported: usize = stdout
.lines()
.find_map(|line| line.rsplit_once(':'))
.and_then(|(_, line_num)| line_num.trim().parse().ok())
.unwrap_or_else(|| panic!("no line number in:\n{stdout}"));
assert_eq!(reported, task, "reported line disagrees with the file");
}

#[test]
fn test_add_does_not_steal_a_body_split_into_paragraphs() {
// A blank line inside a body does not end the block; indented content
// resumes after it.
let project = TestProject::builder()
.with_index("test-project", "Test Project")
.with_file(
"tasks.md",
r#"# Tasks

@id: tasks

## Tasks

- [ ] Only task
First paragraph of the body.

Second paragraph of the body.

## Notes

Prose that must stay under its heading.
"#,
)
.build();
index(&project);

add_task(&project, "Second task", "tasks.md");

let content = fs::read_to_string(project.file_path("tasks.md")).unwrap();

let task = task_line_number(&content, "- [ ] Second task");
assert!(
task > task_line_number(&content, "Second paragraph of the body."),
"the new task split the body's paragraphs:\n{content}"
);
assert!(
task < task_line_number(&content, "## Notes"),
"task escaped the Tasks section:\n{content}"
);
}

#[test]
fn test_add_does_not_steal_a_tasks_contextual_notes() {
// Indented plain bullets are contextual notes on the task above them.
let project = TestProject::builder()
.with_index("test-project", "Test Project")
.with_file(
"tasks.md",
r#"# Tasks

@id: tasks

## Tasks

- [ ] Only task
- a contextual note
- another contextual note
"#,
)
.build();
index(&project);

add_task(&project, "Second task", "tasks.md");

let content = fs::read_to_string(project.file_path("tasks.md")).unwrap();

assert!(
task_line_number(&content, "- [ ] Second task")
> task_line_number(&content, "- another contextual note"),
"the new task was spliced into the note bullets:\n{content}"
);
}

// ---------------------------------------------------------------------
// The reported task ID must be the indexed one
//
Expand Down
174 changes: 172 additions & 2 deletions crates/lash-core/src/creation/emitter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use std::path::{Path, PathBuf};

use super::placement::{InsertAnchor, PlacementInfo};
use super::validation::ValidationContext;
use crate::parser::header;
use crate::parser::{checkbox, header};

/// Markdown emitter for task creation
///
Expand Down Expand Up @@ -161,6 +161,17 @@ impl MarkdownEmitter {
// Copy lines before insertion point
new_lines.extend_from_slice(&lines[..insert_idx]);

// Separate the new task from a preceding task's body. Landing directly
// under the last line of someone else's prose reads as a continuation
// of it, which is the confusion this insertion point exists to avoid.
// Tasks with no body keep butting up against each other as before.
let follows_body = insert_idx
.checked_sub(1)
.is_some_and(|prev| Self::continues_task_block(lines[prev]));
if follows_body {
new_lines.push("");
}

// Insert task line
new_lines.push(task_line);

Expand All @@ -181,7 +192,11 @@ impl MarkdownEmitter {
updated.push('\n');
Self::write_file_atomic(file_path, &updated)?;

Ok(insert_idx + 1)
Ok(if follows_body {
insert_idx + 2
} else {
insert_idx + 1
})
}

/// Translate a [`InsertAnchor`] into a 0-indexed position in `lines`
Expand All @@ -195,6 +210,10 @@ impl MarkdownEmitter {
fn resolve_insert_index(content: &str, lines: &[&str], anchor: InsertAnchor) -> usize {
match anchor {
InsertAnchor::Line(line_number) => line_number.saturating_sub(1).min(lines.len()),
InsertAnchor::AfterTaskBlock(line_number) => {
let start = line_number.saturating_sub(1).min(lines.len());
Self::skip_task_body(lines, start)
}
InsertAnchor::EndOfTasksSection => {
// No `## Tasks` heading at all: append at the end of the file
// rather than guessing where the section would have been.
Expand All @@ -217,6 +236,65 @@ impl MarkdownEmitter {
}
}

/// Advance past the body lines that belong to the preceding task
///
/// A parsed task records its checkbox line and its annotation lines and
/// nothing else, so the free-text body a task may carry underneath —
/// prose, numbered steps, acceptance criteria, indented contextual note
/// bullets — is invisible to the placement resolver. Anchoring on parsed
/// data alone therefore lands "after this task" between the task's title
/// and its own body, which silently reassigns that body to the newly
/// inserted task (GitHub issue #48). Only the source text says where the
/// block actually ends.
///
/// A line continues the block when it is indented and is not itself a
/// checkbox; a checkbox at any depth starts a new task, and an
/// unindented line has left the block. Blank lines are consumed only when
/// indented content resumes after them, so a body split into paragraphs
/// stays whole while a blank line that genuinely ends the block still
/// stops the scan.
///
/// `start` is a 0-indexed position in `lines`; the return value is the
/// 0-indexed position to insert at.
fn skip_task_body(lines: &[&str], start: usize) -> usize {
let mut idx = start;

while idx < lines.len() {
if lines[idx].trim().is_empty() {
// A blank line only belongs to the block if the block resumes
// after it. Otherwise the block ended at this blank line.
let resumes = lines[idx..]
.iter()
.position(|line| !line.trim().is_empty())
.map(|offset| idx + offset)
.filter(|&next| Self::continues_task_block(lines[next]));

match resumes {
Some(next) => idx = next,
None => break,
}
} else if Self::continues_task_block(lines[idx]) {
idx += 1;
} else {
break;
}
}

idx
}

/// Does this line continue the block that a task's checkbox line opened?
///
/// Indented non-checkbox content belongs to the task above it: body prose,
/// folded annotation values, contextual note bullets. A checkbox at any
/// depth starts a new task, and an unindented line has left the block
/// entirely.
fn continues_task_block(line: &str) -> bool {
!line.trim().is_empty()
&& line.starts_with(char::is_whitespace)
&& checkbox::CheckboxLine::parse(line, 1).is_none()
}

/// Create a new task file with proper structure
///
/// Generates a complete task file with header, metadata, and the first task.
Expand Down Expand Up @@ -986,6 +1064,98 @@ mod tests {
assert_eq!(insert_index(content, InsertAnchor::Line(99)), 3);
}

#[test]
fn test_after_task_block_steps_over_the_previous_tasks_body() {
// GitHub issue #48: the parser records the checkbox line and nothing
// about the body under it, so the resolver anchors on line 6 — between
// the task's title and its own body. The body must stay with its task.
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";
assert_eq!(insert_index(content, InsertAnchor::AfterTaskBlock(6)), 8);
}

#[test]
fn test_after_task_block_keeps_a_body_split_into_paragraphs_whole() {
// A blank line inside a body is not the end of the block: indented
// content resumes after it.
let content =
"# T\n\n## Tasks\n\n- [x] One\n First paragraph.\n\n Second paragraph.\n\n## Notes\n";
assert_eq!(insert_index(content, InsertAnchor::AfterTaskBlock(6)), 8);
}

#[test]
fn test_after_task_block_stops_at_a_blank_line_that_ends_the_block() {
// Nothing indented follows the blank, so the block really did end.
let content = "# T\n\n## Tasks\n\n- [x] One\n Body.\n\nUnindented prose.\n";
assert_eq!(insert_index(content, InsertAnchor::AfterTaskBlock(6)), 6);
}

#[test]
fn test_after_task_block_stops_at_the_next_checkbox() {
// An indented checkbox is a child task, not body text.
let content = "# T\n\n## Tasks\n\n- [x] One\n - [ ] Child\n- [ ] Two\n";
assert_eq!(insert_index(content, InsertAnchor::AfterTaskBlock(6)), 5);
}

#[test]
fn test_after_task_block_leaves_bodyless_tasks_where_they_were() {
// The common case must resolve exactly like `Line` did before.
let content = "# T\n\n## Tasks\n\n- [ ] One\n- [ ] Two\n";
assert_eq!(insert_index(content, InsertAnchor::AfterTaskBlock(6)), 5);
assert_eq!(insert_index(content, InsertAnchor::AfterTaskBlock(7)), 6);
}

#[test]
fn test_after_task_block_absorbs_indented_note_bullets() {
// Contextual note bullets belong to the task above them.
let content = "# T\n\n## Tasks\n\n- [x] One\n - a note\n - another note\n";
assert_eq!(insert_index(content, InsertAnchor::AfterTaskBlock(6)), 7);
}

/// Insert `task_line` into `content` at `anchor`, returning the new file
/// text and the 1-indexed line the task landed on.
fn insert_at(content: &str, anchor: InsertAnchor, task_line: &str) -> (String, usize) {
let temp_dir =
std::env::temp_dir().join(format!("lash-test-insert-{:p}", content.as_ptr()));
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let file_path = temp_dir.join("tasks.md");
fs::write(&file_path, content).unwrap();

let placement = PlacementInfo {
anchor,
order_index: 0,
indent_level: 0,
};
let line = MarkdownEmitter::insert_into_existing(&file_path, task_line, &[], &placement)
.expect("insert must succeed");
let updated = fs::read_to_string(&file_path).unwrap();
let _ = fs::remove_dir_all(&temp_dir);
(updated, line)
}

#[test]
fn test_insert_after_a_body_separates_the_new_task_from_it() {
// GitHub issue #48's expected output: the new task sits below the whole
// block, with a blank line so it does not read as more of that prose.
let content = "# T\n\n## Tasks\n\n- [x] One\n Body prose.\n Acceptance: mine.\n";
let (updated, line) = insert_at(content, InsertAnchor::AfterTaskBlock(6), "- [ ] New task");

assert_eq!(
updated,
"# T\n\n## Tasks\n\n- [x] One\n Body prose.\n Acceptance: mine.\n\n- [ ] New task\n"
);
assert_eq!(line, 9, "reported line must be where the task really is");
}

#[test]
fn test_insert_after_a_bodyless_task_adds_no_separator() {
let content = "# T\n\n## Tasks\n\n- [ ] One\n";
let (updated, line) = insert_at(content, InsertAnchor::AfterTaskBlock(6), "- [ ] New task");

assert_eq!(updated, "# T\n\n## Tasks\n\n- [ ] One\n- [ ] New task\n");
assert_eq!(line, 6);
}

/// Emit `note` as annotation lines, then read it back the way the parser
/// would when it encounters those lines under a task.
fn round_trip_agent_note(indent: usize, note: &str) -> Option<String> {
Expand Down
Loading
Loading