Skip to content

Commit 25eee6b

Browse files
committed
Merge remote-tracking branch 'origin/main' into fix/54-id-derivation-drift
# Conflicts: # CHANGELOG.md # devlog.md
2 parents 5f8f679 + 34c9ce3 commit 25eee6b

11 files changed

Lines changed: 818 additions & 43 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,19 @@ While the major version is 0, minor version bumps may contain breaking changes.
4242
derivation change moved, rather than at a task that is missing. Without it
4343
the error reads as a false positive: the ID it names is exactly the one
4444
`lash show` prints back.
45+
- `lash add --before/--after` now accept the file-qualified task ID that `lash
46+
show` and `lash list` print (`index#beta-task`), not just the bare slug. The
47+
target file is already fixed by `--file`, so the qualifier was redundant, but
48+
passing it back failed with "task not found" — which read as the task being
49+
missing rather than the argument being spelled the way lash spells it. A
50+
qualifier naming a *different* file is still rejected, since that means the
51+
task was expected somewhere it is not. The not-found error now lists the IDs
52+
that do exist at that level.
53+
- `lash add --dry-run` now resolves the request instead of echoing it back. It
54+
never opened the target file, so it reported success for a `--before` naming
55+
a task that did not exist and the real add then failed on the same argument.
56+
Dry run and the real add now share one code path, and dry run reports the
57+
insert line it resolved.
4558

4659
## [0.3.1] - 2026-08-11
4760

‎crates/lash-cli/src/commands/add.rs‎

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use anyhow::{Context as AnyhowContext, Result};
66
use clap::Args;
77
use lash::theme::CliTheme;
8+
use lash_core::creation::placement::InsertAnchor;
89
use lash_core::creation::service::TaskCreationService;
910
use lash_types::creation::{
1011
FileTarget, InsertPosition, ParentRef, TaskCreationRequest, TaskCreationRequestBuilder,
@@ -166,16 +167,17 @@ pub fn execute(args: &AddArgs) -> Result<i32> {
166167
depends_on_warnings = validation.warnings;
167168
}
168169

169-
// 4. Handle dry-run mode
170-
if args.dry_run {
171-
return handle_dry_run(&request, args);
172-
}
173-
174-
// 5. Create service and execute
170+
// 4. Create the service. Dry run needs it too: it reports what would
171+
// happen by actually resolving it, not by echoing the request back.
175172
let config = lash_types::config::LashConfig::from_root(&project_root)
176173
.unwrap_or_else(|_| lash_types::config::LashConfig::default());
177174
let service = TaskCreationService::new(config.clone());
178175

176+
// 5. Handle dry-run mode
177+
if args.dry_run {
178+
return handle_dry_run(&service, &request, args, theme.as_ref());
179+
}
180+
179181
match service.create_task(&request) {
180182
Ok(result) => {
181183
// 6. Re-index to update the database with the new task
@@ -388,9 +390,27 @@ fn output_errors(
388390
}
389391

390392
/// Handle dry-run mode
391-
#[allow(clippy::unnecessary_wraps)]
392-
fn handle_dry_run(request: &TaskCreationRequest, _args: &AddArgs) -> Result<i32> {
393-
// In dry-run mode, we just validate and show what would be created
393+
///
394+
/// Resolves the request exactly as a real add would — parsing the target
395+
/// file, validating against it, and locating the insert position — and
396+
/// reports the outcome without writing. Reporting the request back
397+
/// unexamined, as this used to, made `--dry-run` a false green: it passed for
398+
/// a `--before` naming a task that did not exist, and the real add then failed
399+
/// on the same argument (GitHub issue #53).
400+
fn handle_dry_run(
401+
service: &TaskCreationService,
402+
request: &TaskCreationRequest,
403+
args: &AddArgs,
404+
theme: Option<&CliTheme>,
405+
) -> Result<i32> {
406+
let plan = match service.plan_task(request) {
407+
Ok(plan) => plan,
408+
Err(errors) => {
409+
output_errors(&errors, &args.format, theme)?;
410+
return Ok(1);
411+
}
412+
};
413+
394414
println!("Validation passed. Task would be created:");
395415
println!(" Title: {}", request.title);
396416

@@ -415,13 +435,24 @@ fn handle_dry_run(request: &TaskCreationRequest, _args: &AddArgs) -> Result<i32>
415435
ParentRef::AppendAtDepth(depth) => println!(" Parent: at depth {depth}"),
416436
}
417437

418-
// Position
438+
// Position, as asked for and as resolved
419439
match &request.position {
420440
InsertPosition::Append => println!(" Position: append"),
421441
InsertPosition::AtIndex(idx) => println!(" Position: at index {idx}"),
422442
InsertPosition::Before(id) => println!(" Position: before {id}"),
423443
InsertPosition::After(id) => println!(" Position: after {id}"),
424444
}
445+
match plan.placement.anchor {
446+
InsertAnchor::Line(line) => println!(" Insert at: line {line}"),
447+
// The parser records a task's checkbox and annotation lines but not
448+
// the free-text body underneath it, so the emitter may push the
449+
// insertion further down than this. Saying so beats printing a line
450+
// number that turns out to be wrong.
451+
InsertAnchor::AfterTaskBlock(line) => {
452+
println!(" Insert at: line {line} or below, past the preceding task's body");
453+
}
454+
InsertAnchor::EndOfTasksSection => println!(" Insert at: end of the ## Tasks section"),
455+
}
425456

426457
// Status
427458
if let Some(ref status) = request.status {

‎crates/lash-cli/tests/add_command_test.rs‎

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1072,3 +1072,282 @@ fn test_add_reported_id_resolves_as_a_dependency_target() {
10721072
.success()
10731073
.stderr(predicate::str::contains("Warning").not());
10741074
}
1075+
1076+
// ---------------------------------------------------------------------
1077+
// Issue #53: `--before`/`--after` reject the qualified ID lash prints,
1078+
// and `--dry-run` does not resolve the position at all
1079+
// ---------------------------------------------------------------------
1080+
1081+
/// A two-task file, so there is something to position against.
1082+
fn project_with_two_tasks() -> TestProject {
1083+
TestProject::builder()
1084+
.with_index("test-project", "Test Project")
1085+
.with_file(
1086+
"tasks.md",
1087+
r#"# Tasks
1088+
1089+
@id: tasks
1090+
1091+
## Tasks
1092+
1093+
- [ ] Alpha task
1094+
- [ ] Beta task
1095+
"#,
1096+
)
1097+
.build()
1098+
}
1099+
1100+
/// The order top-level task titles appear in `tasks.md`.
1101+
fn task_titles(project: &TestProject) -> Vec<String> {
1102+
fs::read_to_string(project.file_path("tasks.md"))
1103+
.unwrap()
1104+
.lines()
1105+
.filter_map(|line| line.trim_start().strip_prefix("- [ ] ").map(str::to_string))
1106+
.collect()
1107+
}
1108+
1109+
#[test]
1110+
fn test_add_before_accepts_the_qualified_id_that_show_prints() {
1111+
// `lash show` reports `tasks#beta-task`; pasting that back into --before
1112+
// used to fail with "task not found" even though the task existed.
1113+
let project = project_with_two_tasks();
1114+
index(&project);
1115+
1116+
run_lash_command()
1117+
.arg("--root")
1118+
.arg(project.path())
1119+
.arg("add")
1120+
.arg("Gamma")
1121+
.arg("--file")
1122+
.arg("tasks.md")
1123+
.arg("--before")
1124+
.arg("tasks#beta-task")
1125+
.assert()
1126+
.success();
1127+
1128+
assert_eq!(
1129+
task_titles(&project),
1130+
vec!["Alpha task", "Gamma", "Beta task"],
1131+
"Gamma should sit between Alpha and Beta"
1132+
);
1133+
}
1134+
1135+
#[test]
1136+
fn test_add_after_accepts_the_qualified_id_that_show_prints() {
1137+
let project = project_with_two_tasks();
1138+
index(&project);
1139+
1140+
run_lash_command()
1141+
.arg("--root")
1142+
.arg(project.path())
1143+
.arg("add")
1144+
.arg("Gamma")
1145+
.arg("--file")
1146+
.arg("tasks.md")
1147+
.arg("--after")
1148+
.arg("tasks#alpha-task")
1149+
.assert()
1150+
.success();
1151+
1152+
assert_eq!(
1153+
task_titles(&project),
1154+
vec!["Alpha task", "Gamma", "Beta task"]
1155+
);
1156+
}
1157+
1158+
#[test]
1159+
fn test_add_before_still_accepts_the_bare_slug() {
1160+
// The form that already worked must keep working.
1161+
let project = project_with_two_tasks();
1162+
index(&project);
1163+
1164+
run_lash_command()
1165+
.arg("--root")
1166+
.arg(project.path())
1167+
.arg("add")
1168+
.arg("Gamma")
1169+
.arg("--file")
1170+
.arg("tasks.md")
1171+
.arg("--before")
1172+
.arg("beta-task")
1173+
.assert()
1174+
.success();
1175+
1176+
assert_eq!(
1177+
task_titles(&project),
1178+
vec!["Alpha task", "Gamma", "Beta task"]
1179+
);
1180+
}
1181+
1182+
#[test]
1183+
fn test_add_before_accepts_the_file_path_as_qualifier() {
1184+
// `tasks.md#beta-task` is the other spelling a caller can have in hand,
1185+
// since `@depends-on` references are written against paths.
1186+
let project = project_with_two_tasks();
1187+
index(&project);
1188+
1189+
run_lash_command()
1190+
.arg("--root")
1191+
.arg(project.path())
1192+
.arg("add")
1193+
.arg("Gamma")
1194+
.arg("--file")
1195+
.arg("tasks.md")
1196+
.arg("--before")
1197+
.arg("tasks.md#task:beta-task")
1198+
.assert()
1199+
.success();
1200+
1201+
assert_eq!(
1202+
task_titles(&project),
1203+
vec!["Alpha task", "Gamma", "Beta task"]
1204+
);
1205+
}
1206+
1207+
#[test]
1208+
fn test_add_before_rejects_a_qualifier_naming_a_different_file() {
1209+
// Accepting the qualifier must not mean ignoring it: a qualifier naming
1210+
// another file means the caller expected the task somewhere it is not,
1211+
// and inserting next to a same-named task here would be wrong.
1212+
let project = project_with_two_tasks();
1213+
index(&project);
1214+
1215+
run_lash_command()
1216+
.arg("--root")
1217+
.arg(project.path())
1218+
.arg("add")
1219+
.arg("Gamma")
1220+
.arg("--file")
1221+
.arg("tasks.md")
1222+
.arg("--before")
1223+
.arg("index#beta-task")
1224+
.assert()
1225+
.failure()
1226+
.stderr(predicate::str::contains("names file 'index'"));
1227+
1228+
assert_eq!(
1229+
task_titles(&project),
1230+
vec!["Alpha task", "Beta task"],
1231+
"nothing should have been written"
1232+
);
1233+
}
1234+
1235+
#[test]
1236+
fn test_add_dry_run_fails_on_a_position_that_does_not_exist() {
1237+
// Dry run used to echo the requested position back and exit 0, so it
1238+
// passed for arguments the real add rejected.
1239+
let project = project_with_two_tasks();
1240+
index(&project);
1241+
1242+
run_lash_command()
1243+
.arg("--root")
1244+
.arg(project.path())
1245+
.arg("add")
1246+
.arg("Epsilon")
1247+
.arg("--file")
1248+
.arg("tasks.md")
1249+
.arg("--before")
1250+
.arg("no-such-task-at-all")
1251+
.arg("--dry-run")
1252+
.assert()
1253+
.failure()
1254+
.stderr(predicate::str::contains("not found"))
1255+
.stdout(predicate::str::contains("Validation passed").not());
1256+
}
1257+
1258+
#[test]
1259+
fn test_add_dry_run_error_names_the_ids_that_do_exist() {
1260+
let project = project_with_two_tasks();
1261+
index(&project);
1262+
1263+
run_lash_command()
1264+
.arg("--root")
1265+
.arg(project.path())
1266+
.arg("add")
1267+
.arg("Epsilon")
1268+
.arg("--file")
1269+
.arg("tasks.md")
1270+
.arg("--before")
1271+
.arg("no-such-task-at-all")
1272+
.arg("--dry-run")
1273+
.assert()
1274+
.failure()
1275+
.stderr(predicate::str::contains("alpha-task"))
1276+
.stderr(predicate::str::contains("beta-task"));
1277+
}
1278+
1279+
#[test]
1280+
fn test_add_dry_run_reports_the_resolved_insert_line() {
1281+
// The point of dry run is to check placement, so it has to report the
1282+
// placement it resolved rather than the argument it was handed.
1283+
let project = project_with_two_tasks();
1284+
index(&project);
1285+
1286+
run_lash_command()
1287+
.arg("--root")
1288+
.arg(project.path())
1289+
.arg("add")
1290+
.arg("Gamma")
1291+
.arg("--file")
1292+
.arg("tasks.md")
1293+
.arg("--before")
1294+
.arg("tasks#beta-task")
1295+
.arg("--dry-run")
1296+
.assert()
1297+
.success()
1298+
.stdout(predicate::str::contains("Validation passed"))
1299+
// `- [ ] Beta task` is on line 8 of the fixture.
1300+
.stdout(predicate::str::contains("Insert at: line 8"));
1301+
1302+
assert_eq!(
1303+
task_titles(&project),
1304+
vec!["Alpha task", "Beta task"],
1305+
"dry run must not write"
1306+
);
1307+
}
1308+
1309+
#[test]
1310+
fn test_add_dry_run_still_fails_on_other_validation_errors() {
1311+
// Position resolution is the new check, but dry run must keep catching
1312+
// everything it caught before it reached the file at all.
1313+
let project = project_with_two_tasks();
1314+
index(&project);
1315+
1316+
run_lash_command()
1317+
.arg("--root")
1318+
.arg(project.path())
1319+
.arg("add")
1320+
.arg("Gamma")
1321+
.arg("--file")
1322+
.arg("tasks.md")
1323+
.arg("--estimate")
1324+
.arg("not-a-duration")
1325+
.arg("--dry-run")
1326+
.assert()
1327+
.failure()
1328+
.stderr(predicate::str::contains("E_CREATE_INVALID_ESTIMATE"));
1329+
}
1330+
1331+
#[test]
1332+
fn test_add_dry_run_passes_for_a_plain_append() {
1333+
let project = project_with_two_tasks();
1334+
index(&project);
1335+
1336+
run_lash_command()
1337+
.arg("--root")
1338+
.arg(project.path())
1339+
.arg("add")
1340+
.arg("Gamma")
1341+
.arg("--file")
1342+
.arg("tasks.md")
1343+
.arg("--dry-run")
1344+
.assert()
1345+
.success()
1346+
.stdout(predicate::str::contains("Validation passed"));
1347+
1348+
assert_eq!(
1349+
task_titles(&project),
1350+
vec!["Alpha task", "Beta task"],
1351+
"dry run must not write"
1352+
);
1353+
}

0 commit comments

Comments
 (0)