vivac 0.17.0

Provenance tree for work: every node knows which node it was born from
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
//! The stack is always a contiguous stretch of its top's lineage (`d554`,
//! `t533` ยง2).
//!
//! `done` and `park` used to unstack a node wherever it sat, which let the
//! stack skip over an ancestor still open below the one just closed. From
//! here on, `done` and `park` only unstack when the node they act on is the
//! stack's own top; anywhere else, the path still runs through it and the
//! spine marks it (`f134`, `f55`).

mod common;
use common::Sandbox;
use serde_json::Value;

fn stack_aliases(c: &Sandbox) -> Vec<String> {
    let s = c.ok(&["stack", "--json"]);
    let v: Value = serde_json::from_str(&s).unwrap_or_else(|e| panic!("not JSON: {e}\n{s}"));
    v["stack"]
        .as_array()
        .expect("stack is an array")
        .iter()
        .map(|n| n["alias"].as_str().unwrap().to_string())
        .collect()
}

fn why_json(c: &Sandbox, id: &str) -> Value {
    let s = c.ok(&["why", id, "--json"]);
    serde_json::from_str(&s).unwrap_or_else(|e| panic!("not JSON: {e}\n{s}"))
}

/// `g > t > u`. `done t` (the middle of the stack, not its top) leaves the
/// stack exactly as it was, the footer reports the same depth, and the spine
/// marks `t` as closed. Popping from `u` afterwards still lands back on `t`.
#[test]
fn done_in_the_middle_of_the_stack_does_not_unstack_it() {
    let c = Sandbox::new_seeded("stack-done-middle");
    c.ok(&["push", "Base goal", "--why", "it anchors the branch"]);
    c.ok(&["push", "Task two", "--why", "the goal needs it"]);
    c.ok(&["push", "Task three", "--why", "one more step"]);
    assert_eq!(stack_aliases(&c), vec!["g1", "t2", "t3"]);

    c.ok(&["done", "t2", "settled early"]);
    assert_eq!(
        stack_aliases(&c),
        vec!["g1", "t2", "t3"],
        "done in the middle popped the stack"
    );
    assert_eq!(why_json(&c, "t2")["node"]["state"], "done");

    let brief = c.ok(&["brief"]);
    assert!(brief.contains("depth 3"), "{brief}");
    assert!(brief.contains("Task two  [closed]"), "{brief}");

    let out = c.ok(&["pop", "wrapped up"]);
    assert!(out.contains("back to t2"), "{out}");
    assert_eq!(stack_aliases(&c), vec!["g1", "t2"]);
}

/// The same shape, with `park` instead of `done`: the middle node stays on
/// the stack and the spine marks it parked.
#[test]
fn park_in_the_middle_of_the_stack_does_not_unstack_it() {
    let c = Sandbox::new_seeded("stack-park-middle");
    c.ok(&["push", "Base goal", "--why", "it anchors the branch"]);
    c.ok(&["push", "Task two", "--why", "the goal needs it"]);
    c.ok(&["push", "Task three", "--why", "one more step"]);

    c.ok(&["park", "t2", "stuck on something else"]);
    assert_eq!(
        stack_aliases(&c),
        vec!["g1", "t2", "t3"],
        "park in the middle popped the stack"
    );
    assert_eq!(why_json(&c, "t2")["node"]["state"], "suspended");

    let brief = c.ok(&["brief"]);
    assert!(brief.contains("Task two  [parked]"), "{brief}");

    let parked = c.ok(&["parked"]);
    assert!(parked.contains("Task two"), "{parked}");
}

/// `g > a > b > c > d`, `done g` (the base, not the top), then another push:
/// the depth advice still names the bottom of the stack, now with its mark.
#[test]
fn the_depth_advice_carries_the_bottom_mark_once_it_is_closed() {
    let c = Sandbox::new_seeded("stack-depth-mark");
    c.ok(&["push", "Base goal", "--why", "root of the branch"]);
    c.ok(&["push", "A", "--why", "first step"]);
    c.ok(&["push", "B", "--why", "second step"]);
    c.ok(&["push", "C", "--why", "third step"]);
    c.ok(&["push", "D", "--why", "fourth step"]);
    assert_eq!(stack_aliases(&c), vec!["g1", "t2", "t3", "t4", "t5"]);

    c.ok(&["done", "g1", "superseded already"]);

    let out = c.ok(&["push", "E", "--why", "fifth step"]);
    assert!(
        out.contains("You are 6 levels away from g1 \"Base goal\" [achieved]."),
        "{out}"
    );
}

/// `f552`: a decision that becomes superseded while still on the stack stays
/// superseded when `pop` finally reaches it, its outcome untouched, and the
/// pop line says so instead of claiming a close.
#[test]
fn a_pop_into_a_superseded_focus_leaves_it_as_it_was() {
    let c = Sandbox::new_seeded("stack-pop-superseded");
    c.ok(&["push", "Base goal", "--why", "root of the branch"]);
    c.ok(&[
        "push",
        "Old approach",
        "--why",
        "the call being made",
        "--type",
        "decision",
    ]);
    c.ok(&["push", "Follow-up", "--why", "work under the decision"]);
    c.ok(&[
        "decide",
        "New approach",
        "--reason",
        "the old one did not hold",
        "--supersedes",
        "2",
    ]);
    assert_eq!(why_json(&c, "d2")["node"]["state"], "superseded");
    assert_eq!(why_json(&c, "d2")["node"]["outcome"], "superseded by d4");

    c.ok(&["pop", "follow-up done"]);
    assert_eq!(stack_aliases(&c), vec!["g1", "d2"]);

    let out = c.ok(&["pop", "moving on"]);
    assert!(
        out.contains("d2  Old approach  -> already superseded, left as it was"),
        "{out}"
    );

    assert_eq!(why_json(&c, "d2")["node"]["state"], "superseded");
    assert_eq!(
        why_json(&c, "d2")["node"]["outcome"],
        "superseded by d4",
        "the pop overwrote the outcome the supersession wrote"
    );
}

/// `f884`/`d885`: `pop` on an ACTIVE decision does not close it either --
/// the same rule `d879` gave `done`, at the other door. The stack still
/// steps back, the decision stays `active`, still standing in the brief,
/// and the log gains a `stack.popped` with no `state.changed` beside it.
#[test]
fn pop_on_an_active_decision_leaves_it_standing_off_the_stack() {
    let c = Sandbox::new_seeded("stack-pop-active-decision");
    c.ok(&["push", "Base goal", "--why", "root of the branch"]);
    c.ok(&[
        "push",
        "Use short-lived tokens",
        "--why",
        "the leak surface is smaller",
        "--type",
        "decision",
    ]);
    assert_eq!(stack_aliases(&c), vec!["g1", "d2"]);
    let log_before = c.log();

    let out = c.ok(&["pop"]);
    assert!(
        out.contains("d2  Use short-lived tokens  -> off the stack, still standing"),
        "{out}"
    );
    assert!(!out.contains("kept as a note"), "{out}");
    assert_eq!(stack_aliases(&c), vec!["g1"]);

    let v = why_json(&c, "d2");
    assert_eq!(v["node"]["state"], "active");
    assert_eq!(v["node"]["notes"].as_array().unwrap().len(), 0, "{v}");

    let new_log = &c.log()[log_before.len()..];
    assert!(
        new_log.contains(r#""type":"stack.popped""#),
        "no stack.popped event was written:\n{new_log}"
    );
    assert!(
        !new_log.contains(r#""type":"state.changed""#),
        "pop closed the decision instead of stepping back:\n{new_log}"
    );

    let brief = c.ok(&["brief"]);
    assert!(
        brief.contains("Use short-lived tokens"),
        "the decision dropped out of STANDING DECISIONS:\n{brief}"
    );
}

/// The outcome text `pop` is given would otherwise be dropped on the floor:
/// it is kept as a note on the decision instead, in the same write.
#[test]
fn pop_on_an_active_decision_keeps_the_outcome_as_a_note() {
    let c = Sandbox::new_seeded("stack-pop-active-decision-note");
    c.ok(&["push", "Base goal", "--why", "root of the branch"]);
    c.ok(&[
        "push",
        "Use short-lived tokens",
        "--why",
        "the leak surface is smaller",
        "--type",
        "decision",
    ]);

    let out = c.ok(&["pop", "rolled out to every service"]);
    assert!(
        out.contains(
            "d2  Use short-lived tokens  -> off the stack, still standing; the outcome is kept as a note"
        ),
        "{out}"
    );

    let v = why_json(&c, "d2");
    assert_eq!(v["node"]["state"], "active");
    let notes = v["node"]["notes"].as_array().expect("notes is an array");
    assert_eq!(notes.len(), 1, "{v}");
    assert_eq!(notes[0]["note"], "rolled out to every service", "{v}");

    let new_log = &c.log()[..];
    assert!(new_log.contains(r#""type":"node.noted""#), "{new_log}");
    assert!(!new_log.contains(r#""type":"state.changed""#), "{new_log}");
}

/// `--force` is for closure conditions; there is nothing to force past
/// here, so it changes nothing about how `pop` treats an active decision.
#[test]
fn force_does_not_change_pop_on_an_active_decision() {
    let c = Sandbox::new_seeded("stack-pop-active-decision-forced");
    c.ok(&["push", "Base goal", "--why", "root of the branch"]);
    c.ok(&[
        "push",
        "Use short-lived tokens",
        "--why",
        "the leak surface is smaller",
        "--type",
        "decision",
    ]);

    let out = c.ok(&["pop", "carried out", "--force"]);
    assert!(
        out.contains(
            "d2  Use short-lived tokens  -> off the stack, still standing; the outcome is kept as a note"
        ),
        "{out}"
    );
    assert_eq!(why_json(&c, "d2")["node"]["state"], "active");
}

/// `restore` rebuilds a contiguous path from the point's frozen stack: it
/// starts at the saved node nearest the root that is the deepest still-open
/// node or one of its ancestors, and reports every closed node still on that
/// path, separately from what fell off the stack entirely.
#[test]
fn restore_reports_closed_nodes_still_on_the_path_apart_from_what_left() {
    let c = Sandbox::new_seeded("stack-restore-path");
    c.ok(&["push", "Base goal", "--why", "root of the branch"]);
    c.ok(&["push", "A", "--why", "first step"]);
    c.ok(&["push", "B", "--why", "second step"]);
    c.ok(&["push", "C", "--why", "third step"]);
    let saved = c.ok(&["save", "checkpoint", "--next", "carry on"]);
    let vivac_num = saved
        .lines()
        .next()
        .unwrap()
        .split_whitespace()
        .next()
        .unwrap()
        .to_string();

    c.ok(&["done", "g1", "closed from below"]);

    let out = c.ok(&["restore", &vivac_num]);
    assert!(
        out.contains("still on the path:  g1 Base goal [achieved]"),
        "{out}"
    );
    assert!(
        !out.contains("no longer on the stack"),
        "nothing should have fallen off yet:\n{out}"
    );
    assert_eq!(stack_aliases(&c), vec!["g1", "t2", "t3", "t4"]);

    c.ok(&["done", "t4", "closed the tip"]);
    let out = c.ok(&["restore", &vivac_num]);
    assert!(
        out.contains("still on the path:  g1 Base goal [achieved]"),
        "{out}"
    );
    assert!(
        out.contains("no longer on the stack:  t4 C [closed]"),
        "{out}"
    );
    assert_eq!(stack_aliases(&c), vec!["g1", "t2", "t3"]);
}

// ---------------------------------------------------------------------------
// `stack --lanes` (`t594` ยง5.5): every lane's own stack, this folder's
// included, marked `(folder gone)` rather than dropped when the registry
// no longer finds that folder on disk.
// ---------------------------------------------------------------------------

/// A second folder, joined to `on`'s own tree as a lane named `name`,
/// with nothing pushed yet -- the same shape `brief`'s own OTHER LANES
/// gone-folder test already uses.
fn join_lane(on: &Sandbox, folder: &str, name: &str) -> Sandbox {
    let joined = Sandbox::new_empty_in(folder, on.global_home());
    joined.ok(&[
        "init",
        "--yes",
        "--join",
        on.0.to_str().unwrap(),
        "--lane-name",
        name,
    ]);
    joined
}

/// `t594` ยง5.5, decision 2: a lane's folder the registry no longer finds
/// on disk is marked, never dropped -- unlike OTHER LANES, `stack
/// --lanes` exists to name every lane, not only the ones still
/// reachable.
#[test]
fn stack_lanes_marks_a_lane_whose_folder_is_gone() {
    let a = Sandbox::new_seeded("stack-lanes-gone");
    a.ok(&["push", "Track the sonar release", "--why", "seed"]);
    let b = join_lane(&a, "stack-lanes-gone-b", "sonar");
    b.ok(&["push", "Ship the sonar dashboard", "--why", "seed"]);

    // With the folder still there, `sonar` shows with no mark.
    let before = a.ok(&["stack", "--lanes", "--json"]);
    let v: Value = serde_json::from_str(&before)
        .unwrap_or_else(|e| panic!("stack --lanes --json did not print an object: {e}\n{before}"));
    let joined = v["lanes"]
        .as_array()
        .expect("lanes is an array")
        .iter()
        .find(|l| l["name"] == "sonar")
        .unwrap_or_else(|| panic!("the joined lane is missing:\n{before}"));
    assert_eq!(joined["folder_gone"], false, "{before}");
    let text = a.ok(&["stack", "--lanes"]);
    assert!(!text.contains("(folder gone)"), "{text}");

    // Gone, and it stays listed but marked -- never dropped, unlike
    // OTHER LANES (`d33`).
    std::fs::remove_dir_all(&b.0).unwrap();
    let after = a.ok(&["stack", "--lanes", "--json"]);
    let v: Value = serde_json::from_str(&after)
        .unwrap_or_else(|e| panic!("stack --lanes --json did not print an object: {e}\n{after}"));
    let joined = v["lanes"]
        .as_array()
        .expect("lanes is an array")
        .iter()
        .find(|l| l["name"] == "sonar")
        .unwrap_or_else(|| panic!("the joined lane should still be listed once gone:\n{after}"));
    assert_eq!(joined["folder_gone"], true, "{after}");

    let text = a.ok(&["stack", "--lanes"]);
    assert!(text.contains("Ship the sonar dashboard"), "{text}");
    assert!(text.contains("(folder gone)"), "{text}");
}

/// `t594` ยง5.5: a tree with one lane must not notice this tranche
/// happened. `stack` without `--lanes` prints exactly what it printed
/// before this task, byte for byte, and `--json` carries exactly the
/// same fields.
#[test]
fn stack_without_the_flag_prints_exactly_what_it_did() {
    let c = Sandbox::new_seeded("stack-no-lanes-flag");
    c.ok(&["push", "Base goal", "--why", "it anchors the branch"]);
    c.ok(&["push", "Task two", "--why", "the goal needs it"]);

    let out = c.ok(&["stack"]);
    assert_eq!(
        out, "\n  g1     Base goal\n    t2     Task two   <- focus\n\n",
        "a single-lane tree's stack must read exactly as it did before this tranche"
    );

    let json = c.ok(&["stack", "--json"]);
    let v: Value = serde_json::from_str(&json)
        .unwrap_or_else(|e| panic!("stack --json did not print an object: {e}\n{json}"));
    assert_eq!(
        v.as_object().unwrap().keys().collect::<Vec<_>>(),
        vec!["depth", "stack"],
        "stack --json gained a field without --lanes:\n{json}"
    );
    assert_eq!(v["depth"], 2, "{json}");
}

// ---------------------------------------------------------------------------
// `stack --lanes` names every lane the tree knows, focus or not (`f668`):
// `lanes_with_a_stack` -- and OTHER LANES, which is built on it -- keeps
// filtering out the ones with nothing on their own stack; this list stops
// doing that.
// ---------------------------------------------------------------------------

/// Declares `on`'s own lane (`--yes`, no terminal needed) without pushing
/// anything, so it carries an empty stack -- the plan `stack --lanes`
/// needs a lane with no front of its own to name. `d723` piece B:
/// declaring a lane is `init`'s alone now.
fn declare_with_no_front(on: &Sandbox) {
    on.ok(&["init", "--yes"]);
}

/// Two lanes, neither with anything pushed: both still have to be named.
#[test]
fn stack_lanes_names_a_lane_with_nothing_pushed() {
    let a = Sandbox::new_seeded("stack-lanes-no-front-both");
    declare_with_no_front(&a);
    join_lane(&a, "stack-lanes-no-front-both-b", "sonar");

    let json = a.ok(&["stack", "--lanes", "--json"]);
    let v: Value = serde_json::from_str(&json)
        .unwrap_or_else(|e| panic!("stack --lanes --json did not print an object: {e}\n{json}"));
    let lanes = v["lanes"].as_array().expect("lanes is an array");
    assert_eq!(lanes.len(), 2, "{json}");
    for row in lanes {
        assert!(row["focus"].is_null(), "{json}");
    }

    let text = a.ok(&["stack", "--lanes"]);
    assert!(
        !text.contains("Empty stack"),
        "a tree with lanes must not fall back to the empty-stack text:\n{text}"
    );
    for row in lanes {
        assert!(
            text.contains(row["name"].as_str().unwrap()),
            "missing lane {row} in:\n{text}"
        );
    }
}

/// One lane with a front, one without: both are listed, and the one with a
/// front sorts first -- the same order `lanes_with_a_stack`'s own rows
/// already use among themselves.
#[test]
fn stack_lanes_orders_a_lane_with_a_front_before_one_without() {
    let a = Sandbox::new_seeded("stack-lanes-mixed-front");
    declare_with_no_front(&a);
    let b = join_lane(&a, "stack-lanes-mixed-front-b", "sonar");
    b.ok(&["push", "Ship the sonar dashboard", "--why", "seed"]);

    let json = a.ok(&["stack", "--lanes", "--json"]);
    let v: Value = serde_json::from_str(&json)
        .unwrap_or_else(|e| panic!("stack --lanes --json did not print an object: {e}\n{json}"));
    let lanes = v["lanes"].as_array().expect("lanes is an array");
    assert_eq!(lanes.len(), 2, "{json}");
    assert_eq!(lanes[0]["name"], "sonar", "{json}");
    assert!(!lanes[0]["focus"].is_null(), "{json}");
    assert!(lanes[1]["focus"].is_null(), "{json}");

    let text = a.ok(&["stack", "--lanes"]);
    let front_name = lanes[0]["name"].as_str().unwrap();
    let front_at = text
        .find(front_name)
        .unwrap_or_else(|| panic!("missing {front_name} in:\n{text}"));
    let other_name = lanes[1]["name"].as_str().unwrap();
    let other_at = text
        .find(other_name)
        .unwrap_or_else(|| panic!("missing {other_name} in:\n{text}"));
    assert!(
        front_at < other_at,
        "the lane with a front should sort before the one without:\n{text}"
    );
}

/// A tree with no lane at all -- `vivac init`, and nothing else -- names
/// `vivac init` instead of the empty-stack text, which answers a different
/// question (`f668`).
///
/// `f721`: `init` cannot leave a tree with no lane declared any more --
/// it declares the founding one as part of planting now, bare or not --
/// so `common::plant_undeclared` fabricates the config and the empty log
/// by hand instead, the shape `Store::create` used to leave before this
/// fix.
#[test]
fn stack_lanes_on_a_tree_with_no_lane_names_init() {
    let c = Sandbox::new_empty("stack-lanes-no-lane-at-all");
    common::plant_undeclared(&c.0, "stack-no-lane");
    let text = c.ok(&["stack", "--lanes"]);
    assert!(text.contains("vivac init"), "{text}");
    assert!(
        !text.contains("Empty stack"),
        "a tree with no lane answers a different question than an empty stack:\n{text}"
    );
}