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
1 change: 1 addition & 0 deletions crates/lash-cli/src/commands/complete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,7 @@ mod tests {
parent_id: None,
order_index: 0,
line_number: 0,
annotation_line_count: 0,
metadata: TaskMetadata {
depends_on: deps
.iter()
Expand Down
1 change: 1 addition & 0 deletions crates/lash-cli/src/utils/task_target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ mod tests {
parent_id: None,
order_index: order,
line_number: 0,
annotation_line_count: 0,
metadata: TaskMetadata::default(),
body: None,
contextual_notes: Vec::new(),
Expand Down
140 changes: 140 additions & 0 deletions crates/lash-cli/tests/add_command_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -654,3 +654,143 @@ fn test_add_rejects_an_agent_note_line_starting_with_an_annotation() {
let content = fs::read_to_string(project.file_path("tasks.md")).unwrap();
assert!(!content.contains("At note task"));
}

// ---------------------------------------------------------------------
// `@depends-on` written in comma form
//
// `count_annotation_lines` added one line per parsed dependency, which
// holds for what the emitter writes but not for a hand-written
// `@depends-on: a, b, c`. The parser splits that single line into three
// references, so the count ran two lines long and the insertion point
// escaped the task block, landing the new task outside `## Tasks`.
// ---------------------------------------------------------------------

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

@id: tasks

## Tasks

- [ ] Alpha
- [ ] Beta
- [ ] Gamma
- [ ] Last task
@depends-on: alpha, beta, gamma

## 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, "@depends-on: alpha, beta, gamma"),
"task must land after the dependency line:\n{content}"
);
assert!(
task < task_line_number(&content, "## Notes"),
"task escaped the Tasks section:\n{content}"
);
assert!(
content.contains("## Notes\n\nProse that must stay under its heading."),
"the Notes heading was split from its prose:\n{content}"
);
}

#[test]
fn test_add_after_one_dependency_per_line_still_clears_the_block() {
// The other half of the ambiguity: the same three references written the
// way the emitter writes them occupy three lines, not one.
let project = TestProject::builder()
.with_index("test-project", "Test Project")
.with_file(
"tasks.md",
r#"# Tasks

@id: tasks

## Tasks

- [ ] Alpha
- [ ] Beta
- [ ] Gamma
- [ ] Last task
@depends-on: alpha
@depends-on: beta
@depends-on: gamma

## 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, "@depends-on: gamma"),
"task was spliced into the dependency block:\n{content}"
);
assert!(
task < task_line_number(&content, "## Notes"),
"task escaped the Tasks section:\n{content}"
);
}

#[test]
fn test_add_after_a_task_whose_labels_use_the_block_form() {
// `@labels:` used to be uncountable for the same reason, since labels can
// also live inline on the checkbox line.
let project = TestProject::builder()
.with_index("test-project", "Test Project")
.with_file(
"tasks.md",
r#"# Tasks

@id: tasks

## Tasks

- [ ] Last task
@labels: backend, infra

## 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, "@labels: backend, infra"),
"task was spliced above the labels line:\n{content}"
);
assert!(
task < task_line_number(&content, "## Notes"),
"task escaped the Tasks section:\n{content}"
);
}
117 changes: 93 additions & 24 deletions crates/lash-core/src/creation/placement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,27 +279,31 @@ impl PlacementResolver {

/// Count the number of annotation lines that follow a task
///
/// Every annotation type is written on its own line after the task
/// checkbox line, so appending correctly requires knowing exactly how
/// many trailing lines belong to the task being appended after —
/// undercounting here inserts the new task in the middle of that
/// annotation block, silently detaching a trailing annotation (e.g. an
/// existing task's `@id:`) from its owner.
/// Appending after a task means stepping past its whole block: the
/// checkbox line plus every annotation line below it. Getting this wrong
/// in either direction is a bug. Undercounting splices the new task into
/// the middle of the annotation block, detaching a trailing annotation
/// from its owner or destroying the continuation lines of a multi-line
/// note. Overcounting pushes the insertion point past the end of the
/// block, which can land the new task outside the `## Tasks` section
/// entirely.
///
/// An annotation value may span several source lines: the parser folds
/// indented continuation lines into the value, joining them with `\n`
/// (see `parser::annotations::parse_annotation_block`). Such a value
/// therefore occupies `value.lines().count()` source lines, not one, and
/// counting it as one splices the new task into the middle of the note —
/// destroying the continuation lines on the next reindex.
/// For a task that came from a file, the count is whatever the parser
/// counted, recorded on [`Task::annotation_line_count`]. It is the only
/// place with the answer, because the same metadata can be written in more
/// than one shape and the parsed [`lash_types::task::TaskMetadata`] does
/// not record which was used:
///
/// `@labels` is deliberately not counted: labels may be written inline
/// on the checkbox line (`#backend`) or as a separate `@labels:` block,
/// and [`lash_types::task::TaskMetadata`] doesn't record which form was
/// used, so it can't be counted without re-reading the source line.
/// `lash add` itself always emits labels inline (see
/// `MarkdownEmitter::format_task_line`), so this only affects appending
/// after a hand-edited task that used the block form.
/// - `@depends-on: a, b, c` on one line, or one `@depends-on:` line each
/// - `#backend` inline on the checkbox line, or an `@labels:` block
/// - a value folded across several indented continuation lines
///
/// For a task built programmatically rather than parsed (the TUI's
/// in-memory tasks, test fixtures), there are no source lines to have
/// counted, so the count is derived from the metadata on the assumption
/// that it will be written the way [`super::emitter::MarkdownEmitter`]
/// writes it: one line per annotation, one line per `@depends-on` and
/// `@doc` reference, labels inline.
fn count_annotation_lines(task: &Task) -> usize {
/// Source lines occupied by a single annotation value.
///
Expand All @@ -309,6 +313,10 @@ impl PlacementResolver {
value.lines().count().max(1)
}

if task.annotation_line_count > 0 {
return task.annotation_line_count;
}

let mut count = 0;

// @id: one line, only when it's an explicit annotation (not a
Expand All @@ -330,11 +338,7 @@ impl PlacementResolver {
}

// @depends-on / @doc: one line per reference, as emitted by
// `MarkdownEmitter::format_task_annotations`. A hand-written
// comma-separated `@depends-on: a, b, c` parses to several references
// from a single line and is over-counted here; that is the same
// inline-vs-block ambiguity described above for `@labels`, and
// over-counting appends after the block rather than inside it.
// `MarkdownEmitter::format_task_annotations`.
count += task.metadata.depends_on.len();
count += task.metadata.docs.len();

Expand Down Expand Up @@ -982,6 +986,71 @@ mod tests {
assert_eq!(placement.order_index, 3);
}

/// An explicit-id dependency reference, the form `lash add --depends-on`
/// produces.
fn dep_ref(target: &str) -> lash_types::DependencyRef {
lash_types::DependencyRef::new(target.to_string(), lash_types::DependencyKind::ExplicitId)
}

#[test]
fn test_parsed_annotation_line_count_wins_over_the_derived_estimate() {
// A hand-written `@depends-on: a, b, c` parses to three references off
// a single line. Deriving the count from the metadata says three
// lines, which pushes the insertion point two lines past the end of
// the task block; the parser's own count says one.
let config = ConfigBuilder::new().build().unwrap();
let validator = TaskValidator::new(config);

let mut task = TaskBuilder::new("Existing task")
.id("existing")
.order_index(0)
.line_number(6)
.build()
.unwrap();
task.metadata.depends_on = ["a", "b", "c"].map(dep_ref).to_vec();
// `@id:` on line 7, `@depends-on: a, b, c` on line 8.
task.annotation_line_count = 2;

let mut tasks = TaskTree::new();
tasks.add_task(task).unwrap();
let file = create_test_file(tasks);

let request = TaskCreationRequestBuilder::new("New task").build();
let ctx = validator.validate(&request, Some(&file)).unwrap();
let placement = PlacementResolver::resolve(&ctx, &request).unwrap();

assert_eq!(placement.anchor.line(), Some(9));
}

#[test]
fn test_unparsed_task_falls_back_to_deriving_the_count() {
// Tasks built in memory rather than parsed have no source lines to
// have counted, so the derived estimate is all there is. It assumes
// the emitter's shape: one line per reference.
let config = ConfigBuilder::new().build().unwrap();
let validator = TaskValidator::new(config);

let mut task = TaskBuilder::new("Existing task")
.id("existing")
.order_index(0)
.line_number(6)
.build()
.unwrap();
task.metadata.depends_on = ["a", "b"].map(dep_ref).to_vec();
assert_eq!(task.annotation_line_count, 0);

let mut tasks = TaskTree::new();
tasks.add_task(task).unwrap();
let file = create_test_file(tasks);

let request = TaskCreationRequestBuilder::new("New task").build();
let ctx = validator.validate(&request, Some(&file)).unwrap();
let placement = PlacementResolver::resolve(&ctx, &request).unwrap();

// Checkbox on 6, `@id:` on 7, two `@depends-on:` lines on 8 and 9.
assert_eq!(placement.anchor.line(), Some(10));
}

#[test]
fn test_append_after_task_with_explicit_id_skips_its_annotation_line() {
// Regression test: appending after a task that carries an explicit
Expand Down
1 change: 1 addition & 0 deletions crates/lash-core/src/formatter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,7 @@ mod tests {
parent_id: parent.map(std::string::ToString::to_string),
order_index: 0,
line_number: 0,
annotation_line_count: 0,
metadata: TaskMetadata::default(),
body: None,
contextual_notes: Vec::new(),
Expand Down
1 change: 1 addition & 0 deletions crates/lash-core/src/linter/linter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ mod tests {
parent_id: None,
order_index: i,
line_number: 0,
annotation_line_count: 0,
metadata: TaskMetadata::default(),
body: None,
contextual_notes: Vec::new(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ mod tests {
parent_id: None,
order_index: 0,
line_number: 0,
annotation_line_count: 0,
metadata,
body: None,
contextual_notes: Vec::new(),
Expand Down
Loading
Loading