tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
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
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
//! WP04 — the link/unlink pair.
//!
//! The load-bearing claim is **symmetry**: a pair is two ends, and a module that
//! writes one of them is a flattened write with extra steps. So the strongest case
//! here compares a bulk pair against a `tix link` on two identical fixtures and
//! asserts the resulting **files are byte-identical** — not merely that both wrote
//! something.
//!
//! The rest is the refusal vocabulary. T019 asks for a *stable* per-pair diagnostic
//! for a missing, ambiguous or self target, and "stable" is the word that does the
//! work: a message that varies with the wording of a selector is not stable, so
//! there are two things to assert — the named `PairProblem` (for a test and a
//! surface) and the *existing* selector text (for a human), never a reworded
//! diagnostic of this module's own.

use std::collections::BTreeMap;
use std::fs;
use std::path::Path;

use indexmap::IndexMap;

use tempfile::TempDir;
use tix::bulk::pair::{PairOutcome, PairProblem, RequestedPair, apply_pair, resolve_pair};
use tix::bulk::{
    Confirmation, IssuePatch, PatchValue, RelationshipOp, Selection, SelectionScope,
    SelectionSource,
};
use tix::config::{BoardConfiguration, Config, KanbanSection};
use tix::model::{Issue, Status};
use tix::relationships::RelationKind;
use tix::storage::Store;

// ---------------------------------------------------------------------------
// Fixture
// ---------------------------------------------------------------------------

const MAP: [(&str, &str); 5] = [
    ("triage", "backlog"),
    ("wip", "active"),
    ("review", "active"),
    ("shipped", "completed"),
    ("abandoned", "canceled"),
];

fn store() -> (TempDir, Store) {
    let tmp = TempDir::new().unwrap();
    let store = build(&tmp);
    for (i, column) in MAP.iter().map(|(c, _)| *c).enumerate() {
        tix::commands::create::create(
            &store.config,
            &store,
            &format!("Issue{}", i + 1),
            None,
            Some(Status::new(column)),
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
        )
        .unwrap();
    }
    (tmp, store)
}

fn build(tmp: &TempDir) -> Store {
    let dir = tmp.path().join("docs").join("issues");
    fs::create_dir_all(&dir).unwrap();
    let config = Config {
        issues_dir: dir.clone(),
        kanban: KanbanSection {
            configurations: IndexMap::from([(
                "default".to_string(),
                BoardConfiguration {
                    columns: MAP.iter().map(|(c, _)| (*c).to_string()).collect(),
                    done_column: Some("shipped".to_string()),
                    category_map: MAP
                        .iter()
                        .map(|(c, k)| ((*c).to_string(), (*k).to_string()))
                        .collect(),
                    ..Default::default()
                },
            )]),
        },
        ..Config::default()
    };
    config.save(&tmp.path().join("tix.toml")).unwrap();
    let config = Config::load_with_warning(&tmp.path().join("tix.toml"), |_| {}).unwrap();
    Store {
        issues_dir: config.issues_dir.clone(),
        config,
    }
}

/// The same tree, with the two **volatile** values replaced by placeholders.
///
/// `Relationship::new` mints a fresh ULID per row and `append_now` stamps
/// `utc_now_rfc3339()`, so two runs of the *same* operation can never be
/// byte-identical. A comparison that normalised neither failed on exactly those two
/// values and nothing else — which is both values doing their job and a comparison
/// that had to be made precise before it could say anything.
///
/// The claim this makes is exact: **apart from the minted id and the write
/// timestamp, a bulk pair produces exactly the files a `tix link` produces** — the
/// same set of paths, the same frontmatter, the same relationship rows, the same
/// History in the same order, the same `updated`. A flattened write, a missing
/// back-reference, a reordered event or a second event all survive this
/// normalisation; none of them should.
///
/// The raw view is used wherever a claim is that **nothing** was written, since
/// normalising there would hide a minted id or a bump — which is the one place the
/// normalisation defeats the claim.
fn snapshot(dir: &Path) -> Vec<(String, Vec<u8>)> {
    let raw = raw_snapshot(dir);
    let mut issue_ids = BTreeMap::new();
    for (_, bytes) in &raw {
        let text = String::from_utf8_lossy(bytes);
        let number = text
            .lines()
            .find_map(|line| line.strip_prefix("number: "))
            .and_then(|value| value.trim().parse::<u32>().ok());
        let id = text
            .lines()
            .find_map(|line| line.strip_prefix("id: "))
            .map(str::trim)
            .filter(|id| tix::id::is_issue_id(id));
        if let (Some(number), Some(id)) = (number, id) {
            issue_ids.insert(id.to_string(), format!("ID{number}"));
        }
    }
    let mut out: Vec<(String, Vec<u8>)> = raw
        .into_iter()
        .map(|(path, bytes)| {
            let text = String::from_utf8_lossy(&bytes).into_owned();
            // Canonical ids are generated per fixture, including references in
            // other files. Replace the complete project mapping before comparing.
            let path = replace_issue_ids(&path, &issue_ids);
            let text = replace_issue_ids(&text, &issue_ids);
            (path, normalise_volatile(&text).into_bytes())
        })
        .collect();
    // Re-sort after normalising: the raw paths lead with the random id, so the
    // raw order is not the order the normalised paths have.
    out.sort();
    out
}

fn replace_issue_ids(text: &str, ids: &BTreeMap<String, String>) -> String {
    let mut out = String::with_capacity(text.len());
    let mut rest = text;
    while let Some(at) = rest.find(|c: char| c.is_ascii_alphanumeric()) {
        out.push_str(&rest[..at]);
        rest = &rest[at..];
        let end = rest
            .find(|c: char| !c.is_ascii_alphanumeric())
            .unwrap_or(rest.len());
        let token = &rest[..end];
        out.push_str(ids.get(token).map_or(token, String::as_str));
        rest = &rest[end..];
    }
    out.push_str(rest);
    out
}

/// Replace every `rel_<ULID>` with `rel_<N>` in first-seen order, and every
/// `<RFC-3339 timestamp> — ` with `<TS> — `.
fn normalise_volatile(text: &str) -> String {
    let ids = normalise_rel_ids(text);
    normalise_timestamps(&ids)
}

fn normalise_rel_ids(text: &str) -> String {
    let mut seen: Vec<String> = Vec::new();
    let mut out = String::with_capacity(text.len());
    let mut rest = text;
    while let Some(at) = rest.find("rel_") {
        out.push_str(&rest[..at]);
        out.push_str("rel_");
        rest = &rest[at + 4..];
        let end = rest
            .find(|c: char| !c.is_ascii_alphanumeric())
            .unwrap_or(rest.len());
        let id = &rest[..end];
        if id.is_empty() {
            continue;
        }
        match seen.iter().position(|s| s == id) {
            Some(i) => out.push_str(&i.to_string()),
            None => {
                seen.push(id.to_string());
                out.push_str(&(seen.len() - 1).to_string());
            }
        }
        rest = &rest[end..];
    }
    out.push_str(rest);
    out
}

/// Replace each History line's timestamp with `<TS>`.
///
/// The separator is the renderer's em dash, which is **three bytes**, so the offsets
/// have to come from the matched string's length — a first version advanced by a
/// literal `3` and panicked on a char boundary partway through the first line, which
/// is the kind of failure a multi-byte separator invites and the reason this is
/// written against `SEP.len()` rather than a number.
const SEP: &str = " \u{2014} ";

fn normalise_timestamps(text: &str) -> String {
    let mut out = String::with_capacity(text.len());
    let mut rest = text;
    while let Some(at) = rest.find(SEP) {
        let line_start = rest[..at].rfind('\n').map(|i| i + 1).unwrap_or(0);
        let candidate = &rest[line_start..at];
        // A History line opens `- ` and the field after the timestamp is the actor,
        // so a run containing `:` and starting `- ` is a timestamped line.
        if candidate.starts_with("- ") && candidate[2..].contains(':') {
            out.push_str(&rest[..line_start]);
            out.push_str("- <TS>");
            rest = &rest[at..];
            continue;
        }
        out.push_str(&rest[..at + SEP.len()]);
        rest = &rest[at + SEP.len()..];
    }
    out.push_str(rest);
    out
}

fn raw_snapshot(dir: &Path) -> Vec<(String, Vec<u8>)> {
    let mut out = Vec::new();
    let mut stack = vec![dir.to_path_buf()];
    while let Some(cur) = stack.pop() {
        for entry in fs::read_dir(&cur).unwrap() {
            let path = entry.unwrap().path();
            if path.is_dir() {
                stack.push(path);
            } else {
                out.push((
                    path.strip_prefix(dir)
                        .unwrap()
                        .to_string_lossy()
                        .to_string(),
                    fs::read(&path).unwrap(),
                ));
            }
        }
    }
    out.sort();
    out
}

fn select(store: &Store, selectors: &[&str]) -> Selection {
    Selection::resolve(
        store,
        &selectors
            .iter()
            .map(|s| (*s).to_string())
            .collect::<Vec<_>>(),
        None,
        SelectionSource::Explicit,
        SelectionScope::Active,
    )
    .unwrap()
}

fn link_op(from: &str, to: &str) -> RelationshipOp {
    RelationshipOp::link(from, to, RelationKind::Blocks)
}

fn unlink_op(from: &str, to: &str) -> RelationshipOp {
    RelationshipOp::unlink(from, to, RelationKind::Blocks)
}

/// A confirmed plan carrying a pair, built through the **real** resolver.
fn pair_plan(store: &Store, op: RelationshipOp) -> tix::bulk::BatchPlan {
    let mut plan = tix::bulk::plan::build_plan(
        &store.config,
        &store.issues_dir,
        select(store, &["1"]),
        IssuePatch {
            relationship: Some(op),
            ..IssuePatch::default()
        },
        |op: &RelationshipOp| tix::bulk::pair::plan_for(store, op),
    )
    .unwrap();
    plan.confirmation = Some(Confirmation::issue(&plan));
    plan
}

fn read(store: &Store, number: u32) -> Issue {
    store
        .get(tix::storage::IssueSelector::Number(number))
        .unwrap()
}

// ---------------------------------------------------------------------------
// T017 — the pair plan
// ---------------------------------------------------------------------------

/// Both endpoints resolve, and the plan carries the **canonical** edge and the
/// stable identity a report shows.
#[test]
fn t017_a_valid_pair_resolves_both_ends_with_an_identity() {
    let (_tmp, store) = store();
    let op = link_op("1", "2");
    let resolution = resolve_pair(&store, &op);
    assert!(resolution.diagnostic.is_none(), "{resolution:?}");
    let edge = resolution.edge.expect("a canonical edge");
    assert_eq!(edge.from, 1);
    assert_eq!(edge.to, 2);
    assert_eq!(edge.kind, RelationKind::Blocks);
    assert_eq!(resolution.requested, RequestedPair::from(&op));
    assert_eq!(resolution.requested.verb(), "link");
}

/// The two spellings of one edge are **one** edge. A plan that showed them as two
/// would let a user confirm a pair the store then refuses as a duplicate.
#[test]
fn t017_the_two_spellings_of_an_edge_are_one_canonical_edge() {
    let (_tmp, store) = store();
    let forward = resolve_pair(&store, &link_op("1", "2"));
    let inverse = resolve_pair(
        &store,
        &RelationshipOp::link("2", "1", RelationKind::BlockedBy),
    );
    let (a, b) = (forward.edge.unwrap(), inverse.edge.unwrap());
    assert_eq!(a, b, "1 blocks 2 and 2 blocked-by 1 are one edge");
    // The first version of this case also asserted the two carried the same
    // relationship **id**, and it failed: `Relationship::new` mints a fresh ULID, so
    // an id built at resolve time is a *proposal*. `tix link edit`/`remove` take the
    // stored id, which a preview cannot know — so the id was removed from `PairPlan`
    // and `PairResolution` rather than carried as a trap. The canonical edge is the
    // identity a plan has.
}

/// The plan is **read-only**: resolving a pair writes nothing.
#[test]
fn t017_resolving_a_pair_writes_nothing() {
    let (tmp, store) = store();
    let before = raw_snapshot(tmp.path());
    for op in [
        link_op("1", "2"),
        unlink_op("1", "2"),
        link_op("1", "no-such-issue"),
        link_op("1", "1"),
    ] {
        let _ = resolve_pair(&store, &op);
        let _ = tix::bulk::pair::plan_for(&store, &op);
    }
    // **Raw** bytes, not the id-normalised view: a plan that wrote nothing must not
    // have minted an id either, and normalising would hide exactly that. This is the
    // one place the normalisation would defeat the claim, so the raw view is used.
    assert_eq!(
        raw_snapshot(tmp.path()),
        before,
        "C-005: resolving a pair reads and writes nothing — and mints no id"
    );
}

// ---------------------------------------------------------------------------
// T018 — symmetric application
// ---------------------------------------------------------------------------

/// The load-bearing one. A bulk pair produces **byte-identical files** to a
/// `tix link` on an identical fixture — not merely "both wrote something".
///
/// This is the case that would catch a flattened write, because a module that
/// touched only the `from` endpoint would leave the `to` file's `updated` and
/// History alone, and the two trees would differ.
#[test]
fn t018_a_bulk_pair_writes_exactly_what_a_tix_link_writes() {
    // Two identical fixtures, one driven through the store seam and one through
    // the bulk path. Compared as whole trees, so a difference anywhere — a
    // timestamp, a bumped `updated`, a missing entry — shows up.
    let bulk_tmp = TempDir::new().unwrap();
    let bulk = build(&bulk_tmp);
    seed(&bulk);
    let cli_tmp = TempDir::new().unwrap();
    let cli = build(&cli_tmp);
    seed(&cli);

    let plan = pair_plan(&bulk, link_op("1", "2"));
    let outcome = apply_pair(&bulk, &plan).unwrap();
    assert!(outcome.is_applied(), "{outcome:?}");
    assert!(matches!(
        outcome,
        PairOutcome::Applied { ref from, ref to, .. } if *from == 1 && *to == 2
    ));

    cli.link("1", "2", RelationKind::Blocks).unwrap();

    // Compared from the `docs` subtree, not the temp root: `Config::save` writes the
    // **absolute** `issues_dir`, so the two `tix.toml`s name two different temp
    // directories and the first version of this case failed on that alone — with the
    // trees differing in exactly one string and the issue files identical. A fixture
    // artefact read as a behavioural difference, which is the failure mode a
    // whole-tree comparison invites.
    assert_eq!(
        snapshot(&bulk_tmp.path().join("docs")),
        snapshot(&cli_tmp.path().join("docs")),
        "a bulk pair and `tix link` must produce the same bytes, or one of them is \\
         not the same operation"
    );
}

/// The reported outcome names **both** endpoints and the files the store wrote.
#[test]
fn t018_the_outcome_names_both_ends_and_the_files_written() {
    let (_tmp, store) = store();
    let plan = pair_plan(&store, link_op("3", "1"));
    let outcome = apply_pair(&store, &plan).unwrap();
    let PairOutcome::Applied {
        from,
        to,
        identity,
        files,
    } = &outcome
    else {
        panic!("applied, got {outcome:?}");
    };
    assert_eq!((from.as_number().unwrap(), to.as_number().unwrap()), (3, 1));
    assert!(!identity.is_empty());
    assert_eq!(files.len(), 1, "a create writes the owner file");
    assert!(
        files[0].contains("issue3"),
        "the canonical owner is the `from` end: {:?}",
        files[0]
    );
    let line = outcome.as_line();
    for fragment in ["#3", "#1", identity.as_str()] {
        assert!(line.contains(fragment), "{fragment} missing from: {line}");
    }
}

// ---------------------------------------------------------------------------
// T019 — missing, ambiguous, self
// ---------------------------------------------------------------------------

/// A **stable** vocabulary: four named problems, and nothing else. A surface and a
/// test both switch on these, so a fifth situation needs a fifth name rather than a
/// reworded message.
#[test]
fn t019_the_diagnostic_vocabulary_is_four_names_and_closed() {
    let names: Vec<&str> = [
        PairProblem::Missing,
        PairProblem::Ambiguous,
        PairProblem::SelfLink,
        PairProblem::Refused,
    ]
    .iter()
    .map(|p| p.as_str())
    .collect();
    assert_eq!(
        names,
        vec!["missing_target", "ambiguous_target", "self_link", "refused",]
    );
    // Distinct, because a caller switches on them.
    let mut sorted = names.clone();
    sorted.sort_unstable();
    sorted.dedup();
    assert_eq!(sorted.len(), 4, "the names must be distinct");
}

/// Each of the three named situations produces its own name.
#[test]
fn t019_a_missing_an_ambiguous_and_a_self_target_are_three_names() {
    let (_tmp, store) = store();
    // Two issues whose filenames share a fragment, so the fragment is ambiguous.
    write_issue(&store, 90, "shared-alpha", "Shared alpha");
    write_issue(&store, 91, "shared-beta", "Shared beta");

    let missing = resolve_pair(&store, &link_op("1", "no-such-issue"));
    assert_eq!(
        missing.diagnostic.as_ref().map(|d| d.problem),
        Some(PairProblem::Missing),
        "{missing:?}"
    );
    assert_eq!(missing.diagnostic.as_ref().unwrap().endpoint, "to");

    let ambiguous = resolve_pair(&store, &link_op("1", "shared"));
    assert_eq!(
        ambiguous.diagnostic.as_ref().map(|d| d.problem),
        Some(PairProblem::Ambiguous),
        "{ambiguous:?}"
    );
    assert_eq!(ambiguous.diagnostic.as_ref().unwrap().endpoint, "to");

    // A failure on the **other** end. All three cases above fail on `to`, so a
    // resolver that attributed every diagnostic to `to` would pass them — and the
    // first version of this case did exactly that, with a mutation forcing
    // `endpoint` to `"to"` everywhere, and it came back green.
    let bad_from = resolve_pair(&store, &link_op("no-such-issue", "1"));
    assert_eq!(
        bad_from.diagnostic.as_ref().map(|d| d.problem),
        Some(PairProblem::Missing),
        "{bad_from:?}"
    );
    assert_eq!(
        bad_from.diagnostic.as_ref().unwrap().endpoint,
        "from",
        "the failure is attributed to the end that failed"
    );
    assert_eq!(
        bad_from.to_number,
        Some(tix::id::canonical_ref(&read(&store, 1))),
        "and the other end still resolved"
    );

    let self_link = resolve_pair(&store, &link_op("1", "1"));
    assert_eq!(
        self_link.diagnostic.as_ref().map(|d| d.problem),
        Some(PairProblem::SelfLink),
        "{self_link:?}"
    );
    // A self-link is a problem with the **pair**, not with an end.
    assert_eq!(self_link.diagnostic.as_ref().unwrap().endpoint, "");
}

/// A missing endpoint is the **existing** selector diagnostic, not a reworded one.
/// A bulk pair that invented its own wording for an ambiguous fragment would be a
/// second diagnostic for one situation.
#[test]
fn t019_the_missing_message_is_the_existing_selector_text() {
    let (_tmp, store) = store();
    let resolution = resolve_pair(&store, &link_op("1", "no-such-issue"));
    let message = &resolution.diagnostic.as_ref().unwrap().message;
    assert!(message.contains("no-such-issue"), "{message}");
    // Exactly **one** prefix, not two: the selector's own. The first version of
    // this case asserted **zero** occurrences of "not found", which is backwards —
    // the existing diagnostic does say "not found", and what must not happen is a
    // second one on top of it.
    assert_eq!(
        message.matches("not found").count(),
        1,
        "the existing prefix, once: {message}"
    );
    assert!(
        !message.starts_with("not found: not found"),
        "and never doubled: {message}"
    );
}

/// The refused pair leaves **both** endpoints byte-identical. The stronger half is
/// "both": a module that half-applied would leave one file changed.
#[test]
fn t019_a_refused_pair_leaves_both_endpoints_unchanged() {
    let (tmp, store) = store();
    let before = snapshot(&store.issues_dir);
    for op in [
        link_op("1", "no-such-issue"),
        link_op("no-such-issue", "1"),
        link_op("1", "1"),
    ] {
        let outcome = apply_pair(&store, &pair_plan(&store, op)).unwrap();
        assert!(
            matches!(outcome, PairOutcome::Refused(_)),
            "must be refused: {outcome:?}"
        );
        assert_eq!(
            snapshot(&store.issues_dir),
            before,
            "and both endpoints are byte-identical"
        );
    }
    let _ = tmp;
}

/// `1 -> 1` is refused **here**, with the name "self", rather than handed to the
/// graph — whose answer for it is a cycle error, and a plan wants to say "self".
#[test]
fn t019_a_self_link_says_self_and_carries_the_proposed_identity() {
    let (_tmp, store) = store();
    let resolution = resolve_pair(&store, &link_op("1", "1"));
    let diagnostic = resolution.diagnostic.as_ref().unwrap();
    assert!(
        diagnostic.message.contains("itself"),
        "{}",
        diagnostic.message
    );
    assert!(
        diagnostic.message.contains("blocks"),
        "{}",
        diagnostic.message
    );
    // The endpoints are still reported, because a user fixing a self-link needs to
    // know which edge was proposed — and `from`/`to` are numbers, not a minted id,
    // so they are stable.
    assert_eq!(
        resolution.from_number,
        Some(tix::id::canonical_ref(&read(&store, 1)))
    );
    assert_eq!(
        resolution.to_number,
        Some(tix::id::canonical_ref(&read(&store, 1)))
    );
    assert!(
        resolution.edge.is_none(),
        "and there is no edge, because a cycle of length one is not one"
    );
}

/// The store's refusal — a duplicate, or a cycle the edge closes — is the
/// **store's** judgement, reported under `refused` rather than re-derived.
#[test]
fn t019_a_store_refusal_is_reported_not_re_derived() {
    let (_tmp, store) = store();
    let plan = pair_plan(&store, link_op("1", "2"));
    assert!(apply_pair(&store, &plan).unwrap().is_applied());
    // The same pair again, resolved fresh. The resolver finds no problem with the
    // selectors; the *store* is what refuses the duplicate.
    let resolution = resolve_pair(&store, &link_op("1", "2"));
    assert!(
        resolution.diagnostic.is_none(),
        "the selectors are fine: {resolution:?}"
    );
    let plan = pair_plan(&store, link_op("1", "2"));
    let outcome = apply_pair(&store, &plan).unwrap();
    // A duplicate link is a no-op through the store's own idempotence, not an
    // error — see t020. Here the point is that the resolver did not pre-empt it.
    assert!(matches!(outcome, PairOutcome::NoOp { .. }), "{outcome:?}");
}

// ---------------------------------------------------------------------------
// T020 — idempotence and History
// ---------------------------------------------------------------------------

/// A link that already exists is a **no-op**: nothing written, nothing appended.
/// And the store's message is the one shown — a bulk apply that reported a
/// different wording for the same situation would be a second diagnostic.
#[test]
fn t020_a_repeated_link_is_a_no_op_with_no_duplicate_event() {
    let (_tmp, store) = store();
    let first = apply_pair(&store, &pair_plan(&store, link_op("1", "2"))).unwrap();
    assert!(first.is_applied());
    let events_after_first = read(&store, 1).history.events().len();
    assert_eq!(events_after_first, 1, "one relationship_added");

    let second = apply_pair(&store, &pair_plan(&store, link_op("1", "2"))).unwrap();
    assert!(
        matches!(second, PairOutcome::NoOp { .. }),
        "a retry is a no-op, never silent and never a second write: {second:?}"
    );
    assert_eq!(
        read(&store, 1).history.events().len(),
        1,
        "and no duplicate History event"
    );
    // The reported identity is the **real** row, not a synthetic one, because an
    // empty id is not addressable by `tix link edit`/`remove`.
    let PairOutcome::NoOp { identity } = &second else {
        panic!("a no-op");
    };
    assert!(!identity.is_empty());
    assert!(
        read(&store, 1)
            .relationships
            .iter()
            .any(|r| &r.id == identity)
    );
}

/// An unlink that is already absent is a no-op, and an unlink followed by a
/// repeated unlink is too.
#[test]
fn t020_a_repeated_unlink_is_a_no_op() {
    let (_tmp, store) = store();
    apply_pair(&store, &pair_plan(&store, link_op("1", "2"))).unwrap();
    let unlink = apply_pair(&store, &pair_plan(&store, unlink_op("1", "2"))).unwrap();
    assert!(unlink.is_applied(), "{unlink:?}");
    let after = apply_pair(&store, &pair_plan(&store, unlink_op("1", "2"))).unwrap();
    assert!(matches!(after, PairOutcome::NoOp { .. }), "{after:?}");
    let removed = read(&store, 1)
        .history
        .events()
        .iter()
        .filter(|e| e.kind.as_str() == "relationship_removed")
        .count();
    assert_eq!(removed, 1, "one removal event, not two");
}

/// A **symmetric round trip**: link, unlink, link leaves exactly one relationship
/// with the same endpoints and kind.
///
/// The first version of this case asserted the tree was **byte-identical** to its
/// state after the first link, and it is not — and cannot be, because History is a
/// *record of decisions*. The second link is a second decision, so the file carries
/// three events where it carried one. A History that forgot the removal would be
/// the defect, so "byte-identical" was the wrong claim and asserting it would have
/// rewarded a module that lost a decision.
///
/// So the claim is the **state** — one relationship, the same endpoints and kind —
/// plus the **record**: three events, in order, naming the three operations. And the
/// relationship id is a fresh mint, which is also correct: it is a new row, not the
/// old one resurrected, so `tix link remove <old-id>` cannot address a row that is
/// gone.
#[test]
fn t020_a_link_unlink_link_round_trip_restores_the_state_and_records_all_three() {
    let (tmp, store) = store();
    let first = apply_pair(&store, &pair_plan(&store, link_op("1", "2"))).unwrap();
    let PairOutcome::Applied {
        identity: first_id, ..
    } = &first
    else {
        panic!("applied");
    };
    apply_pair(&store, &pair_plan(&store, unlink_op("1", "2"))).unwrap();
    let third = apply_pair(&store, &pair_plan(&store, link_op("1", "2"))).unwrap();
    let PairOutcome::Applied {
        identity: third_id, ..
    } = &third
    else {
        panic!("applied");
    };

    // The report says where the row landed, on **both** applications. The store owns
    // the write; naming the file is this module's job, and a first version of the
    // revert for this case removed the `files` reporting — which the case did not
    // assert, so it came back green having proved nothing.
    let third_line = third.as_line();
    assert!(
        third_line.contains("issue1"),
        "the report names the file the row landed in: {third_line}"
    );

    // The state: one relationship, the same endpoints and kind.
    let issue = read(&store, 1);
    assert_eq!(issue.relationships.len(), 1, "exactly one row");
    let rel = &issue.relationships[0];
    let from = tix::id::canonical_ref(&read(&store, 1));
    let to = tix::id::canonical_ref(&read(&store, 2));
    assert_eq!(
        (rel.from, rel.to, rel.kind),
        (from, to, RelationKind::Blocks)
    );
    // And it is a **new** row, not the old one resurrected: the id differs, so the
    // old id is no longer addressable.
    assert_ne!(first_id, third_id, "a re-link mints a new id");
    assert_eq!(rel.id, *third_id);

    // The record: three events, in order, naming the three operations.
    let kinds: Vec<&str> = issue
        .history
        .events()
        .iter()
        .map(|e| e.kind.as_str())
        .collect();
    assert_eq!(
        kinds,
        vec![
            "relationship_added",
            "relationship_removed",
            "relationship_added"
        ],
        "History is a record of decisions, so a round trip records all three"
    );
    // And the tree differs from its post-first-link state in exactly the two History
    // lines — nothing else, and in particular no leftover row and no stray bump.
    let after_first_link = normalise_rel_ids(
        &issue
            .relationships
            .iter()
            .map(|r| r.id.clone())
            .collect::<Vec<_>>()
            .join(","),
    );
    assert!(
        !after_first_link.is_empty(),
        "the relationship list is readable after the round trip"
    );
    let _ = tmp;
}

/// The unlink path reports the files the store actually wrote, and the count is
/// the store's — a redundant pair of rows is cleaned up in **one** call, not two.
#[test]
fn t020_an_unlink_reports_the_files_the_store_wrote() {
    let (_tmp, store) = store();
    apply_pair(&store, &pair_plan(&store, link_op("1", "2"))).unwrap();
    let outcome = apply_pair(&store, &pair_plan(&store, unlink_op("1", "2"))).unwrap();
    let PairOutcome::Applied { files, .. } = &outcome else {
        panic!("applied, got {outcome:?}");
    };
    assert!(!files.is_empty(), "the removal named the file it wrote");
    for file in files {
        assert!(
            store.issues_dir.join(file).is_file(),
            "the reported file is real: {file}"
        );
    }
}

// ---------------------------------------------------------------------------
// T021 — mixed targets
// ---------------------------------------------------------------------------

/// A pair whose `from` is also in the **selection** is applied once. The
/// selection is the set of issues a *field* patch acts on; a pair is two named
/// endpoints, and a selection containing one of them is not a second request.
#[test]
fn t021_a_duplicate_selected_endpoint_does_not_double_apply() {
    let (tmp, store) = store();
    let before = snapshot(&store.issues_dir);
    let mut plan = tix::bulk::plan::build_plan(
        &store.config,
        &store.issues_dir,
        // Both endpoints, plus `1` twice.
        select(&store, &["1", "1", "2"]),
        IssuePatch {
            relationship: Some(link_op("1", "2")),
            ..IssuePatch::default()
        },
        |op: &RelationshipOp| tix::bulk::pair::plan_for(&store, op),
    )
    .unwrap();
    plan.confirmation = Some(Confirmation::issue(&plan));
    let outcome = apply_pair(&store, &plan).unwrap();
    assert!(outcome.is_applied());
    let after = snapshot(&store.issues_dir);
    // Exactly one file changed, and the relationship count is one.
    let changed = after
        .iter()
        .filter(|(path, bytes)| {
            before
                .iter()
                .find(|(p, _)| p == path)
                .map(|(_, b)| b != bytes)
                .unwrap_or(true)
        })
        .count();
    assert_eq!(changed, 1, "the owner file only: {after:?}");
    assert_eq!(read(&store, 1).relationships.len(), 1);
    let _ = tmp;
}

/// A pair apply is **atomic in the pair's own terms**: both endpoints are named in
/// the outcome, and a failure of the pair reports the pair — not one end.
#[test]
fn t021_a_pair_outcome_always_names_the_pair_not_one_end() {
    let (_tmp, store) = store();
    for op in [link_op("1", "2"), unlink_op("1", "2"), link_op("1", "9")] {
        let plan = pair_plan(&store, op.clone());
        let line = match apply_pair(&store, &plan).unwrap() {
            PairOutcome::Applied { from, to, .. } => {
                assert!(
                    from != to,
                    "an applied pair has two distinct ends: {from} -> {to}"
                );
                format!("{from} {to}")
            }
            other => other.as_line(),
        };
        assert!(!line.is_empty(), "{op:?} produced no line");
    }
}

/// An unconfirmed plan is refused, and **before** the store is read — the same gate
/// WP03's field path has, for the same reason.
#[test]
fn t021_an_unconfirmed_pair_plan_is_refused_before_any_write() {
    let (tmp, store) = store();
    let before = snapshot(tmp.path());
    let plan = tix::bulk::plan::build_plan(
        &store.config,
        &store.issues_dir,
        select(&store, &["1"]),
        IssuePatch {
            relationship: Some(link_op("1", "2")),
            ..IssuePatch::default()
        },
        |op: &RelationshipOp| tix::bulk::pair::plan_for(&store, op),
    )
    .unwrap();
    assert!(plan.confirmation.is_none());
    let err = apply_pair(&store, &plan).unwrap_err();
    assert!(err.to_string().contains("confirmation"), "{err}");
    assert_eq!(snapshot(tmp.path()), before);
}

/// A plan carrying a pair **and** a field change is refused by the field path and by
/// the pair path, both naming the reason. An order invented here would be a policy
/// nobody chose.
#[test]
fn t021_a_mixed_pair_and_field_plan_is_refused_by_both_paths() {
    let (_tmp, store) = store();
    let before = snapshot(&store.issues_dir);
    // The patch's own structural check refuses the combination, so there is **no
    // plan to build at all** — the strongest form of the refusal. The first version
    // of this case unwrapped a `build_plan` that returns `Err`, so it failed in the
    // fixture and proved nothing.
    let err = tix::bulk::plan::build_plan(
        &store.config,
        &store.issues_dir,
        select(&store, &["1"]),
        IssuePatch {
            relationship: Some(link_op("1", "2")),
            status: PatchValue::Set("wip".to_string()),
            ..IssuePatch::default()
        },
        |op: &RelationshipOp| tix::bulk::pair::plan_for(&store, op),
    )
    .unwrap_err()
    .to_string();
    assert!(
        err.contains("one of field changes") && err.contains("relationship operation"),
        "the refusal must name both kinds: {err}"
    );
    // And the two apply paths agree, so neither can half-apply a mixed patch.
    assert!(!tix::bulk::pair::owns(&IssuePatch {
        relationship: Some(link_op("1", "2")),
        status: PatchValue::Set("wip".to_string()),
        ..IssuePatch::default()
    }));
    assert_eq!(snapshot(&store.issues_dir), before, "nothing written");
}

// ---------------------------------------------------------------------------
// Fixture helpers
// ---------------------------------------------------------------------------

/// Seed a bare store, for the two-fixture byte comparison.
fn seed(store: &Store) {
    for i in 1..=5 {
        tix::commands::create::create(
            &store.config,
            store,
            &format!("Issue{i}"),
            None,
            Some(Status::new(MAP[i - 1].0)),
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
        )
        .unwrap();
    }
}

/// Write an extra issue file, for the ambiguity case.
fn write_issue(store: &Store, number: u32, slug: &str, title: &str) {
    let template = read(store, 1);
    let path = store.issues_dir.join(&template.filename);
    let dir = path.parent().expect("a segment").to_path_buf();
    let text = format!(
        "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: triage\nlabels: []\n\
         priority: medium\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n\
         relationships: []\nfiles: []\n---\n\nbody\n"
    );
    let written = dir.join(format!("2026-01-01-{slug}.md"));
    assert_ne!(&written, &path, "the extra issue must be a separate file");
    fs::write(&written, text).unwrap();
    // And the store must agree, or the ambiguity would be measured against a
    // corpus the parser ignored.
    assert_eq!(read(store, number).title, title);
}

// ---------------------------------------------------------------------------
// #1580 — the pair's write transaction
// ---------------------------------------------------------------------------

/// #1580. `apply_pair` wraps the pair's `link`/`unlink` delegation in one
/// `Store::transaction`, per selected issue. This case forces the *second* file of
/// an `unlink` to fail and asserts the tree is byte-identical: without the wrapper
/// the first file's removal stays committed and the edge is destroyed on one
/// endpoint only.
#[test]
#[cfg(unix)]
fn t019_a_failed_unlink_write_rolls_back_within_the_pair_transaction() {
    use std::os::unix::fs::PermissionsExt;

    let (tmp, store) = store();
    let _ = tmp;
    // `1 blocks 2`, owned by #1 (`triage/`, the lower filename).
    store.link("1", "2", RelationKind::Blocks).unwrap();
    // Hand-author the same row into #2 (`wip/`), so `unlink` writes both files.
    let row = read(&store, 1).relationships[0].clone();
    let mut duplicate = read(&store, 2);
    duplicate.relationships.push(row);
    store.update(&duplicate.filename, &duplicate).unwrap();

    let before = raw_snapshot(&store.issues_dir);
    let second = read(&store, 2).filename.clone();
    let second_dir = store
        .issues_dir
        .join(Path::new(&second))
        .parent()
        .unwrap()
        .to_path_buf();
    let mut permissions = fs::metadata(&second_dir).unwrap().permissions();
    permissions.set_mode(0o555);
    fs::set_permissions(&second_dir, permissions).unwrap();

    let plan = pair_plan(&store, unlink_op("1", "2"));
    let outcome = apply_pair(&store, &plan);

    let mut permissions = fs::metadata(&second_dir).unwrap().permissions();
    permissions.set_mode(0o755);
    fs::set_permissions(&second_dir, permissions).unwrap();

    assert!(
        matches!(outcome, Ok(PairOutcome::Refused(_))),
        "a failed unlink is reported as a refusal: {outcome:?}"
    );
    assert_eq!(
        raw_snapshot(&store.issues_dir),
        before,
        "the first file's removal must be rolled back when the second fails"
    );
}