Skip to main content

tix/time_tracking/
portable.rs

1//! The local portable time document (mission `per-issue-time-tracking-01M3C9A4`,
2//! WP07, T034–T038; C-002, C-007, FR-004, FR-005, NFR-003).
3//!
4//! [`PortableTimeDocument`] is the **local, loss-aware interchange form** of the time
5//! surfaces: a versioned JSON document carrying, for every issue in scope, the
6//! current [`TimeEntry`] values verbatim (stable id, whole minutes, optional calendar
7//! date, author, optional note, and whatever unknown metadata the file carries), the
8//! audit context History retains (the prior values deletions left behind, and how many
9//! corrections each issue keeps, with the shared [`TimeAudit`]
10//! (crate::time_tracking::aggregate::TimeAudit) summary beside them), the shared
11//! [`TimeTotal`] for the scope and for each issue, and the diagnostics — the entry
12//! findings, the preserved-metadata findings and the remote-target findings that say
13//! what a remote tracker cannot carry. It is a **projection** of the shared DTOs: every
14//! number is a field a shared value held, every label is a shared rendering, and there
15//! is no arithmetic in this module at all.
16//!
17//! # The approved interchange contracts, and what they fix
18//!
19//! The approved local interchange contracts fix the *properties* this format must
20//! have, not its field names: a versioned machine-readable schema, a round trip that
21//! **preserves every supported field without loss**, explicit item-level diagnostics
22//! instead of a silent drop, and no external CLI or hosted service required. The field
23//! set is therefore the shared DTO's own field set, in the house shape the sibling
24//! portable documents already use ([`crate::health::portable`],
25//! [`crate::roadmap::portable`], [`crate::views::portable`],
26//! [`crate::capacity::portable`]): a `format` marker, a `version`, ordered lists, a
27//! `diagnostics` vocabulary, and a preserved-metadata bag.
28//!
29//! # One DTO, no private formula (C-006/NFR-003)
30//!
31//! Every value in this document is read from a shared value: the entries from
32//! [`TimeSnapshot::entries`], the audit from [`TimeSnapshot::audit`], the numbers from
33//! [`TimeTotal`] (the same value `tix time total`, the TUI and MCP consume), and the
34//! labels and states from the shared renderings ([`TimeFilter::describe`],
35//! [`TimeGroup::label`](crate::time_tracking::aggregate::TimeGroup::label),
36//! [`TimeTotalState::as_str`]). There is no sum, no count and no unit fold in this
37//! module — a number that is not a field of one of those values cannot be printed
38//! here. That is what makes the parity obligation structural: `tix time total` and
39//! `tix export --target time` cannot disagree, because there is no second
40//! implementation to disagree with. The one predicate this module applies to an entry
41//! ([`TimeFilter::selects`]) is the shared filter's own, not a second spelling of it.
42//!
43//! # An absent value is absent, never zero
44//!
45//! Every value that can be unanswerable is `Option`, serialized as an explicit JSON
46//! `null` (deliberately not omitted, so the schema is self-describing) and read back as
47//! `None`: an entry whose `duration_minutes` is missing is `null` there and `null` in
48//! `minutes`, never `0`; a scope that selected nothing and one whose entries were all
49//! malformed are distinguished by the total's own `state` (`nothing` versus `unknown`),
50//! never by a zero. `state` is the shared [`TimeTotalState`] spelling, so this document
51//! cannot invent a fifth state or read a refusal as an empty answer. The CLI's `-`
52//! convention is a *text* rendering; JSON's `null` is the same statement.
53//!
54//! The **attributed** strings follow the same rule rather than an empty-string one: an
55//! entry's blank author, and a deletion's blank actor, are unanswerable and are `null` —
56//! never `""`, which a consumer could only tell apart from "no attribution" by
57//! re-implementing `trim().is_empty()`. The reader accepts `null` and an omitted key for
58//! every optional field, so a hand-written document may state either.
59//!
60//! # Two namespaces, so a preserved key can never shadow a value
61//!
62//! A node that preserves data keeps it in **two separate channels** rather than one
63//! shared namespace:
64//!
65//! * `extensions` — a real nested object ([`PortableExtensions`]). This is the channel
66//!   this build *writes*, and exactly the two nodes with something to preserve carry it:
67//!   an entry's own [`TimeEntryExtensions`] (the keys a newer `tix` added beside `id`,
68//!   `duration_minutes`, `date`, `author` and `note`), and whatever a caller adds to the
69//!   document through [`PortableTimeDocument::insert_extension`]. Because it is its own
70//!   object, an entry metadata key named `minutes`, `id` or `author` lands *inside* it and
71//!   can never duplicate or replace the projected field beside it — for this reader **or
72//!   for a lenient one** (a last-key-wins JSON parser would otherwise read metadata where
73//!   a value belongs).
74//! * the flattened catch-all ([`PortableExtensions`] again, field `unknown`, never a
75//!   wire key) — the channel this build *only reads*. serde delivers a key a newer build
76//!   put directly on the node here, and it is written back verbatim; this build never
77//!   writes to it, so it cannot collide with anything. Dropping it would be the silent
78//!   loss the interchange contract forbids, and it is on **every** composite node
79//!   (document, issue row, entry row) for that reason. The bag is **private** on every
80//!   node and read through an accessor, so there is no public way to hand a node a key
81//!   that names a projected field: the only public write path into a bag is the nested,
82//!   checked one above, and [`PortableExtensions`] carries the `compile_fail` example
83//!   that asserts it.
84//!
85//! A reader therefore cannot make this build emit a duplicate key, and this build
86//! cannot fail to re-read its own output: a hand-written document that repeats a
87//! *projected* key inside one object is refused by name (`duplicate field`), which is
88//! the honest answer — the alternative is a lenient consumer reading the metadata in
89//! place of the value. [`PortableTimeFilter`], [`PortableTimeTotal`],
90//! [`PortableTimeDeletion`] and [`PortableTimeDiagnostic`] are pure projections of
91//! shared values with no extension channel at all, deliberately: there is nothing a
92//! writer could have preserved in them.
93//!
94//! # Preserved values are projected faithfully, or diagnosed
95//!
96//! Entry metadata is YAML in the issue file and JSON in this document, and it is
97//! converted **by hand** rather than through `serde_yaml`'s own serializer, because that
98//! bridge is not faithful for three shapes: a non-finite float (`.nan`, `.inf`) has no
99//! JSON number, a YAML tag has no JSON form, and a nested mapping with a non-string key
100//! has none either. A value that genuinely cannot be represented is **withheld and
101//! reported** as a [`METADATA_NOT_CARRIED`] diagnostic; it is never silently turned into
102//! `null`. Null *is* representable and is kept as JSON `null`.
103//!
104//! Withholding is **per key**: the offending key is withheld and the diagnostic names it
105//! and the reason — not a path into it. "Preserved verbatim" is therefore a claim about
106//! the keys a document carries, not a promise that a partially representable value
107//! survives in part.
108//!
109//! # The version marker: read first, checked, and a mismatch is a diagnostic
110//!
111//! The document carries a [`FORMAT`] marker and a [`VERSION`]. The marker is read
112//! **before** the document is deserialized, so a document that is not a time document at
113//! all (a `tix-capacity`, `tix-roadmap` or `tix-health` payload, whose fields overlap
114//! structurally) is **refused by name** rather than by whatever shape error its fields
115//! happened to raise. A document of a *different version* is read — its own fields and
116//! its preserved keys are kept — and the mismatch is reported as a [`VERSION_MISMATCH`]
117//! diagnostic. Refusing it would make forward compatibility impossible, and dropping the
118//! newer fields is exactly what the interchange contract forbids; the diagnostic is the
119//! honest third answer, and it is merged (never appended twice), so the document
120//! stabilizes after one write instead of growing on every read.
121//!
122//! # Remote namespaces are separate (T036/T037, FR-005)
123//!
124//! `tix export --target gitlab|linear` mirrors **issues** to a remote tracker
125//! ([`crate::sync`], `[export.*]`): it writes remote ids back into issue files and shells
126//! out to `glab`/`linear-cli`. This document is a *local* interchange form and never
127//! reaches either tool. Where a remote target is configured, the target's inability to
128//! carry time is **diagnosed explicitly** — `unsupported_remote_field`, naming the target
129//! and the field, stating that the values stay local — because a silent drop and an
130//! invented mapping are both worse than a refusal. That finding is produced here, in the
131//! document and on the export's own stderr; `src/sync/**` is untouched and `time` is
132//! dispatched by name before `Tool::from_target` is consulted.
133//!
134//! # Read-only, on every path
135//!
136//! [`PortableTimeDocument::build`] only reads the snapshot the caller already built, and
137//! nothing here opens a file for writing, appends a `History` event, mutates a current
138//! entry or reaches a remote tool. A refused filter is a refusal (the shared findings,
139//! with their remedies) rather than a document carrying an invented total, exactly as
140//! `tix time total` refuses a scope it cannot total.
141//!
142//! [`TimeSnapshot::entries`]: crate::time_tracking::aggregate::TimeSnapshot::entries
143//! [`TimeSnapshot::audit`]: crate::time_tracking::aggregate::TimeSnapshot::audit
144//! [`TimeFilter::selects`]: crate::time_tracking::filters::TimeFilter::selects
145#![allow(clippy::module_name_repetitions)]
146
147use std::collections::BTreeMap;
148
149use serde::{Deserialize, Serialize};
150
151use crate::config::Config;
152use crate::diagnostics::Diagnostic;
153use crate::error::{Result, TixError};
154use crate::time_tracking::TIME_ENTRY_FRONTMATTER_KEY;
155use crate::time_tracking::aggregate::{
156    IndexedEntry, TimeGroup, TimeSnapshot, TimeTotal, TimeTotalState,
157};
158use crate::time_tracking::diagnostics::{TimeDiagnostic, TimeDiagnosticKind};
159use crate::time_tracking::entry::{TimeEntry, TimeEntryExtensions};
160use crate::time_tracking::filters::TimeFilter;
161
162/// The document's format marker. A consumer reads this before treating the payload as a
163/// time document; a document with another marker is refused by name.
164pub const FORMAT: &str = "tix-time";
165
166/// The document's format version. Bumped when the shape changes incompatibly.
167pub const VERSION: u32 = 1;
168
169/// The duration precision every number in this document is stated in (C-002).
170///
171/// The mission's confirmed precision is whole minutes; the document states it once,
172/// beside the numbers, so a consumer never has to infer it from the field name.
173pub const PRECISION: &str = "minutes";
174
175/// The stable kind spelling of a remote-mapping diagnostic.
176///
177/// The same spelling the health, roadmap, views and capacity portable documents use for
178/// the same finding — and the same spelling the shared time vocabulary declares
179/// ([`TimeDiagnosticKind::UnsupportedRemoteField`]), so a reader who learned one
180/// vocabulary reads the others.
181pub const UNSUPPORTED_REMOTE_FIELD: &str = "unsupported_remote_field";
182
183/// The stable kind spelling of an "unknown metadata preserved" finding.
184pub const UNKNOWN_METADATA: &str = "unknown_metadata";
185
186/// The stable kind spelling of a "metadata could not be represented" finding.
187pub const METADATA_NOT_CARRIED: &str = "metadata_not_carried";
188
189/// The stable kind spelling of a "different document version" finding.
190pub const VERSION_MISMATCH: &str = "version_mismatch";
191
192// ---------------------------------------------------------------------------
193
194// The preserved-metadata bags
195// ---------------------------------------------------------------------------
196
197/// A bag of preserved `(key, value)` pairs, in the document's own vocabulary.
198///
199/// Used for both channels (see the module docs): the nested `extensions` object this
200/// build writes, and the flattened catch-all it only reads. A newtype over a JSON value
201/// map, rather than a bare `Map`, so the one inserter is private and every write goes
202/// through [`PortableTimeDocument::insert_extension`]. A JSON map (not a YAML map)
203/// because this document is JSON and a YAML value JSON cannot carry is withheld and
204/// diagnosed rather than silently reshaped. The map is sorted (`serde_json`'s
205/// `preserve_order` feature is off), so key *order* is normalised while the values — the
206/// thing a round trip preserves — are kept exactly.
207///
208/// # No public route to a key that could shadow a projected field
209///
210/// `Deserialize` is derived (a document has to be readable), so a caller can *obtain* a
211/// bag naming any key. What a caller cannot do is hand one to a flattened channel: those
212/// fields are private everywhere they exist, and the only public way in is the checked
213/// nesting shown below, where the key lands inside an object of its own and can therefore
214/// never duplicate — or be read in place of — the value beside it. That is a compile-time
215/// property, so it is asserted as one:
216///
217/// ```compile_fail
218/// use tix::time_tracking::portable::{PortableExtensions, PortableTimeDocument};
219///
220/// let mut document: PortableTimeDocument = unimplemented!();
221/// // The catch-all is private: there is no public field to drop a shadowing key into.
222/// document.unknown = PortableExtensions::default();
223/// ```
224///
225/// The nested channel, for contrast, is checked rather than forbidden:
226///
227/// ```no_run
228/// use tix::time_tracking::portable::{PortableTimeDocument, FORMAT};
229/// use serde_json::json;
230///
231/// let mut document: PortableTimeDocument = unimplemented!();
232/// document.insert_extension("format", json!("metadata, not a marker"));
233/// let _ = FORMAT;
234/// ```
235#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
236#[serde(transparent)]
237pub struct PortableExtensions(serde_json::Map<String, serde_json::Value>);
238
239impl PortableExtensions {
240    /// An empty bag.
241    #[must_use]
242    pub fn new() -> Self {
243        Self(serde_json::Map::new())
244    }
245
246    /// Whether no metadata is present.
247    #[must_use]
248    pub fn is_empty(&self) -> bool {
249        self.0.is_empty()
250    }
251
252    /// The number of preserved keys.
253    #[must_use]
254    pub fn len(&self) -> usize {
255        self.0.len()
256    }
257
258    /// Look up a preserved key.
259    #[must_use]
260    pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
261        self.0.get(key)
262    }
263
264    /// The preserved keys, in canonical (sorted) order.
265    pub fn keys(&self) -> impl Iterator<Item = &str> {
266        self.0.keys().map(String::as_str)
267    }
268
269    /// The underlying map, for a caller that must inspect it whole.
270    #[must_use]
271    pub fn as_map(&self) -> &serde_json::Map<String, serde_json::Value> {
272        &self.0
273    }
274
275    /// Preserve one `(key, value)` pair, returning the previous value if any.
276    ///
277    /// Deliberately **private**: a preserved key is only safe inside a bag of its own
278    /// (the nested `extensions` object), so the only public way in is the checked
279    /// [`PortableTimeDocument::insert_extension`]. A public raw inserter would let a
280    /// caller write a key a document field already owns and make this build refuse its
281    /// own output.
282    fn insert(
283        &mut self,
284        key: impl Into<String>,
285        value: serde_json::Value,
286    ) -> Option<serde_json::Value> {
287        self.0.insert(key.into(), value)
288    }
289
290    /// Carry one entry's unknown metadata (`TimeEntry::extensions`).
291    ///
292    /// Returns the bag and the keys that had to be **withheld** because JSON cannot
293    /// represent their value faithfully (see [`yaml_to_json`]).
294    fn from_yaml(extensions: &TimeEntryExtensions) -> (Self, Vec<NotCarried>) {
295        let mut bag = Self::new();
296        let mut not_carried = Vec::new();
297        for (key, value) in extensions.iter() {
298            match yaml_to_json(value) {
299                Ok(json) => {
300                    bag.insert(key, json);
301                }
302                Err(reason) => not_carried.push(NotCarried {
303                    key: key.to_string(),
304                    reason,
305                }),
306            }
307        }
308        (bag, not_carried)
309    }
310}
311
312/// One preserved key whose value could not be projected, and why.
313#[derive(Debug, Clone)]
314struct NotCarried {
315    /// The metadata key.
316    key: String,
317    /// Why JSON cannot represent it faithfully.
318    reason: String,
319}
320
321/// Project one `serde_yaml` value into JSON, or say why it cannot be carried.
322///
323/// Hand-written rather than `serde_yaml`'s serializer because that bridge has three
324/// gaps JSON cannot paper over: a non-finite number has no JSON number, a YAML tag has
325/// no JSON form, and a mapping key that is not a string has none either. All three are
326/// reported and the key is withheld rather than silently turned into `null`. Null,
327/// bools, numbers, strings, sequences and string-keyed mappings are carried as what they
328/// are.
329fn yaml_to_json(value: &serde_yaml::Value) -> std::result::Result<serde_json::Value, String> {
330    Ok(match value {
331        serde_yaml::Value::Null => serde_json::Value::Null,
332        serde_yaml::Value::Bool(flag) => serde_json::Value::Bool(*flag),
333        serde_yaml::Value::Number(number) => {
334            if let Some(signed) = number.as_i64() {
335                serde_json::Value::from(signed)
336            } else if let Some(unsigned) = number.as_u64() {
337                serde_json::Value::from(unsigned)
338            } else {
339                let Some(float) = number.as_f64() else {
340                    return Err(format!("the number `{number:?}` has no JSON form"));
341                };
342                let Some(number) = serde_json::Number::from_f64(float) else {
343                    return Err(format!(
344                        "the number `{float}` is not finite, and JSON has no number for it"
345                    ));
346                };
347                serde_json::Value::Number(number)
348            }
349        }
350        serde_yaml::Value::String(text) => serde_json::Value::String(text.clone()),
351        serde_yaml::Value::Sequence(items) => {
352            let mut array = Vec::with_capacity(items.len());
353            for item in items {
354                array.push(yaml_to_json(item)?);
355            }
356            serde_json::Value::Array(array)
357        }
358        serde_yaml::Value::Mapping(mapping) => {
359            let mut map = serde_json::Map::new();
360            for (key, item) in mapping {
361                let serde_yaml::Value::String(key) = key else {
362                    return Err(
363                        "a mapping key is not a string, and JSON cannot carry one".to_string()
364                    );
365                };
366                map.insert(key.clone(), yaml_to_json(item)?);
367            }
368            serde_json::Value::Object(map)
369        }
370        serde_yaml::Value::Tagged(tagged) => {
371            return Err(format!(
372                "the value carries the YAML tag `{}`, which JSON cannot carry",
373                tagged.tag
374            ));
375        }
376    })
377}
378
379// ---------------------------------------------------------------------------
380// T034/T035 — the projected shared values
381// ---------------------------------------------------------------------------
382
383/// One finding, in the document's vocabulary.
384///
385/// Used for **both** halves of the document: a shared [`TimeDiagnostic`] the snapshot or
386/// the aggregation raised (an entry's field problem, a refused filter, an unknown issue)
387/// and a document-level finding (preserved metadata, metadata that could not be carried,
388/// an unsupported remote field, a version mismatch). One shape means a reader learns one
389/// vocabulary, the way the health, roadmap, views and capacity documents do.
390#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
391pub struct PortableTimeDiagnostic {
392    /// The stable contract name (`invalid_duration`, `invalid_date`, `missing_author`,
393    /// `malformed_entry`, `invalid_period`, `invalid_assignee`, `unknown_issue`,
394    /// `unsupported_remote_field`, `unknown_metadata`, `metadata_not_carried`,
395    /// `version_mismatch`, …).
396    ///
397    /// A plain string, so a kind a newer build adds survives a round trip as a string
398    /// instead of failing to parse.
399    pub kind: String,
400    /// The issue the finding is about, when it is about one.
401    #[serde(default)]
402    pub issue: Option<u32>,
403    /// The entry the finding is about, when it is about one.
404    #[serde(default)]
405    pub entry_id: Option<String>,
406    /// The frontmatter field the problem sits at, when it is field-shaped
407    /// (`duration_minutes`, `date`, `time_entries`).
408    #[serde(default)]
409    pub field: Option<String>,
410    /// The remote target, when the finding is about a mapping a target cannot carry.
411    #[serde(default)]
412    pub target: Option<String>,
413    /// What is wrong, in one sentence.
414    pub reason: String,
415    /// What the reader can do about it.
416    pub remedy: String,
417}
418
419impl PortableTimeDiagnostic {
420    /// Project one shared [`TimeDiagnostic`] into the wire vocabulary.
421    ///
422    /// A pure field copy: the kind is the shared registry's own spelling, the issue,
423    /// entry and field are the finding's own, and the reason and remedy are the sentence
424    /// pair the finding already carries. Nothing is recomputed and nothing is added.
425    #[must_use]
426    pub fn from_shared(finding: &TimeDiagnostic) -> Self {
427        Self {
428            kind: finding.kind.as_str().to_string(),
429            issue: finding.issue,
430            entry_id: finding.entry_id.clone(),
431            field: finding.field.map(str::to_string),
432            target: None,
433            reason: finding.reason.clone(),
434            remedy: finding.remedy.clone(),
435        }
436    }
437
438    /// Name the remote target this finding is about.
439    fn on_target(mut self, target: &str) -> Self {
440        self.target = Some(target.to_string());
441        self
442    }
443
444    /// A document-level finding about metadata this build preserved.
445    fn unknown_metadata(field: String, keys: Vec<String>) -> Self {
446        Self {
447            kind: UNKNOWN_METADATA.to_string(),
448            issue: None,
449            entry_id: None,
450            field: Some(field),
451            target: None,
452            reason: format!(
453                "{} key(s) this build does not interpret are preserved verbatim under \
454                 `extensions`: {}",
455                keys.len(),
456                keys.join(", ")
457            ),
458            remedy: "keep the metadata: a build that understands the key reads it back unchanged"
459                .to_string(),
460        }
461    }
462
463    /// A document-level finding about a preserved key that could not be carried.
464    fn metadata_not_carried(field: String, not_carried: &NotCarried) -> Self {
465        Self {
466            kind: METADATA_NOT_CARRIED.to_string(),
467            issue: None,
468            entry_id: None,
469            field: Some(format!("{field}.{}", not_carried.key)),
470            target: None,
471            reason: format!(
472                "`{key}` is not carried: {reason}",
473                key = not_carried.key,
474                reason = not_carried.reason
475            ),
476            remedy: "rewrite the value in a form JSON can represent (a finite number, a string, a \
477                 sequence, or a mapping with string keys), or remove the key"
478                .to_string(),
479        }
480    }
481
482    /// A document-level finding about a remote target that cannot carry time data.
483    ///
484    /// The kind and the remedy are the shared vocabulary's own
485    /// ([`TimeDiagnosticKind::UnsupportedRemoteField`]); the field is the frontmatter key
486    /// the mission registers and the target is the configured `[export.*]` section's name.
487    /// It is **not** a mapping: no remote id is invented, no value is converted, and
488    /// nothing is sent anywhere.
489    fn unsupported_remote(target: &str, counts: RemoteCounts) -> Self {
490        let finding = TimeDiagnostic::new(
491            TimeDiagnosticKind::UnsupportedRemoteField,
492            format!(
493                "`{target}` has no `{TIME_ENTRY_FRONTMATTER_KEY}` field, so this document's \
494                 {issues} issue row(s), their current entries, {deletions} deletion(s) and \
495                 {corrections} correction(s) stay local; nothing is mapped and nothing is dropped",
496                issues = counts.issues,
497                deletions = counts.deletions,
498                corrections = counts.corrections,
499            ),
500        );
501        Self::from_shared(&finding)
502            .on_target(target)
503            .in_field(TIME_ENTRY_FRONTMATTER_KEY)
504    }
505
506    /// A document-level finding about a version this build does not write.
507    fn version_mismatch(found: u32) -> Self {
508        Self {
509            kind: VERSION_MISMATCH.to_string(),
510            issue: None,
511            entry_id: None,
512            field: Some("version".to_string()),
513            target: None,
514            reason: format!(
515                "the document declares version {found}, but this build writes version {VERSION}; \
516                 its fields are preserved rather than refused or dropped"
517            ),
518            remedy:
519                "read it with a build that writes that version; this one preserves the fields it \
520                 does not interpret"
521                    .to_string(),
522        }
523    }
524
525    /// Name the field the problem sits at.
526    fn in_field(mut self, field: &str) -> Self {
527        self.field = Some(field.to_string());
528        self
529    }
530
531    /// The stable contract name.
532    #[must_use]
533    pub fn kind(&self) -> &str {
534        &self.kind
535    }
536
537    /// One line, `kind [field]: reason` — what a surface prints.
538    ///
539    /// The reason is the sentence that names the value, and for a remote finding it names
540    /// the target too, so the rendered line carries the target, the field and the promise
541    /// that the values stay local without a second layout to learn.
542    #[must_use]
543    pub fn render(&self) -> String {
544        match self.field.as_deref() {
545            Some(field) => format!("{} [{field}]: {}", self.kind, self.reason),
546            None => format!("{}: {}", self.kind, self.reason),
547        }
548    }
549
550    /// Whether two diagnostics describe the same problem, which is how a derived
551    /// diagnostic is merged rather than appended twice on a repeated round trip.
552    #[must_use]
553    fn same_as(&self, other: &Self) -> bool {
554        self.kind == other.kind
555            && self.field == other.field
556            && self.target == other.target
557            && self.reason == other.reason
558    }
559}
560
561/// The normalized filter the document's totals were evaluated under (T035).
562///
563/// Every criterion is the filter's own value, verbatim, so a period or an identity that
564/// was asked for survives a round trip; `assignee_identity` is the canonical identity the
565/// shared filter resolves the criterion to, carried *beside* the criterion rather than
566/// instead of it (a generated/hand-written criterion and its canonical form are one
567/// scope, and hiding either would lose what the caller asked for). `description` is
568/// [`TimeFilter::describe`], the one spelling every surface prints.
569#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
570pub struct PortableTimeFilter {
571    /// The issue criterion, when the scope names one.
572    #[serde(default)]
573    pub issue: Option<u32>,
574    /// The assignee criterion, verbatim as it was asked for.
575    #[serde(default)]
576    pub assignee: Option<String>,
577    /// The canonical identity the criterion resolves to, when it resolves.
578    #[serde(default)]
579    pub assignee_identity: Option<String>,
580    /// The inclusive lower period bound, verbatim.
581    #[serde(default)]
582    pub date_from: Option<String>,
583    /// The inclusive upper period bound, verbatim.
584    #[serde(default)]
585    pub date_to: Option<String>,
586    /// Whether audit context (the deleted counts) was asked for.
587    pub include_deleted: bool,
588    /// Whether every criterion is usable ([`TimeFilter::is_valid`]).
589    pub valid: bool,
590    /// The scope as one label ([`TimeFilter::describe`]).
591    pub description: String,
592}
593
594impl PortableTimeFilter {
595    /// Project the shared filter.
596    #[must_use]
597    pub fn from_filter(filter: &TimeFilter) -> Self {
598        Self {
599            issue: filter.issue,
600            assignee: filter.assignee.clone(),
601            assignee_identity: filter.canonical_assignee(),
602            date_from: filter.date_from.clone(),
603            date_to: filter.date_to.clone(),
604            include_deleted: filter.include_deleted,
605            valid: filter.is_valid(),
606            description: filter.describe(),
607        }
608    }
609}
610
611/// One current entry, projected field for field (T034).
612///
613/// The values are the entry's own and nothing is smoothed over: an entry the file holds
614/// with a missing or negative `duration_minutes` keeps that value here (a diagnostic on
615/// the row says which problem it is), `minutes` is the shared
616/// [`TimeEntry::minutes`] answer — `None` exactly when the duration is unusable, so no
617/// reader can mistake a malformed entry for a zero-minute one — and `valid` is the
618/// shared [`TimeEntry::is_valid`]. `extensions` and `unknown` are the two preservation
619/// channels the module docs describe: the entry's unknown metadata lives in the nested
620/// object, so a key named `minutes` or `author` cannot shadow the real one, and the
621/// flattened catch-all is read-only, so no caller can put one there.
622#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
623pub struct PortableTimeEntry {
624    /// The stable id: identity for edits, deletions and History references, verbatim.
625    pub id: String,
626    /// The duration the file states, verbatim; `null` when the entry states none.
627    #[serde(default)]
628    pub duration_minutes: Option<i64>,
629    /// The calendar date the effort belongs to, verbatim; `null` when it carries none.
630    #[serde(default)]
631    pub date: Option<String>,
632    /// Who recorded the effort, verbatim; `null` when the entry names no attribution.
633    ///
634    /// A blank author (`author: ""`, or the key omitted) is **unanswerable**, not an empty
635    /// name: the file states no identity, so `null` is the honest rendering — the same
636    /// statement every other absent field in this row makes — and `valid: false` with the
637    /// shared `missing_author` finding beside it says why. The reader accepts `null` and an
638    /// omitted key as well as a string, so a document that says either re-reads.
639    #[serde(default)]
640    pub author: Option<String>,
641    /// Optional free text, verbatim; `null` when the entry carries none.
642    #[serde(default)]
643    pub note: Option<String>,
644    /// The duration when it is usable ([`TimeEntry::minutes`]): `null` exactly when the
645    /// stated duration is absent or negative, never `0`.
646    #[serde(default)]
647    pub minutes: Option<i64>,
648    /// Whether the entry has no field-level problem ([`TimeEntry::is_valid`]).
649    pub valid: bool,
650    /// Every field-level problem, through the shared entry-to-diagnostic mapping.
651    #[serde(default)]
652    pub diagnostics: Vec<PortableTimeDiagnostic>,
653    /// The entry's unknown metadata, preserved verbatim in a namespace of its own.
654    #[serde(default)]
655    pub extensions: PortableExtensions,
656    /// Keys a newer writer placed directly on this node, preserved verbatim.
657    ///
658    /// Deliberately **private**: this is the bag whose entries are emitted *at* the node's
659    /// level, so a public field would let a caller put a projected field's name there and
660    /// make this build emit a duplicate key. It is filled by reading a document — where a
661    /// repeated key is refused — and read back through [`Self::unknown`].
662    #[serde(flatten)]
663    unknown: PortableExtensions,
664}
665
666impl PortableTimeEntry {
667    /// The keys a newer writer placed directly on this entry row, preserved verbatim.
668    ///
669    /// The read side of the entry's catch-all channel; there is no write side, by design
670    /// (see the field).
671    #[must_use]
672    pub fn unknown(&self) -> &PortableExtensions {
673        &self.unknown
674    }
675
676    /// Project one current entry of one issue.
677    ///
678    /// `issue` only labels the diagnostics: an entry problem is reported with the issue
679    /// and entry it belongs to, through the shared mapping, so a reader never has to
680    /// guess which row a finding is about.
681    /// The returned [`NotCarried`] list is the metadata that could not be projected; the
682    /// caller reports it rather than letting a value disappear.
683    fn of_entry(issue: u32, entry: &TimeEntry) -> (Self, Vec<NotCarried>) {
684        let diagnostics = entry
685            .problems()
686            .iter()
687            .map(|problem| {
688                PortableTimeDiagnostic::from_shared(&TimeDiagnostic::from_entry_problem(
689                    issue, &entry.id, problem,
690                ))
691            })
692            .collect();
693        let (extensions, not_carried) = PortableExtensions::from_yaml(&entry.extensions);
694        (
695            Self {
696                id: entry.id.clone(),
697                duration_minutes: entry.duration_minutes,
698                date: entry.date.clone(),
699                author: answerable(&entry.author),
700                note: entry.note.clone(),
701                minutes: entry.minutes(),
702                valid: entry.is_valid(),
703                diagnostics,
704                extensions,
705                unknown: PortableExtensions::new(),
706            },
707            not_carried,
708        )
709    }
710}
711
712/// One prior value a deletion left in History (T035).
713///
714/// The provenance the append-only log records, verbatim: which entry the event named
715/// (when its detail names one that the shared grammar can recover), when the deletion was
716/// recorded and who recorded it. The prior *value's* minutes live in the `## History`
717/// line itself and are deliberately **not** re-parsed here — the shared aggregation
718/// owns that grammar, and a second reader of it would be a second definition of what a
719/// deletion means.
720///
721/// An unanswerable field is `null`, never an empty string, exactly as the CLI renders it
722/// (`tix time history` prints `-` for a blank value through its own one-line cell). The
723/// shared grammar refuses a blank *timestamp*, so `deleted_at` is present for every event
724/// this build reads from a file; a blank **actor** is a shape the grammar and the writer
725/// both allow ("the actor may legitimately be blank, which is exactly what `render` writes
726/// when no identity was recorded"), and it is `null` here. The reader accepts `null` and an
727/// omitted key for all three, which is what a hand-written or newer document may state.
728#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
729pub struct PortableTimeDeletion {
730    /// The deleted entry's stable id, when the event's detail names one.
731    #[serde(default)]
732    pub entry_id: Option<String>,
733    /// When the deletion was recorded, as the event records it; `null` when it states none.
734    #[serde(default)]
735    pub deleted_at: Option<String>,
736    /// Who recorded the deletion, as the event records it; `null` when it names nobody.
737    #[serde(default)]
738    pub deleted_by: Option<String>,
739}
740
741/// A shared string that is unanswerable when it is blank.
742///
743/// The one rule the attributed fields share: a value that is absent or only whitespace is
744/// **no value**, so it is `null` in the document rather than an empty string — the same
745/// statement, in JSON, that the CLI's `-` makes on a table row. A caller cannot tell "no
746/// attribution" from "an empty attribution" by re-implementing `trim().is_empty()` against
747/// an empty string, and the format should not ask it to. A non-blank value is carried
748/// verbatim, whitespace included.
749fn answerable(value: &str) -> Option<String> {
750    if value.trim().is_empty() {
751        None
752    } else {
753        Some(value.to_string())
754    }
755}
756
757/// The shared total, projected field for field (T034).
758///
759/// Every number is the DTO's own — the sum, the counts beside it and the optional deleted
760/// count — and `state` is the shared [`TimeTotalState`] spelling, so an empty scope
761/// (`nothing`) and a scope whose entries were all unusable (`unknown`) stay different
762/// answers here exactly as they are on the CLI (C-005). `group` is the shared
763/// [`TimeGroup`](crate::time_tracking::aggregate::TimeGroup) label and `scope` the
764/// filtered scope the total was evaluated under.
765#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
766pub struct PortableTimeTotal {
767    /// The shared group label (`all`, `#3`, `@ada`, `(unattributed)`, a date,
768    /// `(undated)`).
769    pub group: String,
770    /// The filter the total was evaluated under.
771    pub scope: PortableTimeFilter,
772    /// The explicit state, spelled as [`TimeTotalState::as_str`].
773    pub state: String,
774    /// Whether some selected entry was left out of the sum
775    /// ([`TimeTotal::is_partial`]).
776    pub is_partial: bool,
777    /// The sum of the counted entries' minutes; `null` when nothing was counted, and
778    /// `0` only when a zero was genuinely recorded.
779    #[serde(default)]
780    pub total_minutes: Option<i128>,
781    /// Valid entries counted into the sum.
782    pub entry_count: usize,
783    /// Entries whose own fields are unusable; each has its own finding.
784    pub malformed_count: usize,
785    /// Valid entries a period-bounded scope could not place because they carry no date.
786    pub undated_count: usize,
787    /// Prior values deletions left in History on the issues this total is about; `null`
788    /// unless the filter asked for audit context.
789    #[serde(default)]
790    pub deleted_count: Option<usize>,
791    /// Every finding the total carries, in the DTO's own order.
792    #[serde(default)]
793    pub diagnostics: Vec<PortableTimeDiagnostic>,
794}
795
796impl PortableTimeTotal {
797    /// Project one shared [`TimeTotal`].
798    #[must_use]
799    pub fn from_total(total: &TimeTotal) -> Self {
800        Self {
801            group: total.group.label(),
802            scope: PortableTimeFilter::from_filter(&total.filter),
803            state: total.state().as_str().to_string(),
804            is_partial: total.is_partial(),
805            total_minutes: total.total_minutes,
806            entry_count: total.entry_count,
807            malformed_count: total.malformed_count,
808            undated_count: total.undated_count,
809            deleted_count: total.deleted_count,
810            diagnostics: total
811                .diagnostics
812                .iter()
813                .map(PortableTimeDiagnostic::from_shared)
814                .collect(),
815        }
816    }
817}
818
819/// The scope's audit summary, projected from
820/// [`TimeAudit`](crate::time_tracking::aggregate::TimeAudit) (T035).
821///
822/// The summary half of the audit DTO: the scope it was requested under, how many prior
823/// values deletions left in History across that scope and how many corrections it retains,
824/// and the findings about the request itself (a refused filter, an unknown issue). The
825/// per-issue halves are the document's `issues` rows, so a reader gets both views of one
826/// audit value and no second reading of History.
827///
828/// These counts are **not** the totals' `deleted_count`, which is `null` unless the filter
829/// asked for audit context: an unrequested count and a zero count are different facts, and
830/// this pair keeps them apart.
831#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
832pub struct PortableTimeAudit {
833    /// The filter the audit was requested under.
834    pub scope: PortableTimeFilter,
835    /// Prior values deletions left in History across the scope.
836    pub deleted_count: usize,
837    /// Corrections the scope's issues retain.
838    pub corrected_count: usize,
839    /// Findings about the request itself, in the DTO's own order.
840    #[serde(default)]
841    pub diagnostics: Vec<PortableTimeDiagnostic>,
842}
843
844impl PortableTimeAudit {
845    /// Project one shared audit's summary halves.
846    #[must_use]
847    pub fn from_audit(
848        filter: &TimeFilter,
849        deleted_count: usize,
850        corrected_count: usize,
851        diagnostics: &[TimeDiagnostic],
852    ) -> Self {
853        Self {
854            scope: PortableTimeFilter::from_filter(filter),
855            deleted_count,
856            corrected_count,
857            diagnostics: diagnostics
858                .iter()
859                .map(PortableTimeDiagnostic::from_shared)
860                .collect(),
861        }
862    }
863}
864
865/// One issue's current entries beside what History retains (T035).
866///
867/// `entries` are the issue's current entries the document's scope selects, in the
868/// snapshot's deterministic order; `current` is the shared audit's count of the issue's
869/// current entries (whatever the scope's identity or period criteria select), which is
870/// the comparison the audit exists for. `deletions` and `corrections` are the shared
871/// audit's own, so a deletion and a correction are both visible here without this module
872/// reading the History grammar. `total` is the shared per-issue breakdown value, or
873/// `null` when the breakdown answered for no group of that issue — an absent answer,
874/// never an invented zero.
875#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
876pub struct PortableTimeIssue {
877    /// The issue number the row describes.
878    pub number: u32,
879    /// The issue's current entries the scope selects, in snapshot order.
880    #[serde(default)]
881    pub entries: Vec<PortableTimeEntry>,
882    /// How many current entries the issue holds, from the shared audit.
883    pub current: usize,
884    /// The prior values deletions left in this issue's History, in History order.
885    #[serde(default)]
886    pub deletions: Vec<PortableTimeDeletion>,
887    /// How many corrections this issue's History retains.
888    pub corrections: usize,
889    /// The issue's own shared total, or `null` when the breakdown answered for no group
890    /// of it.
891    #[serde(default)]
892    pub total: Option<PortableTimeTotal>,
893    /// Keys a newer writer placed directly on this node, preserved verbatim.
894    ///
895    /// Private for the reason the entry row's is: a public field could put a projected
896    /// field's name at this node's level. Read through [`Self::unknown`].
897    #[serde(flatten)]
898    unknown: PortableExtensions,
899}
900
901impl PortableTimeIssue {
902    /// The keys a newer writer placed directly on this issue row, preserved verbatim.
903    #[must_use]
904    pub fn unknown(&self) -> &PortableExtensions {
905        &self.unknown
906    }
907}
908
909// ---------------------------------------------------------------------------
910// T034 — the document
911// ---------------------------------------------------------------------------
912
913/// Everything the local time surfaces know, in one versioned document.
914#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
915pub struct PortableTimeDocument {
916    /// The format marker: [`FORMAT`].
917    pub format: String,
918    /// The format version: [`VERSION`].
919    pub version: u32,
920    /// The duration precision every number is stated in: [`PRECISION`].
921    pub precision: String,
922    /// The normalized scope the totals were evaluated under.
923    pub filter: PortableTimeFilter,
924    /// The scope's shared total: the same value `tix time total` prints first.
925    pub total: PortableTimeTotal,
926    /// The scope's audit summary: the shared counts of what History retains.
927    pub audit: PortableTimeAudit,
928    /// One row per issue the scope's selector covers, in issue order.
929    #[serde(default)]
930    pub issues: Vec<PortableTimeIssue>,
931    /// Document-level findings (preserved metadata, unsupported remote fields, version).
932    #[serde(default)]
933    pub diagnostics: Vec<PortableTimeDiagnostic>,
934    /// Document-level keys this build preserved, in a namespace of their own.
935    #[serde(default)]
936    pub extensions: PortableExtensions,
937    /// Keys a newer writer placed directly on the document, preserved verbatim.
938    ///
939    /// Private for the reason the row-level bags are: this is the one bag whose entries are
940    /// emitted *at* the document's level, so a public field would let a caller put
941    /// `version` (or any other projected name) there and make this build emit a document it
942    /// then refuses to read. Read through [`Self::unknown`].
943    #[serde(flatten)]
944    unknown: PortableExtensions,
945}
946
947/// The marker-only prefix of a document, read before the document itself.
948///
949/// The same shape the sibling portable documents read first: a payload that is not a time
950/// document is refused **by name** rather than by whatever shape error its fields happened
951/// to raise against this one's.
952#[derive(Deserialize)]
953struct Marker {
954    format: String,
955}
956
957/// What a remote finding counts, so the finding's sentence names real numbers.
958///
959/// Two of the three are the shared audit DTO's own counts (the prior values deletions left
960/// and the corrections retained); the third is the document's own issue-row length. Nothing
961/// is summed or derived here.
962#[derive(Debug, Clone, Copy)]
963struct RemoteCounts {
964    /// The issue rows in the document.
965    issues: usize,
966    /// The prior values deletions left in History across the scope.
967    deletions: usize,
968    /// The corrections the scope's issues retain.
969    corrections: usize,
970}
971
972impl PortableTimeDocument {
973    /// Build the whole local time document for one scope.
974    ///
975    /// `snapshot` is the one-pass index the caller already built (the same
976    /// [`TimeSnapshot::of`] `tix time total` and the other read surfaces use), so the
977    /// document's numbers are the shared calculation's and a multi-issue export scans
978    /// the entries **once** (NFR-001). `filter` is the scope: every total in the document
979    /// is evaluated under it, the entry rows are the entries [`TimeFilter::selects`]
980    /// selects, and the issue rows are the issues the shared audit's selector covers.
981    ///
982    /// A **refused** scope — an unusable bound or an identity that names no person, and
983    /// an issue the snapshot does not hold — is a refusal with the shared findings and
984    /// their remedies, exactly as `tix time total` refuses it: no entry was evaluated, so
985    /// there is no total to publish and no document to write. Reaching this is a
986    /// read-only failure: nothing is written on either path.
987    pub fn build(config: &Config, snapshot: &TimeSnapshot, filter: &TimeFilter) -> Result<Self> {
988        let total = snapshot.total(filter);
989        if matches!(
990            total.state(),
991            TimeTotalState::InvalidFilter | TimeTotalState::UnknownIssue
992        ) {
993            return Err(refusal(&total.diagnostics));
994        }
995
996        // The audit's rows are the issues the scope's selector covers, with the
997        // current-versus-History context; the breakdown is the same snapshot's own
998        // per-issue totals, so a row's number and the scope's number cannot come from two
999        // readings.
1000        let audit = snapshot.audit(filter);
1001        let breakdown: BTreeMap<u32, TimeTotal> = snapshot
1002            .by_issue(filter)
1003            .into_iter()
1004            .filter_map(|group| match group.group {
1005                TimeGroup::Issue(number) => Some((number, group)),
1006                _ => None,
1007            })
1008            .collect();
1009
1010        let entries_by_issue = index_entries(snapshot);
1011
1012        let mut document = Self {
1013            format: FORMAT.to_string(),
1014            version: VERSION,
1015            precision: PRECISION.to_string(),
1016            filter: PortableTimeFilter::from_filter(filter),
1017            total: PortableTimeTotal::from_total(&total),
1018            audit: PortableTimeAudit::from_audit(
1019                &audit.filter,
1020                audit.deleted_count,
1021                audit.corrected_count,
1022                &audit.diagnostics,
1023            ),
1024            issues: Vec::new(),
1025            diagnostics: Vec::new(),
1026            extensions: PortableExtensions::new(),
1027            unknown: PortableExtensions::new(),
1028        };
1029
1030        for row in &audit.issues {
1031            let number = row.issue;
1032            let mut entries = Vec::new();
1033            if let Some(indexed) = entries_by_issue.get(&number) {
1034                for indexed in indexed {
1035                    if !filter.selects(indexed.issue, &indexed.entry) {
1036                        continue;
1037                    }
1038                    let bag = format!("issues.{number}.entries.{}.extensions", indexed.entry.id);
1039                    let (projected, not_carried) =
1040                        PortableTimeEntry::of_entry(number, &indexed.entry);
1041                    for missing in &not_carried {
1042                        document
1043                            .diagnostics
1044                            .push(PortableTimeDiagnostic::metadata_not_carried(
1045                                bag.clone(),
1046                                missing,
1047                            ));
1048                    }
1049                    if !projected.extensions.is_empty() {
1050                        document
1051                            .diagnostics
1052                            .push(PortableTimeDiagnostic::unknown_metadata(
1053                                bag,
1054                                projected.extensions.keys().map(str::to_string).collect(),
1055                            ));
1056                    }
1057                    entries.push(projected);
1058                }
1059            }
1060            document.issues.push(PortableTimeIssue {
1061                number,
1062                entries,
1063                current: row.current,
1064                deletions: row
1065                    .deletions
1066                    .iter()
1067                    .map(|deletion| PortableTimeDeletion {
1068                        entry_id: deletion.entry_id.clone(),
1069                        deleted_at: answerable(&deletion.deleted_at),
1070                        deleted_by: answerable(&deletion.deleted_by),
1071                    })
1072                    .collect(),
1073                corrections: row.corrections,
1074                total: breakdown.get(&number).map(PortableTimeTotal::from_total),
1075                unknown: PortableExtensions::new(),
1076            });
1077        }
1078
1079        // The remote half: a remote tracker has no time field, and saying so is the
1080        // deliverable. One finding per configured target — the values stay local.
1081        document
1082            .diagnostics
1083            .extend(remote_diagnostics(config, &document));
1084
1085        Ok(document)
1086    }
1087
1088    /// Whether this document carries no entry, no deletion and no correction at all.
1089    #[must_use]
1090    pub fn is_empty(&self) -> bool {
1091        self.issues.iter().all(|issue| {
1092            issue.entries.is_empty() && issue.deletions.is_empty() && issue.corrections == 0
1093        })
1094    }
1095
1096    /// The document's format marker.
1097    #[must_use]
1098    pub fn format(&self) -> &str {
1099        &self.format
1100    }
1101
1102    /// The document's format version.
1103    #[must_use]
1104    pub const fn version(&self) -> u32 {
1105        self.version
1106    }
1107
1108    /// Preserve one document-level key, in the nested `extensions` object.
1109    ///
1110    /// The **only** public write path into a bag, and it cannot produce a document this
1111    /// build cannot read: the key lands in a namespace of its own, so it can never
1112    /// duplicate — or be read in place of — a document field.
1113    pub fn insert_extension(&mut self, key: impl Into<String>, value: serde_json::Value) {
1114        self.extensions.insert(key, value);
1115    }
1116
1117    /// The keys a newer writer placed directly on the document, preserved verbatim.
1118    ///
1119    /// The read side of the document's catch-all channel. There is no write side, and the
1120    /// bag is private, which is what makes a node-level key naming a projected field
1121    /// impossible to produce through this API (see [`PortableExtensions`]'s own docs for
1122    /// the compile-checked statement of that).
1123    #[must_use]
1124    pub fn unknown(&self) -> &PortableExtensions {
1125        &self.unknown
1126    }
1127
1128    /// Serialize the document as compact JSON.
1129    pub fn to_json(&self) -> Result<String> {
1130        serde_json::to_string(self).map_err(|error| {
1131            TixError::Parse(format!("time document cannot be serialized: {error}"))
1132        })
1133    }
1134
1135    /// Serialize the document as pretty JSON — the export surface's shape.
1136    pub fn to_json_pretty(&self) -> Result<String> {
1137        serde_json::to_string_pretty(self).map_err(|error| {
1138            TixError::Parse(format!("time document cannot be serialized: {error}"))
1139        })
1140    }
1141
1142    /// Read a document back.
1143    ///
1144    /// The [`Marker`] is read **first**: a document that is not a time document is
1145    /// refused by name (its format marker) rather than by whatever shape error its fields
1146    /// happened to raise — a `tix-capacity` or `tix-roadmap` payload overlaps structurally
1147    /// and would otherwise produce a misleading field error. A document of another
1148    /// *version* is read, its own fields and preserved keys kept, and the mismatch is
1149    /// merged into the diagnostics once — the checked-and-reported third answer between
1150    /// refusing a newer writer and silently dropping its fields.
1151    ///
1152    /// A hand-written document that repeats a *projected* key inside one object is refused
1153    /// (`duplicate field`), which is the honest answer: the alternative is a lenient
1154    /// consumer reading preserved metadata in place of the value it shadows. This build
1155    /// never writes such a key twice — preserved keys live in their own object — so it can
1156    /// always read back what it wrote.
1157    pub fn from_json(raw: &str) -> Result<Self> {
1158        let marker: Marker = serde_json::from_str(raw)
1159            .map_err(|error| TixError::Parse(format!("not a portable time document: {error}")))?;
1160        if marker.format != FORMAT {
1161            return Err(TixError::Parse(format!(
1162                "portable time document has format `{}`, but this build reads `{FORMAT}`",
1163                marker.format
1164            )));
1165        }
1166        let mut document: Self = serde_json::from_str(raw).map_err(|error| {
1167            TixError::Parse(format!(
1168                "portable time document is not valid JSON of this shape: {error}"
1169            ))
1170        })?;
1171        if document.version != VERSION {
1172            let diagnostic = PortableTimeDiagnostic::version_mismatch(document.version);
1173            if !document
1174                .diagnostics
1175                .iter()
1176                .any(|existing| existing.same_as(&diagnostic))
1177            {
1178                document.diagnostics.push(diagnostic);
1179            }
1180        }
1181        Ok(document)
1182    }
1183}
1184
1185/// The current entries of one snapshot, grouped by the issue they belong to.
1186///
1187/// One pass over the snapshot's own index, in its own order: the grouping is a lookup, not
1188/// a second reading, and an issue with no entry simply has no group.
1189fn index_entries(snapshot: &TimeSnapshot) -> BTreeMap<u32, Vec<&IndexedEntry>> {
1190    let mut by_issue: BTreeMap<u32, Vec<&IndexedEntry>> = BTreeMap::new();
1191    for indexed in snapshot.entries() {
1192        by_issue.entry(indexed.issue).or_default().push(indexed);
1193    }
1194    by_issue
1195}
1196
1197/// The scope refusals as one rendered block, with their remedies.
1198///
1199/// A refused scope is a refusal, not a document: no entry was evaluated, so there is no
1200/// total to publish. The block is the shared findings' own reason/field/issue/entry notes
1201/// plus their remedies, rendered at **error** severity so the rendered level and the
1202/// non-zero exit agree — the same rule (and the same layout) `tix time total` uses.
1203fn refusal(diagnostics: &[TimeDiagnostic]) -> TixError {
1204    let block = diagnostics
1205        .iter()
1206        .map(|finding| {
1207            let mut diagnostic = Diagnostic::error("E-tix-time", finding.reason.clone())
1208                .with_note(format!("kind: {}", finding.kind.as_str()));
1209            if let Some(field) = finding.field {
1210                diagnostic = diagnostic.with_note(format!("field: {field}"));
1211            }
1212            if let Some(issue) = finding.issue {
1213                diagnostic = diagnostic.with_note(format!("issue: #{issue}"));
1214            }
1215            diagnostic.with_help(&finding.remedy).render()
1216        })
1217        .collect::<Vec<String>>()
1218        .join("\n");
1219    TixError::Refused(block)
1220}
1221
1222/// The remote-target findings: one per configured `[export.*]` target.
1223///
1224/// The targets are the configuration's own (`config.export.gitlab`/`.linear`), and the
1225/// finding names the target and the field it cannot carry. It is **not** a mapping: no
1226/// remote id is invented, no value is converted, and nothing is sent anywhere.
1227fn remote_diagnostics(
1228    config: &Config,
1229    document: &PortableTimeDocument,
1230) -> Vec<PortableTimeDiagnostic> {
1231    let mut targets = Vec::new();
1232    if config.export.gitlab.is_some() {
1233        targets.push("gitlab");
1234    }
1235    if config.export.linear.is_some() {
1236        targets.push("linear");
1237    }
1238    if targets.is_empty() {
1239        return Vec::new();
1240    }
1241    let counts = RemoteCounts {
1242        issues: document.issues.len(),
1243        deletions: document.audit.deleted_count,
1244        corrections: document.audit.corrected_count,
1245    };
1246    targets
1247        .into_iter()
1248        .map(|target| PortableTimeDiagnostic::unsupported_remote(target, counts))
1249        .collect()
1250}