tix-tracker 0.10.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
//! The local portable time document (mission `per-issue-time-tracking-01M3C9A4`,
//! WP07, T034–T038; C-002, C-007, FR-004, FR-005, NFR-003).
//!
//! [`PortableTimeDocument`] is the **local, loss-aware interchange form** of the time
//! surfaces: a versioned JSON document carrying, for every issue in scope, the
//! current [`TimeEntry`] values verbatim (stable id, whole minutes, optional calendar
//! date, author, optional note, and whatever unknown metadata the file carries), the
//! audit context History retains (the prior values deletions left behind, and how many
//! corrections each issue keeps, with the shared [`TimeAudit`]
//! (crate::time_tracking::aggregate::TimeAudit) summary beside them), the shared
//! [`TimeTotal`] for the scope and for each issue, and the diagnostics — the entry
//! findings, the preserved-metadata findings and the remote-target findings that say
//! what a remote tracker cannot carry. It is a **projection** of the shared DTOs: every
//! number is a field a shared value held, every label is a shared rendering, and there
//! is no arithmetic in this module at all.
//!
//! # The approved interchange contracts, and what they fix
//!
//! The approved local interchange contracts fix the *properties* this format must
//! have, not its field names: a versioned machine-readable schema, a round trip that
//! **preserves every supported field without loss**, explicit item-level diagnostics
//! instead of a silent drop, and no external CLI or hosted service required. The field
//! set is therefore the shared DTO's own field set, in the house shape the sibling
//! portable documents already use ([`crate::health::portable`],
//! [`crate::roadmap::portable`], [`crate::views::portable`],
//! [`crate::capacity::portable`]): a `format` marker, a `version`, ordered lists, a
//! `diagnostics` vocabulary, and a preserved-metadata bag.
//!
//! # One DTO, no private formula (C-006/NFR-003)
//!
//! Every value in this document is read from a shared value: the entries from
//! [`TimeSnapshot::entries`], the audit from [`TimeSnapshot::audit`], the numbers from
//! [`TimeTotal`] (the same value `tix time total`, the TUI and MCP consume), and the
//! labels and states from the shared renderings ([`TimeFilter::describe`],
//! [`TimeGroup::label`](crate::time_tracking::aggregate::TimeGroup::label),
//! [`TimeTotalState::as_str`]). There is no sum, no count and no unit fold in this
//! module — a number that is not a field of one of those values cannot be printed
//! here. That is what makes the parity obligation structural: `tix time total` and
//! `tix export --target time` cannot disagree, because there is no second
//! implementation to disagree with. The one predicate this module applies to an entry
//! ([`TimeFilter::selects`]) is the shared filter's own, not a second spelling of it.
//!
//! # An absent value is absent, never zero
//!
//! Every value that can be unanswerable is `Option`, serialized as an explicit JSON
//! `null` (deliberately not omitted, so the schema is self-describing) and read back as
//! `None`: an entry whose `duration_minutes` is missing is `null` there and `null` in
//! `minutes`, never `0`; a scope that selected nothing and one whose entries were all
//! malformed are distinguished by the total's own `state` (`nothing` versus `unknown`),
//! never by a zero. `state` is the shared [`TimeTotalState`] spelling, so this document
//! cannot invent a fifth state or read a refusal as an empty answer. The CLI's `-`
//! convention is a *text* rendering; JSON's `null` is the same statement.
//!
//! The **attributed** strings follow the same rule rather than an empty-string one: an
//! entry's blank author, and a deletion's blank actor, are unanswerable and are `null` —
//! never `""`, which a consumer could only tell apart from "no attribution" by
//! re-implementing `trim().is_empty()`. The reader accepts `null` and an omitted key for
//! every optional field, so a hand-written document may state either.
//!
//! # Two namespaces, so a preserved key can never shadow a value
//!
//! A node that preserves data keeps it in **two separate channels** rather than one
//! shared namespace:
//!
//! * `extensions` — a real nested object ([`PortableExtensions`]). This is the channel
//!   this build *writes*, and exactly the two nodes with something to preserve carry it:
//!   an entry's own [`TimeEntryExtensions`] (the keys a newer `tix` added beside `id`,
//!   `duration_minutes`, `date`, `author` and `note`), and whatever a caller adds to the
//!   document through [`PortableTimeDocument::insert_extension`]. Because it is its own
//!   object, an entry metadata key named `minutes`, `id` or `author` lands *inside* it and
//!   can never duplicate or replace the projected field beside it — for this reader **or
//!   for a lenient one** (a last-key-wins JSON parser would otherwise read metadata where
//!   a value belongs).
//! * the flattened catch-all ([`PortableExtensions`] again, field `unknown`, never a
//!   wire key) — the channel this build *only reads*. serde delivers a key a newer build
//!   put directly on the node here, and it is written back verbatim; this build never
//!   writes to it, so it cannot collide with anything. Dropping it would be the silent
//!   loss the interchange contract forbids, and it is on **every** composite node
//!   (document, issue row, entry row) for that reason. The bag is **private** on every
//!   node and read through an accessor, so there is no public way to hand a node a key
//!   that names a projected field: the only public write path into a bag is the nested,
//!   checked one above, and [`PortableExtensions`] carries the `compile_fail` example
//!   that asserts it.
//!
//! A reader therefore cannot make this build emit a duplicate key, and this build
//! cannot fail to re-read its own output: a hand-written document that repeats a
//! *projected* key inside one object is refused by name (`duplicate field`), which is
//! the honest answer — the alternative is a lenient consumer reading the metadata in
//! place of the value. [`PortableTimeFilter`], [`PortableTimeTotal`],
//! [`PortableTimeDeletion`] and [`PortableTimeDiagnostic`] are pure projections of
//! shared values with no extension channel at all, deliberately: there is nothing a
//! writer could have preserved in them.
//!
//! # Preserved values are projected faithfully, or diagnosed
//!
//! Entry metadata is YAML in the issue file and JSON in this document, and it is
//! converted **by hand** rather than through `serde_yaml`'s own serializer, because that
//! bridge is not faithful for three shapes: a non-finite float (`.nan`, `.inf`) has no
//! JSON number, a YAML tag has no JSON form, and a nested mapping with a non-string key
//! has none either. A value that genuinely cannot be represented is **withheld and
//! reported** as a [`METADATA_NOT_CARRIED`] diagnostic; it is never silently turned into
//! `null`. Null *is* representable and is kept as JSON `null`.
//!
//! Withholding is **per key**: the offending key is withheld and the diagnostic names it
//! and the reason — not a path into it. "Preserved verbatim" is therefore a claim about
//! the keys a document carries, not a promise that a partially representable value
//! survives in part.
//!
//! # The version marker: read first, checked, and a mismatch is a diagnostic
//!
//! The document carries a [`FORMAT`] marker and a [`VERSION`]. The marker is read
//! **before** the document is deserialized, so a document that is not a time document at
//! all (a `tix-capacity`, `tix-roadmap` or `tix-health` payload, whose fields overlap
//! structurally) is **refused by name** rather than by whatever shape error its fields
//! happened to raise. A document of a *different version* is read — its own fields and
//! its preserved keys are kept — and the mismatch is reported as a [`VERSION_MISMATCH`]
//! diagnostic. Refusing it would make forward compatibility impossible, and dropping the
//! newer fields is exactly what the interchange contract forbids; the diagnostic is the
//! honest third answer, and it is merged (never appended twice), so the document
//! stabilizes after one write instead of growing on every read.
//!
//! # Remote namespaces are separate (T036/T037, FR-005)
//!
//! `tix export --target gitlab|linear` mirrors **issues** to a remote tracker
//! ([`crate::sync`], `[export.*]`): it writes remote ids back into issue files and shells
//! out to `glab`/`linear-cli`. This document is a *local* interchange form and never
//! reaches either tool. Where a remote target is configured, the target's inability to
//! carry time is **diagnosed explicitly** — `unsupported_remote_field`, naming the target
//! and the field, stating that the values stay local — because a silent drop and an
//! invented mapping are both worse than a refusal. That finding is produced here, in the
//! document and on the export's own stderr; `src/sync/**` is untouched and `time` is
//! dispatched by name before `Tool::from_target` is consulted.
//!
//! # Read-only, on every path
//!
//! [`PortableTimeDocument::build`] only reads the snapshot the caller already built, and
//! nothing here opens a file for writing, appends a `History` event, mutates a current
//! entry or reaches a remote tool. A refused filter is a refusal (the shared findings,
//! with their remedies) rather than a document carrying an invented total, exactly as
//! `tix time total` refuses a scope it cannot total.
//!
//! [`TimeSnapshot::entries`]: crate::time_tracking::aggregate::TimeSnapshot::entries
//! [`TimeSnapshot::audit`]: crate::time_tracking::aggregate::TimeSnapshot::audit
//! [`TimeFilter::selects`]: crate::time_tracking::filters::TimeFilter::selects
#![allow(clippy::module_name_repetitions)]

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

use crate::config::Config;
use crate::diagnostics::Diagnostic;
use crate::error::{Result, TixError};
use crate::time_tracking::TIME_ENTRY_FRONTMATTER_KEY;
use crate::time_tracking::aggregate::{
    IndexedEntry, TimeGroup, TimeSnapshot, TimeTotal, TimeTotalState,
};
use crate::time_tracking::diagnostics::{TimeDiagnostic, TimeDiagnosticKind};
use crate::time_tracking::entry::{TimeEntry, TimeEntryExtensions};
use crate::time_tracking::filters::TimeFilter;

/// The document's format marker. A consumer reads this before treating the payload as a
/// time document; a document with another marker is refused by name.
pub const FORMAT: &str = "tix-time";

/// The document's format version. Bumped when the shape changes incompatibly.
pub const VERSION: u32 = 1;

/// The duration precision every number in this document is stated in (C-002).
///
/// The mission's confirmed precision is whole minutes; the document states it once,
/// beside the numbers, so a consumer never has to infer it from the field name.
pub const PRECISION: &str = "minutes";

/// The stable kind spelling of a remote-mapping diagnostic.
///
/// The same spelling the health, roadmap, views and capacity portable documents use for
/// the same finding — and the same spelling the shared time vocabulary declares
/// ([`TimeDiagnosticKind::UnsupportedRemoteField`]), so a reader who learned one
/// vocabulary reads the others.
pub const UNSUPPORTED_REMOTE_FIELD: &str = "unsupported_remote_field";

/// The stable kind spelling of an "unknown metadata preserved" finding.
pub const UNKNOWN_METADATA: &str = "unknown_metadata";

/// The stable kind spelling of a "metadata could not be represented" finding.
pub const METADATA_NOT_CARRIED: &str = "metadata_not_carried";

/// The stable kind spelling of a "different document version" finding.
pub const VERSION_MISMATCH: &str = "version_mismatch";

// ---------------------------------------------------------------------------

// The preserved-metadata bags
// ---------------------------------------------------------------------------

/// A bag of preserved `(key, value)` pairs, in the document's own vocabulary.
///
/// Used for both channels (see the module docs): the nested `extensions` object this
/// build writes, and the flattened catch-all it only reads. A newtype over a JSON value
/// map, rather than a bare `Map`, so the one inserter is private and every write goes
/// through [`PortableTimeDocument::insert_extension`]. A JSON map (not a YAML map)
/// because this document is JSON and a YAML value JSON cannot carry is withheld and
/// diagnosed rather than silently reshaped. The map is sorted (`serde_json`'s
/// `preserve_order` feature is off), so key *order* is normalised while the values — the
/// thing a round trip preserves — are kept exactly.
///
/// # No public route to a key that could shadow a projected field
///
/// `Deserialize` is derived (a document has to be readable), so a caller can *obtain* a
/// bag naming any key. What a caller cannot do is hand one to a flattened channel: those
/// fields are private everywhere they exist, and the only public way in is the checked
/// nesting shown below, where the key lands inside an object of its own and can therefore
/// never duplicate — or be read in place of — the value beside it. That is a compile-time
/// property, so it is asserted as one:
///
/// ```compile_fail
/// use tix::time_tracking::portable::{PortableExtensions, PortableTimeDocument};
///
/// let mut document: PortableTimeDocument = unimplemented!();
/// // The catch-all is private: there is no public field to drop a shadowing key into.
/// document.unknown = PortableExtensions::default();
/// ```
///
/// The nested channel, for contrast, is checked rather than forbidden:
///
/// ```no_run
/// use tix::time_tracking::portable::{PortableTimeDocument, FORMAT};
/// use serde_json::json;
///
/// let mut document: PortableTimeDocument = unimplemented!();
/// document.insert_extension("format", json!("metadata, not a marker"));
/// let _ = FORMAT;
/// ```
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PortableExtensions(serde_json::Map<String, serde_json::Value>);

impl PortableExtensions {
    /// An empty bag.
    #[must_use]
    pub fn new() -> Self {
        Self(serde_json::Map::new())
    }

    /// Whether no metadata is present.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.0.is_empty()
    }

    /// The number of preserved keys.
    #[must_use]
    pub fn len(&self) -> usize {
        self.0.len()
    }

    /// Look up a preserved key.
    #[must_use]
    pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
        self.0.get(key)
    }

    /// The preserved keys, in canonical (sorted) order.
    pub fn keys(&self) -> impl Iterator<Item = &str> {
        self.0.keys().map(String::as_str)
    }

    /// The underlying map, for a caller that must inspect it whole.
    #[must_use]
    pub fn as_map(&self) -> &serde_json::Map<String, serde_json::Value> {
        &self.0
    }

    /// Preserve one `(key, value)` pair, returning the previous value if any.
    ///
    /// Deliberately **private**: a preserved key is only safe inside a bag of its own
    /// (the nested `extensions` object), so the only public way in is the checked
    /// [`PortableTimeDocument::insert_extension`]. A public raw inserter would let a
    /// caller write a key a document field already owns and make this build refuse its
    /// own output.
    fn insert(
        &mut self,
        key: impl Into<String>,
        value: serde_json::Value,
    ) -> Option<serde_json::Value> {
        self.0.insert(key.into(), value)
    }

    /// Carry one entry's unknown metadata (`TimeEntry::extensions`).
    ///
    /// Returns the bag and the keys that had to be **withheld** because JSON cannot
    /// represent their value faithfully (see [`yaml_to_json`]).
    fn from_yaml(extensions: &TimeEntryExtensions) -> (Self, Vec<NotCarried>) {
        let mut bag = Self::new();
        let mut not_carried = Vec::new();
        for (key, value) in extensions.iter() {
            match yaml_to_json(value) {
                Ok(json) => {
                    bag.insert(key, json);
                }
                Err(reason) => not_carried.push(NotCarried {
                    key: key.to_string(),
                    reason,
                }),
            }
        }
        (bag, not_carried)
    }
}

/// One preserved key whose value could not be projected, and why.
#[derive(Debug, Clone)]
struct NotCarried {
    /// The metadata key.
    key: String,
    /// Why JSON cannot represent it faithfully.
    reason: String,
}

/// Project one `serde_yaml` value into JSON, or say why it cannot be carried.
///
/// Hand-written rather than `serde_yaml`'s serializer because that bridge has three
/// gaps JSON cannot paper over: a non-finite number has no JSON number, a YAML tag has
/// no JSON form, and a mapping key that is not a string has none either. All three are
/// reported and the key is withheld rather than silently turned into `null`. Null,
/// bools, numbers, strings, sequences and string-keyed mappings are carried as what they
/// are.
fn yaml_to_json(value: &serde_yaml::Value) -> std::result::Result<serde_json::Value, String> {
    Ok(match value {
        serde_yaml::Value::Null => serde_json::Value::Null,
        serde_yaml::Value::Bool(flag) => serde_json::Value::Bool(*flag),
        serde_yaml::Value::Number(number) => {
            if let Some(signed) = number.as_i64() {
                serde_json::Value::from(signed)
            } else if let Some(unsigned) = number.as_u64() {
                serde_json::Value::from(unsigned)
            } else {
                let Some(float) = number.as_f64() else {
                    return Err(format!("the number `{number:?}` has no JSON form"));
                };
                let Some(number) = serde_json::Number::from_f64(float) else {
                    return Err(format!(
                        "the number `{float}` is not finite, and JSON has no number for it"
                    ));
                };
                serde_json::Value::Number(number)
            }
        }
        serde_yaml::Value::String(text) => serde_json::Value::String(text.clone()),
        serde_yaml::Value::Sequence(items) => {
            let mut array = Vec::with_capacity(items.len());
            for item in items {
                array.push(yaml_to_json(item)?);
            }
            serde_json::Value::Array(array)
        }
        serde_yaml::Value::Mapping(mapping) => {
            let mut map = serde_json::Map::new();
            for (key, item) in mapping {
                let serde_yaml::Value::String(key) = key else {
                    return Err(
                        "a mapping key is not a string, and JSON cannot carry one".to_string()
                    );
                };
                map.insert(key.clone(), yaml_to_json(item)?);
            }
            serde_json::Value::Object(map)
        }
        serde_yaml::Value::Tagged(tagged) => {
            return Err(format!(
                "the value carries the YAML tag `{}`, which JSON cannot carry",
                tagged.tag
            ));
        }
    })
}

// ---------------------------------------------------------------------------
// T034/T035 — the projected shared values
// ---------------------------------------------------------------------------

/// One finding, in the document's vocabulary.
///
/// Used for **both** halves of the document: a shared [`TimeDiagnostic`] the snapshot or
/// the aggregation raised (an entry's field problem, a refused filter, an unknown issue)
/// and a document-level finding (preserved metadata, metadata that could not be carried,
/// an unsupported remote field, a version mismatch). One shape means a reader learns one
/// vocabulary, the way the health, roadmap, views and capacity documents do.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableTimeDiagnostic {
    /// The stable contract name (`invalid_duration`, `invalid_date`, `missing_author`,
    /// `malformed_entry`, `invalid_period`, `invalid_assignee`, `unknown_issue`,
    /// `unsupported_remote_field`, `unknown_metadata`, `metadata_not_carried`,
    /// `version_mismatch`, …).
    ///
    /// A plain string, so a kind a newer build adds survives a round trip as a string
    /// instead of failing to parse.
    pub kind: String,
    /// The issue the finding is about, when it is about one.
    #[serde(default)]
    pub issue: Option<u32>,
    /// The entry the finding is about, when it is about one.
    #[serde(default)]
    pub entry_id: Option<String>,
    /// The frontmatter field the problem sits at, when it is field-shaped
    /// (`duration_minutes`, `date`, `time_entries`).
    #[serde(default)]
    pub field: Option<String>,
    /// The remote target, when the finding is about a mapping a target cannot carry.
    #[serde(default)]
    pub target: Option<String>,
    /// What is wrong, in one sentence.
    pub reason: String,
    /// What the reader can do about it.
    pub remedy: String,
}

impl PortableTimeDiagnostic {
    /// Project one shared [`TimeDiagnostic`] into the wire vocabulary.
    ///
    /// A pure field copy: the kind is the shared registry's own spelling, the issue,
    /// entry and field are the finding's own, and the reason and remedy are the sentence
    /// pair the finding already carries. Nothing is recomputed and nothing is added.
    #[must_use]
    pub fn from_shared(finding: &TimeDiagnostic) -> Self {
        Self {
            kind: finding.kind.as_str().to_string(),
            issue: finding.issue,
            entry_id: finding.entry_id.clone(),
            field: finding.field.map(str::to_string),
            target: None,
            reason: finding.reason.clone(),
            remedy: finding.remedy.clone(),
        }
    }

    /// Name the remote target this finding is about.
    fn on_target(mut self, target: &str) -> Self {
        self.target = Some(target.to_string());
        self
    }

    /// A document-level finding about metadata this build preserved.
    fn unknown_metadata(field: String, keys: Vec<String>) -> Self {
        Self {
            kind: UNKNOWN_METADATA.to_string(),
            issue: None,
            entry_id: None,
            field: Some(field),
            target: None,
            reason: format!(
                "{} key(s) this build does not interpret are preserved verbatim under \
                 `extensions`: {}",
                keys.len(),
                keys.join(", ")
            ),
            remedy: "keep the metadata: a build that understands the key reads it back unchanged"
                .to_string(),
        }
    }

    /// A document-level finding about a preserved key that could not be carried.
    fn metadata_not_carried(field: String, not_carried: &NotCarried) -> Self {
        Self {
            kind: METADATA_NOT_CARRIED.to_string(),
            issue: None,
            entry_id: None,
            field: Some(format!("{field}.{}", not_carried.key)),
            target: None,
            reason: format!(
                "`{key}` is not carried: {reason}",
                key = not_carried.key,
                reason = not_carried.reason
            ),
            remedy: "rewrite the value in a form JSON can represent (a finite number, a string, a \
                 sequence, or a mapping with string keys), or remove the key"
                .to_string(),
        }
    }

    /// A document-level finding about a remote target that cannot carry time data.
    ///
    /// The kind and the remedy are the shared vocabulary's own
    /// ([`TimeDiagnosticKind::UnsupportedRemoteField`]); the field is the frontmatter key
    /// the mission registers and the target is the configured `[export.*]` section's name.
    /// It is **not** a mapping: no remote id is invented, no value is converted, and
    /// nothing is sent anywhere.
    fn unsupported_remote(target: &str, counts: RemoteCounts) -> Self {
        let finding = TimeDiagnostic::new(
            TimeDiagnosticKind::UnsupportedRemoteField,
            format!(
                "`{target}` has no `{TIME_ENTRY_FRONTMATTER_KEY}` field, so this document's \
                 {issues} issue row(s), their current entries, {deletions} deletion(s) and \
                 {corrections} correction(s) stay local; nothing is mapped and nothing is dropped",
                issues = counts.issues,
                deletions = counts.deletions,
                corrections = counts.corrections,
            ),
        );
        Self::from_shared(&finding)
            .on_target(target)
            .in_field(TIME_ENTRY_FRONTMATTER_KEY)
    }

    /// A document-level finding about a version this build does not write.
    fn version_mismatch(found: u32) -> Self {
        Self {
            kind: VERSION_MISMATCH.to_string(),
            issue: None,
            entry_id: None,
            field: Some("version".to_string()),
            target: None,
            reason: format!(
                "the document declares version {found}, but this build writes version {VERSION}; \
                 its fields are preserved rather than refused or dropped"
            ),
            remedy:
                "read it with a build that writes that version; this one preserves the fields it \
                 does not interpret"
                    .to_string(),
        }
    }

    /// Name the field the problem sits at.
    fn in_field(mut self, field: &str) -> Self {
        self.field = Some(field.to_string());
        self
    }

    /// The stable contract name.
    #[must_use]
    pub fn kind(&self) -> &str {
        &self.kind
    }

    /// One line, `kind [field]: reason` — what a surface prints.
    ///
    /// The reason is the sentence that names the value, and for a remote finding it names
    /// the target too, so the rendered line carries the target, the field and the promise
    /// that the values stay local without a second layout to learn.
    #[must_use]
    pub fn render(&self) -> String {
        match self.field.as_deref() {
            Some(field) => format!("{} [{field}]: {}", self.kind, self.reason),
            None => format!("{}: {}", self.kind, self.reason),
        }
    }

    /// Whether two diagnostics describe the same problem, which is how a derived
    /// diagnostic is merged rather than appended twice on a repeated round trip.
    #[must_use]
    fn same_as(&self, other: &Self) -> bool {
        self.kind == other.kind
            && self.field == other.field
            && self.target == other.target
            && self.reason == other.reason
    }
}

/// The normalized filter the document's totals were evaluated under (T035).
///
/// Every criterion is the filter's own value, verbatim, so a period or an identity that
/// was asked for survives a round trip; `assignee_identity` is the canonical identity the
/// shared filter resolves the criterion to, carried *beside* the criterion rather than
/// instead of it (a generated/hand-written criterion and its canonical form are one
/// scope, and hiding either would lose what the caller asked for). `description` is
/// [`TimeFilter::describe`], the one spelling every surface prints.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableTimeFilter {
    /// The issue criterion, when the scope names one.
    #[serde(default)]
    pub issue: Option<u32>,
    /// The assignee criterion, verbatim as it was asked for.
    #[serde(default)]
    pub assignee: Option<String>,
    /// The canonical identity the criterion resolves to, when it resolves.
    #[serde(default)]
    pub assignee_identity: Option<String>,
    /// The inclusive lower period bound, verbatim.
    #[serde(default)]
    pub date_from: Option<String>,
    /// The inclusive upper period bound, verbatim.
    #[serde(default)]
    pub date_to: Option<String>,
    /// Whether audit context (the deleted counts) was asked for.
    pub include_deleted: bool,
    /// Whether every criterion is usable ([`TimeFilter::is_valid`]).
    pub valid: bool,
    /// The scope as one label ([`TimeFilter::describe`]).
    pub description: String,
}

impl PortableTimeFilter {
    /// Project the shared filter.
    #[must_use]
    pub fn from_filter(filter: &TimeFilter) -> Self {
        Self {
            issue: filter.issue,
            assignee: filter.assignee.clone(),
            assignee_identity: filter.canonical_assignee(),
            date_from: filter.date_from.clone(),
            date_to: filter.date_to.clone(),
            include_deleted: filter.include_deleted,
            valid: filter.is_valid(),
            description: filter.describe(),
        }
    }
}

/// One current entry, projected field for field (T034).
///
/// The values are the entry's own and nothing is smoothed over: an entry the file holds
/// with a missing or negative `duration_minutes` keeps that value here (a diagnostic on
/// the row says which problem it is), `minutes` is the shared
/// [`TimeEntry::minutes`] answer — `None` exactly when the duration is unusable, so no
/// reader can mistake a malformed entry for a zero-minute one — and `valid` is the
/// shared [`TimeEntry::is_valid`]. `extensions` and `unknown` are the two preservation
/// channels the module docs describe: the entry's unknown metadata lives in the nested
/// object, so a key named `minutes` or `author` cannot shadow the real one, and the
/// flattened catch-all is read-only, so no caller can put one there.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PortableTimeEntry {
    /// The stable id: identity for edits, deletions and History references, verbatim.
    pub id: String,
    /// The duration the file states, verbatim; `null` when the entry states none.
    #[serde(default)]
    pub duration_minutes: Option<i64>,
    /// The calendar date the effort belongs to, verbatim; `null` when it carries none.
    #[serde(default)]
    pub date: Option<String>,
    /// Who recorded the effort, verbatim; `null` when the entry names no attribution.
    ///
    /// A blank author (`author: ""`, or the key omitted) is **unanswerable**, not an empty
    /// name: the file states no identity, so `null` is the honest rendering — the same
    /// statement every other absent field in this row makes — and `valid: false` with the
    /// shared `missing_author` finding beside it says why. The reader accepts `null` and an
    /// omitted key as well as a string, so a document that says either re-reads.
    #[serde(default)]
    pub author: Option<String>,
    /// Optional free text, verbatim; `null` when the entry carries none.
    #[serde(default)]
    pub note: Option<String>,
    /// The duration when it is usable ([`TimeEntry::minutes`]): `null` exactly when the
    /// stated duration is absent or negative, never `0`.
    #[serde(default)]
    pub minutes: Option<i64>,
    /// Whether the entry has no field-level problem ([`TimeEntry::is_valid`]).
    pub valid: bool,
    /// Every field-level problem, through the shared entry-to-diagnostic mapping.
    #[serde(default)]
    pub diagnostics: Vec<PortableTimeDiagnostic>,
    /// The entry's unknown metadata, preserved verbatim in a namespace of its own.
    #[serde(default)]
    pub extensions: PortableExtensions,
    /// Keys a newer writer placed directly on this node, preserved verbatim.
    ///
    /// Deliberately **private**: this is the bag whose entries are emitted *at* the node's
    /// level, so a public field would let a caller put a projected field's name there and
    /// make this build emit a duplicate key. It is filled by reading a document — where a
    /// repeated key is refused — and read back through [`Self::unknown`].
    #[serde(flatten)]
    unknown: PortableExtensions,
}

impl PortableTimeEntry {
    /// The keys a newer writer placed directly on this entry row, preserved verbatim.
    ///
    /// The read side of the entry's catch-all channel; there is no write side, by design
    /// (see the field).
    #[must_use]
    pub fn unknown(&self) -> &PortableExtensions {
        &self.unknown
    }

    /// Project one current entry of one issue.
    ///
    /// `issue` only labels the diagnostics: an entry problem is reported with the issue
    /// and entry it belongs to, through the shared mapping, so a reader never has to
    /// guess which row a finding is about.
    /// The returned [`NotCarried`] list is the metadata that could not be projected; the
    /// caller reports it rather than letting a value disappear.
    fn of_entry(issue: u32, entry: &TimeEntry) -> (Self, Vec<NotCarried>) {
        let diagnostics = entry
            .problems()
            .iter()
            .map(|problem| {
                PortableTimeDiagnostic::from_shared(&TimeDiagnostic::from_entry_problem(
                    issue, &entry.id, problem,
                ))
            })
            .collect();
        let (extensions, not_carried) = PortableExtensions::from_yaml(&entry.extensions);
        (
            Self {
                id: entry.id.clone(),
                duration_minutes: entry.duration_minutes,
                date: entry.date.clone(),
                author: answerable(&entry.author),
                note: entry.note.clone(),
                minutes: entry.minutes(),
                valid: entry.is_valid(),
                diagnostics,
                extensions,
                unknown: PortableExtensions::new(),
            },
            not_carried,
        )
    }
}

/// One prior value a deletion left in History (T035).
///
/// The provenance the append-only log records, verbatim: which entry the event named
/// (when its detail names one that the shared grammar can recover), when the deletion was
/// recorded and who recorded it. The prior *value's* minutes live in the `## History`
/// line itself and are deliberately **not** re-parsed here — the shared aggregation
/// owns that grammar, and a second reader of it would be a second definition of what a
/// deletion means.
///
/// An unanswerable field is `null`, never an empty string, exactly as the CLI renders it
/// (`tix time history` prints `-` for a blank value through its own one-line cell). The
/// shared grammar refuses a blank *timestamp*, so `deleted_at` is present for every event
/// this build reads from a file; a blank **actor** is a shape the grammar and the writer
/// both allow ("the actor may legitimately be blank, which is exactly what `render` writes
/// when no identity was recorded"), and it is `null` here. The reader accepts `null` and an
/// omitted key for all three, which is what a hand-written or newer document may state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableTimeDeletion {
    /// The deleted entry's stable id, when the event's detail names one.
    #[serde(default)]
    pub entry_id: Option<String>,
    /// When the deletion was recorded, as the event records it; `null` when it states none.
    #[serde(default)]
    pub deleted_at: Option<String>,
    /// Who recorded the deletion, as the event records it; `null` when it names nobody.
    #[serde(default)]
    pub deleted_by: Option<String>,
}

/// A shared string that is unanswerable when it is blank.
///
/// The one rule the attributed fields share: a value that is absent or only whitespace is
/// **no value**, so it is `null` in the document rather than an empty string — the same
/// statement, in JSON, that the CLI's `-` makes on a table row. A caller cannot tell "no
/// attribution" from "an empty attribution" by re-implementing `trim().is_empty()` against
/// an empty string, and the format should not ask it to. A non-blank value is carried
/// verbatim, whitespace included.
fn answerable(value: &str) -> Option<String> {
    if value.trim().is_empty() {
        None
    } else {
        Some(value.to_string())
    }
}

/// The shared total, projected field for field (T034).
///
/// Every number is the DTO's own — the sum, the counts beside it and the optional deleted
/// count — and `state` is the shared [`TimeTotalState`] spelling, so an empty scope
/// (`nothing`) and a scope whose entries were all unusable (`unknown`) stay different
/// answers here exactly as they are on the CLI (C-005). `group` is the shared
/// [`TimeGroup`](crate::time_tracking::aggregate::TimeGroup) label and `scope` the
/// filtered scope the total was evaluated under.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableTimeTotal {
    /// The shared group label (`all`, `#3`, `@ada`, `(unattributed)`, a date,
    /// `(undated)`).
    pub group: String,
    /// The filter the total was evaluated under.
    pub scope: PortableTimeFilter,
    /// The explicit state, spelled as [`TimeTotalState::as_str`].
    pub state: String,
    /// Whether some selected entry was left out of the sum
    /// ([`TimeTotal::is_partial`]).
    pub is_partial: bool,
    /// The sum of the counted entries' minutes; `null` when nothing was counted, and
    /// `0` only when a zero was genuinely recorded.
    #[serde(default)]
    pub total_minutes: Option<i128>,
    /// Valid entries counted into the sum.
    pub entry_count: usize,
    /// Entries whose own fields are unusable; each has its own finding.
    pub malformed_count: usize,
    /// Valid entries a period-bounded scope could not place because they carry no date.
    pub undated_count: usize,
    /// Prior values deletions left in History on the issues this total is about; `null`
    /// unless the filter asked for audit context.
    #[serde(default)]
    pub deleted_count: Option<usize>,
    /// Every finding the total carries, in the DTO's own order.
    #[serde(default)]
    pub diagnostics: Vec<PortableTimeDiagnostic>,
}

impl PortableTimeTotal {
    /// Project one shared [`TimeTotal`].
    #[must_use]
    pub fn from_total(total: &TimeTotal) -> Self {
        Self {
            group: total.group.label(),
            scope: PortableTimeFilter::from_filter(&total.filter),
            state: total.state().as_str().to_string(),
            is_partial: total.is_partial(),
            total_minutes: total.total_minutes,
            entry_count: total.entry_count,
            malformed_count: total.malformed_count,
            undated_count: total.undated_count,
            deleted_count: total.deleted_count,
            diagnostics: total
                .diagnostics
                .iter()
                .map(PortableTimeDiagnostic::from_shared)
                .collect(),
        }
    }
}

/// The scope's audit summary, projected from
/// [`TimeAudit`](crate::time_tracking::aggregate::TimeAudit) (T035).
///
/// The summary half of the audit DTO: the scope it was requested under, how many prior
/// values deletions left in History across that scope and how many corrections it retains,
/// and the findings about the request itself (a refused filter, an unknown issue). The
/// per-issue halves are the document's `issues` rows, so a reader gets both views of one
/// audit value and no second reading of History.
///
/// These counts are **not** the totals' `deleted_count`, which is `null` unless the filter
/// asked for audit context: an unrequested count and a zero count are different facts, and
/// this pair keeps them apart.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableTimeAudit {
    /// The filter the audit was requested under.
    pub scope: PortableTimeFilter,
    /// Prior values deletions left in History across the scope.
    pub deleted_count: usize,
    /// Corrections the scope's issues retain.
    pub corrected_count: usize,
    /// Findings about the request itself, in the DTO's own order.
    #[serde(default)]
    pub diagnostics: Vec<PortableTimeDiagnostic>,
}

impl PortableTimeAudit {
    /// Project one shared audit's summary halves.
    #[must_use]
    pub fn from_audit(
        filter: &TimeFilter,
        deleted_count: usize,
        corrected_count: usize,
        diagnostics: &[TimeDiagnostic],
    ) -> Self {
        Self {
            scope: PortableTimeFilter::from_filter(filter),
            deleted_count,
            corrected_count,
            diagnostics: diagnostics
                .iter()
                .map(PortableTimeDiagnostic::from_shared)
                .collect(),
        }
    }
}

/// One issue's current entries beside what History retains (T035).
///
/// `entries` are the issue's current entries the document's scope selects, in the
/// snapshot's deterministic order; `current` is the shared audit's count of the issue's
/// current entries (whatever the scope's identity or period criteria select), which is
/// the comparison the audit exists for. `deletions` and `corrections` are the shared
/// audit's own, so a deletion and a correction are both visible here without this module
/// reading the History grammar. `total` is the shared per-issue breakdown value, or
/// `null` when the breakdown answered for no group of that issue — an absent answer,
/// never an invented zero.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PortableTimeIssue {
    /// The issue number the row describes.
    pub number: u32,
    /// The issue's current entries the scope selects, in snapshot order.
    #[serde(default)]
    pub entries: Vec<PortableTimeEntry>,
    /// How many current entries the issue holds, from the shared audit.
    pub current: usize,
    /// The prior values deletions left in this issue's History, in History order.
    #[serde(default)]
    pub deletions: Vec<PortableTimeDeletion>,
    /// How many corrections this issue's History retains.
    pub corrections: usize,
    /// The issue's own shared total, or `null` when the breakdown answered for no group
    /// of it.
    #[serde(default)]
    pub total: Option<PortableTimeTotal>,
    /// Keys a newer writer placed directly on this node, preserved verbatim.
    ///
    /// Private for the reason the entry row's is: a public field could put a projected
    /// field's name at this node's level. Read through [`Self::unknown`].
    #[serde(flatten)]
    unknown: PortableExtensions,
}

impl PortableTimeIssue {
    /// The keys a newer writer placed directly on this issue row, preserved verbatim.
    #[must_use]
    pub fn unknown(&self) -> &PortableExtensions {
        &self.unknown
    }
}

// ---------------------------------------------------------------------------
// T034 — the document
// ---------------------------------------------------------------------------

/// Everything the local time surfaces know, in one versioned document.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PortableTimeDocument {
    /// The format marker: [`FORMAT`].
    pub format: String,
    /// The format version: [`VERSION`].
    pub version: u32,
    /// The duration precision every number is stated in: [`PRECISION`].
    pub precision: String,
    /// The normalized scope the totals were evaluated under.
    pub filter: PortableTimeFilter,
    /// The scope's shared total: the same value `tix time total` prints first.
    pub total: PortableTimeTotal,
    /// The scope's audit summary: the shared counts of what History retains.
    pub audit: PortableTimeAudit,
    /// One row per issue the scope's selector covers, in issue order.
    #[serde(default)]
    pub issues: Vec<PortableTimeIssue>,
    /// Document-level findings (preserved metadata, unsupported remote fields, version).
    #[serde(default)]
    pub diagnostics: Vec<PortableTimeDiagnostic>,
    /// Document-level keys this build preserved, in a namespace of their own.
    #[serde(default)]
    pub extensions: PortableExtensions,
    /// Keys a newer writer placed directly on the document, preserved verbatim.
    ///
    /// Private for the reason the row-level bags are: this is the one bag whose entries are
    /// emitted *at* the document's level, so a public field would let a caller put
    /// `version` (or any other projected name) there and make this build emit a document it
    /// then refuses to read. Read through [`Self::unknown`].
    #[serde(flatten)]
    unknown: PortableExtensions,
}

/// The marker-only prefix of a document, read before the document itself.
///
/// The same shape the sibling portable documents read first: a payload that is not a time
/// document is refused **by name** rather than by whatever shape error its fields happened
/// to raise against this one's.
#[derive(Deserialize)]
struct Marker {
    format: String,
}

/// What a remote finding counts, so the finding's sentence names real numbers.
///
/// Two of the three are the shared audit DTO's own counts (the prior values deletions left
/// and the corrections retained); the third is the document's own issue-row length. Nothing
/// is summed or derived here.
#[derive(Debug, Clone, Copy)]
struct RemoteCounts {
    /// The issue rows in the document.
    issues: usize,
    /// The prior values deletions left in History across the scope.
    deletions: usize,
    /// The corrections the scope's issues retain.
    corrections: usize,
}

impl PortableTimeDocument {
    /// Build the whole local time document for one scope.
    ///
    /// `snapshot` is the one-pass index the caller already built (the same
    /// [`TimeSnapshot::of`] `tix time total` and the other read surfaces use), so the
    /// document's numbers are the shared calculation's and a multi-issue export scans
    /// the entries **once** (NFR-001). `filter` is the scope: every total in the document
    /// is evaluated under it, the entry rows are the entries [`TimeFilter::selects`]
    /// selects, and the issue rows are the issues the shared audit's selector covers.
    ///
    /// A **refused** scope — an unusable bound or an identity that names no person, and
    /// an issue the snapshot does not hold — is a refusal with the shared findings and
    /// their remedies, exactly as `tix time total` refuses it: no entry was evaluated, so
    /// there is no total to publish and no document to write. Reaching this is a
    /// read-only failure: nothing is written on either path.
    pub fn build(config: &Config, snapshot: &TimeSnapshot, filter: &TimeFilter) -> Result<Self> {
        let total = snapshot.total(filter);
        if matches!(
            total.state(),
            TimeTotalState::InvalidFilter | TimeTotalState::UnknownIssue
        ) {
            return Err(refusal(&total.diagnostics));
        }

        // The audit's rows are the issues the scope's selector covers, with the
        // current-versus-History context; the breakdown is the same snapshot's own
        // per-issue totals, so a row's number and the scope's number cannot come from two
        // readings.
        let audit = snapshot.audit(filter);
        let breakdown: BTreeMap<u32, TimeTotal> = snapshot
            .by_issue(filter)
            .into_iter()
            .filter_map(|group| match group.group {
                TimeGroup::Issue(number) => Some((number, group)),
                _ => None,
            })
            .collect();

        let entries_by_issue = index_entries(snapshot);

        let mut document = Self {
            format: FORMAT.to_string(),
            version: VERSION,
            precision: PRECISION.to_string(),
            filter: PortableTimeFilter::from_filter(filter),
            total: PortableTimeTotal::from_total(&total),
            audit: PortableTimeAudit::from_audit(
                &audit.filter,
                audit.deleted_count,
                audit.corrected_count,
                &audit.diagnostics,
            ),
            issues: Vec::new(),
            diagnostics: Vec::new(),
            extensions: PortableExtensions::new(),
            unknown: PortableExtensions::new(),
        };

        for row in &audit.issues {
            let number = row.issue;
            let mut entries = Vec::new();
            if let Some(indexed) = entries_by_issue.get(&number) {
                for indexed in indexed {
                    if !filter.selects(indexed.issue, &indexed.entry) {
                        continue;
                    }
                    let bag = format!("issues.{number}.entries.{}.extensions", indexed.entry.id);
                    let (projected, not_carried) =
                        PortableTimeEntry::of_entry(number, &indexed.entry);
                    for missing in &not_carried {
                        document
                            .diagnostics
                            .push(PortableTimeDiagnostic::metadata_not_carried(
                                bag.clone(),
                                missing,
                            ));
                    }
                    if !projected.extensions.is_empty() {
                        document
                            .diagnostics
                            .push(PortableTimeDiagnostic::unknown_metadata(
                                bag,
                                projected.extensions.keys().map(str::to_string).collect(),
                            ));
                    }
                    entries.push(projected);
                }
            }
            document.issues.push(PortableTimeIssue {
                number,
                entries,
                current: row.current,
                deletions: row
                    .deletions
                    .iter()
                    .map(|deletion| PortableTimeDeletion {
                        entry_id: deletion.entry_id.clone(),
                        deleted_at: answerable(&deletion.deleted_at),
                        deleted_by: answerable(&deletion.deleted_by),
                    })
                    .collect(),
                corrections: row.corrections,
                total: breakdown.get(&number).map(PortableTimeTotal::from_total),
                unknown: PortableExtensions::new(),
            });
        }

        // The remote half: a remote tracker has no time field, and saying so is the
        // deliverable. One finding per configured target — the values stay local.
        document
            .diagnostics
            .extend(remote_diagnostics(config, &document));

        Ok(document)
    }

    /// Whether this document carries no entry, no deletion and no correction at all.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.issues.iter().all(|issue| {
            issue.entries.is_empty() && issue.deletions.is_empty() && issue.corrections == 0
        })
    }

    /// The document's format marker.
    #[must_use]
    pub fn format(&self) -> &str {
        &self.format
    }

    /// The document's format version.
    #[must_use]
    pub const fn version(&self) -> u32 {
        self.version
    }

    /// Preserve one document-level key, in the nested `extensions` object.
    ///
    /// The **only** public write path into a bag, and it cannot produce a document this
    /// build cannot read: the key lands in a namespace of its own, so it can never
    /// duplicate — or be read in place of — a document field.
    pub fn insert_extension(&mut self, key: impl Into<String>, value: serde_json::Value) {
        self.extensions.insert(key, value);
    }

    /// The keys a newer writer placed directly on the document, preserved verbatim.
    ///
    /// The read side of the document's catch-all channel. There is no write side, and the
    /// bag is private, which is what makes a node-level key naming a projected field
    /// impossible to produce through this API (see [`PortableExtensions`]'s own docs for
    /// the compile-checked statement of that).
    #[must_use]
    pub fn unknown(&self) -> &PortableExtensions {
        &self.unknown
    }

    /// Serialize the document as compact JSON.
    pub fn to_json(&self) -> Result<String> {
        serde_json::to_string(self).map_err(|error| {
            TixError::Parse(format!("time document cannot be serialized: {error}"))
        })
    }

    /// Serialize the document as pretty JSON — the export surface's shape.
    pub fn to_json_pretty(&self) -> Result<String> {
        serde_json::to_string_pretty(self).map_err(|error| {
            TixError::Parse(format!("time document cannot be serialized: {error}"))
        })
    }

    /// Read a document back.
    ///
    /// The [`Marker`] is read **first**: a document that is not a time document is
    /// refused by name (its format marker) rather than by whatever shape error its fields
    /// happened to raise — a `tix-capacity` or `tix-roadmap` payload overlaps structurally
    /// and would otherwise produce a misleading field error. A document of another
    /// *version* is read, its own fields and preserved keys kept, and the mismatch is
    /// merged into the diagnostics once — the checked-and-reported third answer between
    /// refusing a newer writer and silently dropping its fields.
    ///
    /// A hand-written document that repeats a *projected* key inside one object is refused
    /// (`duplicate field`), which is the honest answer: the alternative is a lenient
    /// consumer reading preserved metadata in place of the value it shadows. This build
    /// never writes such a key twice — preserved keys live in their own object — so it can
    /// always read back what it wrote.
    pub fn from_json(raw: &str) -> Result<Self> {
        let marker: Marker = serde_json::from_str(raw)
            .map_err(|error| TixError::Parse(format!("not a portable time document: {error}")))?;
        if marker.format != FORMAT {
            return Err(TixError::Parse(format!(
                "portable time document has format `{}`, but this build reads `{FORMAT}`",
                marker.format
            )));
        }
        let mut document: Self = serde_json::from_str(raw).map_err(|error| {
            TixError::Parse(format!(
                "portable time document is not valid JSON of this shape: {error}"
            ))
        })?;
        if document.version != VERSION {
            let diagnostic = PortableTimeDiagnostic::version_mismatch(document.version);
            if !document
                .diagnostics
                .iter()
                .any(|existing| existing.same_as(&diagnostic))
            {
                document.diagnostics.push(diagnostic);
            }
        }
        Ok(document)
    }
}

/// The current entries of one snapshot, grouped by the issue they belong to.
///
/// One pass over the snapshot's own index, in its own order: the grouping is a lookup, not
/// a second reading, and an issue with no entry simply has no group.
fn index_entries(snapshot: &TimeSnapshot) -> BTreeMap<u32, Vec<&IndexedEntry>> {
    let mut by_issue: BTreeMap<u32, Vec<&IndexedEntry>> = BTreeMap::new();
    for indexed in snapshot.entries() {
        by_issue.entry(indexed.issue).or_default().push(indexed);
    }
    by_issue
}

/// The scope refusals as one rendered block, with their remedies.
///
/// A refused scope is a refusal, not a document: no entry was evaluated, so there is no
/// total to publish. The block is the shared findings' own reason/field/issue/entry notes
/// plus their remedies, rendered at **error** severity so the rendered level and the
/// non-zero exit agree — the same rule (and the same layout) `tix time total` uses.
fn refusal(diagnostics: &[TimeDiagnostic]) -> TixError {
    let block = diagnostics
        .iter()
        .map(|finding| {
            let mut diagnostic = Diagnostic::error("E-tix-time", finding.reason.clone())
                .with_note(format!("kind: {}", finding.kind.as_str()));
            if let Some(field) = finding.field {
                diagnostic = diagnostic.with_note(format!("field: {field}"));
            }
            if let Some(issue) = finding.issue {
                diagnostic = diagnostic.with_note(format!("issue: #{issue}"));
            }
            diagnostic.with_help(&finding.remedy).render()
        })
        .collect::<Vec<String>>()
        .join("\n");
    TixError::Refused(block)
}

/// The remote-target findings: one per configured `[export.*]` target.
///
/// The targets are the configuration's own (`config.export.gitlab`/`.linear`), and the
/// finding names the target and the field it cannot carry. It is **not** a mapping: no
/// remote id is invented, no value is converted, and nothing is sent anywhere.
fn remote_diagnostics(
    config: &Config,
    document: &PortableTimeDocument,
) -> Vec<PortableTimeDiagnostic> {
    let mut targets = Vec::new();
    if config.export.gitlab.is_some() {
        targets.push("gitlab");
    }
    if config.export.linear.is_some() {
        targets.push("linear");
    }
    if targets.is_empty() {
        return Vec::new();
    }
    let counts = RemoteCounts {
        issues: document.issues.len(),
        deletions: document.audit.deleted_count,
        corrections: document.audit.corrected_count,
    };
    targets
        .into_iter()
        .map(|target| PortableTimeDiagnostic::unsupported_remote(target, counts))
        .collect()
}