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 ¬_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}