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
191 changes: 191 additions & 0 deletions crates/lash-cli/tests/add_command_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -366,3 +366,194 @@ fn test_add_preserves_multiline_agent_note_through_reindex() {
);
}
}

// ---------------------------------------------------------------------
// Appending to a file whose `## Tasks` section is empty
//
// `resolve_append` used to return the magic line number 0 whenever the
// target file held no tasks, meaning "this is a brand-new file, write at
// offset 0". For a file that already existed with an empty `## Tasks`
// section that was catastrophically wrong: the checkbox was prepended
// above the H1, so the parser never saw it as a task, `lash index` found
// nothing, and `lash lint` still passed. The task was on disk and
// invisible.
// ---------------------------------------------------------------------

/// Line the task line lands on, 1-indexed.
fn task_line_number(content: &str, needle: &str) -> usize {
content
.lines()
.position(|line| line.contains(needle))
.map(|idx| idx + 1)
.unwrap_or_else(|| panic!("'{needle}' not found in:\n{content}"))
}

/// Run `lash add`, returning the command's stdout.
fn add_task(project: &TestProject, title: &str, file: &str) -> String {
let output = run_lash_command()
.arg("--root")
.arg(project.path())
.arg("add")
.arg(title)
.arg("--file")
.arg(file)
.assert()
.success()
.get_output()
.stdout
.clone();
String::from_utf8(output).unwrap()
}

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

@id: empty

## Tasks
"#,
)
.build();
index(&project);

let stdout = add_task(&project, "Ship it", "empty.md");

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

assert_eq!(
content.lines().next(),
Some("# Empty"),
"the H1 must still be the first line:\n{content}"
);
// The old sentinel surfaced here too, as a literal `empty.md:0`.
assert!(
stdout.contains(&format!(
"empty.md:{}",
task_line_number(&content, "- [ ] Ship it")
)),
"reported line does not match where the task was written:\n{stdout}"
);
assert!(
task_line_number(&content, "- [ ] Ship it") > task_line_number(&content, "## Tasks"),
"task must land inside the Tasks section:\n{content}"
);
}

#[test]
fn test_add_to_empty_tasks_section_is_indexed() {
// The destructive part of the bug: the task was written to a place the
// parser does not read, so it existed on disk and nowhere else.
let project = TestProject::builder()
.with_index("test-project", "Test Project")
.with_file(
"empty.md",
r#"# Empty

@id: empty

## Tasks
"#,
)
.build();
index(&project);

add_task(&project, "Ship it", "empty.md");
index(&project);

run_lash_command()
.arg("--root")
.arg(project.path())
.arg("list")
.assert()
.success()
.stdout(predicate::str::contains("Ship it"));
}

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

@id: sections

## Tasks

## Notes

Prose that must survive.
"#,
)
.build();
index(&project);

add_task(&project, "Ship it", "sections.md");

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

assert_eq!(
content.lines().next(),
Some("# Sections"),
"the H1 must still be the first line:\n{content}"
);
assert!(
content.contains("Prose that must survive."),
"trailing section content was destroyed:\n{content}"
);

let task = task_line_number(&content, "- [ ] Ship it");
assert!(
task > task_line_number(&content, "## Tasks")
&& task < task_line_number(&content, "## Notes"),
"task escaped the Tasks section:\n{content}"
);
}

#[test]
fn test_add_creating_a_new_file_still_writes_a_well_formed_file() {
// Guards the other half of the split: the old code used one sentinel for
// both "new file" and "existing file with an empty section".
let project = TestProject::builder()
.with_index("test-project", "Test Project")
.build();
index(&project);

add_task(&project, "Ship it", "fresh.md");

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

assert!(
task_line_number(&content, "- [ ] Ship it") > task_line_number(&content, "## Tasks"),
"task must land inside the Tasks section:\n{content}"
);

index(&project);
run_lash_command()
.arg("--root")
.arg(project.path())
.arg("list")
.assert()
.success()
.stdout(predicate::str::contains("Ship it"));
}

#[test]
fn test_add_leaves_the_file_ending_in_a_newline() {
let project = project_with_tasks_file();
index(&project);

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

let content = fs::read_to_string(project.file_path("tasks.md")).unwrap();
assert!(
content.ends_with('\n'),
"file ends mid-line, every later diff shows '\\ No newline at end of file':\n{content}"
);
}
Loading
Loading