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
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
#[allow(clippy::wildcard_imports)]
use super::*;

/// The supported external tracker, selected via `--target gitlab|linear`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tool {
    GitLab,
    Linear,
}

impl Tool {
    #[must_use]
    pub fn from_target(target: &str) -> Result<Tool> {
        match target {
            "gitlab" => Ok(Tool::GitLab),
            "linear" => Ok(Tool::Linear),
            other => Err(TixError::InvalidInput(format!(
                "unknown --target '{other}' — use gitlab or linear"
            ))),
        }
    }

    /// The token env var that wins over any `tix.toml` token — the tool's
    /// **primary** name, which is what the `tix.toml` warning and the injection
    /// tests name.
    #[must_use]
    pub fn token_var(self) -> &'static str {
        match self {
            Tool::GitLab => "GITLAB_TOKEN",
            Tool::Linear => "LINEAR_TOKEN",
        }
    }

    /// Every environment variable that can carry this tool's token, in
    /// precedence order.
    ///
    /// `token_var` is the primary name tix documents; the extras are the names
    /// the **tool itself** already reads, so a user who has the tracker
    /// configured does not have to export a second, tix-specific variable. The
    /// canonical `linear-cli` reads `LINEAR_API_KEY` (its help shows
    /// `printf '%s\n' "$LINEAR_API_KEY" | linear config set-key`), so that is
    /// accepted after tix's own `LINEAR_TOKEN`.
    #[must_use]
    pub fn token_vars(self) -> &'static [&'static str] {
        match self {
            Tool::GitLab => &["GITLAB_TOKEN"],
            Tool::Linear => &["LINEAR_TOKEN", "LINEAR_API_KEY"],
        }
    }

    /// The binary shelled out to.
    #[must_use]
    pub fn binary(self) -> &'static str {
        match self {
            Tool::GitLab => "glab",
            Tool::Linear => "linear-cli",
        }
    }

    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Tool::GitLab => "gitlab",
            Tool::Linear => "linear",
        }
    }
}

impl std::fmt::Display for Tool {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// The captured result of a spawned external tool.
///
/// `ProcessRunner` waits synchronously on the child (never kills it), so a
/// blocking tool blocks the sync — the contract's supported "wait" model for
/// `glab`/`linear-cli`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandOutcome {
    pub success: bool,
    pub status: Option<i32>,
    pub stdout: String,
    pub stderr: String,
}

impl CommandOutcome {
    /// The human-readable output chosen for error reporting: stderr when the
    /// tool failed and produced any, otherwise stdout.
    #[must_use]
    pub fn text(&self) -> &str {
        if self.success {
            &self.stdout
        } else if !self.stderr.trim().is_empty() {
            &self.stderr
        } else {
            &self.stdout
        }
    }
}

/// Production runner: `std::process::Command` with a synchronous output wait.
///
/// A missing binary surfaces as [`TixError::External`] with the contract's
/// exit-3 wording (`contracts/external-tools.md`: "missing binary ... exit 3").
pub struct ProcessRunner;

impl CommandRunner for ProcessRunner {
    fn run(
        &self,
        program: &str,
        args: &[String],
        env: &[(String, String)],
    ) -> Result<CommandOutcome> {
        use std::process::Command;
        let mut cmd = Command::new(program);
        cmd.args(args);
        for (key, value) in env {
            cmd.env(key, value);
        }
        let output = cmd.output().map_err(|e| {
            if e.kind() == std::io::ErrorKind::NotFound {
                TixError::External(not_found_message(program))
            } else {
                TixError::External(format!("`{program}` failed to start: {e}"))
            }
        })?;
        Ok(CommandOutcome {
            success: output.status.success(),
            status: output.status.code(),
            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
        })
    }

    fn as_any(&self) -> &dyn std::any::Any {
        self
    }
}

/// A remote issue parsed from the tracker's JSON list output.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteIssue {
    /// Remote id (GitLab `iid`, Linear `identifier`).
    pub id: String,
    pub title: String,
    pub description: String,
    pub labels: Vec<String>,
    /// Remote state (`closed` / `Done` / a column label, ...).
    pub state: String,
    /// ISO-8601 `updated_at`; `None` when the tool omitted it.
    pub updated_at: Option<String>,
    /// Best-effort single assignee (bare name, no `@`).
    pub assignee: Option<String>,
    /// The remote id of this issue's parent, when the remote reported one.
    ///
    /// A **remote** id, not a local number, because it is read from a remote
    /// payload and the two id spaces are not interchangeable. It is resolved to a
    /// local number by `local_parent_number` before anything is written, and
    /// `None` — rather than a guess — when it cannot be resolved.
    pub parent: Option<String>,
    /// The project references the remote reported for this issue (WP08).
    ///
    /// Read from the payload, in the order the payload listed them, and matched
    /// against the local registry by **id** before anything is written. An empty
    /// vector is the answer for a payload that reported none, which is every
    /// GitLab payload today: nothing in this build ever *sends* a membership, so
    /// this is only ever what the remote side already said.
    pub projects: Vec<RemoteProject>,
    /// The epic references the remote reported for this issue (WP08, mission
    /// `epics-work-items-01M3BYW4`).
    ///
    /// Read from the payload, in the order the payload listed them, and matched
    /// against the local registry by **id** before anything is written. An empty
    /// vector is the answer for a payload that reported none — and for both trackers
    /// today nothing in this build ever *sends* one (`EPIC_WIRE_VERIFIED` is empty),
    /// so this is only ever what the remote already said.
    pub epics: Vec<RemoteEpic>,
}

/// What one export or import run did, and what it could not do.
///
/// Returned rather than only written to the caller's `Write`, so a caller can
/// surface the diagnostics without scraping its own output — and so the count and
/// the diagnostics cannot disagree. Both entry points had `Result<usize>`; a count
/// alone cannot express "three issues synced, one relationship could not be".
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SyncReport {
    /// Issues created, updated, or overwritten.
    pub handled: usize,
    /// Local data the remote could not represent — relationships, parent
    /// references, health values, project associations, and epic references — in
    /// encounter order.
    pub diagnostics: Vec<RemoteMappingDiagnostic>,
    /// Items whose **status** the transition policy refused (WP07 T031).
    ///
    /// Counted separately from `handled` because an import can do both: an issue
    /// whose title and body imported fine while its status was refused still counts
    /// as handled — the file was written — and also as skipped, because a column did
    /// not move. A single number cannot express that, and the report line it backs
    /// (`overwrote … without its status`) is the one a user has to be able to find.
    pub skipped: usize,
    /// Whether diagnostics are written beside the report lines.
    ///
    /// Import's historical surface includes them in the text report. Remote
    /// export keeps them separate so the CLI can put warnings on stderr and use
    /// the exit status for a partial mirror.
    report_diagnostics: bool,
}

impl SyncReport {
    /// Ask the note methods to write diagnostics beside report lines.
    pub(crate) fn diagnostics_in_report(mut self) -> Self {
        self.report_diagnostics = true;
        self
    }

    /// Record one unrepresentable relationship for the caller to present.
    pub(super) fn note(
        &mut self,
        mapped: MappedRelation,
        out: &mut dyn Write,
    ) -> std::io::Result<()> {
        if let MappedRelation::Unsupported(diagnostic) = mapped {
            if self.report_diagnostics {
                writeln!(out, "{diagnostic}")?;
            }
            self.diagnostics.push(diagnostic);
        }
        Ok(())
    }

    /// The same, for a **parent** reference.
    ///
    /// Its own method rather than coercing a `MappedParent` into a
    /// `MappedRelation`: a parent is not a relationship, and the first cut did
    /// exactly that with an empty `field` and an empty `value` on the remote arm
    /// — which reads as though something were being sent when nothing is. The
    /// coercion compiled, produced the right output, and was wrong in a way only
    /// reading the type would have caught.
    pub(super) fn note_parent(
        &mut self,
        mapped: MappedParent,
        out: &mut dyn Write,
    ) -> std::io::Result<()> {
        if let MappedParent::Unsupported(diagnostic) = mapped {
            if self.report_diagnostics {
                writeln!(out, "{diagnostic}")?;
            }
            self.diagnostics.push(diagnostic);
        }
        Ok(())
    }

    /// The same, for a **project association**.
    ///
    /// Its own method for the reason [`SyncReport::note_parent`] gives: a project
    /// membership is not a relationship, and coercing a `MappedProject` into a
    /// `MappedRelation` would put a project id where a relationship id belongs.
    /// The three methods share the diagnostic type and the count, which is the
    /// vocabulary that matters.
    pub(super) fn note_project(
        &mut self,
        mapped: MappedProject,
        out: &mut dyn Write,
    ) -> std::io::Result<()> {
        if let MappedProject::Unsupported(diagnostic) = mapped {
            if self.report_diagnostics {
                writeln!(out, "{diagnostic}")?;
            }
            self.diagnostics.push(diagnostic);
        }
        Ok(())
    }

    /// The same, for an **epic reference** (WP08, mission
    /// `epics-work-items-01M3BYW4`).
    ///
    /// Its own method for the reason [`SyncReport::note_project`] gives: an epic
    /// reference is not a relationship, and coercing a `MappedEpic` into a
    /// `MappedRelation` would put an epic id where a relationship id belongs. The
    /// four methods share the diagnostic type and the count, which is the vocabulary
    /// that matters.
    pub(super) fn note_epic(
        &mut self,
        mapped: MappedEpic,
        out: &mut dyn Write,
    ) -> std::io::Result<()> {
        if let MappedEpic::Unsupported(diagnostic) = mapped {
            if self.report_diagnostics {
                writeln!(out, "{diagnostic}")?;
            }
            self.diagnostics.push(diagnostic);
        }
        Ok(())
    }
}

/// The record emitted when a remote tracker cannot hold a piece of local data —
/// a relation (FR-005, C-005, `data-model.md` §6), a parent reference, a health
/// value, a project association (WP08, mission `first-class-projects-01M3BYBW`),
/// or an epic reference (WP08, mission `epics-work-items-01M3BYW4`).
///
/// The four fields are fixed by the data model. `field` is the important one: it
/// is what makes the diagnostic actionable rather than a shrug, and
/// `data-model.md` §6 requires it for exactly that reason — "so the message can
/// name it".
///
/// One type for all of them because the vocabulary matters more than the noun: a
/// reader of `tix export` output learns one shape ("tool: field cannot represent
/// this local data (subject) — reason") and does not have to recognise which kind
/// of local data each line is about. The **subject** in `relation_id` is whatever
/// the diagnostic is about — a relationship id, an `issue->parent` pair, a health
/// scope, an `issue->project` pair, or an `issue->epic` pair.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteMappingDiagnostic {
    /// The local item the diagnostic is about: a relationship id, an
    /// `issue->parent` pair, a health scope, or an `issue->project` pair.
    pub relation_id: String,
    /// The specific unsupported remote field or relation name.
    pub field: String,
    /// Which adapter produced the diagnostic.
    pub tool: Tool,
    /// Human-readable text, safe for CLI output, the TUI, and lint.
    pub message: String,
}

impl std::fmt::Display for RemoteMappingDiagnostic {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // "this local data", not "this relationship": the type now carries project
        // associations and health values too, and a line that called an association
        // a relationship would send the reader looking for an edge that is not there.
        write!(
            f,
            "{}: {} cannot represent this local data ({}) — {}",
            self.tool.as_str(),
            self.field,
            self.relation_id,
            self.message
        )
    }
}

/// The outcome of mapping one local relationship for one tracker: either a
/// remote representation, or a diagnostic naming why there is none.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MappedRelation {
    /// The remote representation to send: the relation `field` (the remote
    /// relation kind, e.g. `blocks`) and the `value` the adapter needs. For a
    /// relation verb taking two endpoints (Linear's `relations add`) the `value`
    /// is the **target's remote identifier** (`ENG-42`); a tracker whose verb
    /// carries the target inside a field renders its own value there.
    Remote { field: String, value: String },
    /// The tracker cannot express this relation. The **local** relation is
    /// untouched — C-005 is that a relation the remote cannot hold still survives.
    Unsupported(RemoteMappingDiagnostic),
}

/// What one parent reference maps to on the wire.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MappedParent {
    /// The remote value to send, as a field/value pair like a relation.
    Remote { field: String, value: String },
    /// The adapter cannot carry it. The **local** reference is untouched, which is
    /// the same contract the relationship mapper follows (C-005): a reference the
    /// remote cannot hold still survives here.
    Unsupported(RemoteMappingDiagnostic),
}

/// Map one local parent reference to its remote representation, or say why not.
///
/// Pure: no adapter, no I/O, no configuration, and shared by both trackers so a
/// future one cannot be added with a divergent contract (T033).
#[must_use]
pub fn map_parent_to_remote(tool: Tool, issue_number: u32, parent: Option<u32>) -> MappedParent {
    let Some(parent) = parent else {
        // No reference is not a mapping problem. Reporting it would put a
        // diagnostic on every parentless issue in the repository, which is every
        // diagnostic being ignored.
        return MappedParent::Remote {
            field: parent_field(tool).to_string(),
            value: String::new(),
        };
    };
    let field = parent_field(tool);
    if PARENT_WIRE_VERIFIED.contains(&tool) {
        return MappedParent::Remote {
            field: field.to_string(),
            value: format!("#{parent}"),
        };
    }
    MappedParent::Unsupported(RemoteMappingDiagnostic {
        relation_id: format!("{issue_number}->{parent}"),
        field: field.to_string(),
        tool,
        message: format!(
            "#{issue_number}'s parent #{parent} is expressible in {}'s model as \
             `{field}`, but the CLI verb that would carry it is not verified, so \
             nothing is sent; the local reference is kept",
            tool.as_str()
        ),
    })
}

/// The field an adapter would carry a parent in.
pub(super) fn parent_field(tool: Tool) -> &'static str {
    match tool {
        Tool::GitLab => GITLAB_PARENT_FIELD,
        Tool::Linear => LINEAR_PARENT_FIELD,
    }
}

/// Parse the project references one remote issue reported.
///
/// **Pure and total**, in the shape [`crate::sync::mod`'s parent parser] is: an
/// unrecognised payload yields no references — which the import then reports
/// nothing about — rather than a deserialisation failure that aborts the whole
/// list. Accepted, for both trackers, because the field's exact shape is a claim
/// about a program this repository cannot run:
///
/// - an array of ids (`"projects": ["p-1", …]`) or of objects;
/// - a single id, or a single object (`"project": {"id": …}`);
/// - an object's id under `id`, `identifier`, `key`, or `project_id`, as a
///   string or a number (`iid`-style numeric ids are real on the wire);
/// - its name under `name`, `title`, or `display_name`, as a string.
///
/// **Entry order is preserved**, which is what keeps a round trip byte-stable
/// (NFR-002): reordering here would reorder the local list.
///
/// An entry with a name but no id is **kept** with an empty id, so the import can
/// report it by name instead of dropping it in silence; an entry with neither is
/// not a reference at all and yields nothing.
#[must_use]
pub fn remote_projects(value: Option<&serde_json::Value>) -> Vec<RemoteProject> {
    fn id_of(value: &serde_json::Value) -> String {
        match value {
            serde_json::Value::String(s) => s.clone(),
            serde_json::Value::Number(n) => n.to_string(),
            _ => String::new(),
        }
    }
    fn one(value: &serde_json::Value) -> Option<RemoteProject> {
        match value {
            serde_json::Value::String(_) | serde_json::Value::Number(_) => Some(RemoteProject {
                id: id_of(value),
                name: None,
            }),
            serde_json::Value::Object(map) => {
                let id = ["id", "identifier", "key", "project_id"]
                    .iter()
                    .find_map(|key| map.get(*key))
                    .map(id_of)
                    .unwrap_or_default();
                let name = ["name", "title", "display_name"]
                    .iter()
                    .find_map(|key| map.get(*key))
                    .and_then(serde_json::Value::as_str)
                    .map(str::to_string);
                if id.trim().is_empty() && name.is_none() {
                    return None;
                }
                Some(RemoteProject { id, name })
            }
            _ => None,
        }
    }
    let Some(value) = value else {
        return Vec::new();
    };
    match value {
        serde_json::Value::Array(items) => items.iter().filter_map(one).collect(),
        other => one(other).into_iter().collect(),
    }
}

/// The field an adapter would carry project associations in.
pub(super) fn project_field(tool: Tool) -> &'static str {
    match tool {
        Tool::GitLab => GITLAB_PROJECT_FIELD,
        Tool::Linear => LINEAR_PROJECT_FIELD,
    }
}

/// What one local project association maps to on the wire.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MappedProject {
    /// The remote value to send, as a field/value pair like a relation.
    Remote { field: String, value: String },
    /// The adapter cannot carry it. The **local** association is untouched — C-005
    /// is that a membership the remote cannot hold still survives.
    Unsupported(RemoteMappingDiagnostic),
}

/// Map one local project association to its remote representation, or say why not.
///
/// Pure: no adapter, no I/O, no configuration. Both trackers share it, so a future
/// one cannot be added with a divergent contract — and the representability
/// decision lives in exactly one place instead of once per adapter.
///
/// `issue_number` appears in the message because the diagnostic has to be
/// actionable: the association list can be long, and "project `p-3` cannot be
/// sent" without the issue is a shrug.
#[must_use]
pub fn map_project_to_remote(tool: Tool, issue_number: u32, project_id: &str) -> MappedProject {
    let field = project_field(tool);
    let id = project_id.trim();
    if id.is_empty() {
        // Not a representability question: there is nothing to send, and an empty
        // entry in the list is a repair job rather than a remote capability gap.
        return MappedProject::Unsupported(RemoteMappingDiagnostic {
            relation_id: format!("{issue_number}->"),
            field: field.to_string(),
            tool,
            message: format!(
                "issue #{issue_number} carries a project association with no id, so there \
                 is nothing to send; the entry is kept locally — dissociating the empty \
                 reference, or editing the issue's project list, clears it"
            ),
        });
    }
    if PROJECT_WIRE_VERIFIED.contains(&tool) {
        return MappedProject::Remote {
            field: field.to_string(),
            value: id.to_string(),
        };
    }
    MappedProject::Unsupported(RemoteMappingDiagnostic {
        relation_id: format!("{issue_number}->{id}"),
        field: field.to_string(),
        tool,
        message: format!(
            "#{issue_number}'s membership of project `{id}` has no {} field to travel in \
             — {} — so nothing is sent and the local association is kept",
            tool.as_str(),
            project_absence_reason(tool)
        ),
    })
}

/// Why a tracker's CLI cannot carry a project membership, per tool.
///
/// A **permanent** claim about the CLI the adapter drives, not "not yet
/// verified", and the wording says so for the same reason `RELATION_CLI_ABSENT`
/// does: a reader told "the verb is unverified" will wait for it, and there is
/// nothing to wait for. The two reasons differ, so the message does.
pub(super) fn project_absence_reason(tool: Tool) -> &'static str {
    match tool {
        Tool::GitLab => {
            "`glab` names one owning project (`--project`) and exposes no membership field"
        }
        Tool::Linear => {
            "Linear's issue carries one `project` and no list, so a second \
                         membership has nowhere to go"
        }
    }
}

/// Map one remote project reference back onto a local association id, or say why
/// not.
///
/// The reverse value is **local-shaped**, like [`map_relation_from_remote`]'s: the
/// frontmatter key the association lives under ([`crate::projects::PROJECTS_KEY`])
/// and the id, not a copy of the tool's field name — echoing the field back would
/// make the same association read differently per tracker, which is the opposite
/// of a shared seam.
///
/// Not gated on [`PROJECT_WIRE_VERIFIED`], and the module header records why: an
/// id is validated against the local registry before anything is written, so this
/// direction cannot invent local data the way an unverified relation field could.
#[must_use]
pub fn map_project_from_remote(tool: Tool, remote: &RemoteProject) -> MappedProject {
    let id = remote.id.trim();
    if id.is_empty() {
        return MappedProject::Unsupported(RemoteMappingDiagnostic {
            relation_id: remote.name.clone().unwrap_or_default(),
            field: project_field(tool).to_string(),
            tool,
            message: format!(
                "the remote reported a project it named `{}` but gave no id, and an id is \
                 the only thing a local association can match on; nothing was associated \
                 and the local list is unchanged",
                remote.name.as_deref().unwrap_or("")
            ),
        });
    }
    MappedProject::Remote {
        field: crate::projects::PROJECTS_KEY.to_string(),
        value: id.to_string(),
    }
}

/// Redact the resolved secret from a diagnostic's message.
///
/// The diagnostics this module builds itself are made of local ids and field
/// names. The **import** builds four — a reference it cannot read, a project the
/// registry does not declare, an epic the registry does not declare, and a service
/// refusal — and the first three quote the name a remote payload reported. Remote
/// output is the one input that can carry a credential, which is why the import's
/// diagnostics go through here, using the same [`scrub_secret`] the adapters use on
/// their errors.
pub(super) fn scrub_diagnostic(diagnostic: &mut RemoteMappingDiagnostic, secret: Option<&str>) {
    if let Some(secret) = secret {
        diagnostic.message = scrub_secret(&diagnostic.message, secret);
        diagnostic.relation_id = scrub_secret(&diagnostic.relation_id, secret);
    }
}

/// Parse the epic references one remote issue reported.
///
/// **Pure and total**, in the shape [`remote_projects`] is: an unrecognised payload
/// yields no references — which the import then reports nothing about — rather than
/// a deserialisation failure that aborts the whole list. Accepted, for both
/// trackers, because the field's exact shape is a claim about a program this
/// repository cannot run:
///
/// - an array of ids (`"epics": ["e-1", …]`) or of objects;
/// - a single id, or a single object (`"epic": {"id": …}`);
/// - an object's id under `id`, `identifier`, `key`, or `epic_id`, as a string or a
///   number (numeric ids are real on the wire);
/// - its name under `name`, `title`, or `display_name`, as a string.
///
/// **Entry order is preserved**, which is what keeps a round trip byte-stable
/// (NFR-002): reordering here would reorder the local list. An entry with a name
/// but no id is **kept** with an empty id, so the import can report it by name
/// instead of dropping it in silence; an entry with neither is not a reference at
/// all and yields nothing.
#[must_use]
pub fn remote_epics(value: Option<&serde_json::Value>) -> Vec<RemoteEpic> {
    fn id_of(value: &serde_json::Value) -> String {
        match value {
            serde_json::Value::String(s) => s.clone(),
            serde_json::Value::Number(n) => n.to_string(),
            _ => String::new(),
        }
    }
    fn one(value: &serde_json::Value) -> Option<RemoteEpic> {
        match value {
            serde_json::Value::String(_) | serde_json::Value::Number(_) => Some(RemoteEpic {
                id: id_of(value),
                name: None,
            }),
            serde_json::Value::Object(map) => {
                let id = ["id", "identifier", "key", "epic_id"]
                    .iter()
                    .find_map(|key| map.get(*key))
                    .map(id_of)
                    .unwrap_or_default();
                let name = ["name", "title", "display_name"]
                    .iter()
                    .find_map(|key| map.get(*key))
                    .and_then(serde_json::Value::as_str)
                    .map(str::to_string);
                if id.trim().is_empty() && name.is_none() {
                    return None;
                }
                Some(RemoteEpic { id, name })
            }
            _ => None,
        }
    }
    let Some(value) = value else {
        return Vec::new();
    };
    match value {
        serde_json::Value::Array(items) => items.iter().filter_map(one).collect(),
        other => one(other).into_iter().collect(),
    }
}

/// What one local epic reference maps to on the wire.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MappedEpic {
    /// The remote value to send, as a field/value pair like a relation.
    Remote { field: String, value: String },
    /// The adapter cannot carry it. The **local** reference is untouched — C-007 is
    /// that a reference the remote cannot hold still survives.
    Unsupported(RemoteMappingDiagnostic),
}

/// Map one local epic reference to its remote representation, or say why not.
///
/// Pure: no adapter, no I/O, no configuration. Both trackers share it, so a future
/// one cannot be added with a divergent contract — and the representability decision
/// lives in exactly one place instead of once per adapter.
///
/// `issue_number` appears in the message because the diagnostic has to be
/// actionable: the reference list can be long, and "epic `e-3` cannot be sent"
/// without the issue is a shrug.
#[must_use]
pub fn map_epic_to_remote(tool: Tool, issue_number: u32, epic_id: &str) -> MappedEpic {
    let field = EPIC_FIELD;
    let id = epic_id.trim();
    if id.is_empty() {
        // Not a representability question: there is nothing to send, and an empty
        // entry in the list is a repair job rather than a remote capability gap.
        return MappedEpic::Unsupported(RemoteMappingDiagnostic {
            relation_id: format!("{issue_number}->"),
            field: field.to_string(),
            tool,
            message: format!(
                "issue #{issue_number} carries an epic reference with no id, so there is \
                 nothing to send; the entry is kept locally — dereferencing the empty \
                 reference, or editing the issue's epic list, clears it"
            ),
        });
    }
    if EPIC_WIRE_VERIFIED.contains(&tool) {
        return MappedEpic::Remote {
            field: field.to_string(),
            value: id.to_string(),
        };
    }
    MappedEpic::Unsupported(RemoteMappingDiagnostic {
        relation_id: format!("{issue_number}->{id}"),
        field: field.to_string(),
        tool,
        message: format!(
            "#{issue_number}'s reference to epic `{id}` has no {} field to travel in \
             — {} — so nothing is sent and the local reference is kept",
            tool.as_str(),
            epic_absence_reason(tool)
        ),
    })
}

/// Why a tracker's CLI cannot carry an epic membership, per tool.
///
/// A **permanent** claim about the CLI the adapter drives, not "not yet verified",
/// and the wording says so for the same reason `PROJECT_WIRE_VERIFIED`'s reason
/// does: a reader told "the verb is unverified" will wait for it, and there is
/// nothing to wait for. The two reasons differ, so the message does.
pub(super) fn epic_absence_reason(tool: Tool) -> &'static str {
    match tool {
        Tool::GitLab => {
            "`glab` names at most one epic per issue (`epic_iid`) and exposes no flag \
             to send one, so a membership list has nowhere to go"
        }
        Tool::Linear => {
            "Linear has no epic level at all — the nearest concept is a project — so an \
             epic reference has nowhere to go"
        }
    }
}

/// Map one remote epic reference back onto a local reference id, or say why not.
///
/// The reverse value is **local-shaped**, like [`map_project_from_remote`]'s: the
/// frontmatter key the reference lives under ([`crate::epics::EPICS_KEY`]) and the
/// id, not a copy of the tool's field name — echoing the field back would make the
/// same reference read differently per tracker, which is the opposite of a shared
/// seam.
///
/// Not gated on [`EPIC_WIRE_VERIFIED`], and the module header records why: an id is
/// validated against the local registry before anything is written, so this
/// direction cannot invent local data the way an unverified relation field could.
#[must_use]
pub fn map_epic_from_remote(tool: Tool, remote: &RemoteEpic) -> MappedEpic {
    let id = remote.id.trim();
    if id.is_empty() {
        return MappedEpic::Unsupported(RemoteMappingDiagnostic {
            relation_id: remote.name.clone().unwrap_or_default(),
            field: EPIC_FIELD.to_string(),
            tool,
            message: format!(
                "the remote reported an epic it named `{}` but gave no id, and an id is \
                 the only thing a local reference can match on; nothing was referenced \
                 and the local list is unchanged",
                remote.name.as_deref().unwrap_or("")
            ),
        });
    }
    MappedEpic::Remote {
        field: crate::epics::EPICS_KEY.to_string(),
        value: id.to_string(),
    }
}

/// What one local health value maps to on the wire.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MappedHealth {
    /// The remote value to send, as a field/value pair like a relation.
    Remote { field: String, value: String },
    /// The adapter cannot carry it. The **local** value is untouched, which is the
    /// same contract the relationship and parent mappers follow (C-008): a value the
    /// remote cannot hold still survives here, and a remote that lacks the field can
    /// never clear it.
    Unsupported(RemoteMappingDiagnostic),
}

/// Map one local health value to its remote representation, or say why not (T022,
/// T023).
///
/// Pure: no adapter, no I/O, no configuration. Shared by both trackers so a future
/// one cannot be added with a divergent contract, and the representability table
/// lives in exactly one place.
///
/// Only an **issue**-scoped value has a remote home: a rollup belongs to this
/// repository's memberships, and a remote that knew nothing of the parent would carry
/// a number the local side derived. A non-issue scope is therefore reported, never
/// silently sent as if it were the issue's own health.
#[must_use]
pub fn map_health_to_remote(
    tool: Tool,
    scope: &crate::health::HealthScope,
    state: crate::health::HealthState,
) -> MappedHealth {
    let field = health_field(tool);
    if HEALTH_MODEL_ABSENT.contains(&tool) {
        return MappedHealth::Unsupported(RemoteMappingDiagnostic {
            relation_id: scope.as_str(),
            field: field.to_string(),
            tool,
            message: format!(
                "{}'s issue model has no health field, so `{}` stays local; a remote that \
                 cannot carry the value never clears it",
                tool.as_str(),
                state.as_str()
            ),
        });
    }
    let crate::health::HealthScope::Issue(number) = scope else {
        return MappedHealth::Unsupported(RemoteMappingDiagnostic {
            relation_id: scope.as_str(),
            field: field.to_string(),
            tool,
            message: format!(
                "`{}` is a derived value for {}, which has no remote representation; only \
                 an issue's own health can be sent, so the local value is kept",
                state.as_str(),
                scope
            ),
        });
    };
    if HEALTH_WIRE_VERIFIED.contains(&tool) {
        return MappedHealth::Remote {
            field: field.to_string(),
            value: state.as_str().to_string(),
        };
    }
    MappedHealth::Unsupported(RemoteMappingDiagnostic {
        relation_id: format!("issue:{number}"),
        field: field.to_string(),
        tool,
        message: format!(
            "issue #{number}'s `{}` is expressible in {}'s model as `{field}`, but the CLI \
             verb that would carry it is not verified, so nothing is sent; the local value \
             is kept",
            state.as_str(),
            tool.as_str()
        ),
    })
}

/// The field an adapter carries an issue's health in.
pub(super) fn health_field(tool: Tool) -> &'static str {
    match tool {
        Tool::GitLab => GITLAB_HEALTH_FIELD,
        Tool::Linear => LINEAR_HEALTH_FIELD,
    }
}

/// What one local initiative maps to on the wire.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MappedInitiative {
    /// The remote value to send, as a field/value pair like a relation. A verified
    /// adapter would put the **remote reference** in `value`; until one exists this
    /// arm is unreachable, exactly as [`MappedParent::Remote`] is.
    Remote { field: String, value: String },
    /// The target cannot carry the record. The **local** record is untouched,
    /// which is the contract every other mapper follows (C-005): a record the
    /// remote cannot hold still survives, and a remote that lacks the field can
    /// never clear it.
    Unsupported(RemoteMappingDiagnostic),
}

/// Map one local initiative to its remote representation, or say why not (T034,
/// T035).
///
/// Pure: no adapter, no I/O, no configuration. Shared by both trackers so a future
/// one cannot be added with a divergent contract — and the representability
/// decision lives in exactly one place instead of once per adapter.
///
/// `initiative_id` is the **stable** id (C-002), never a display name: it is the
/// subject the diagnostic names, and the value a verified adapter would send.
#[must_use]
pub fn map_initiative_to_remote(tool: Tool, initiative_id: &str) -> MappedInitiative {
    let field = INITIATIVE_FIELD;
    if INITIATIVE_WIRE_VERIFIED.contains(&tool) {
        return MappedInitiative::Remote {
            field: field.to_string(),
            value: initiative_id.to_string(),
        };
    }
    MappedInitiative::Unsupported(RemoteMappingDiagnostic {
        relation_id: initiative_id.to_string(),
        field: field.to_string(),
        tool,
        message: initiative_absence_reason(tool).to_string(),
    })
}

/// Why a tracker's CLI cannot carry a local initiative, per tool.
///
/// The two reasons differ and the message must not blur them: GitLab's is a
/// **permanent** "the CLI has no such command", while Linear's is "the verb exists
/// but cannot hold the record's fields". A reader told the wrong one would either
/// wait for a command that will never appear or look for a flag that is not there.
pub(super) fn initiative_absence_reason(tool: Tool) -> &'static str {
    if INITIATIVE_CLI_ABSENT.contains(&tool) {
        return "the CLI exposes no initiative command at all, so the whole local record is \
                kept; a remote that cannot hold an initiative never clears one";
    }
    "the CLI's `initiatives` verbs take only a name, a description and a status — they \
     cannot carry `owner`, `priority`, `labels`, `target_date`, or the typed \
     project/epic/milestone associations — so sending those three fields would drop the \
     rest; the whole local record is kept instead"
}

/// Map one local relationship to its remote representation, or say why not.
///
/// Pure: no adapter, no I/O, no configuration. Both trackers share it, so a future
/// one cannot be added with a divergent contract (T033) — and the representability
/// table lives in exactly one place instead of once per adapter.
///
/// `target` is the **target issue's remote reference**, if it is known: Linear's
/// `relations add` needs the target's remote identifier (`ENG-42`), and an edge
/// whose target has not been exported yet cannot be sent. Passing `None` therefore
/// yields a diagnostic that names the target and says so — never a guessed
/// identifier (C-005: the local edge survives). Trackers whose verb does not need a
/// second endpoint (or none of whose verbs are verified) ignore `target`.
#[must_use]
pub fn map_relation_to_remote(
    tool: Tool,
    relation: &crate::relationships::Relationship,
    target: Option<&str>,
) -> MappedRelation {
    use crate::relationships::RelationKind;
    let (field, value) = match tool {
        Tool::GitLab => (GITLAB_BLOCKING_FIELD, GITLAB_BLOCKING_VALUE),
        Tool::Linear => (LINEAR_BLOCKING_FIELD, LINEAR_BLOCKING_VALUE),
    };
    // The tracker's *model* can express a blocking edge — and the adapter drives a
    // CLI whose verb is verified only for the tools on `RELATION_WIRE_VERIFIED`.
    let model_supports = matches!(
        relation.kind,
        RelationKind::Blocks | RelationKind::BlockedBy
    );
    if model_supports && RELATION_WIRE_VERIFIED.contains(&tool) {
        let value = match tool {
            // Linear's `relations add` takes the target's **remote identifier** as
            // its second endpoint. Without one there is nothing to send, and a
            // guessed identifier would be worse than a diagnostic.
            Tool::Linear => match target {
                Some(target) => target.to_string(),
                None => {
                    return MappedRelation::Unsupported(RemoteMappingDiagnostic {
                        relation_id: relation.id.clone(),
                        field: field.to_string(),
                        tool,
                        message: format!(
                            "`{}` targets #{}, which has not been exported yet, so its \
                             remote identifier is unknown and the edge cannot be sent; \
                             the local edge is kept",
                            relation.kind.as_str(),
                            relation.to
                        ),
                    });
                }
            },
            // GitLab is never on `RELATION_WIRE_VERIFIED` (its CLI has no relation
            // command at all), so this arm is unreachable today; its rendering is
            // left exactly as it was rather than "fixed" into an unverified shape.
            Tool::GitLab => format!("{value} #{}", relation.to),
        };
        return MappedRelation::Remote {
            field: field.to_string(),
            value,
        };
    }
    if model_supports {
        // Expressible in the model, not drivable from the CLI. A *different* claim
        // from "unsupported", and the message says which — a reader who is told
        // "GitLab cannot express this" will not go looking for the verb. And for a
        // CLI verified to have no relation command at all, the message must not say
        // "not verified": there is nothing to verify (tix #1354).
        let reason = if RELATION_CLI_ABSENT.contains(&tool) {
            format!(
                "the {} CLI exposes no issue-relation command, so it cannot be sent",
                tool.as_str()
            )
        } else {
            "the CLI verb that would carry it is not verified, so nothing is sent".to_string()
        };
        return MappedRelation::Unsupported(RemoteMappingDiagnostic {
            relation_id: relation.id.clone(),
            field: field.to_string(),
            tool,
            message: format!(
                "`{}` is expressible in {}'s model as `{field}`, but {reason}; the local \
                 edge is kept",
                relation.kind.as_str(),
                tool.as_str()
            ),
        });
    }
    MappedRelation::Unsupported(RemoteMappingDiagnostic {
        relation_id: relation.id.clone(),
        field: field.to_string(),
        tool,
        message: format!(
            "`{}` is a local-only relation; {} has no field for it, so the local \
             edge is kept and the remote one is not created",
            relation.kind.as_str(),
            tool.as_str()
        ),
    })
}

/// The inverse: read a remote relation back to a local kind and target.
///
/// Symmetric with [`map_relation_to_remote`] by construction — the blocking field
/// maps back to `blocks`, and nothing else maps back at all. A remote payload
/// carrying a field this function does not recognise yields a diagnostic rather
/// than a default, so an import cannot invent a relation the export never made.
#[must_use]
pub fn map_relation_from_remote(
    tool: Tool,
    field: &str,
    value: &str,
    relation_id: &str,
) -> MappedRelation {
    let expected = match tool {
        Tool::GitLab => GITLAB_BLOCKING_FIELD,
        Tool::Linear => LINEAR_BLOCKING_FIELD,
    };
    // Gated the same way as the forward direction: without a verified verb there is
    // no payload this could have received, and coercing one would invent a
    // relationship the export never made.
    if field == expected && RELATION_WIRE_VERIFIED.contains(&tool) {
        // The value this module writes is `"{kind} #{n}"`, so the target is the
        // **last** whitespace-separated token. Taking the first reads the kind word
        // and fails — which is what an earlier version did, and it made the round
        // trip look asymmetric when only the parser was.
        if let Some(target) = value
            .split_whitespace()
            .last()
            .and_then(|t| t.strip_prefix('#'))
            .and_then(|t| t.parse::<u32>().ok())
        {
            // The reverse value is **local**-shaped — the canonical kind spelling
            // and a `#`-prefixed number — not a copy of the remote field name.
            // GitLab's field is `blocking` and Linear's is `blocks`; echoing the
            // field back would make the round trip return different text per
            // tracker for the same relationship, which is the opposite of
            // symmetric.
            return MappedRelation::Remote {
                field: "kind".to_string(),
                value: format!("blocks #{target}"),
            };
        }
        return MappedRelation::Unsupported(RemoteMappingDiagnostic {
            relation_id: relation_id.to_string(),
            field: field.to_string(),
            tool,
            message: format!(
                "`{value}` is not a target issue number, so the remote relation \
                 cannot be read back; no local edge was created"
            ),
        });
    }
    MappedRelation::Unsupported(RemoteMappingDiagnostic {
        relation_id: relation_id.to_string(),
        field: field.to_string(),
        tool,
        message: format!(
            "`{field}` is not a relation field {} writes, so it is ignored; the \
             local relationship set is unchanged",
            tool.as_str()
        ),
    })
}

/// Map a local column to the remote state/label sent to the tracker
/// (`contracts/external-tools.md` mapping table).
///
/// - GitLab: done → `closed`, anything else → `opened`.
/// - Linear: done → `Done`, otherwise the first non-done column label.
#[must_use]
pub fn local_to_remote_status(tool: Tool, local_status: &str, config: &Config) -> String {
    let board = config.default_config();
    match tool {
        Tool::GitLab => {
            if is_done_column(board, local_status, config) {
                "closed".into()
            } else {
                "opened".into()
            }
        }
        Tool::Linear => {
            if is_done_column(board, local_status, config) {
                "Done".into()
            } else {
                first_work_column(board, config).to_string()
            }
        }
    }
}

/// Map a remote state/label back onto a local column. Closed/Done maps to
/// the effective done column; anything else lands in the column whose name
/// matches (case-insensitive), else the first column — per the contract's
/// "whose name matches, else into the first column".
#[must_use]
pub fn remote_to_local_status(tool: Tool, remote_status: &str, config: &Config) -> String {
    let board = config.default_config();
    // WP07 T032: the *service's* completion column, not `done_column`. See
    // `completion_column` for why choosing one local column is this module's business
    // while the meaning of "complete" is not.
    let done = completion_column(board, config);
    match tool {
        Tool::GitLab => {
            if remote_status == "closed" {
                done
            } else {
                board
                    .columns
                    .first()
                    .cloned()
                    .unwrap_or_else(|| "todo".into())
            }
        }
        Tool::Linear => {
            if remote_status == "Done" {
                done
            } else {
                board
                    .columns
                    .iter()
                    .find(|c| c.eq_ignore_ascii_case(remote_status))
                    .cloned()
                    .unwrap_or_else(|| {
                        board
                            .columns
                            .first()
                            .cloned()
                            .unwrap_or_else(|| "todo".into())
                    })
            }
        }
    }
}

/// Whether `local_status` is the board's **completion** column, by the service.
///
/// WP07 T032. This was `board.effective_done_column() == Some(local_status)` — a
/// column-*name* comparison, which is a second local definition of "done" and exactly
/// what C-004 forbids. It agreed with the service on a board with no
/// `category_map` — every existing configuration — and disagreed on precisely the
/// boards this mission exists to support: a board mapping `shipped` to `completed`
/// exports `shipped` as *open* and leaves `done_column` (mapped to `active`) as
/// *closed*.
///
/// `sync/mod.rs` is out of this mission's map; the edit is the completion
/// reconciliation T032 names, and the rationale is recorded here and in
/// `decisions/WP07-verification.md`.
pub(super) fn is_done_column(
    board: &BoardConfiguration,
    local_status: &str,
    config: &Config,
) -> bool {
    let resolution =
        crate::workflow::resolution(config, board, crate::workflow::active_board_name(config));
    resolution
        .resolve(local_status)
        .is_some_and(crate::workflow::WorkflowCategory::is_complete)
}

/// The board's completion **column**, chosen by the service rather than by
/// `done_column`.
///
/// The remote trackers have a two-state notion of "closed", so the export path has to
/// pick one local column to send as closed. That is a **remote-translation** concern
/// and belongs here; deciding what "complete" *means* is not, and is the service's.
///
/// Preferring the column the service calls complete means a board whose completion
/// category sits on a column other than `done_column` still round-trips: export
/// closes the column that is genuinely complete, and import brings it back to that
/// same column rather than to `done_column`.
pub(super) fn completion_column(board: &BoardConfiguration, config: &Config) -> String {
    // WP09: this was a private re-implementation — the same "first complete column,
    // else `done_column`, else `done`" rule the service now owns, duplicated here so
    // that `first_work_column` and `remote_to_local_status` could each have a total
    // `String`. It is the module's own doc comment, written in WP07, that says a
    // second local definition is a C-004 defect, so the duplicate had to go.
    crate::workflow::completion_column_or_default(board, config)
}

/// The first column that is **not** the board's completion column, or the
/// completion column when every column is complete.
///
/// WP09: "not the done column" is a question about **completion**, so it is asked of
/// the service rather than of `done_column`. On a board whose `category_map` puts
/// `completed` on `shipped` while `done_column` is `done`, the old version returned
/// `todo` as the first *work* column when `shipped` is the first column that is
/// genuinely finished — so an export labelled a shipped issue as work in progress.
pub(super) fn first_work_column<'a>(board: &'a BoardConfiguration, config: &Config) -> &'a str {
    // The lifetime is `board`'s, and the `done` fallback cannot borrow from `config`
    // — so the completion column is compared as a `String` and the fallback is a
    // literal. The alternative, returning `&'a str` from a local, is what forces the
    // old `unwrap_or("done")` to exist at all.
    let done = crate::workflow::completion_column_or_default(board, config);
    board
        .columns
        .iter()
        .find(|c| c.as_str() != done)
        .map_or("done", String::as_str)
}

/// The inverse of `model::civil_from_days` (Howard Hinnant's
/// `days_from_civil`): calendar date → days since the Unix epoch.
#[must_use]
pub fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
    let y = if m <= 2 { y - 1 } else { y };
    let era = if y >= 0 { y } else { y - 399 } / 400;
    let yoe = y - era * 400; // [0, 399]
    let mp = (m + 9) % 12; // [0, 11]
    let doy = i64::from((153 * mp + 2) / 5 + d - 1); // [0, 365]
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
    era * 146_097 + doe - 719_468
}

/// Parse `YYYY-MM-DD` into days since the Unix epoch.
#[must_use]
pub fn date_to_epoch_days(ymd: &str) -> Option<i64> {
    let mut parts = ymd.trim().split('-');
    let y: i64 = parts.next()?.parse().ok()?;
    let m: u32 = parts.next()?.parse().ok()?;
    let d: u32 = parts.next()?.parse().ok()?;
    if parts.next().is_some() {
        return None;
    }
    if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
        return None;
    }
    Some(days_from_civil(y, m, d))
}

/// Parse an ISO-8601 remote timestamp into epoch seconds. Accepts
/// `YYYY-MM-DDTHH:MM:SS` (the `T` may be a space) with optional fractional
/// seconds and a `Z` or `±HH:MM` zone.
#[must_use]
pub fn iso_to_epoch_secs(input: &str) -> Option<i64> {
    let (date, time) = input.trim().split_once(['T', ' '])?;
    let days = date_to_epoch_days(date)?;
    let (time, zone) = split_zone(time);
    let mut hms = time.split(':');
    let hh: i64 = hms.next()?.parse().ok()?;
    let mm: i64 = hms.next()?.parse().ok()?;
    let ss_part = hms.next()?;
    let whole: i64 = ss_part.split('.').next()?.parse().ok()?;
    if !(0..=23).contains(&hh) || !(0..=59).contains(&mm) || !(0..=59).contains(&whole) {
        return None;
    }
    let mut secs = days * 86_400 + hh * 3600 + mm * 60 + whole;
    if let Some((zh, zm)) = zone {
        secs -= zh * 3600 + zm * 60;
    }
    Some(secs)
}

/// Split `HH:MM:SS[.frac][zone]` into the bare time and an optional
/// `(hours, minutes)` zone offset (already sign-adjusted).
pub(super) fn split_zone(time: &str) -> (&str, Option<(i64, i64)>) {
    if time.ends_with('Z') || time.ends_with('z') {
        return (&time[..time.len() - 1], Some((0, 0)));
    }
    match time.rfind(['+', '-']) {
        Some(i) if i > 0 => {
            let (bare, zone) = time.split_at(i);
            let sign = if zone.starts_with('-') { -1i64 } else { 1i64 };
            let digits = &zone[1..];
            let (zh, zm) = if let Some((h, m)) = digits.split_once(':') {
                (h.parse::<i64>().ok(), m.parse::<i64>().ok())
            } else if digits.len() == 2 {
                (digits.parse::<i64>().ok(), Some(0))
            } else if digits.len() >= 3 {
                let (h, m) = digits.split_at(digits.len() - 2);
                (h.parse::<i64>().ok(), m.parse::<i64>().ok())
            } else {
                (None, None)
            };
            match (zh, zm) {
                (Some(zh), Some(zm)) => (bare, Some((sign * zh, sign * zm))),
                _ => (bare, None),
            }
        }
        Some(_) | None => (time, None),
    }
}

/// The conflict rule: the remote wins when its `updated_at` is strictly later
/// than the local issue's `updated` (a `YYYY-MM-DD` interpreted as end of
/// day). A remote timestamp that cannot be parsed counts as *not newer*, so
/// the local issue is kept.
#[must_use]
pub fn is_remote_newer(remote_updated_at: &str, local_updated: &str) -> bool {
    let Some(remote) = iso_to_epoch_secs(remote_updated_at) else {
        return false;
    };
    let local_end_of_day = date_to_epoch_days(local_updated).map_or(0, |d| d * 86_400 + 86_399);
    remote > local_end_of_day
}

/// Redact a resolved secret from text flowing through error/report surfaces.
#[must_use]
pub fn scrub_secret(text: &str, secret: &str) -> String {
    if secret.is_empty() {
        return text.to_string();
    }
    text.replace(secret, "<token>")
}

/// The contract's missing-binary wording (`contracts/external-tools.md`):
/// exit code 3 with `required tool not found: <tool> — install from <source>`.
pub(super) fn not_found_message(program: &str) -> String {
    match program {
        "glab" => "required tool not found: glab — install from https://gitlab.com/gitlab-org/cli"
            .to_string(),
        _ => format!("required tool not found: {program} — install it and retry"),
    }
}

/// Build and configure the adapter for `tool`: resolves the token with env
/// precedence, validates the `[export]` target section, and returns the
/// tool-specific adapter wired to a [`ProcessRunner`].
pub(super) fn adapter_for(tool: Tool, config: &Config) -> Result<Box<dyn TrackerAdapter>> {
    // The contract's warning starts with "warning:" — no `tix:` prefix.
    let mut warn = |w: &str| eprintln!("{w}");
    let resolution = token::resolve(tool, config, &token::RealEnv, &mut warn);
    let secret = resolution.secret().map(str::to_string);
    match tool {
        Tool::GitLab => {
            let rc = config.export.gitlab.as_ref().ok_or_else(|| {
                TixError::Config("no `[export.gitlab]` section in tix.toml — run `tix init --with gitlab` or edit tix.toml".to_string())
            })?;
            let project = rc.project.clone().ok_or_else(|| {
                TixError::Config("`[export.gitlab].project` is not set — add `project = \"acme/tix\"` to tix.toml".to_string())
            })?;
            Ok(Box::new(glab::GitLabAdapter::new(
                ProcessRunner,
                secret,
                rc.base_url.clone(),
                project,
            )))
        }
        Tool::Linear => {
            let rc = config.export.linear.as_ref().ok_or_else(|| {
                TixError::Config("no `[export.linear]` section in tix.toml — run `tix init --with linear` or edit tix.toml".to_string())
            })?;
            let team = rc.team.clone().ok_or_else(|| {
                TixError::Config(
                    "`[export.linear].team` is not set — add `team = \"Eng\"` to tix.toml"
                        .to_string(),
                )
            })?;
            Ok(Box::new(linear::LinearAdapter::new(
                ProcessRunner,
                secret,
                team,
            )))
        }
    }
}