@richhaase/c2 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,776 @@
1
+ import { beforeAll, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { isAbsolute, join, sep } from "node:path";
5
+
6
+ let home: string;
7
+
8
+ function ymd(d: Date): string {
9
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
10
+ const dd = String(d.getDate()).padStart(2, "0");
11
+ return `${d.getFullYear()}-${mm}-${dd}`;
12
+ }
13
+
14
+ function daysFromNow(n: number): Date {
15
+ const d = new Date();
16
+ d.setDate(d.getDate() + n);
17
+ return d;
18
+ }
19
+
20
+ const RECENT = ymd(daysFromNow(-2));
21
+ const OLDER = ymd(daysFromNow(-9));
22
+ const FUTURE = ymd(daysFromNow(365));
23
+
24
+ function run(
25
+ args: string[],
26
+ opts: { cwd?: string; home?: string } = {},
27
+ ): { code: number; stdout: string; stderr: string } {
28
+ const proc = Bun.spawnSync({
29
+ cmd: ["bun", join(import.meta.dir, "index.ts"), ...args],
30
+ env: { ...process.env, HOME: opts.home ?? home },
31
+ cwd: opts.cwd,
32
+ stdout: "pipe",
33
+ stderr: "pipe",
34
+ });
35
+ return {
36
+ code: proc.exitCode,
37
+ stdout: proc.stdout.toString(),
38
+ stderr: proc.stderr.toString(),
39
+ };
40
+ }
41
+
42
+ beforeAll(async () => {
43
+ home = await mkdtemp(join(tmpdir(), "c2-cli-test-"));
44
+ const dataDir = join(home, ".config", "c2", "data");
45
+ await mkdir(join(dataDir, "strokes"), { recursive: true });
46
+ await writeFile(
47
+ join(home, ".config", "c2", "config.json"),
48
+ JSON.stringify({
49
+ api: { base_url: "https://log.concept2.com", token: "tok" },
50
+ goal: {
51
+ target_meters: 1_000_000,
52
+ start_date: ymd(daysFromNow(-180)),
53
+ end_date: ymd(daysFromNow(180)),
54
+ },
55
+ }),
56
+ "utf-8",
57
+ );
58
+ const workouts = [
59
+ {
60
+ id: 1,
61
+ user_id: 1,
62
+ date: `${RECENT} 08:00:00`,
63
+ distance: 8000,
64
+ type: "rower",
65
+ time: 12000,
66
+ time_formatted: "20:00.0",
67
+ stroke_rate: 24,
68
+ heart_rate: { average: 140 },
69
+ comments: "felt strong",
70
+ workout: {
71
+ targets: { pace: 1500 },
72
+ splits: [
73
+ {
74
+ type: "distance",
75
+ time: 6100,
76
+ distance: 4000,
77
+ stroke_rate: 24,
78
+ heart_rate: { average: 135, max: 142 },
79
+ },
80
+ {
81
+ type: "distance",
82
+ time: 5900,
83
+ distance: 4000,
84
+ stroke_rate: 25,
85
+ heart_rate: { average: 144, max: 150 },
86
+ },
87
+ ],
88
+ },
89
+ },
90
+ {
91
+ id: 2,
92
+ user_id: 1,
93
+ date: `${OLDER} 08:00:00`,
94
+ distance: 6000,
95
+ type: "rower",
96
+ time: 9000,
97
+ time_formatted: "15:00.0",
98
+ },
99
+ ];
100
+ await writeFile(
101
+ join(dataDir, "workouts.jsonl"),
102
+ `${workouts.map((w) => JSON.stringify(w)).join("\n")}\n`,
103
+ "utf-8",
104
+ );
105
+ await writeFile(
106
+ join(dataDir, "strokes", "1.jsonl"),
107
+ `${JSON.stringify({ t: 100, d: 500, p: 1500, spm: 24, hr: 130 })}\n${JSON.stringify({ t: 200, d: 1000, p: 1480, spm: 25, hr: 140 })}\n`,
108
+ "utf-8",
109
+ );
110
+ });
111
+
112
+ test("bare c2 prints help and exits 0", () => {
113
+ const r = run([]);
114
+ expect(r.code).toBe(0);
115
+ expect(r.stdout).toContain("Usage: c2");
116
+ expect(r.stdout).not.toContain("Report written");
117
+ });
118
+
119
+ test("unknown command errors cleanly", () => {
120
+ const r = run(["statsu"]);
121
+ expect(r.code).toBe(1);
122
+ expect(r.stderr).toContain("unknown command");
123
+ expect(r.stderr).not.toContain("report");
124
+ });
125
+
126
+ test("status --json emits versioned envelope", () => {
127
+ const r = run(["status", "--json"]);
128
+ expect(r.code).toBe(0);
129
+ const parsed = JSON.parse(r.stdout);
130
+ expect(parsed.schema).toBe("c2.status.v1");
131
+ expect(parsed.data.goal.totalMeters).toBe(14000);
132
+ expect(parsed.data.this_week).toBeDefined();
133
+ expect(parsed.data.recent_weeks.length).toBe(4);
134
+ });
135
+
136
+ test("log --json emits workout rows", () => {
137
+ const r = run(["log", "--json"]);
138
+ expect(r.code).toBe(0);
139
+ const parsed = JSON.parse(r.stdout);
140
+ expect(parsed.schema).toBe("c2.log.v1");
141
+ expect(parsed.data.count).toBe(2);
142
+ expect(parsed.data.workouts[0].id).toBe(1);
143
+ expect(parsed.data.workouts[0].pace_500m).toBe("1:15.0");
144
+ expect(parsed.data.workouts[0].comments).toBe("felt strong");
145
+ });
146
+
147
+ test("log human output shows comments", () => {
148
+ const r = run(["log"]);
149
+ expect(r.code).toBe(0);
150
+ expect(r.stdout).toContain("— felt strong");
151
+ });
152
+
153
+ test("log date filters work and reject garbage", () => {
154
+ const filtered = run(["log", "--from", RECENT, "--json"]);
155
+ expect(JSON.parse(filtered.stdout).data.count).toBe(1);
156
+
157
+ const bad = run(["log", "--from", "not-a-date"]);
158
+ expect(bad.code).toBe(1);
159
+ expect(bad.stderr).toContain("invalid --from date");
160
+
161
+ const none = run(["log", "--from", FUTURE]);
162
+ expect(none.code).toBe(0);
163
+ expect(none.stdout).toContain("No workouts match");
164
+ });
165
+
166
+ test("trend --json emits week summaries", () => {
167
+ const r = run(["trend", "--json", "-w", "3"]);
168
+ expect(r.code).toBe(0);
169
+ const parsed = JSON.parse(r.stdout);
170
+ expect(parsed.schema).toBe("c2.trend.v1");
171
+ expect(parsed.data.weeks.length).toBe(3);
172
+ const withData = parsed.data.weeks.filter((w: { meters: number }) => w.meters > 0);
173
+ expect(withData.length).toBe(2);
174
+ });
175
+
176
+ test("show resolves last and ids, renders splits and strokes", () => {
177
+ const last = run(["show", "last"]);
178
+ expect(last.code).toBe(0);
179
+ expect(last.stdout).toContain("Id: 1");
180
+ expect(last.stdout).toContain("Splits (negative):");
181
+ expect(last.stdout).toContain("Stroke data: 2 samples");
182
+ expect(last.stdout).toContain("Comments: felt strong");
183
+ expect(last.stdout).toContain("Target pace: 2:30.0/500m");
184
+
185
+ const byId = run(["show", "2"]);
186
+ expect(byId.code).toBe(0);
187
+ expect(byId.stdout).toContain("Id: 2");
188
+ expect(byId.stdout).not.toContain("Splits");
189
+
190
+ const missing = run(["show", "999"]);
191
+ expect(missing.code).toBe(1);
192
+ expect(missing.stderr).toContain("No workout with id 999");
193
+ });
194
+
195
+ test("show --json emits full detail envelope", () => {
196
+ const r = run(["show", "last", "--json"]);
197
+ expect(r.code).toBe(0);
198
+ const parsed = JSON.parse(r.stdout);
199
+ expect(parsed.schema).toBe("c2.show.v1");
200
+ expect(parsed.data.workout.id).toBe(1);
201
+ expect(parsed.data.splits.length).toBe(2);
202
+ expect(parsed.data.splits[0].pace_500m).toBe("1:16.3");
203
+ expect(parsed.data.split_shape).toBe("negative");
204
+ expect(parsed.data.stroke_summary.samples).toBe(2);
205
+ expect(parsed.data.target_pace_500m_seconds).toBe(150);
206
+ expect(parsed.data.raw.time).toBe(12000);
207
+ expect(parsed.data.raw.workout.targets.pace).toBe(1500);
208
+ expect(parsed.data.raw.workout.splits.length).toBe(2);
209
+ });
210
+
211
+ test("stats weekly/goal/splits/hr-pace emit versioned envelopes", () => {
212
+ const weekly = run(["stats", "weekly", "-w", "3", "--json"]);
213
+ expect(weekly.code).toBe(0);
214
+ const weeklyParsed = JSON.parse(weekly.stdout);
215
+ expect(weeklyParsed.schema).toBe("c2.stats.weekly.v1");
216
+ expect(weeklyParsed.data.weeks.length).toBe(3);
217
+
218
+ const goal = run(["stats", "goal", "--json"]);
219
+ expect(goal.code).toBe(0);
220
+ const goalParsed = JSON.parse(goal.stdout);
221
+ expect(goalParsed.schema).toBe("c2.stats.goal.v1");
222
+ expect(goalParsed.data.goal.totalMeters).toBe(14000);
223
+ expect(goalParsed.data.projection.projected_total_meters).toBeGreaterThan(0);
224
+ expect(goalParsed.data.this_week).toBeDefined();
225
+
226
+ const splits = run(["stats", "splits", "1", "--json"]);
227
+ expect(splits.code).toBe(0);
228
+ const splitsParsed = JSON.parse(splits.stdout);
229
+ expect(splitsParsed.schema).toBe("c2.stats.splits.v1");
230
+ expect(splitsParsed.data.split_shape).toBe("negative");
231
+ expect(splitsParsed.data.splits.length).toBe(2);
232
+
233
+ const hr = run(["stats", "hr-pace", "-w", "4", "--json"]);
234
+ expect(hr.code).toBe(0);
235
+ const hrParsed = JSON.parse(hr.stdout);
236
+ expect(hrParsed.schema).toBe("c2.stats.hr-pace.v1");
237
+ expect(Array.isArray(hrParsed.data.bands)).toBe(true);
238
+ });
239
+
240
+ test("stats splits handles workouts without split data", () => {
241
+ const r = run(["stats", "splits", "2"]);
242
+ expect(r.code).toBe(0);
243
+ expect(r.stdout).toContain("no split data");
244
+ });
245
+
246
+ test("note add/list/show round-trip through the CLI", () => {
247
+ const added = run([
248
+ "note",
249
+ "add",
250
+ "--type",
251
+ "subjective",
252
+ "--workout",
253
+ "last",
254
+ "--tags",
255
+ "hr,ramp",
256
+ "felt slow early, opened up late",
257
+ ]);
258
+ expect(added.code).toBe(0);
259
+ const noteId = added.stdout.trim();
260
+ expect(noteId.length).toBe(26);
261
+
262
+ const list = run(["note", "list", "--json"]);
263
+ const parsed = JSON.parse(list.stdout);
264
+ expect(parsed.schema).toBe("c2.notes.v1");
265
+ expect(parsed.data.count).toBe(1);
266
+ expect(parsed.data.notes[0].workout_id).toBe(1);
267
+ expect(parsed.data.notes[0].tags).toEqual(["hr", "ramp"]);
268
+
269
+ const shown = run(["note", "show", noteId]);
270
+ expect(shown.code).toBe(0);
271
+ expect(shown.stdout).toContain("felt slow early");
272
+
273
+ const filtered = run(["note", "list", "--type", "lesson"]);
274
+ expect(filtered.stdout).toContain("No notes found");
275
+
276
+ const badType = run(["note", "add", "--type", "vibes", "x"]);
277
+ expect(badType.code).toBe(1);
278
+ expect(badType.stderr).toContain("--type must be one of");
279
+
280
+ const rollover = run(["note", "add", "--date", "2026-02-31", "x"]);
281
+ expect(rollover.code).toBe(1);
282
+ expect(rollover.stderr).toContain('invalid --date "2026-02-31"');
283
+
284
+ const partial = run(["note", "add", "--date", "2026-07", "x"]);
285
+ expect(partial.code).toBe(1);
286
+ expect(partial.stderr).toContain('invalid --date "2026-07"');
287
+
288
+ const badWorkout = run(["note", "list", "--workout", "banana"]);
289
+ expect(badWorkout.code).toBe(1);
290
+ expect(badWorkout.stderr).toContain('invalid --workout id "banana"');
291
+
292
+ const badFilter = run(["note", "list", "--type", "vibes"]);
293
+ expect(badFilter.code).toBe(1);
294
+ expect(badFilter.stderr).toContain("--type must be one of");
295
+ });
296
+
297
+ test("failed workout links leave no store behind", async () => {
298
+ const home8 = await mkdtemp(join(tmpdir(), "c2-cli-noworkout-"));
299
+ await mkdir(join(home8, ".config", "c2"), { recursive: true });
300
+ await writeFile(join(home8, ".config", "c2", "config.json"), JSON.stringify({}), "utf-8");
301
+
302
+ const bad = run(["note", "add", "--workout", "999", "orphan note"], { home: home8 });
303
+ expect(bad.code).toBe(1);
304
+ expect(bad.stderr).toContain('no workout matching "999"');
305
+
306
+ const info = run(["data", "info"], { home: home8 });
307
+ expect(info.code).toBe(1);
308
+ expect(info.stderr).toContain("No data store");
309
+ });
310
+
311
+ test("coaching reads reject foreign directories", async () => {
312
+ const home9 = await mkdtemp(join(tmpdir(), "c2-cli-foreignread-"));
313
+ await mkdir(join(home9, ".config", "c2"), { recursive: true });
314
+ const foreignDir = join(home9, "someones-docs");
315
+ await mkdir(foreignDir);
316
+ await writeFile(join(foreignDir, "plan.md"), "# someone else's plan\n", "utf-8");
317
+ await writeFile(join(foreignDir, "novel.docx"), "chapter one", "utf-8");
318
+ await writeFile(
319
+ join(home9, ".config", "c2", "config.json"),
320
+ JSON.stringify({ data_dir: foreignDir }),
321
+ "utf-8",
322
+ );
323
+
324
+ const planShow = run(["plan", "show"], { home: home9 });
325
+ expect(planShow.code).toBe(1);
326
+ expect(planShow.stderr).toContain("not a c2 data store");
327
+ expect(planShow.stdout).not.toContain("someone else's plan");
328
+
329
+ const noteList = run(["note", "list"], { home: home9 });
330
+ expect(noteList.code).toBe(1);
331
+ expect(noteList.stderr).toContain("not a c2 data store");
332
+
333
+ await writeFile(join(foreignDir, "workouts.jsonl"), "{ not json at all\n", "utf-8");
334
+ const linked = run(["note", "add", "--workout", "1", "x"], { home: home9 });
335
+ expect(linked.code).toBe(1);
336
+ expect(linked.stderr).toContain("not a c2 data store");
337
+ });
338
+
339
+ test("invalid --date rejects before any store side effects", async () => {
340
+ const home7 = await mkdtemp(join(tmpdir(), "c2-cli-nodate-"));
341
+ await mkdir(join(home7, ".config", "c2"), { recursive: true });
342
+ await writeFile(join(home7, ".config", "c2", "config.json"), JSON.stringify({}), "utf-8");
343
+
344
+ const bad = run(["note", "add", "--date", "2026-02-31", "x"], { home: home7 });
345
+ expect(bad.code).toBe(1);
346
+ expect(bad.stderr).toContain('invalid --date "2026-02-31"');
347
+
348
+ const info = run(["data", "info"], { home: home7 });
349
+ expect(info.code).toBe(1);
350
+ expect(info.stderr).toContain("No data store");
351
+ });
352
+
353
+ test("first coaching write initializes a proper store", async () => {
354
+ const home5 = await mkdtemp(join(tmpdir(), "c2-cli-first-write-"));
355
+ await mkdir(join(home5, ".config", "c2"), { recursive: true });
356
+ await writeFile(join(home5, ".config", "c2", "config.json"), JSON.stringify({}), "utf-8");
357
+
358
+ const planFile = join(home5, "p.md");
359
+ await writeFile(planFile, "# Plan\n", "utf-8");
360
+ expect(run(["plan", "set", planFile], { home: home5 }).code).toBe(0);
361
+
362
+ const info = run(["data", "info", "--json"], { home: home5 });
363
+ expect(info.code).toBe(0);
364
+ const parsed = JSON.parse(info.stdout);
365
+ expect(parsed.data.state).toBe("store");
366
+ expect(parsed.data.schema_version).toBe(1);
367
+
368
+ expect(run(["note", "add", "first note ever"], { home: home5 }).code).toBe(0);
369
+ expect(run(["data", "doctor"], { home: home5 }).code).toBe(0);
370
+ });
371
+
372
+ test("note add links into show output", () => {
373
+ const shown = run(["show", "1"]);
374
+ expect(shown.stdout).toContain("Notes:");
375
+ expect(shown.stdout).toContain("felt slow early");
376
+
377
+ const json = run(["show", "1", "--json"]);
378
+ expect(JSON.parse(json.stdout).data.notes.length).toBe(1);
379
+ });
380
+
381
+ test("backdated notes and compaction via data compact", async () => {
382
+ const old = run([
383
+ "note",
384
+ "add",
385
+ "--date",
386
+ ymd(daysFromNow(-30)),
387
+ "--type",
388
+ "lesson",
389
+ "--author",
390
+ "coach",
391
+ "old lesson to archive",
392
+ ]);
393
+ expect(old.code).toBe(0);
394
+
395
+ const compact = run(["data", "compact"]);
396
+ expect(compact.code).toBe(0);
397
+ expect(compact.stdout).toContain("Compacted 1 note");
398
+
399
+ const list = run(["note", "list", "--json"]);
400
+ expect(JSON.parse(list.stdout).data.count).toBe(2);
401
+
402
+ const again = run(["data", "compact"]);
403
+ expect(again.stdout).toContain("Nothing to compact");
404
+
405
+ const doctor = run(["data", "doctor"]);
406
+ expect(doctor.code).toBe(0);
407
+ expect(doctor.stdout).toContain("no problems found");
408
+
409
+ const info = run(["data", "info", "--json"]);
410
+ expect(JSON.parse(info.stdout).data.notes).toBe(2);
411
+ });
412
+
413
+ test("plan, playbook, and narrative round-trip", async () => {
414
+ const planFile = join(home, "plan-src.md");
415
+ await writeFile(planFile, "# Plan\nEvery other day, 6K.\n", "utf-8");
416
+ expect(run(["plan", "set", planFile]).code).toBe(0);
417
+ const plan = run(["plan", "show"]);
418
+ expect(plan.code).toBe(0);
419
+ expect(plan.stdout).toContain("Every other day");
420
+
421
+ const missing = run(["playbook", "show"]);
422
+ expect(missing.code).toBe(1);
423
+ expect(missing.stderr).toContain("No playbook recorded");
424
+
425
+ const narrFile = join(home, "narr.md");
426
+ await writeFile(narrFile, "Solid week.\n", "utf-8");
427
+ expect(run(["narrative", "add", RECENT, narrFile]).code).toBe(0);
428
+ expect(run(["narrative", "show"]).stdout).toContain("Solid week");
429
+ expect(run(["narrative", "show", RECENT]).stdout).toContain("Solid week");
430
+ const narrList = run(["narrative", "list", "--json"]);
431
+ expect(JSON.parse(narrList.stdout).data.dates).toEqual([RECENT]);
432
+
433
+ const badDate = run(["narrative", "add", "not-a-date", narrFile]);
434
+ expect(badDate.code).toBe(1);
435
+ });
436
+
437
+ test("report renders coaching content from the store", async () => {
438
+ const evil = run([
439
+ "note",
440
+ "add",
441
+ "--type",
442
+ "observation",
443
+ "--author",
444
+ "coach",
445
+ "<script>alert(1)</script> & such",
446
+ ]);
447
+ expect(evil.code).toBe(0);
448
+
449
+ const out = join(home, "report.html");
450
+ const r = run(["report", "-o", out, "--no-open"]);
451
+ expect(r.code).toBe(0);
452
+ const html = await readFile(out, "utf-8");
453
+
454
+ expect(html).toContain("Coach's Report");
455
+ expect(html).toContain(RECENT);
456
+ expect(html).toContain("Solid week.");
457
+ expect(html).toContain("Recent Notes");
458
+ expect(html).toContain("felt slow early, opened up late");
459
+ expect(html).toContain("Training Plan");
460
+ expect(html).toContain("Every other day, 6K.");
461
+ expect(html).toContain("&lt;script&gt;alert(1)&lt;/script&gt; &amp; such");
462
+ expect(html).not.toContain("<script>alert(1)");
463
+ });
464
+
465
+ test("report --data emits the full report envelope", () => {
466
+ const r = run(["report", "--data"]);
467
+ expect(r.code).toBe(0);
468
+ const parsed = JSON.parse(r.stdout);
469
+ expect(parsed.schema).toBe("c2.report.v1");
470
+ expect(parsed.data.goal.totalMeters).toBe(14000);
471
+ expect(parsed.data.projection.projected_total_meters).toBeGreaterThan(0);
472
+ expect(parsed.data.weekly.length).toBe(12);
473
+ expect(parsed.data.recent_workouts.length).toBe(2);
474
+ expect(parsed.data.latest_splits.split_shape).toBe("negative");
475
+ expect(parsed.data.narrative.text).toContain("Solid week");
476
+ expect(parsed.data.notes.length).toBeGreaterThanOrEqual(2);
477
+ expect(parsed.data.plan_excerpt).toContain("Every other day");
478
+ });
479
+
480
+ test("report without coaching content has no coaching sections", async () => {
481
+ const home11 = await mkdtemp(join(tmpdir(), "c2-cli-plainreport-"));
482
+ const dataDir = join(home11, ".config", "c2", "data");
483
+ await mkdir(join(dataDir, "strokes"), { recursive: true });
484
+ await writeFile(
485
+ join(home11, ".config", "c2", "config.json"),
486
+ JSON.stringify({
487
+ goal: {
488
+ target_meters: 1_000_000,
489
+ start_date: ymd(daysFromNow(-180)),
490
+ end_date: ymd(daysFromNow(180)),
491
+ },
492
+ }),
493
+ "utf-8",
494
+ );
495
+ await writeFile(
496
+ join(dataDir, "workouts.jsonl"),
497
+ `${JSON.stringify({
498
+ id: 5,
499
+ user_id: 1,
500
+ date: `${RECENT} 08:00:00`,
501
+ distance: 5000,
502
+ type: "rower",
503
+ time: 9000,
504
+ time_formatted: "15:00.0",
505
+ })}\n`,
506
+ "utf-8",
507
+ );
508
+
509
+ const out = join(home11, "plain.html");
510
+ const r = run(["report", "-o", out, "--no-open"], { home: home11 });
511
+ expect(r.code).toBe(0);
512
+ const html = await readFile(out, "utf-8");
513
+ expect(html).toContain("Rowing Progress");
514
+ expect(html).not.toContain("Coach");
515
+ expect(html).not.toContain("Recent Notes");
516
+ expect(html).not.toContain("Training Plan");
517
+ });
518
+
519
+ test("report --data stays machine-readable on empty stores and picks the session's main piece", async () => {
520
+ const home13 = await mkdtemp(join(tmpdir(), "c2-cli-datareport-"));
521
+ const dataDir = join(home13, ".config", "c2", "data");
522
+ await mkdir(join(dataDir, "strokes"), { recursive: true });
523
+ await writeFile(
524
+ join(home13, ".config", "c2", "config.json"),
525
+ JSON.stringify({
526
+ goal: {
527
+ target_meters: 1_000_000,
528
+ start_date: ymd(daysFromNow(-180)),
529
+ end_date: ymd(daysFromNow(180)),
530
+ },
531
+ }),
532
+ "utf-8",
533
+ );
534
+
535
+ const empty = run(["report", "--data"], { home: home13 });
536
+ expect(empty.code).toBe(0);
537
+ const emptyParsed = JSON.parse(empty.stdout);
538
+ expect(emptyParsed.schema).toBe("c2.report.v1");
539
+ expect(emptyParsed.data.goal.totalMeters).toBe(0);
540
+ expect(emptyParsed.data.recent_workouts).toEqual([]);
541
+ expect(emptyParsed.data.latest_splits).toBeNull();
542
+ expect(emptyParsed.data.period.to).toBeNull();
543
+
544
+ const main = {
545
+ id: 10,
546
+ user_id: 1,
547
+ date: `${RECENT} 08:00:00`,
548
+ distance: 6000,
549
+ type: "rower",
550
+ time: 20857,
551
+ time_formatted: "34:45.7",
552
+ workout: {
553
+ splits: [
554
+ { type: "distance", time: 6100, distance: 3000, stroke_rate: 24 },
555
+ { type: "distance", time: 5900, distance: 3000, stroke_rate: 25 },
556
+ ],
557
+ },
558
+ };
559
+ const cooldown = {
560
+ id: 11,
561
+ user_id: 1,
562
+ date: `${RECENT} 09:00:00`,
563
+ distance: 1000,
564
+ type: "rower",
565
+ time: 4000,
566
+ time_formatted: "6:40.0",
567
+ };
568
+ await writeFile(
569
+ join(dataDir, "workouts.jsonl"),
570
+ `${JSON.stringify(main)}\n${JSON.stringify(cooldown)}\n`,
571
+ "utf-8",
572
+ );
573
+
574
+ const withData = run(["report", "--data"], { home: home13 });
575
+ const parsed = JSON.parse(withData.stdout);
576
+ expect(parsed.data.latest_splits.workout_id).toBe(10);
577
+ expect(parsed.data.period.to).toBe(RECENT);
578
+
579
+ const planFile = join(home13, "titled-plan.md");
580
+ await writeFile(
581
+ planFile,
582
+ "# Training Plan\n\n## Current Block\nEvery other day, 6K steady.\n\n## Later\nRamp to 6.5K.\n",
583
+ "utf-8",
584
+ );
585
+ expect(run(["plan", "set", planFile], { home: home13 }).code).toBe(0);
586
+ const withPlan = run(["report", "--data"], { home: home13 });
587
+ const planParsed = JSON.parse(withPlan.stdout);
588
+ expect(planParsed.data.plan_excerpt).toContain("Every other day, 6K steady.");
589
+ expect(planParsed.data.plan_excerpt).not.toContain("Ramp to 6.5K");
590
+ });
591
+
592
+ test("data doctor reports corruption", async () => {
593
+ const info = run(["data", "info", "--json"]);
594
+ const root = JSON.parse(info.stdout).data.root;
595
+ await writeFile(join(root, "notes", "ZZBAD.json"), "{ nope", "utf-8");
596
+ const doctor = run(["data", "doctor"]);
597
+ expect(doctor.code).toBe(1);
598
+ expect(doctor.stderr).toContain("ZZBAD.json: not valid JSON");
599
+ await rm(join(root, "notes", "ZZBAD.json"));
600
+ expect(run(["data", "doctor"]).code).toBe(0);
601
+ });
602
+
603
+ test("export rejects invalid dates but allows empty ranges", () => {
604
+ const bad = run(["export", "--from", "not-a-date"]);
605
+ expect(bad.code).toBe(1);
606
+ expect(bad.stderr).toContain("invalid --from date");
607
+
608
+ const rollover = run(["export", "--from", "2026-02-31"]);
609
+ expect(rollover.code).toBe(1);
610
+ expect(rollover.stderr).toContain("invalid --from date");
611
+
612
+ const empty = run(["export", "--from", FUTURE, "-f", "json"]);
613
+ expect(empty.code).toBe(0);
614
+ expect(empty.stderr).toContain("No workouts match");
615
+ const emptyParsed = JSON.parse(empty.stdout);
616
+ expect(emptyParsed.schema).toBe("c2.export.v1");
617
+ expect(emptyParsed.data).toEqual({ count: 0, workouts: [] });
618
+
619
+ const emptyCSV = run(["export", "--from", FUTURE]);
620
+ expect(emptyCSV.code).toBe(0);
621
+ expect(emptyCSV.stdout.trim().split("\n").length).toBe(1);
622
+ expect(emptyCSV.stdout).toContain("id,date,distance");
623
+ });
624
+
625
+ test("--json emits envelopes even on an empty store", async () => {
626
+ const home2 = await mkdtemp(join(tmpdir(), "c2-cli-empty-"));
627
+ await mkdir(join(home2, ".config", "c2", "data", "strokes"), { recursive: true });
628
+ await writeFile(
629
+ join(home2, ".config", "c2", "config.json"),
630
+ JSON.stringify({
631
+ goal: {
632
+ target_meters: 1_000_000,
633
+ start_date: ymd(daysFromNow(-180)),
634
+ end_date: ymd(daysFromNow(180)),
635
+ },
636
+ }),
637
+ "utf-8",
638
+ );
639
+
640
+ const status = run(["status", "--json"], { home: home2 });
641
+ expect(status.code).toBe(0);
642
+ const statusParsed = JSON.parse(status.stdout);
643
+ expect(statusParsed.schema).toBe("c2.status.v1");
644
+ expect(statusParsed.data.goal.totalMeters).toBe(0);
645
+
646
+ const log = run(["log", "--json"], { home: home2 });
647
+ expect(JSON.parse(log.stdout).data.count).toBe(0);
648
+
649
+ const trend = run(["trend", "--json", "-w", "2"], { home: home2 });
650
+ expect(JSON.parse(trend.stdout).data.weeks.length).toBe(2);
651
+ });
652
+
653
+ test("export json emits a versioned envelope", () => {
654
+ const r = run(["export", "-f", "json"]);
655
+ expect(r.code).toBe(0);
656
+ const parsed = JSON.parse(r.stdout);
657
+ expect(parsed.schema).toBe("c2.export.v1");
658
+ expect(parsed.data.count).toBe(2);
659
+ expect(Array.isArray(parsed.data.workouts)).toBe(true);
660
+ expect(parsed.data.workouts.length).toBe(2);
661
+ expect(parsed.data.workouts[0].date.slice(0, 10)).toBe(OLDER);
662
+ expect(parsed.data.workouts[0].id).toBeDefined();
663
+ expect(parsed.data.workouts[0].distance).toBeDefined();
664
+ });
665
+
666
+ test("data info reports the store", () => {
667
+ const r = run(["data", "info", "--json"]);
668
+ expect(r.code).toBe(0);
669
+ const parsed = JSON.parse(r.stdout);
670
+ expect(parsed.schema).toBe("c2.data.info.v1");
671
+ expect(parsed.data.workouts).toBe(2);
672
+ expect(parsed.data.first_date).toBe(OLDER);
673
+ expect(parsed.data.root).toBe(join(home, ".config", "c2", "data"));
674
+ });
675
+
676
+ test("data move relocates the store and updates config", async () => {
677
+ const target = join(home, "kb-data");
678
+ const r = run(["data", "move", target]);
679
+ expect(r.code).toBe(0);
680
+ expect(r.stdout).toContain("Config updated");
681
+
682
+ const info = run(["data", "info", "--json"]);
683
+ const parsed = JSON.parse(info.stdout);
684
+ expect(parsed.data.root.endsWith(`${sep}kb-data`)).toBe(true);
685
+ expect(parsed.data.workouts).toBe(2);
686
+
687
+ const back = run(["log", "--json"]);
688
+ expect(JSON.parse(back.stdout).data.count).toBe(2);
689
+ });
690
+
691
+ test("data move persists an absolute path for relative targets", async () => {
692
+ const r = run(["data", "move", "rel-store"], { cwd: home });
693
+ expect(r.code).toBe(0);
694
+
695
+ const cfg = JSON.parse(await readFile(join(home, ".config", "c2", "config.json"), "utf-8"));
696
+ expect(isAbsolute(cfg.data_dir)).toBe(true);
697
+ expect(cfg.data_dir.endsWith(`${sep}rel-store`)).toBe(true);
698
+
699
+ const elsewhere = run(["log", "--json"], { cwd: tmpdir() });
700
+ expect(JSON.parse(elsewhere.stdout).data.count).toBe(2);
701
+ });
702
+
703
+ test("data move refuses targets nested in the store", () => {
704
+ const r = run(["data", "move", join(home, "rel-store", "sub")]);
705
+ expect(r.code).toBe(1);
706
+ expect(r.stderr).toContain("inside the current data directory");
707
+ });
708
+
709
+ test("foreign data_dir gets clean errors, not raw failures", async () => {
710
+ const home3 = await mkdtemp(join(tmpdir(), "c2-cli-foreign-"));
711
+ await mkdir(join(home3, ".config", "c2"), { recursive: true });
712
+ const filePath = join(home3, "not-a-dir");
713
+ await writeFile(filePath, "regular file", "utf-8");
714
+ await writeFile(
715
+ join(home3, ".config", "c2", "config.json"),
716
+ JSON.stringify({ data_dir: filePath, api: { token: "tok" } }),
717
+ "utf-8",
718
+ );
719
+
720
+ const info = run(["data", "info"], { home: home3 });
721
+ expect(info.code).toBe(1);
722
+ expect(info.stderr).toContain("not a c2 data store");
723
+
724
+ const move = run(["data", "move", join(home3, "elsewhere")], { home: home3 });
725
+ expect(move.code).toBe(1);
726
+ expect(move.stderr).toContain("not a c2 data store");
727
+
728
+ const sync = run(["sync"], { home: home3 });
729
+ expect(sync.code).toBe(1);
730
+ expect(sync.stderr).toContain("not a c2 data store");
731
+
732
+ const noteAdd = run(["note", "add", "should not land here"], { home: home3 });
733
+ expect(noteAdd.code).toBe(1);
734
+ expect(noteAdd.stderr).toContain("not a c2 data store");
735
+
736
+ const compact = run(["data", "compact"], { home: home3 });
737
+ expect(compact.code).toBe(1);
738
+ expect(compact.stderr).toContain("nothing to compact");
739
+
740
+ await writeFile(
741
+ join(home3, ".config", "c2", "config.json"),
742
+ JSON.stringify({ data_dir: join(filePath, "nested"), api: { token: "tok" } }),
743
+ "utf-8",
744
+ );
745
+ const nested = run(["data", "info"], { home: home3 });
746
+ expect(nested.code).toBe(1);
747
+ expect(nested.stderr).toContain("not a c2 data store");
748
+
749
+ const readThrough = run(["log"], { home: home3 });
750
+ expect(readThrough.code).toBe(0);
751
+ expect(readThrough.stdout).toContain("No workouts found");
752
+
753
+ const emptyDir = join(home3, "empty-dir");
754
+ await mkdir(emptyDir);
755
+ await writeFile(
756
+ join(home3, ".config", "c2", "config.json"),
757
+ JSON.stringify({ data_dir: emptyDir, api: { token: "tok" } }),
758
+ "utf-8",
759
+ );
760
+ const emptyInfo = run(["data", "info"], { home: home3 });
761
+ expect(emptyInfo.code).toBe(1);
762
+ expect(emptyInfo.stderr).toContain("empty directory");
763
+
764
+ const emptyDoctor = run(["data", "doctor"], { home: home3 });
765
+ expect(emptyDoctor.code).toBe(1);
766
+ expect(emptyDoctor.stderr).toContain("No data store");
767
+
768
+ await writeFile(
769
+ join(home3, ".config", "c2", "config.json"),
770
+ JSON.stringify({ data_dir: 123, api: { token: "tok" } }),
771
+ "utf-8",
772
+ );
773
+ const badType = run(["data", "info"], { home: home3 });
774
+ expect(badType.code).toBe(1);
775
+ expect(badType.stderr).toContain(join(".config", "c2", "data"));
776
+ });