Skip to main content

tix/sync/
mod.rs

1//! External tracker sync: orchestration for `tix export` / `tix import`,
2//! the [`Tool`] selector, the [`CommandRunner`] spawn seam, and the pure
3//! mapping / conflict-resolution rules.
4//!
5//! WP05 wires the CLI to `glab` (GitLab) and `linear-cli` (Linear) under
6//! `contracts/external-tools.md`. Tokens are resolved through
7//! [`token::resolve`] with env precedence, injected only as an env pair (never
8//! argv), and every error/report surface scrubs the secret as `<token>`.
9//!
10//! The orchestration lives here so it is testable at module granularity by
11//! pointing PATH at fake tool scripts; [`export`] / [`import`] are pure in the
12//! sense that they take the store, tool, config and an output sink, and the
13//! only externally visible side effects are local issue files, `tix.toml`,
14//! the tool subprocesses, and what is written to `out`.
15//!
16//! ## One definition of "complete", and what this module's job actually is
17//!
18//! **A second local completion definition in this module is a C-004 defect.** There
19//! used to be one: `remote_status_is_done(status) == (status == "closed" ||
20//! status == "Done")`, a helper that looked like it decided whether an issue was
21//! complete and in fact decided whether a *remote* state word was one of the two
22//! this module happens to send. It had **no production callers** — WP02 removed the
23//! last one — and its only remaining referrer was its own unit test, which is the
24//! shape dead code takes when a test keeps it alive. Removed in WP07.
25//!
26//! What this module legitimately owns is the **translation**: the remote trackers
27//! have a closed set of states (`opened`/`closed` for GitLab, a workflow-state label
28//! for Linear), so something here has to say which remote word means closed and which
29//! local column to send it as. That is a protocol concern and it is this module's
30//! job.
31//!
32//! What it does **not** own is the meaning of *complete*. That is
33//! [`crate::workflow::is_completed`] and nothing else — so [`local_to_remote_status`]
34//! asks the service whether a local column is complete, and
35//! [`remote_to_local_status`] picks the column the service calls complete via
36//! [`completion_column`].
37//!
38//! The distinction matters because the two can disagree. A board whose `category_map`
39//! puts `completed` on `shipped` while `done_column` is `done` has a completion
40//! column that is *not* the one `done_column` names: comparing names exports
41//! `shipped` as open and closes `done` instead, so the tracker and the board
42//! disagree about the same issue in both directions.
43
44use std::collections::HashMap;
45use std::collections::HashSet;
46use std::io::Write;
47
48use crate::config::{BoardConfiguration, Config};
49use crate::error::{Result, TixError};
50use crate::model::{Issue, Status};
51use crate::storage::Store;
52
53pub mod glab;
54pub mod linear;
55pub mod token;
56
57/// The supported external tracker, selected via `--target gitlab|linear`.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum Tool {
60    GitLab,
61    Linear,
62}
63
64impl Tool {
65    #[must_use]
66    pub fn from_target(target: &str) -> Result<Tool> {
67        match target {
68            "gitlab" => Ok(Tool::GitLab),
69            "linear" => Ok(Tool::Linear),
70            other => Err(TixError::InvalidInput(format!(
71                "unknown --target '{other}' — use gitlab or linear"
72            ))),
73        }
74    }
75
76    /// The token env var that wins over any `tix.toml` token — the tool's
77    /// **primary** name, which is what the `tix.toml` warning and the injection
78    /// tests name.
79    #[must_use]
80    pub fn token_var(self) -> &'static str {
81        match self {
82            Tool::GitLab => "GITLAB_TOKEN",
83            Tool::Linear => "LINEAR_TOKEN",
84        }
85    }
86
87    /// Every environment variable that can carry this tool's token, in
88    /// precedence order.
89    ///
90    /// `token_var` is the primary name tix documents; the extras are the names
91    /// the **tool itself** already reads, so a user who has the tracker
92    /// configured does not have to export a second, tix-specific variable. The
93    /// canonical `linear-cli` reads `LINEAR_API_KEY` (its help shows
94    /// `printf '%s\n' "$LINEAR_API_KEY" | linear config set-key`), so that is
95    /// accepted after tix's own `LINEAR_TOKEN`.
96    #[must_use]
97    pub fn token_vars(self) -> &'static [&'static str] {
98        match self {
99            Tool::GitLab => &["GITLAB_TOKEN"],
100            Tool::Linear => &["LINEAR_TOKEN", "LINEAR_API_KEY"],
101        }
102    }
103
104    /// The binary shelled out to.
105    #[must_use]
106    pub fn binary(self) -> &'static str {
107        match self {
108            Tool::GitLab => "glab",
109            Tool::Linear => "linear-cli",
110        }
111    }
112
113    #[must_use]
114    pub fn as_str(self) -> &'static str {
115        match self {
116            Tool::GitLab => "gitlab",
117            Tool::Linear => "linear",
118        }
119    }
120}
121
122impl std::fmt::Display for Tool {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        f.write_str(self.as_str())
125    }
126}
127
128/// The captured result of a spawned external tool.
129///
130/// `ProcessRunner` waits synchronously on the child (never kills it), so a
131/// blocking tool blocks the sync — the contract's supported "wait" model for
132/// `glab`/`linear-cli`.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct CommandOutcome {
135    pub success: bool,
136    pub status: Option<i32>,
137    pub stdout: String,
138    pub stderr: String,
139}
140
141impl CommandOutcome {
142    /// The human-readable output chosen for error reporting: stderr when the
143    /// tool failed and produced any, otherwise stdout.
144    #[must_use]
145    pub fn text(&self) -> &str {
146        if self.success {
147            &self.stdout
148        } else if !self.stderr.trim().is_empty() {
149            &self.stderr
150        } else {
151            &self.stdout
152        }
153    }
154}
155
156/// Spawn seam: production uses [`ProcessRunner`]; tests inject a
157/// `FakeRunner` that records argv/env without spawning anything.
158pub trait CommandRunner: Send {
159    fn run(
160        &self,
161        program: &str,
162        args: &[String],
163        env: &[(String, String)],
164    ) -> Result<CommandOutcome>;
165
166    /// Downcasting hook so tests can inspect recorded calls on a fake.
167    fn as_any(&self) -> &dyn std::any::Any;
168}
169
170/// Production runner: `std::process::Command` with a synchronous output wait.
171///
172/// A missing binary surfaces as [`TixError::External`] with the contract's
173/// exit-3 wording (`contracts/external-tools.md`: "missing binary ... exit 3").
174pub struct ProcessRunner;
175
176impl CommandRunner for ProcessRunner {
177    fn run(
178        &self,
179        program: &str,
180        args: &[String],
181        env: &[(String, String)],
182    ) -> Result<CommandOutcome> {
183        use std::process::Command;
184        let mut cmd = Command::new(program);
185        cmd.args(args);
186        for (key, value) in env {
187            cmd.env(key, value);
188        }
189        let output = cmd.output().map_err(|e| {
190            if e.kind() == std::io::ErrorKind::NotFound {
191                TixError::External(not_found_message(program))
192            } else {
193                TixError::External(format!("`{program}` failed to start: {e}"))
194            }
195        })?;
196        Ok(CommandOutcome {
197            success: output.status.success(),
198            status: output.status.code(),
199            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
200            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
201        })
202    }
203
204    fn as_any(&self) -> &dyn std::any::Any {
205        self
206    }
207}
208
209/// Common surface both tracker adapters implement so the orchestration is
210/// tool-agnostic.
211///
212/// `status` is the *remote* state/label already computed by the pure mapping
213/// helpers (e.g. `closed`, `Done`, or the first work column). GitLab conveys
214/// "done" via a separate close call ([`TrackerAdapter::mark_done`]); Linear
215/// conveys it with `-s Done` ([`TrackerAdapter::done_via_status`]).
216pub trait TrackerAdapter {
217    fn tool(&self) -> Tool;
218
219    /// Create a remote issue and return its remote id (GitLab `iid`, Linear
220    /// `identifier`).
221    fn create_issue(
222        &mut self,
223        title: &str,
224        body: &str,
225        labels: &[String],
226        assignee: Option<&str>,
227        priority: Option<&str>,
228        status: &str,
229    ) -> Result<String>;
230
231    /// Update an existing remote issue in place.
232    #[allow(clippy::too_many_arguments)]
233    fn update_issue(
234        &mut self,
235        remote_ref: &str,
236        title: &str,
237        body: &str,
238        labels: &[String],
239        assignee: Option<&str>,
240        priority: Option<&str>,
241        status: &str,
242    ) -> Result<()>;
243
244    /// Convey "done" (GitLab: `issue close <id>`). Only invoked when
245    /// `done_via_status` is false.
246    fn mark_done(&mut self, remote_ref: &str) -> Result<()>;
247
248    /// List open remote issues (the import source). Closed/done issues are
249    /// excluded.
250    fn list_open(&mut self) -> Result<Vec<RemoteIssue>>;
251
252    /// Linear sets done through the status flag; GitLab needs a separate
253    /// close call.
254    fn done_via_status(&self) -> bool;
255
256    /// Send the relationships this tracker can express, after the issue itself
257    /// exists (WP07 T030/T031).
258    ///
259    /// **Required, not defaulted.** A default no-op would let a tracker be added
260    /// that silently never syncs relationships — the failure mode is a local
261    /// relation that appears to sync and does not, which is the same silent-loss
262    /// shape as a dropped edge. Two implementors is a small price for making the
263    /// omission a compile error.
264    ///
265    /// The relationships arrive already filtered by
266    /// [`map_relation_to_remote`], so an adapter never decides representability:
267    /// it sends what the shared seam says it can send, and reports the rest. A
268    /// `blocks` edge reaches here; a `related` edge never does.
269    fn set_relations(&mut self, remote_ref: &str, relations: &[MappedRelation]) -> Result<()>;
270
271    /// Send the project associations this tracker can express, after the issue
272    /// itself exists (WP08 T036).
273    ///
274    /// **Required, not defaulted**, for the same reason [`Self::set_relations`] is:
275    /// a default no-op would let a tracker be added that silently never syncs
276    /// project membership, which is the silent-loss shape C-005 forbids. Both
277    /// adapters implement `PROJECT_WIRE_VERIFIED`'s answer, which is "nothing", and
278    /// the signature is why a future tracker cannot answer with a different shape.
279    ///
280    /// The associations arrive already filtered by [`map_project_to_remote`], so an
281    /// adapter never decides representability — and while `PROJECT_WIRE_VERIFIED` is
282    /// empty it never receives a sendable value either, so both implementations
283    /// return without spawning anything. A run that cannot express membership must
284    /// send **nothing**: an empty membership flag that the tool interpreted as
285    /// "clear it" would delete remote state on a run that had nothing to say, which
286    /// is the same trap `set_relations` avoids by returning early on an empty set.
287    fn set_projects(&mut self, remote_ref: &str, projects: &[MappedProject]) -> Result<()>;
288
289    /// Send the epic references this tracker can express, after the issue itself
290    /// exists (WP08, mission `epics-work-items-01M3BYW4`).
291    ///
292    /// **Required, not defaulted**, for the reason [`Self::set_relations`] and
293    /// [`Self::set_projects`] give: a default no-op would let a tracker be added
294    /// that silently never syncs an epic reference, which is the silent-loss shape
295    /// C-007 forbids. The signature is why a future tracker cannot answer with a
296    /// different shape.
297    ///
298    /// The references arrive already filtered by [`map_epic_to_remote`], so an
299    /// adapter never decides representability. Both adapters implement
300    /// `EPIC_WIRE_VERIFIED`'s answer, which is "nothing": neither `glab` nor
301    /// `linear-cli` carries an epic membership list, so both return without spawning
302    /// anything, and an empty set must spawn nothing (an empty flag the tool read as
303    /// "clear it" would delete remote state on a run with nothing to say — the same
304    /// trap `set_relations` avoids by returning early).
305    fn set_epics(&mut self, remote_ref: &str, epics: &[MappedEpic]) -> Result<()>;
306}
307
308/// A remote issue parsed from the tracker's JSON list output.
309#[derive(Debug, Clone, PartialEq, Eq)]
310pub struct RemoteIssue {
311    /// Remote id (GitLab `iid`, Linear `identifier`).
312    pub id: String,
313    pub title: String,
314    pub description: String,
315    pub labels: Vec<String>,
316    /// Remote state (`closed` / `Done` / a column label, ...).
317    pub state: String,
318    /// ISO-8601 `updated_at`; `None` when the tool omitted it.
319    pub updated_at: Option<String>,
320    /// Best-effort single assignee (bare name, no `@`).
321    pub assignee: Option<String>,
322    /// The remote id of this issue's parent, when the remote reported one.
323    ///
324    /// A **remote** id, not a local number, because it is read from a remote
325    /// payload and the two id spaces are not interchangeable. It is resolved to a
326    /// local number by `local_parent_number` before anything is written, and
327    /// `None` — rather than a guess — when it cannot be resolved.
328    pub parent: Option<String>,
329    /// The project references the remote reported for this issue (WP08).
330    ///
331    /// Read from the payload, in the order the payload listed them, and matched
332    /// against the local registry by **id** before anything is written. An empty
333    /// vector is the answer for a payload that reported none, which is every
334    /// GitLab payload today: nothing in this build ever *sends* a membership, so
335    /// this is only ever what the remote side already said.
336    pub projects: Vec<RemoteProject>,
337    /// The epic references the remote reported for this issue (WP08, mission
338    /// `epics-work-items-01M3BYW4`).
339    ///
340    /// Read from the payload, in the order the payload listed them, and matched
341    /// against the local registry by **id** before anything is written. An empty
342    /// vector is the answer for a payload that reported none — and for both trackers
343    /// today nothing in this build ever *sends* one (`EPIC_WIRE_VERIFIED` is empty),
344    /// so this is only ever what the remote already said.
345    pub epics: Vec<RemoteEpic>,
346}
347
348/// What one export or import run did, and what it could not do.
349///
350/// Returned rather than only written to the caller's `Write`, so a caller can
351/// surface the diagnostics without scraping its own output — and so the count and
352/// the diagnostics cannot disagree. Both entry points had `Result<usize>`; a count
353/// alone cannot express "three issues synced, one relationship could not be".
354#[derive(Debug, Clone, Default, PartialEq, Eq)]
355pub struct SyncReport {
356    /// Issues created, updated, or overwritten.
357    pub handled: usize,
358    /// Local data the remote could not represent — relationships, parent
359    /// references, health values, project associations, and epic references — in
360    /// encounter order.
361    pub diagnostics: Vec<RemoteMappingDiagnostic>,
362    /// Items whose **status** the transition policy refused (WP07 T031).
363    ///
364    /// Counted separately from `handled` because an import can do both: an issue
365    /// whose title and body imported fine while its status was refused still counts
366    /// as handled — the file was written — and also as skipped, because a column did
367    /// not move. A single number cannot express that, and the report line it backs
368    /// (`overwrote … without its status`) is the one a user has to be able to find.
369    pub skipped: usize,
370}
371
372impl SyncReport {
373    /// Record one unrepresentable relationship: keep the diagnostic for the
374    /// caller, and say so on the human-facing stream too.
375    fn note(&mut self, mapped: MappedRelation, out: &mut dyn Write) -> std::io::Result<()> {
376        if let MappedRelation::Unsupported(diagnostic) = mapped {
377            writeln!(out, "{diagnostic}")?;
378            self.diagnostics.push(diagnostic);
379        }
380        Ok(())
381    }
382
383    /// The same, for a **parent** reference.
384    ///
385    /// Its own method rather than coercing a `MappedParent` into a
386    /// `MappedRelation`: a parent is not a relationship, and the first cut did
387    /// exactly that with an empty `field` and an empty `value` on the remote arm
388    /// — which reads as though something were being sent when nothing is. The
389    /// coercion compiled, produced the right output, and was wrong in a way only
390    /// reading the type would have caught.
391    fn note_parent(&mut self, mapped: MappedParent, out: &mut dyn Write) -> std::io::Result<()> {
392        if let MappedParent::Unsupported(diagnostic) = mapped {
393            writeln!(out, "{diagnostic}")?;
394            self.diagnostics.push(diagnostic);
395        }
396        Ok(())
397    }
398
399    /// The same, for a **project association**.
400    ///
401    /// Its own method for the reason [`SyncReport::note_parent`] gives: a project
402    /// membership is not a relationship, and coercing a `MappedProject` into a
403    /// `MappedRelation` would put a project id where a relationship id belongs.
404    /// The three methods share the diagnostic type and the count, which is the
405    /// vocabulary that matters.
406    fn note_project(&mut self, mapped: MappedProject, out: &mut dyn Write) -> std::io::Result<()> {
407        if let MappedProject::Unsupported(diagnostic) = mapped {
408            writeln!(out, "{diagnostic}")?;
409            self.diagnostics.push(diagnostic);
410        }
411        Ok(())
412    }
413
414    /// The same, for an **epic reference** (WP08, mission
415    /// `epics-work-items-01M3BYW4`).
416    ///
417    /// Its own method for the reason [`SyncReport::note_project`] gives: an epic
418    /// reference is not a relationship, and coercing a `MappedEpic` into a
419    /// `MappedRelation` would put an epic id where a relationship id belongs. The
420    /// four methods share the diagnostic type and the count, which is the vocabulary
421    /// that matters.
422    fn note_epic(&mut self, mapped: MappedEpic, out: &mut dyn Write) -> std::io::Result<()> {
423        if let MappedEpic::Unsupported(diagnostic) = mapped {
424            writeln!(out, "{diagnostic}")?;
425            self.diagnostics.push(diagnostic);
426        }
427        Ok(())
428    }
429}
430
431/// The record emitted when a remote tracker cannot hold a piece of local data —
432/// a relation (FR-005, C-005, `data-model.md` §6), a parent reference, a health
433/// value, a project association (WP08, mission `first-class-projects-01M3BYBW`),
434/// or an epic reference (WP08, mission `epics-work-items-01M3BYW4`).
435///
436/// The four fields are fixed by the data model. `field` is the important one: it
437/// is what makes the diagnostic actionable rather than a shrug, and
438/// `data-model.md` §6 requires it for exactly that reason — "so the message can
439/// name it".
440///
441/// One type for all of them because the vocabulary matters more than the noun: a
442/// reader of `tix export` output learns one shape ("tool: field cannot represent
443/// this local data (subject) — reason") and does not have to recognise which kind
444/// of local data each line is about. The **subject** in `relation_id` is whatever
445/// the diagnostic is about — a relationship id, an `issue->parent` pair, a health
446/// scope, an `issue->project` pair, or an `issue->epic` pair.
447#[derive(Debug, Clone, PartialEq, Eq)]
448pub struct RemoteMappingDiagnostic {
449    /// The local item the diagnostic is about: a relationship id, an
450    /// `issue->parent` pair, a health scope, or an `issue->project` pair.
451    pub relation_id: String,
452    /// The specific unsupported remote field or relation name.
453    pub field: String,
454    /// Which adapter produced the diagnostic.
455    pub tool: Tool,
456    /// Human-readable text, safe for CLI output, the TUI, and lint.
457    pub message: String,
458}
459
460impl std::fmt::Display for RemoteMappingDiagnostic {
461    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
462        // "this local data", not "this relationship": the type now carries project
463        // associations and health values too, and a line that called an association
464        // a relationship would send the reader looking for an edge that is not there.
465        write!(
466            f,
467            "{}: {} cannot represent this local data ({}) — {}",
468            self.tool.as_str(),
469            self.field,
470            self.relation_id,
471            self.message
472        )
473    }
474}
475
476/// The outcome of mapping one local relationship for one tracker: either a
477/// remote representation, or a diagnostic naming why there is none.
478#[derive(Debug, Clone, PartialEq, Eq)]
479pub enum MappedRelation {
480    /// The remote representation to send: the relation `field` (the remote
481    /// relation kind, e.g. `blocks`) and the `value` the adapter needs. For a
482    /// relation verb taking two endpoints (Linear's `relations add`) the `value`
483    /// is the **target's remote identifier** (`ENG-42`); a tracker whose verb
484    /// carries the target inside a field renders its own value there.
485    Remote { field: String, value: String },
486    /// The tracker cannot express this relation. The **local** relation is
487    /// untouched — C-005 is that a relation the remote cannot hold still survives.
488    Unsupported(RemoteMappingDiagnostic),
489}
490
491/// The remote fields each tracker can express for a typed relation, and the one it
492/// maps every blocking edge onto.
493///
494/// This is the whole of the representability decision, and it is deliberately a
495/// table keyed on `(Tool, RelationKind)` rather than logic spread across the two
496/// adapters. Two consequences worth stating:
497///
498/// - **It is derived from what the adapters can actually send**, not from a guess
499///   about their APIs. `linear-cli relations add -r <kind> <FROM> <TO>` is the only
500///   relation verb either CLI exposes, so `blocks` maps for Linear and nothing else
501///   does; `glab` exposes no relation verb at all (see `RELATION_CLI_ABSENT`).
502/// - **`blocks` maps; `related`, `duplicates`, and `blocked_by` do not.** Both
503///   trackers' issue-update CLIs carry a title, body, labels, assignee, and state —
504///   and no field for "this issue is related to that one". Guessing otherwise
505///   would mean writing a title into a field, which is not a mapping.
506///
507/// `blocked_by` is the inverse of `blocks`, not a third thing, so it is reported
508/// against the same remote field: naming `blocking` for it would be true but would
509/// send the reader looking for a relation the tracker does not have.
510///
511/// ## Linear is wire-verified; GitLab is verified to have no command at all
512///
513/// The mapping knows what each tracker's *model* can express — GitLab and Linear
514/// both have blocking relations in their APIs — and the adapters drive a **CLI**,
515/// so what matters is whether the CLI exposes a verb.
516///
517/// **Linear's does.** `linear-cli` 0.3.28 (nesszer/linear-cli, the
518/// `cargo:linear-cli` crate) has `relations add --relation
519/// <blocks|blocked-by|related|duplicate> <FROM> <TO>`, checked against the
520/// installed binary on 2026-10-09. Four shapes parse — they fail only with
521/// `No workspace selected` (exit 1, no workspace configured here):
522/// `relations add -r blocks ENG-1 ENG-2`, `issues create "T" -t ENG -d D -p 2 -s
523/// Todo -o json`, `issues update ENG-1 -T T -d D -s Done -o json`, and
524/// `issues list -t ENG -s Todo -o json`; and two clap errors confirm the shape —
525/// `linear-cli issue create "T"` (exit 2, `unrecognized subcommand 'issue'`) and
526/// `linear-cli issues create --title T` (exit 2, `unexpected argument
527/// '--title'`). The API path itself is unverified (no workspace), so the
528/// adapter is checked at the grammar level and end to end against the fake.
529///
530/// Sending an unverified command line is not a conservative default; it is a
531/// production failure with a confusing message. So the seam maps, and an adapter
532/// sends only what a verified verb carries; **GitLab remains on the separate,
533/// permanent `RELATION_CLI_ABSENT` list** below — the two answers must not blur.
534///
535/// The follow-up this closes: verify the verb against the real CLI, add the tool
536/// to this list, and implement its `set_relations` ([`Tool::Linear`] does).
537const RELATION_WIRE_VERIFIED: &[Tool] = &[Tool::Linear];
538
539/// Tools whose CLI is **verified to expose no relation command at all**.
540///
541/// This is a *different, permanent* answer from "not yet verified", and the two
542/// must not share wording. A reader told "the verb is not verified" will reasonably
543/// wait for (or go looking for) the missing command; a reader told the CLI has no
544/// such command can stop.
545///
546/// `glab` 1.119.0, checked against the installed binary (tix #1354):
547/// `glab issue update --help` lists no `--blocking` (or any relation) flag, and
548/// `glab issue` has no relation/`link` subcommand. GitLab's *model* has issue links
549/// and the REST API exposes them, but the CLI this adapter drives does not.
550const RELATION_CLI_ABSENT: &[Tool] = &[Tool::GitLab];
551
552// ---------------------------------------------------------------------------
553// WP08 — the parent reference across a sync round trip
554// ---------------------------------------------------------------------------
555
556/// The CLI verbs that would carry a parent reference, per adapter.
557///
558/// **Empty: no adapter has a verified parent verb.** Both trackers' *models* have
559/// a hierarchy — GitLab has epics and child issues, Linear has parent issues — but
560/// the adapters drive a CLI, and whether `glab issue create --parent` or
561/// `linear-cli issues create --parent` exists is a claim about a program this
562/// repository cannot run. (Linear's `relations add` is verified for *relations*
563/// and is on `RELATION_WIRE_VERIFIED`; that is a different verb and says nothing
564/// about a parent, and the fake in `tests/bin/` accepts no parent flag.)
565///
566/// So the seam maps, and nothing is sent until a verb is verified. What *does*
567/// work today — and is what the round trip covers — is that the reference
568/// survives: the local parent is retained, the mapping is reported, and an import
569/// restores a parent that came from the remote. Flipping a tool on here is a
570/// one-line change plus its adapter implementation, once the verb is confirmed by
571/// hand.
572const PARENT_WIRE_VERIFIED: &[Tool] = &[];
573
574/// The remote field each adapter would carry a parent in, if its verb existed.
575///
576/// Present so the diagnostic can *name* the thing that is missing, rather than
577/// saying only that something is. A reader told "GitLab cannot express this" will
578/// not go looking for the flag.
579const GITLAB_PARENT_FIELD: &str = "parent";
580const LINEAR_PARENT_FIELD: &str = "parent";
581
582/// What one parent reference maps to on the wire.
583#[derive(Debug, Clone, PartialEq, Eq)]
584pub enum MappedParent {
585    /// The remote value to send, as a field/value pair like a relation.
586    Remote { field: String, value: String },
587    /// The adapter cannot carry it. The **local** reference is untouched, which is
588    /// the same contract the relationship mapper follows (C-005): a reference the
589    /// remote cannot hold still survives here.
590    Unsupported(RemoteMappingDiagnostic),
591}
592
593/// Map one local parent reference to its remote representation, or say why not.
594///
595/// Pure: no adapter, no I/O, no configuration, and shared by both trackers so a
596/// future one cannot be added with a divergent contract (T033).
597#[must_use]
598pub fn map_parent_to_remote(tool: Tool, issue_number: u32, parent: Option<u32>) -> MappedParent {
599    let Some(parent) = parent else {
600        // No reference is not a mapping problem. Reporting it would put a
601        // diagnostic on every parentless issue in the repository, which is every
602        // diagnostic being ignored.
603        return MappedParent::Remote {
604            field: parent_field(tool).to_string(),
605            value: String::new(),
606        };
607    };
608    let field = parent_field(tool);
609    if PARENT_WIRE_VERIFIED.contains(&tool) {
610        return MappedParent::Remote {
611            field: field.to_string(),
612            value: format!("#{parent}"),
613        };
614    }
615    MappedParent::Unsupported(RemoteMappingDiagnostic {
616        relation_id: format!("{issue_number}->{parent}"),
617        field: field.to_string(),
618        tool,
619        message: format!(
620            "#{issue_number}'s parent #{parent} is expressible in {}'s model as \
621             `{field}`, but the CLI verb that would carry it is not verified, so \
622             nothing is sent; the local reference is kept",
623            tool.as_str()
624        ),
625    })
626}
627
628/// The field an adapter would carry a parent in.
629fn parent_field(tool: Tool) -> &'static str {
630    match tool {
631        Tool::GitLab => GITLAB_PARENT_FIELD,
632        Tool::Linear => LINEAR_PARENT_FIELD,
633    }
634}
635
636// ---------------------------------------------------------------------------
637// WP08 (mission first-class-projects-01M3BYBW) — project associations across a
638// sync round trip
639// ---------------------------------------------------------------------------
640//
641// ## The two directions are not symmetric, and each says so
642//
643// **Outward: nothing leaves the machine, ever.** GitLab's and Linear's *models*
644// both know what a project is; the CLIs these adapters drive do not. `glab`
645// already passes the one project an issue lives in (`--project <path>`) on every
646// call and exposes no membership field at all, and Linear's issue carries a
647// single `project` — so neither can hold the *list* this mission's C-003
648// requires. Multi-membership is the intake's named risk, and the contract's
649// answer for it is C-005, not a guess: every association diagnoses, and the local
650// association list is untouched. Sending one member of a list would be a silent
651// drop with extra steps.
652//
653// **Inward: a payload that reports associations is honoured.** The read direction
654// is deliberately *not* gated the way the relation mapper's is, and the reason is
655// not convenience. `map_relation_from_remote` must refuse an unrecognised field
656// because turning remote text into a local edge can invent a relationship no
657// export ever made. A project id cannot: it is matched against the local
658// **registry** before anything is written, and an id the registry does not know
659// is refused with a diagnostic rather than written (C-005). So the worst an
660// invented id can produce is a named diagnostic, never local data. Reading a
661// field a payload reported is also not the same claim as sending one — the same
662// distinction `GlabIssue::parent` already draws.
663//
664// This is what makes the round trip non-vacuous: the export says what cannot
665// leave, the import applies what came back through the project **service**, and
666// the association list is byte-stable either way (NFR-002).
667
668/// A project reference a remote reported on one issue.
669#[derive(Debug, Clone, PartialEq, Eq)]
670pub struct RemoteProject {
671    /// The remote's project identity. It is the **only** thing an import matches
672    /// on (C-002): an id is allocated once and never changes, so a remote that
673    /// renames a project still names the same one.
674    pub id: String,
675    /// The remote's display name for the project, when it sent one.
676    ///
677    /// Carried for the diagnostics — a reader recognises "the remote calls it
678    /// *Platform hardening*" — and **never** matched on. The local display name
679    /// stays the registry's (C-002), so a remote rename cannot change it, and it
680    /// is the only remote text in a diagnostic, which is why that diagnostic is
681    /// scrubbed.
682    pub name: Option<String>,
683}
684
685/// Parse the project references one remote issue reported.
686///
687/// **Pure and total**, in the shape [`crate::sync::mod`'s parent parser] is: an
688/// unrecognised payload yields no references — which the import then reports
689/// nothing about — rather than a deserialisation failure that aborts the whole
690/// list. Accepted, for both trackers, because the field's exact shape is a claim
691/// about a program this repository cannot run:
692///
693/// - an array of ids (`"projects": ["p-1", …]`) or of objects;
694/// - a single id, or a single object (`"project": {"id": …}`);
695/// - an object's id under `id`, `identifier`, `key`, or `project_id`, as a
696///   string or a number (`iid`-style numeric ids are real on the wire);
697/// - its name under `name`, `title`, or `display_name`, as a string.
698///
699/// **Entry order is preserved**, which is what keeps a round trip byte-stable
700/// (NFR-002): reordering here would reorder the local list.
701///
702/// An entry with a name but no id is **kept** with an empty id, so the import can
703/// report it by name instead of dropping it in silence; an entry with neither is
704/// not a reference at all and yields nothing.
705#[must_use]
706pub fn remote_projects(value: Option<&serde_json::Value>) -> Vec<RemoteProject> {
707    fn id_of(value: &serde_json::Value) -> String {
708        match value {
709            serde_json::Value::String(s) => s.clone(),
710            serde_json::Value::Number(n) => n.to_string(),
711            _ => String::new(),
712        }
713    }
714    fn one(value: &serde_json::Value) -> Option<RemoteProject> {
715        match value {
716            serde_json::Value::String(_) | serde_json::Value::Number(_) => Some(RemoteProject {
717                id: id_of(value),
718                name: None,
719            }),
720            serde_json::Value::Object(map) => {
721                let id = ["id", "identifier", "key", "project_id"]
722                    .iter()
723                    .find_map(|key| map.get(*key))
724                    .map(id_of)
725                    .unwrap_or_default();
726                let name = ["name", "title", "display_name"]
727                    .iter()
728                    .find_map(|key| map.get(*key))
729                    .and_then(serde_json::Value::as_str)
730                    .map(str::to_string);
731                if id.trim().is_empty() && name.is_none() {
732                    return None;
733                }
734                Some(RemoteProject { id, name })
735            }
736            _ => None,
737        }
738    }
739    let Some(value) = value else {
740        return Vec::new();
741    };
742    match value {
743        serde_json::Value::Array(items) => items.iter().filter_map(one).collect(),
744        other => one(other).into_iter().collect(),
745    }
746}
747
748/// The remote field each adapter would carry an issue's project associations in.
749///
750/// The two differ because the trackers' models differ, and the diagnostic names
751/// the field the reader should stop looking for:
752///
753/// - **GitLab** — `projects`. An issue JSON carries the one project that owns it;
754///   the field a *membership list* would need is `projects`, which no `glab`
755///   command exposes.
756/// - **Linear** — `project`. Its issue carries exactly one project, so the field
757///   that exists is named and the list it cannot hold is what diagnoses.
758const GITLAB_PROJECT_FIELD: &str = "projects";
759const LINEAR_PROJECT_FIELD: &str = "project";
760
761/// The field an adapter would carry project associations in.
762fn project_field(tool: Tool) -> &'static str {
763    match tool {
764        Tool::GitLab => GITLAB_PROJECT_FIELD,
765        Tool::Linear => LINEAR_PROJECT_FIELD,
766    }
767}
768
769/// Tools verified to drive project **membership** from their CLI.
770///
771/// **Empty, and not because the verb is merely unverified.** The relation and
772/// parent tables are empty pending a verb confirmation; this one is empty because
773/// neither CLI has a verb to confirm: `glab issue update --help` (1.119.0) lists
774/// `--project` only as *which project owns the issue*, and Linear's issue carries
775/// one `project` with no list form. So an issue with three projects cannot be
776/// expressed as three of anything, and C-003's multi-membership — the intake's
777/// named risk — is what makes this terminal rather than pending.
778///
779/// The table still exists rather than a bare `unsupported` constant so the seam
780/// has the same shape as the other three, and so a future tracker that *does*
781/// model membership in its CLI is a one-line addition plus its `set_projects`.
782const PROJECT_WIRE_VERIFIED: &[Tool] = &[];
783
784/// What one local project association maps to on the wire.
785#[derive(Debug, Clone, PartialEq, Eq)]
786pub enum MappedProject {
787    /// The remote value to send, as a field/value pair like a relation.
788    Remote { field: String, value: String },
789    /// The adapter cannot carry it. The **local** association is untouched — C-005
790    /// is that a membership the remote cannot hold still survives.
791    Unsupported(RemoteMappingDiagnostic),
792}
793
794/// Map one local project association to its remote representation, or say why not.
795///
796/// Pure: no adapter, no I/O, no configuration. Both trackers share it, so a future
797/// one cannot be added with a divergent contract — and the representability
798/// decision lives in exactly one place instead of once per adapter.
799///
800/// `issue_number` appears in the message because the diagnostic has to be
801/// actionable: the association list can be long, and "project `p-3` cannot be
802/// sent" without the issue is a shrug.
803#[must_use]
804pub fn map_project_to_remote(tool: Tool, issue_number: u32, project_id: &str) -> MappedProject {
805    let field = project_field(tool);
806    let id = project_id.trim();
807    if id.is_empty() {
808        // Not a representability question: there is nothing to send, and an empty
809        // entry in the list is a repair job rather than a remote capability gap.
810        return MappedProject::Unsupported(RemoteMappingDiagnostic {
811            relation_id: format!("{issue_number}->"),
812            field: field.to_string(),
813            tool,
814            message: format!(
815                "issue #{issue_number} carries a project association with no id, so there \
816                 is nothing to send; the entry is kept locally — dissociating the empty \
817                 reference, or editing the issue's project list, clears it"
818            ),
819        });
820    }
821    if PROJECT_WIRE_VERIFIED.contains(&tool) {
822        return MappedProject::Remote {
823            field: field.to_string(),
824            value: id.to_string(),
825        };
826    }
827    MappedProject::Unsupported(RemoteMappingDiagnostic {
828        relation_id: format!("{issue_number}->{id}"),
829        field: field.to_string(),
830        tool,
831        message: format!(
832            "#{issue_number}'s membership of project `{id}` has no {} field to travel in \
833             — {} — so nothing is sent and the local association is kept",
834            tool.as_str(),
835            project_absence_reason(tool)
836        ),
837    })
838}
839
840/// Why a tracker's CLI cannot carry a project membership, per tool.
841///
842/// A **permanent** claim about the CLI the adapter drives, not "not yet
843/// verified", and the wording says so for the same reason `RELATION_CLI_ABSENT`
844/// does: a reader told "the verb is unverified" will wait for it, and there is
845/// nothing to wait for. The two reasons differ, so the message does.
846fn project_absence_reason(tool: Tool) -> &'static str {
847    match tool {
848        Tool::GitLab => {
849            "`glab` names one owning project (`--project`) and exposes no membership field"
850        }
851        Tool::Linear => {
852            "Linear's issue carries one `project` and no list, so a second \
853                         membership has nowhere to go"
854        }
855    }
856}
857
858/// Map one remote project reference back onto a local association id, or say why
859/// not.
860///
861/// The reverse value is **local-shaped**, like [`map_relation_from_remote`]'s: the
862/// frontmatter key the association lives under ([`crate::projects::PROJECTS_KEY`])
863/// and the id, not a copy of the tool's field name — echoing the field back would
864/// make the same association read differently per tracker, which is the opposite
865/// of a shared seam.
866///
867/// Not gated on [`PROJECT_WIRE_VERIFIED`], and the module header records why: an
868/// id is validated against the local registry before anything is written, so this
869/// direction cannot invent local data the way an unverified relation field could.
870#[must_use]
871pub fn map_project_from_remote(tool: Tool, remote: &RemoteProject) -> MappedProject {
872    let id = remote.id.trim();
873    if id.is_empty() {
874        return MappedProject::Unsupported(RemoteMappingDiagnostic {
875            relation_id: remote.name.clone().unwrap_or_default(),
876            field: project_field(tool).to_string(),
877            tool,
878            message: format!(
879                "the remote reported a project it named `{}` but gave no id, and an id is \
880                 the only thing a local association can match on; nothing was associated \
881                 and the local list is unchanged",
882                remote.name.as_deref().unwrap_or("")
883            ),
884        });
885    }
886    MappedProject::Remote {
887        field: crate::projects::PROJECTS_KEY.to_string(),
888        value: id.to_string(),
889    }
890}
891
892/// Redact the resolved secret from a diagnostic's message.
893///
894/// The diagnostics this module builds itself are made of local ids and field
895/// names. The **import** builds four — a reference it cannot read, a project the
896/// registry does not declare, an epic the registry does not declare, and a service
897/// refusal — and the first three quote the name a remote payload reported. Remote
898/// output is the one input that can carry a credential, which is why the import's
899/// diagnostics go through here, using the same [`scrub_secret`] the adapters use on
900/// their errors.
901fn scrub_diagnostic(diagnostic: &mut RemoteMappingDiagnostic, secret: Option<&str>) {
902    if let Some(secret) = secret {
903        diagnostic.message = scrub_secret(&diagnostic.message, secret);
904        diagnostic.relation_id = scrub_secret(&diagnostic.relation_id, secret);
905    }
906}
907
908// ---------------------------------------------------------------------------
909// WP08 (mission epics-work-items-01M3BYW4) — epic references across a sync
910// round trip
911// ---------------------------------------------------------------------------
912//
913// ## What the adapters can actually express
914//
915// **Outward: nothing leaves the machine, and that is the derived answer, not a
916// placeholder.** Research R7 states GitLab's adapter has no epic concept and the
917// `grep` confirms it: neither `glab` nor `linear-cli` carries an epic membership
918// list. `glab`'s issue JSON names at most one epic (`epic_iid`, which this adapter
919// already reads as the issue's single parent) and exposes no flag to send one;
920// Linear has no epic level at all. Because the mission allows **several** epics per
921// issue (C-003), an adapter that sent one member of a list would be a silent drop
922// with extra steps — which C-007 forbids outright. So every reference diagnoses and
923// the local list is untouched.
924//
925// **Inward: a payload that reports epic ids is honoured.** Like the project seam,
926// the read direction is deliberately *not* gated: the id is matched against the
927// local **registry** before anything is written, so the worst an invented id can
928// produce is a named diagnostic, never local data (C-007).
929//
930// This is what makes the round trip non-vacuous: the export says what cannot leave,
931// the import applies what came back through the epic **service**
932// ([`Store::reference_epic`], the same path `tix edit --add-epic` takes), and the
933// reference list is byte-stable either way (NFR-002).
934
935/// An epic reference a remote reported on one issue.
936#[derive(Debug, Clone, PartialEq, Eq)]
937pub struct RemoteEpic {
938    /// The remote's epic identity. It is the **only** thing an import matches on
939    /// (C-003): an epic id is allocated once and never changes, so a remote that
940    /// renames an epic still names the same one.
941    pub id: String,
942    /// The remote's display name for the epic, when it sent one.
943    ///
944    /// Carried for the diagnostics — a reader recognises "the remote calls it
945    /// *Sync hardening*" — and **never** matched on. The local display name stays
946    /// the registry's (C-003), so a remote rename cannot change it; it is the only
947    /// remote text in a diagnostic, which is why that diagnostic is scrubbed.
948    pub name: Option<String>,
949}
950
951/// Parse the epic references one remote issue reported.
952///
953/// **Pure and total**, in the shape [`remote_projects`] is: an unrecognised payload
954/// yields no references — which the import then reports nothing about — rather than
955/// a deserialisation failure that aborts the whole list. Accepted, for both
956/// trackers, because the field's exact shape is a claim about a program this
957/// repository cannot run:
958///
959/// - an array of ids (`"epics": ["e-1", …]`) or of objects;
960/// - a single id, or a single object (`"epic": {"id": …}`);
961/// - an object's id under `id`, `identifier`, `key`, or `epic_id`, as a string or a
962///   number (numeric ids are real on the wire);
963/// - its name under `name`, `title`, or `display_name`, as a string.
964///
965/// **Entry order is preserved**, which is what keeps a round trip byte-stable
966/// (NFR-002): reordering here would reorder the local list. An entry with a name
967/// but no id is **kept** with an empty id, so the import can report it by name
968/// instead of dropping it in silence; an entry with neither is not a reference at
969/// all and yields nothing.
970#[must_use]
971pub fn remote_epics(value: Option<&serde_json::Value>) -> Vec<RemoteEpic> {
972    fn id_of(value: &serde_json::Value) -> String {
973        match value {
974            serde_json::Value::String(s) => s.clone(),
975            serde_json::Value::Number(n) => n.to_string(),
976            _ => String::new(),
977        }
978    }
979    fn one(value: &serde_json::Value) -> Option<RemoteEpic> {
980        match value {
981            serde_json::Value::String(_) | serde_json::Value::Number(_) => Some(RemoteEpic {
982                id: id_of(value),
983                name: None,
984            }),
985            serde_json::Value::Object(map) => {
986                let id = ["id", "identifier", "key", "epic_id"]
987                    .iter()
988                    .find_map(|key| map.get(*key))
989                    .map(id_of)
990                    .unwrap_or_default();
991                let name = ["name", "title", "display_name"]
992                    .iter()
993                    .find_map(|key| map.get(*key))
994                    .and_then(serde_json::Value::as_str)
995                    .map(str::to_string);
996                if id.trim().is_empty() && name.is_none() {
997                    return None;
998                }
999                Some(RemoteEpic { id, name })
1000            }
1001            _ => None,
1002        }
1003    }
1004    let Some(value) = value else {
1005        return Vec::new();
1006    };
1007    match value {
1008        serde_json::Value::Array(items) => items.iter().filter_map(one).collect(),
1009        other => one(other).into_iter().collect(),
1010    }
1011}
1012
1013/// The remote field an adapter would carry an issue's epic references in.
1014///
1015/// **One constant, not a per-tool table.** Unlike a project association — where
1016/// the two models genuinely name different fields (`projects` for GitLab's owning
1017/// project vs Linear's single `project`) — both trackers would name an epic
1018/// membership list `epics`, so a per-tool indirection would carry no information.
1019/// The per-tool difference lives in [`epic_absence_reason`], which is where it
1020/// actually is: `glab` names at most one epic per issue (`epic_iid`), while Linear
1021/// has no epic level at all.
1022const EPIC_FIELD: &str = "epics";
1023
1024/// Tools verified to drive an epic **membership** from their CLI.
1025///
1026/// **Empty, and derived from the adapters rather than assumed.** `glab`'s issue
1027/// carries at most one epic (`epic_iid`) and exposes no flag to set it or to hold a
1028/// list; Linear has no epic level. So an issue in three epics cannot be expressed as
1029/// three of anything, and C-003's multi-membership is what makes this terminal
1030/// rather than pending — the same reasoning `PROJECT_WIRE_VERIFIED` records.
1031///
1032/// The table still exists rather than a bare `unsupported` constant so the seam has
1033/// the same shape as the other three, and so a future tracker that *does* model an
1034/// epic list in its CLI is a one-line addition plus its `set_epics`.
1035const EPIC_WIRE_VERIFIED: &[Tool] = &[];
1036
1037/// What one local epic reference maps to on the wire.
1038#[derive(Debug, Clone, PartialEq, Eq)]
1039pub enum MappedEpic {
1040    /// The remote value to send, as a field/value pair like a relation.
1041    Remote { field: String, value: String },
1042    /// The adapter cannot carry it. The **local** reference is untouched — C-007 is
1043    /// that a reference the remote cannot hold still survives.
1044    Unsupported(RemoteMappingDiagnostic),
1045}
1046
1047/// Map one local epic reference to its remote representation, or say why not.
1048///
1049/// Pure: no adapter, no I/O, no configuration. Both trackers share it, so a future
1050/// one cannot be added with a divergent contract — and the representability decision
1051/// lives in exactly one place instead of once per adapter.
1052///
1053/// `issue_number` appears in the message because the diagnostic has to be
1054/// actionable: the reference list can be long, and "epic `e-3` cannot be sent"
1055/// without the issue is a shrug.
1056#[must_use]
1057pub fn map_epic_to_remote(tool: Tool, issue_number: u32, epic_id: &str) -> MappedEpic {
1058    let field = EPIC_FIELD;
1059    let id = epic_id.trim();
1060    if id.is_empty() {
1061        // Not a representability question: there is nothing to send, and an empty
1062        // entry in the list is a repair job rather than a remote capability gap.
1063        return MappedEpic::Unsupported(RemoteMappingDiagnostic {
1064            relation_id: format!("{issue_number}->"),
1065            field: field.to_string(),
1066            tool,
1067            message: format!(
1068                "issue #{issue_number} carries an epic reference with no id, so there is \
1069                 nothing to send; the entry is kept locally — dereferencing the empty \
1070                 reference, or editing the issue's epic list, clears it"
1071            ),
1072        });
1073    }
1074    if EPIC_WIRE_VERIFIED.contains(&tool) {
1075        return MappedEpic::Remote {
1076            field: field.to_string(),
1077            value: id.to_string(),
1078        };
1079    }
1080    MappedEpic::Unsupported(RemoteMappingDiagnostic {
1081        relation_id: format!("{issue_number}->{id}"),
1082        field: field.to_string(),
1083        tool,
1084        message: format!(
1085            "#{issue_number}'s reference to epic `{id}` has no {} field to travel in \
1086             — {} — so nothing is sent and the local reference is kept",
1087            tool.as_str(),
1088            epic_absence_reason(tool)
1089        ),
1090    })
1091}
1092
1093/// Why a tracker's CLI cannot carry an epic membership, per tool.
1094///
1095/// A **permanent** claim about the CLI the adapter drives, not "not yet verified",
1096/// and the wording says so for the same reason `PROJECT_WIRE_VERIFIED`'s reason
1097/// does: a reader told "the verb is unverified" will wait for it, and there is
1098/// nothing to wait for. The two reasons differ, so the message does.
1099fn epic_absence_reason(tool: Tool) -> &'static str {
1100    match tool {
1101        Tool::GitLab => {
1102            "`glab` names at most one epic per issue (`epic_iid`) and exposes no flag \
1103             to send one, so a membership list has nowhere to go"
1104        }
1105        Tool::Linear => {
1106            "Linear has no epic level at all — the nearest concept is a project — so an \
1107             epic reference has nowhere to go"
1108        }
1109    }
1110}
1111
1112/// Map one remote epic reference back onto a local reference id, or say why not.
1113///
1114/// The reverse value is **local-shaped**, like [`map_project_from_remote`]'s: the
1115/// frontmatter key the reference lives under ([`crate::epics::EPICS_KEY`]) and the
1116/// id, not a copy of the tool's field name — echoing the field back would make the
1117/// same reference read differently per tracker, which is the opposite of a shared
1118/// seam.
1119///
1120/// Not gated on [`EPIC_WIRE_VERIFIED`], and the module header records why: an id is
1121/// validated against the local registry before anything is written, so this
1122/// direction cannot invent local data the way an unverified relation field could.
1123#[must_use]
1124pub fn map_epic_from_remote(tool: Tool, remote: &RemoteEpic) -> MappedEpic {
1125    let id = remote.id.trim();
1126    if id.is_empty() {
1127        return MappedEpic::Unsupported(RemoteMappingDiagnostic {
1128            relation_id: remote.name.clone().unwrap_or_default(),
1129            field: EPIC_FIELD.to_string(),
1130            tool,
1131            message: format!(
1132                "the remote reported an epic it named `{}` but gave no id, and an id is \
1133                 the only thing a local reference can match on; nothing was referenced \
1134                 and the local list is unchanged",
1135                remote.name.as_deref().unwrap_or("")
1136            ),
1137        });
1138    }
1139    MappedEpic::Remote {
1140        field: crate::epics::EPICS_KEY.to_string(),
1141        value: id.to_string(),
1142    }
1143}
1144
1145/// The remote field GitLab's model carries an issue's health in.
1146///
1147/// Named so the diagnostic can say *what* is missing rather than only that something
1148/// is: a reader told "GitLab cannot carry this" will not go looking for the flag.
1149const GITLAB_HEALTH_FIELD: &str = "health_status";
1150
1151/// The remote field Linear's model would need for an issue's health. Linear has no
1152/// issue level health, so the constant names the field a future adapter would have to
1153/// introduce — and the mapping says which of the two reasons applies.
1154const LINEAR_HEALTH_FIELD: &str = "health";
1155
1156/// Tools whose **model has no field** for an issue's health at all.
1157///
1158/// The claim is different from "the verb is not verified", and the message says
1159/// which, for the same reason `RELATION_CLI_ABSENT` does: a reader who is told
1160/// "GitLab can express this but the verb is unverified" will look for the verb.
1161const HEALTH_MODEL_ABSENT: &[Tool] = &[Tool::Linear];
1162
1163/// Tools verified to drive the health field from their CLI.
1164///
1165/// Empty, and for the same reason `PARENT_WIRE_VERIFIED` is: whether `glab issue
1166/// create --health-status ...` exists is a claim about a program this repository
1167/// cannot run. The fakes in `tests/bin/` accept no health flag at all, which is
1168/// better evidence of the surface than an assumption made after it. Flipping a
1169/// tool on here is a one-line change plus its adapter implementation, once the
1170/// verb is confirmed by hand.
1171const HEALTH_WIRE_VERIFIED: &[Tool] = &[];
1172
1173/// What one local health value maps to on the wire.
1174#[derive(Debug, Clone, PartialEq, Eq)]
1175pub enum MappedHealth {
1176    /// The remote value to send, as a field/value pair like a relation.
1177    Remote { field: String, value: String },
1178    /// The adapter cannot carry it. The **local** value is untouched, which is the
1179    /// same contract the relationship and parent mappers follow (C-008): a value the
1180    /// remote cannot hold still survives here, and a remote that lacks the field can
1181    /// never clear it.
1182    Unsupported(RemoteMappingDiagnostic),
1183}
1184
1185/// Map one local health value to its remote representation, or say why not (T022,
1186/// T023).
1187///
1188/// Pure: no adapter, no I/O, no configuration. Shared by both trackers so a future
1189/// one cannot be added with a divergent contract, and the representability table
1190/// lives in exactly one place.
1191///
1192/// Only an **issue**-scoped value has a remote home: a rollup belongs to this
1193/// repository's memberships, and a remote that knew nothing of the parent would carry
1194/// a number the local side derived. A non-issue scope is therefore reported, never
1195/// silently sent as if it were the issue's own health.
1196#[must_use]
1197pub fn map_health_to_remote(
1198    tool: Tool,
1199    scope: &crate::health::HealthScope,
1200    state: crate::health::HealthState,
1201) -> MappedHealth {
1202    let field = health_field(tool);
1203    if HEALTH_MODEL_ABSENT.contains(&tool) {
1204        return MappedHealth::Unsupported(RemoteMappingDiagnostic {
1205            relation_id: scope.as_str(),
1206            field: field.to_string(),
1207            tool,
1208            message: format!(
1209                "{}'s issue model has no health field, so `{}` stays local; a remote that \
1210                 cannot carry the value never clears it",
1211                tool.as_str(),
1212                state.as_str()
1213            ),
1214        });
1215    }
1216    let crate::health::HealthScope::Issue(number) = scope else {
1217        return MappedHealth::Unsupported(RemoteMappingDiagnostic {
1218            relation_id: scope.as_str(),
1219            field: field.to_string(),
1220            tool,
1221            message: format!(
1222                "`{}` is a derived value for {}, which has no remote representation; only \
1223                 an issue's own health can be sent, so the local value is kept",
1224                state.as_str(),
1225                scope
1226            ),
1227        });
1228    };
1229    if HEALTH_WIRE_VERIFIED.contains(&tool) {
1230        return MappedHealth::Remote {
1231            field: field.to_string(),
1232            value: state.as_str().to_string(),
1233        };
1234    }
1235    MappedHealth::Unsupported(RemoteMappingDiagnostic {
1236        relation_id: format!("issue:{number}"),
1237        field: field.to_string(),
1238        tool,
1239        message: format!(
1240            "issue #{number}'s `{}` is expressible in {}'s model as `{field}`, but the CLI \
1241             verb that would carry it is not verified, so nothing is sent; the local value \
1242             is kept",
1243            state.as_str(),
1244            tool.as_str()
1245        ),
1246    })
1247}
1248
1249/// The field an adapter carries an issue's health in.
1250fn health_field(tool: Tool) -> &'static str {
1251    match tool {
1252        Tool::GitLab => GITLAB_HEALTH_FIELD,
1253        Tool::Linear => LINEAR_HEALTH_FIELD,
1254    }
1255}
1256
1257// ---------------------------------------------------------------------------
1258// WP07 (mission roadmap-initiatives-01M3C8SX) — initiatives across a sync
1259// round trip
1260// ---------------------------------------------------------------------------
1261//
1262// ## What the adapters can actually express, checked rather than assumed
1263//
1264// An initiative is a portfolio-level record, and this module's plan is explicit
1265// that the sync side carries **unsupported-field diagnostics only** — no adapter
1266// gains an initiative sender here. The representability answer is still derived
1267// from the CLIs the adapters drive, and the two trackers' answers differ in
1268// *reason* even though both end at "nothing is sent":
1269//
1270// - **GitLab: the CLI has no initiative command at all.** `glab` 1.119.0 (checked
1271//   against the installed binary on 2026-10-09) lists no `initiative` command:
1272//   `glab --help` has `issue` and `milestone`, and its experimental `work-items`
1273//   tree covers epics, issues and tasks. GitLab's *model* has no initiative either.
1274//   That is a **permanent** answer, like [`RELATION_CLI_ABSENT`], and the wording
1275//   says so: a reader told "the verb is unverified" would wait for one, and there
1276//   is nothing to wait for.
1277// - **Linear: the verb exists, but it cannot hold the record.** `linear-cli` 0.3.28
1278//   (checked the same day) *does* expose `initiatives list|get|create|update|delete`;
1279//   `linear-cli initiatives list -o json` parses and fails only with `No workspace
1280//   selected` (exit 1), which is a grammar-level verification of the command line.
1281//   So the honest statement is not "there is no verb" — it is that the
1282//   `create`/`update` verbs take only a **name**, a **description** and a
1283//   **status**, so they cannot carry the local record's `owner`, `priority`,
1284//   `labels`, `target_date`, or its typed project/epic/milestone associations.
1285//   Sending the fields they *can* take while dropping the rest would be a silent
1286//   drop with extra steps (C-005/C-007), so the whole local record is kept.
1287//
1288// `INITIATIVE_WIRE_VERIFIED` is empty, and `initiative_absence_reason` carries the
1289// per-tool difference so the two answers never blur. The table exists rather than a
1290// bare `unsupported` constant for the same reason the parent/health tables do: a
1291// future tracker that models the whole record in its CLI is a one-line addition plus
1292// its `set_initiatives`.
1293
1294/// The remote field an adapter would carry an initiative in.
1295///
1296/// Both trackers would name it `initiatives`, so — unlike a project association,
1297/// where the two models genuinely name different fields — a per-tool indirection
1298/// would carry no information. The per-tool difference lives in
1299/// [`initiative_absence_reason`], which is where it actually is.
1300const INITIATIVE_FIELD: &str = "initiatives";
1301
1302/// Tools whose CLI is **verified to expose no initiative command at all**.
1303///
1304/// A *permanent* answer, distinct from "not yet verified", for the same reason
1305/// [`RELATION_CLI_ABSENT`] is: a reader told "the verb is unverified" will reasonably
1306/// wait for it, and `glab` has no such command to wait for.
1307const INITIATIVE_CLI_ABSENT: &[Tool] = &[Tool::GitLab];
1308
1309/// Tools verified to drive an initiative from their CLI.
1310///
1311/// **Empty**, and deliberately so on both trackers. No adapter implements a
1312/// `set_initiatives`, and the one CLI that exposes initiative verbs
1313/// (`linear-cli`) cannot carry the record's fields — see the section header. The
1314/// table is the same shape as the other four so a future tracker that *does* model
1315/// the whole record is a one-line addition plus its adapter method.
1316const INITIATIVE_WIRE_VERIFIED: &[Tool] = &[];
1317
1318/// What one local initiative maps to on the wire.
1319#[derive(Debug, Clone, PartialEq, Eq)]
1320pub enum MappedInitiative {
1321    /// The remote value to send, as a field/value pair like a relation. A verified
1322    /// adapter would put the **remote reference** in `value`; until one exists this
1323    /// arm is unreachable, exactly as [`MappedParent::Remote`] is.
1324    Remote { field: String, value: String },
1325    /// The target cannot carry the record. The **local** record is untouched,
1326    /// which is the contract every other mapper follows (C-005): a record the
1327    /// remote cannot hold still survives, and a remote that lacks the field can
1328    /// never clear it.
1329    Unsupported(RemoteMappingDiagnostic),
1330}
1331
1332/// Map one local initiative to its remote representation, or say why not (T034,
1333/// T035).
1334///
1335/// Pure: no adapter, no I/O, no configuration. Shared by both trackers so a future
1336/// one cannot be added with a divergent contract — and the representability
1337/// decision lives in exactly one place instead of once per adapter.
1338///
1339/// `initiative_id` is the **stable** id (C-002), never a display name: it is the
1340/// subject the diagnostic names, and the value a verified adapter would send.
1341#[must_use]
1342pub fn map_initiative_to_remote(tool: Tool, initiative_id: &str) -> MappedInitiative {
1343    let field = INITIATIVE_FIELD;
1344    if INITIATIVE_WIRE_VERIFIED.contains(&tool) {
1345        return MappedInitiative::Remote {
1346            field: field.to_string(),
1347            value: initiative_id.to_string(),
1348        };
1349    }
1350    MappedInitiative::Unsupported(RemoteMappingDiagnostic {
1351        relation_id: initiative_id.to_string(),
1352        field: field.to_string(),
1353        tool,
1354        message: initiative_absence_reason(tool).to_string(),
1355    })
1356}
1357
1358/// Why a tracker's CLI cannot carry a local initiative, per tool.
1359///
1360/// The two reasons differ and the message must not blur them: GitLab's is a
1361/// **permanent** "the CLI has no such command", while Linear's is "the verb exists
1362/// but cannot hold the record's fields". A reader told the wrong one would either
1363/// wait for a command that will never appear or look for a flag that is not there.
1364fn initiative_absence_reason(tool: Tool) -> &'static str {
1365    if INITIATIVE_CLI_ABSENT.contains(&tool) {
1366        return "the CLI exposes no initiative command at all, so the whole local record is \
1367                kept; a remote that cannot hold an initiative never clears one";
1368    }
1369    "the CLI's `initiatives` verbs take only a name, a description and a status — they \
1370     cannot carry `owner`, `priority`, `labels`, `target_date`, or the typed \
1371     project/epic/milestone associations — so sending those three fields would drop the \
1372     rest; the whole local record is kept instead"
1373}
1374
1375const GITLAB_BLOCKING_FIELD: &str = "blocking";
1376const GITLAB_BLOCKING_VALUE: &str = "blocks";
1377const LINEAR_BLOCKING_FIELD: &str = "blocks";
1378const LINEAR_BLOCKING_VALUE: &str = "blocks";
1379
1380/// Map one local relationship to its remote representation, or say why not.
1381///
1382/// Pure: no adapter, no I/O, no configuration. Both trackers share it, so a future
1383/// one cannot be added with a divergent contract (T033) — and the representability
1384/// table lives in exactly one place instead of once per adapter.
1385///
1386/// `target` is the **target issue's remote reference**, if it is known: Linear's
1387/// `relations add` needs the target's remote identifier (`ENG-42`), and an edge
1388/// whose target has not been exported yet cannot be sent. Passing `None` therefore
1389/// yields a diagnostic that names the target and says so — never a guessed
1390/// identifier (C-005: the local edge survives). Trackers whose verb does not need a
1391/// second endpoint (or none of whose verbs are verified) ignore `target`.
1392#[must_use]
1393pub fn map_relation_to_remote(
1394    tool: Tool,
1395    relation: &crate::relationships::Relationship,
1396    target: Option<&str>,
1397) -> MappedRelation {
1398    use crate::relationships::RelationKind;
1399    let (field, value) = match tool {
1400        Tool::GitLab => (GITLAB_BLOCKING_FIELD, GITLAB_BLOCKING_VALUE),
1401        Tool::Linear => (LINEAR_BLOCKING_FIELD, LINEAR_BLOCKING_VALUE),
1402    };
1403    // The tracker's *model* can express a blocking edge — and the adapter drives a
1404    // CLI whose verb is verified only for the tools on `RELATION_WIRE_VERIFIED`.
1405    let model_supports = matches!(
1406        relation.kind,
1407        RelationKind::Blocks | RelationKind::BlockedBy
1408    );
1409    if model_supports && RELATION_WIRE_VERIFIED.contains(&tool) {
1410        let value = match tool {
1411            // Linear's `relations add` takes the target's **remote identifier** as
1412            // its second endpoint. Without one there is nothing to send, and a
1413            // guessed identifier would be worse than a diagnostic.
1414            Tool::Linear => match target {
1415                Some(target) => target.to_string(),
1416                None => {
1417                    return MappedRelation::Unsupported(RemoteMappingDiagnostic {
1418                        relation_id: relation.id.clone(),
1419                        field: field.to_string(),
1420                        tool,
1421                        message: format!(
1422                            "`{}` targets #{}, which has not been exported yet, so its \
1423                             remote identifier is unknown and the edge cannot be sent; \
1424                             the local edge is kept",
1425                            relation.kind.as_str(),
1426                            relation.to
1427                        ),
1428                    });
1429                }
1430            },
1431            // GitLab is never on `RELATION_WIRE_VERIFIED` (its CLI has no relation
1432            // command at all), so this arm is unreachable today; its rendering is
1433            // left exactly as it was rather than "fixed" into an unverified shape.
1434            Tool::GitLab => format!("{value} #{}", relation.to),
1435        };
1436        return MappedRelation::Remote {
1437            field: field.to_string(),
1438            value,
1439        };
1440    }
1441    if model_supports {
1442        // Expressible in the model, not drivable from the CLI. A *different* claim
1443        // from "unsupported", and the message says which — a reader who is told
1444        // "GitLab cannot express this" will not go looking for the verb. And for a
1445        // CLI verified to have no relation command at all, the message must not say
1446        // "not verified": there is nothing to verify (tix #1354).
1447        let reason = if RELATION_CLI_ABSENT.contains(&tool) {
1448            format!(
1449                "the {} CLI exposes no issue-relation command, so it cannot be sent",
1450                tool.as_str()
1451            )
1452        } else {
1453            "the CLI verb that would carry it is not verified, so nothing is sent".to_string()
1454        };
1455        return MappedRelation::Unsupported(RemoteMappingDiagnostic {
1456            relation_id: relation.id.clone(),
1457            field: field.to_string(),
1458            tool,
1459            message: format!(
1460                "`{}` is expressible in {}'s model as `{field}`, but {reason}; the local \
1461                 edge is kept",
1462                relation.kind.as_str(),
1463                tool.as_str()
1464            ),
1465        });
1466    }
1467    MappedRelation::Unsupported(RemoteMappingDiagnostic {
1468        relation_id: relation.id.clone(),
1469        field: field.to_string(),
1470        tool,
1471        message: format!(
1472            "`{}` is a local-only relation; {} has no field for it, so the local \
1473             edge is kept and the remote one is not created",
1474            relation.kind.as_str(),
1475            tool.as_str()
1476        ),
1477    })
1478}
1479
1480/// The inverse: read a remote relation back to a local kind and target.
1481///
1482/// Symmetric with [`map_relation_to_remote`] by construction — the blocking field
1483/// maps back to `blocks`, and nothing else maps back at all. A remote payload
1484/// carrying a field this function does not recognise yields a diagnostic rather
1485/// than a default, so an import cannot invent a relation the export never made.
1486#[must_use]
1487pub fn map_relation_from_remote(
1488    tool: Tool,
1489    field: &str,
1490    value: &str,
1491    relation_id: &str,
1492) -> MappedRelation {
1493    let expected = match tool {
1494        Tool::GitLab => GITLAB_BLOCKING_FIELD,
1495        Tool::Linear => LINEAR_BLOCKING_FIELD,
1496    };
1497    // Gated the same way as the forward direction: without a verified verb there is
1498    // no payload this could have received, and coercing one would invent a
1499    // relationship the export never made.
1500    if field == expected && RELATION_WIRE_VERIFIED.contains(&tool) {
1501        // The value this module writes is `"{kind} #{n}"`, so the target is the
1502        // **last** whitespace-separated token. Taking the first reads the kind word
1503        // and fails — which is what an earlier version did, and it made the round
1504        // trip look asymmetric when only the parser was.
1505        if let Some(target) = value
1506            .split_whitespace()
1507            .last()
1508            .and_then(|t| t.strip_prefix('#'))
1509            .and_then(|t| t.parse::<u32>().ok())
1510        {
1511            // The reverse value is **local**-shaped — the canonical kind spelling
1512            // and a `#`-prefixed number — not a copy of the remote field name.
1513            // GitLab's field is `blocking` and Linear's is `blocks`; echoing the
1514            // field back would make the round trip return different text per
1515            // tracker for the same relationship, which is the opposite of
1516            // symmetric.
1517            return MappedRelation::Remote {
1518                field: "kind".to_string(),
1519                value: format!("blocks #{target}"),
1520            };
1521        }
1522        return MappedRelation::Unsupported(RemoteMappingDiagnostic {
1523            relation_id: relation_id.to_string(),
1524            field: field.to_string(),
1525            tool,
1526            message: format!(
1527                "`{value}` is not a target issue number, so the remote relation \
1528                 cannot be read back; no local edge was created"
1529            ),
1530        });
1531    }
1532    MappedRelation::Unsupported(RemoteMappingDiagnostic {
1533        relation_id: relation_id.to_string(),
1534        field: field.to_string(),
1535        tool,
1536        message: format!(
1537            "`{field}` is not a relation field {} writes, so it is ignored; the \
1538             local relationship set is unchanged",
1539            tool.as_str()
1540        ),
1541    })
1542}
1543
1544/// Map a local column to the remote state/label sent to the tracker
1545/// (`contracts/external-tools.md` mapping table).
1546///
1547/// - GitLab: done → `closed`, anything else → `opened`.
1548/// - Linear: done → `Done`, otherwise the first non-done column label.
1549#[must_use]
1550pub fn local_to_remote_status(tool: Tool, local_status: &str, config: &Config) -> String {
1551    let board = config.default_config();
1552    match tool {
1553        Tool::GitLab => {
1554            if is_done_column(board, local_status, config) {
1555                "closed".into()
1556            } else {
1557                "opened".into()
1558            }
1559        }
1560        Tool::Linear => {
1561            if is_done_column(board, local_status, config) {
1562                "Done".into()
1563            } else {
1564                first_work_column(board, config).to_string()
1565            }
1566        }
1567    }
1568}
1569
1570/// Map a remote state/label back onto a local column. Closed/Done maps to
1571/// the effective done column; anything else lands in the column whose name
1572/// matches (case-insensitive), else the first column — per the contract's
1573/// "whose name matches, else into the first column".
1574#[must_use]
1575pub fn remote_to_local_status(tool: Tool, remote_status: &str, config: &Config) -> String {
1576    let board = config.default_config();
1577    // WP07 T032: the *service's* completion column, not `done_column`. See
1578    // `completion_column` for why choosing one local column is this module's business
1579    // while the meaning of "complete" is not.
1580    let done = completion_column(board, config);
1581    match tool {
1582        Tool::GitLab => {
1583            if remote_status == "closed" {
1584                done
1585            } else {
1586                board
1587                    .columns
1588                    .first()
1589                    .cloned()
1590                    .unwrap_or_else(|| "todo".into())
1591            }
1592        }
1593        Tool::Linear => {
1594            if remote_status == "Done" {
1595                done
1596            } else {
1597                board
1598                    .columns
1599                    .iter()
1600                    .find(|c| c.eq_ignore_ascii_case(remote_status))
1601                    .cloned()
1602                    .unwrap_or_else(|| {
1603                        board
1604                            .columns
1605                            .first()
1606                            .cloned()
1607                            .unwrap_or_else(|| "todo".into())
1608                    })
1609            }
1610        }
1611    }
1612}
1613
1614/// Whether `local_status` is the board's **completion** column, by the service.
1615///
1616/// WP07 T032. This was `board.effective_done_column() == Some(local_status)` — a
1617/// column-*name* comparison, which is a second local definition of "done" and exactly
1618/// what C-004 forbids. It agreed with the service on a board with no
1619/// `category_map` — every existing configuration — and disagreed on precisely the
1620/// boards this mission exists to support: a board mapping `shipped` to `completed`
1621/// exports `shipped` as *open* and leaves `done_column` (mapped to `active`) as
1622/// *closed*.
1623///
1624/// `sync/mod.rs` is out of this mission's map; the edit is the completion
1625/// reconciliation T032 names, and the rationale is recorded here and in
1626/// `decisions/WP07-verification.md`.
1627fn is_done_column(board: &BoardConfiguration, local_status: &str, config: &Config) -> bool {
1628    let resolution =
1629        crate::workflow::resolution(config, board, crate::workflow::active_board_name(config));
1630    resolution
1631        .resolve(local_status)
1632        .is_some_and(crate::workflow::WorkflowCategory::is_complete)
1633}
1634
1635/// The board's completion **column**, chosen by the service rather than by
1636/// `done_column`.
1637///
1638/// The remote trackers have a two-state notion of "closed", so the export path has to
1639/// pick one local column to send as closed. That is a **remote-translation** concern
1640/// and belongs here; deciding what "complete" *means* is not, and is the service's.
1641///
1642/// Preferring the column the service calls complete means a board whose completion
1643/// category sits on a column other than `done_column` still round-trips: export
1644/// closes the column that is genuinely complete, and import brings it back to that
1645/// same column rather than to `done_column`.
1646fn completion_column(board: &BoardConfiguration, config: &Config) -> String {
1647    // WP09: this was a private re-implementation — the same "first complete column,
1648    // else `done_column`, else `done`" rule the service now owns, duplicated here so
1649    // that `first_work_column` and `remote_to_local_status` could each have a total
1650    // `String`. It is the module's own doc comment, written in WP07, that says a
1651    // second local definition is a C-004 defect, so the duplicate had to go.
1652    crate::workflow::completion_column_or_default(board, config)
1653}
1654
1655/// The first column that is **not** the board's completion column, or the
1656/// completion column when every column is complete.
1657///
1658/// WP09: "not the done column" is a question about **completion**, so it is asked of
1659/// the service rather than of `done_column`. On a board whose `category_map` puts
1660/// `completed` on `shipped` while `done_column` is `done`, the old version returned
1661/// `todo` as the first *work* column when `shipped` is the first column that is
1662/// genuinely finished — so an export labelled a shipped issue as work in progress.
1663fn first_work_column<'a>(board: &'a BoardConfiguration, config: &Config) -> &'a str {
1664    // The lifetime is `board`'s, and the `done` fallback cannot borrow from `config`
1665    // — so the completion column is compared as a `String` and the fallback is a
1666    // literal. The alternative, returning `&'a str` from a local, is what forces the
1667    // old `unwrap_or("done")` to exist at all.
1668    let done = crate::workflow::completion_column_or_default(board, config);
1669    board
1670        .columns
1671        .iter()
1672        .find(|c| c.as_str() != done)
1673        .map_or("done", String::as_str)
1674}
1675
1676/// The inverse of `model::civil_from_days` (Howard Hinnant's
1677/// `days_from_civil`): calendar date → days since the Unix epoch.
1678#[must_use]
1679pub fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
1680    let y = if m <= 2 { y - 1 } else { y };
1681    let era = if y >= 0 { y } else { y - 399 } / 400;
1682    let yoe = y - era * 400; // [0, 399]
1683    let mp = (m + 9) % 12; // [0, 11]
1684    let doy = i64::from((153 * mp + 2) / 5 + d - 1); // [0, 365]
1685    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
1686    era * 146_097 + doe - 719_468
1687}
1688
1689/// Parse `YYYY-MM-DD` into days since the Unix epoch.
1690#[must_use]
1691pub fn date_to_epoch_days(ymd: &str) -> Option<i64> {
1692    let mut parts = ymd.trim().split('-');
1693    let y: i64 = parts.next()?.parse().ok()?;
1694    let m: u32 = parts.next()?.parse().ok()?;
1695    let d: u32 = parts.next()?.parse().ok()?;
1696    if parts.next().is_some() {
1697        return None;
1698    }
1699    if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
1700        return None;
1701    }
1702    Some(days_from_civil(y, m, d))
1703}
1704
1705/// Parse an ISO-8601 remote timestamp into epoch seconds. Accepts
1706/// `YYYY-MM-DDTHH:MM:SS` (the `T` may be a space) with optional fractional
1707/// seconds and a `Z` or `±HH:MM` zone.
1708#[must_use]
1709pub fn iso_to_epoch_secs(input: &str) -> Option<i64> {
1710    let (date, time) = input.trim().split_once(['T', ' '])?;
1711    let days = date_to_epoch_days(date)?;
1712    let (time, zone) = split_zone(time);
1713    let mut hms = time.split(':');
1714    let hh: i64 = hms.next()?.parse().ok()?;
1715    let mm: i64 = hms.next()?.parse().ok()?;
1716    let ss_part = hms.next()?;
1717    let whole: i64 = ss_part.split('.').next()?.parse().ok()?;
1718    if !(0..=23).contains(&hh) || !(0..=59).contains(&mm) || !(0..=59).contains(&whole) {
1719        return None;
1720    }
1721    let mut secs = days * 86_400 + hh * 3600 + mm * 60 + whole;
1722    if let Some((zh, zm)) = zone {
1723        secs -= zh * 3600 + zm * 60;
1724    }
1725    Some(secs)
1726}
1727
1728/// Split `HH:MM:SS[.frac][zone]` into the bare time and an optional
1729/// `(hours, minutes)` zone offset (already sign-adjusted).
1730fn split_zone(time: &str) -> (&str, Option<(i64, i64)>) {
1731    if time.ends_with('Z') || time.ends_with('z') {
1732        return (&time[..time.len() - 1], Some((0, 0)));
1733    }
1734    match time.rfind(['+', '-']) {
1735        Some(i) if i > 0 => {
1736            let (bare, zone) = time.split_at(i);
1737            let sign = if zone.starts_with('-') { -1i64 } else { 1i64 };
1738            let digits = &zone[1..];
1739            let (zh, zm) = if let Some((h, m)) = digits.split_once(':') {
1740                (h.parse::<i64>().ok(), m.parse::<i64>().ok())
1741            } else if digits.len() == 2 {
1742                (digits.parse::<i64>().ok(), Some(0))
1743            } else if digits.len() >= 3 {
1744                let (h, m) = digits.split_at(digits.len() - 2);
1745                (h.parse::<i64>().ok(), m.parse::<i64>().ok())
1746            } else {
1747                (None, None)
1748            };
1749            match (zh, zm) {
1750                (Some(zh), Some(zm)) => (bare, Some((sign * zh, sign * zm))),
1751                _ => (bare, None),
1752            }
1753        }
1754        Some(_) | None => (time, None),
1755    }
1756}
1757
1758/// The conflict rule: the remote wins when its `updated_at` is strictly later
1759/// than the local issue's `updated` (a `YYYY-MM-DD` interpreted as end of
1760/// day). A remote timestamp that cannot be parsed counts as *not newer*, so
1761/// the local issue is kept.
1762#[must_use]
1763pub fn is_remote_newer(remote_updated_at: &str, local_updated: &str) -> bool {
1764    let Some(remote) = iso_to_epoch_secs(remote_updated_at) else {
1765        return false;
1766    };
1767    let local_end_of_day = date_to_epoch_days(local_updated).map_or(0, |d| d * 86_400 + 86_399);
1768    remote > local_end_of_day
1769}
1770
1771/// Redact a resolved secret from text flowing through error/report surfaces.
1772#[must_use]
1773pub fn scrub_secret(text: &str, secret: &str) -> String {
1774    if secret.is_empty() {
1775        return text.to_string();
1776    }
1777    text.replace(secret, "<token>")
1778}
1779
1780/// The contract's missing-binary wording (`contracts/external-tools.md`):
1781/// exit code 3 with `required tool not found: <tool> — install from <source>`.
1782fn not_found_message(program: &str) -> String {
1783    match program {
1784        "glab" => "required tool not found: glab — install from https://gitlab.com/gitlab-org/cli"
1785            .to_string(),
1786        _ => format!("required tool not found: {program} — install it and retry"),
1787    }
1788}
1789
1790/// A dry-run stand-in for a remote reference that pass 1 of a real run would
1791/// create. Used only in the preview, so an edge to a not-yet-exported issue reads
1792/// as sendable without inventing an identifier.
1793const DRY_RUN_NEW_REF: &str = "<new remote id>";
1794
1795/// `tix export`: mirror local issues to the remote tracker, recording the
1796/// remote id back into the local `gitlab_id`/`linear_id` so a re-run updates
1797/// instead of creating duplicates (idempotency, FR-Remote-sync).
1798///
1799/// Returns the number of issues handled (created or updated — `close` on an
1800/// updated GitLab done issue is not counted separately).
1801pub fn export(
1802    store: &Store,
1803    tool: Tool,
1804    config: &Config,
1805    dry_run: bool,
1806    out: &mut dyn Write,
1807) -> Result<SyncReport> {
1808    let mut adapter = adapter_for(tool, config)?;
1809    let mut report = SyncReport::default();
1810    // Built **once** for the whole run, which is the service's rule: resolution is
1811    // not rebuilt per issue. See `workflow::resolution`.
1812    let resolution = crate::workflow::resolution(config, config.default_config(), "default");
1813
1814    if dry_run {
1815        for issue in store.list()? {
1816            match remote_ref(tool, &issue) {
1817                Some(r) => {
1818                    writeln!(out, "would update {tool} issue {r} \"{}\"", issue.title)?;
1819                }
1820                None => {
1821                    writeln!(out, "would create {tool} issue \"{}\"", issue.title)?;
1822                }
1823            }
1824        }
1825        writeln!(out, "dry run — {tool} CLI was not invoked")?;
1826        report.handled = store.list()?.len();
1827        // Every issue a real run would export, plus the remote references the
1828        // existing ones already carry. A relation endpoint that is **in the export
1829        // set** is resolvable: pass 1 will give it a remote reference, so the edge
1830        // is sendable and the preview must say so rather than report it as
1831        // unsendable. Only a target genuinely absent from the export set (nothing a
1832        // real run would create) is reported as unsendable.
1833        let export_set: HashSet<u32> = store.list()?.into_iter().map(|i| i.number).collect();
1834        let existing: HashMap<u32, String> = store
1835            .list()?
1836            .into_iter()
1837            .filter_map(|i| remote_ref(tool, &i).map(|r| (i.number, r.to_string())))
1838            .collect();
1839        // The dry run reports the same diagnostics a real run would, so
1840        // "preview then commit" is not the only way to find out that a
1841        // relationship cannot leave the machine.
1842        for issue in store.list()? {
1843            for rel in store.relationships_for(issue.number)? {
1844                // The same adjacency rule as the real pass 2: one edge, from its
1845                // declaring side, so a dry run previews exactly what a real run does
1846                // rather than reporting an edge twice.
1847                if rel.from != issue.number {
1848                    continue;
1849                }
1850                let other = if rel.from == issue.number {
1851                    rel.to
1852                } else {
1853                    rel.from
1854                };
1855                let target = if let Some(r) = existing.get(&other) {
1856                    Some(r.as_str())
1857                } else if export_set.contains(&other) {
1858                    // Unknown until pass 1 runs; a marker keeps the edge sendable in
1859                    // the preview without inventing an identifier.
1860                    Some(DRY_RUN_NEW_REF)
1861                } else {
1862                    None
1863                };
1864                match map_relation_to_remote(tool, &rel, target) {
1865                    MappedRelation::Remote { field, value } => {
1866                        writeln!(out, "would export {tool} {field} {value} ({})", rel.id)?;
1867                    }
1868                    unsupported @ MappedRelation::Unsupported(_) => {
1869                        report.note(unsupported, out)?
1870                    }
1871                }
1872            }
1873            // And the parent, so a dry run surfaces exactly what a real run would.
1874            if issue.parent.is_some() {
1875                report.note_parent(map_parent_to_remote(tool, issue.number, issue.parent), out)?;
1876            }
1877            // And every project association, for the same reason: on both trackers
1878            // the answer is "it cannot leave the machine", and a user previewing an
1879            // export is exactly who needs to know that before running it.
1880            for project_id in issue.projects.as_deref().unwrap_or_default() {
1881                report.note_project(map_project_to_remote(tool, issue.number, project_id), out)?;
1882            }
1883            // And every epic reference, for the same reason (C-007): the answer is
1884            // the same on both trackers, and a preview that hid it would be the one
1885            // place a reader could not learn it.
1886            for epic_id in issue.epics.as_deref().unwrap_or_default() {
1887                report.note_epic(map_epic_to_remote(tool, issue.number, epic_id), out)?;
1888            }
1889        }
1890        return Ok(report);
1891    }
1892
1893    // **Pass 1** — create or update every issue, and record `issue.number -> remote
1894    // reference`. Projects and epics are sent here, because they hang off the issue
1895    // itself and need no other endpoint to exist.
1896    let mut remote_ids: HashMap<u32, String> = HashMap::new();
1897    let mut handled = 0;
1898    for mut issue in store.list()? {
1899        let remote_status = local_to_remote_status(tool, issue.status.display(), config);
1900        let labels: Vec<String> = issue.labels.clone();
1901        let assignee = issue.assignee.as_deref();
1902        let priority = issue.priority.to_string();
1903        match remote_ref(tool, &issue) {
1904            Some(r) => {
1905                let r = r.to_string();
1906                adapter.update_issue(
1907                    &r,
1908                    &issue.title,
1909                    &issue.body,
1910                    &labels,
1911                    assignee,
1912                    Some(&priority),
1913                    &remote_status,
1914                )?;
1915                if !adapter.done_via_status() && crate::workflow::is_completed(&issue, &resolution)
1916                {
1917                    adapter.mark_done(&r)?;
1918                }
1919                writeln!(out, "updated {tool} issue {r} \"{}\"", issue.title)?;
1920            }
1921            None => {
1922                let id = adapter.create_issue(
1923                    &issue.title,
1924                    &issue.body,
1925                    &labels,
1926                    assignee,
1927                    Some(&priority),
1928                    &remote_status,
1929                )?;
1930                if !adapter.done_via_status() && crate::workflow::is_completed(&issue, &resolution)
1931                {
1932                    adapter.mark_done(&id)?;
1933                }
1934                match tool {
1935                    Tool::GitLab => issue.gitlab_id = Some(id.clone()),
1936                    Tool::Linear => issue.linear_id = Some(id.clone()),
1937                }
1938                store.update(&issue.filename, &issue)?;
1939                writeln!(
1940                    out,
1941                    "created {tool} issue {id} \"{}\" (remote id recorded)",
1942                    issue.title
1943                )?;
1944            }
1945        }
1946        // The parent reference is offered to the same shared mapper, on the same
1947        // "after the issue" footing as a relationship: a parent only means
1948        // something once the parent itself exists remotely. Nothing is sent while
1949        // `PARENT_WIRE_VERIFIED` is empty, so this reports rather than acts — and
1950        // the local reference is untouched either way.
1951        report.note_parent(map_parent_to_remote(tool, issue.number, issue.parent), out)?;
1952
1953        // Project associations are offered to the same shared mapper, on the same
1954        // "after the issue" footing as a relationship: what it accepts is sent to the
1955        // adapter, what it does not becomes a diagnostic naming the field. Nothing is
1956        // sendable on either tracker — see `PROJECT_WIRE_VERIFIED` — so this reports
1957        // rather than acts, and the local list is untouched either way (C-005). A
1958        // diagnostic is emitted whether or not the issue has a remote id yet, unlike
1959        // the relationship block below: the membership cannot be sent regardless, so
1960        // suppressing the report while the id is still missing would hide the one
1961        // fact the reader needs.
1962        let mut sendable_projects: Vec<MappedProject> = Vec::new();
1963        for project_id in issue.projects.as_deref().unwrap_or_default() {
1964            match map_project_to_remote(tool, issue.number, project_id) {
1965                MappedProject::Remote { field, value } => {
1966                    writeln!(out, "exported {tool} {field} {value} (#{})", issue.number)?;
1967                    sendable_projects.push(MappedProject::Remote { field, value });
1968                }
1969                unsupported @ MappedProject::Unsupported(_) => {
1970                    report.note_project(unsupported, out)?;
1971                }
1972            }
1973        }
1974
1975        // Epic references are offered to the same shared mapper, on the same
1976        // "after the issue" footing: what it accepts would be sent to the adapter,
1977        // what it does not becomes a diagnostic naming the field. Nothing is
1978        // sendable on either tracker — see `EPIC_WIRE_VERIFIED` — so this reports
1979        // rather than acts, and the local reference list is untouched either way
1980        // (C-007). A diagnostic is emitted whether or not the issue has a remote id
1981        // yet, for the same reason the project block gives: the reference cannot be
1982        // sent regardless, so suppressing the report while the id is missing would
1983        // hide the one fact the reader needs.
1984        let mut sendable_epics: Vec<MappedEpic> = Vec::new();
1985        for epic_id in issue.epics.as_deref().unwrap_or_default() {
1986            match map_epic_to_remote(tool, issue.number, epic_id) {
1987                MappedEpic::Remote { field, value } => {
1988                    writeln!(out, "exported {tool} {field} {value} (#{})", issue.number)?;
1989                    sendable_epics.push(MappedEpic::Remote { field, value });
1990                }
1991                unsupported @ MappedEpic::Unsupported(_) => {
1992                    report.note_epic(unsupported, out)?;
1993                }
1994            }
1995        }
1996
1997        let remote = match remote_ref(tool, &issue) {
1998            Some(r) => r.to_string(),
1999            None => match tool {
2000                Tool::GitLab => issue.gitlab_id.clone(),
2001                Tool::Linear => issue.linear_id.clone(),
2002            }
2003            .unwrap_or_default(),
2004        };
2005        if !remote.is_empty() {
2006            adapter.set_projects(&remote, &sendable_projects)?;
2007            adapter.set_epics(&remote, &sendable_epics)?;
2008            remote_ids.insert(issue.number, remote);
2009        }
2010        handled += 1;
2011    }
2012
2013    // **Pass 2** — the relationships, now that **both** endpoints exist remotely.
2014    // An edge is only meaningful once both issues have a remote reference, and on a
2015    // first export the target is frequently created *after* the issue the edge is
2016    // stored on, so this cannot run inside pass 1. Each is offered to the shared
2017    // mapper with the target's resolved remote reference: what it accepts is sent to
2018    // the adapter, what it does not (including a target that was never exported)
2019    // becomes a diagnostic naming the field. The adapter never decides
2020    // representability.
2021    for issue in store.list()? {
2022        let Some(remote) = remote_ids.get(&issue.number).cloned() else {
2023            continue;
2024        };
2025        let mut sendable = Vec::new();
2026        for rel in store.relationships_for(issue.number)? {
2027            // `relationships_for` is an **adjacency** lookup: it returns every edge
2028            // that *touches* this issue, in its stored form (`from` is the declaring
2029            // side). Send each edge exactly once, from its declaring side — Linear's
2030            // `relations add` is additive, so sending it from both endpoints would
2031            // create a duplicate remote edge (and print the line twice).
2032            if rel.from != issue.number {
2033                continue;
2034            }
2035            // `from` is now this issue, so the other endpoint is `to`; spelled out
2036            // rather than assumed, so the adjacency semantics are not re-learnt by
2037            // the next reader.
2038            let other = if rel.from == issue.number {
2039                rel.to
2040            } else {
2041                rel.from
2042            };
2043            let target = remote_ids.get(&other).map(String::as_str);
2044            match map_relation_to_remote(tool, &rel, target) {
2045                MappedRelation::Remote { field, value } => {
2046                    writeln!(out, "exported {tool} {field} {value} ({})", rel.id)?;
2047                    sendable.push(MappedRelation::Remote { field, value });
2048                }
2049                unsupported @ MappedRelation::Unsupported(_) => report.note(unsupported, out)?,
2050            }
2051        }
2052        adapter.set_relations(&remote, &sendable)?;
2053    }
2054    report.handled = handled;
2055    writeln!(
2056        out,
2057        "exported {handled} issue(s) to {tool} ({} diagnostic(s) — local data the remote \
2058         cannot hold)",
2059        report.diagnostics.len()
2060    )?;
2061    Ok(report)
2062}
2063
2064/// `tix import`: pull open remote issues into local files.
2065///
2066/// Conflict resolution: a remote issue that already tracks a local one skips
2067/// the local file when the local `updated` is later or equal; when the remote
2068/// `updated_at` is newer the local file is overwritten (fields + body, keeping
2069/// `created`), with the previous file first copied into the centralized backup
2070/// directory (`.tix-backups/` beside the issues directory, #1581).
2071///
2072/// `policy` is the explicit triage-entry policy (WP07 T034). It decides **only
2073/// where a newly created file lands and what the report says about it**: the
2074/// conflict rules above, remote ids, and adapter behaviour are untouched, and
2075/// the overwrite path never consults it (T036).
2076///
2077/// Returns the number of local files created or overwritten.
2078pub fn import(
2079    store: &Store,
2080    tool: Tool,
2081    config: &Config,
2082    out: &mut dyn Write,
2083    policy: crate::triage::import_policy::ImportPolicy,
2084) -> Result<SyncReport> {
2085    let mut adapter = adapter_for(tool, config)?;
2086    import_with_adapter(store, tool, config, out, policy, adapter.as_mut())
2087}
2088
2089/// [`import`] with the adapter injected — the [`CommandRunner`] pattern, so the
2090/// orchestration under test is the real one and the remote side is a fake that
2091/// returns canned [`RemoteIssue`]s without spawning a process (WP07 T037).
2092///
2093/// `command`s like `tix import` still go through [`import`]; this entry point
2094/// exists so a test can hold every input of the triage policy in one place —
2095/// config, policy, and the remote payloads — instead of installing a fake
2096/// binary on `PATH` and running the compiled CLI to reach it.
2097pub fn import_with_adapter(
2098    store: &Store,
2099    tool: Tool,
2100    config: &Config,
2101    out: &mut dyn Write,
2102    policy: crate::triage::import_policy::ImportPolicy,
2103    adapter: &mut dyn TrackerAdapter,
2104) -> Result<SyncReport> {
2105    // Resolved **before** the loop: a policy this project cannot represent
2106    // refuses the whole import, not the third item of it.
2107    let router = crate::triage::import_policy::resolve(config, policy)?;
2108    let remote_issues = adapter.list_open()?;
2109    let mut report = SyncReport::default();
2110    let mut handled = 0;
2111    // Remote ids imported this run, paired with the local number each one landed
2112    // on. A parent is named by a **remote** id in the payload, so the reference can
2113    // only be restored after the parent itself has been created — which is why
2114    // this is a second pass rather than something set while building each issue.
2115    let mut imported: Vec<(String, u32)> = Vec::new();
2116    // `(child remote id, parent remote id)` for every payload that named one.
2117    let mut claims: Vec<(String, String)> = Vec::new();
2118    // `(remote issue id, the project references that payload reported)` — WP08.
2119    // Collected for **every** payload, and applied after the issues exist, so a
2120    // payload's order cannot decide whether an association lands (T037 step 4).
2121    let mut project_payloads: Vec<(String, Vec<RemoteProject>)> = Vec::new();
2122    // `(remote issue id, the epic references that payload reported)` — WP08. Same
2123    // second-pass treatment as the project payloads, so the order a payload's issues
2124    // arrive in cannot decide whether a reference lands.
2125    let mut epic_payloads: Vec<(String, Vec<RemoteEpic>)> = Vec::new();
2126    // `(remote issue id, the local number it landed on)` for every payload this run
2127    // found a local counterpart for — created here or already tracked. Distinct from
2128    // `imported`, which is create-only and is what the parent pass resolves
2129    // references through.
2130    let mut located: Vec<(String, u32)> = Vec::new();
2131    for remote in remote_issues.clone() {
2132        if let Some(parent) = remote.parent.clone() {
2133            claims.push((remote.id.clone(), parent));
2134        }
2135        if !remote.projects.is_empty() {
2136            project_payloads.push((remote.id.clone(), remote.projects.clone()));
2137        }
2138        if !remote.epics.is_empty() {
2139            epic_payloads.push((remote.id.clone(), remote.epics.clone()));
2140        }
2141        let tracked = store
2142            .list()?
2143            .into_iter()
2144            .find(|i| remote_ref(tool, i) == Some(remote.id.as_str()));
2145        match tracked {
2146            None => {
2147                // WP07 T034/T035: the placement decision is made here, once, from
2148                // the remote state and the configured entry column — before any
2149                // byte of the file exists. `requested` comes from this module's
2150                // own mapper rather than being re-derived by the policy, so the
2151                // two answers can be compared in the report instead of guessed at.
2152                let requested = remote_to_local_status(tool, &remote.state, config);
2153                let mapping = router.place(&remote.state, &requested);
2154                let mut issue = Issue {
2155                    projects: None,
2156                    id: None,
2157                    number: 0,
2158                    title: remote.title.clone(),
2159                    status: Status::new(mapping.landed.clone()),
2160                    labels: remote.labels.clone(),
2161                    priority: crate::model::Priority::Medium,
2162                    assignee: remote.assignee.clone(),
2163                    estimate: None,
2164                    commit: None,
2165                    gitlab_id: None,
2166                    linear_id: None,
2167                    mission: None,
2168                    task: None,
2169                    relationships: vec![],
2170                    related_issues: vec![],
2171                    files: vec![],
2172                    cycles: None,
2173                    milestones: None,
2174                    reviewer: None,
2175                    start_date: None,
2176                    due_date: None,
2177                    created: crate::model::today(),
2178                    type_: None,
2179                    updated: crate::model::today(),
2180                    filename: String::new(),
2181                    body: String::new(),
2182                    history: crate::model::History::new(),
2183                    has_retired_links_key: false,
2184                    time_entries: vec![],
2185                    parent: None,
2186                    series: None,
2187                    health: None,
2188                    epics: None,
2189                    extra_frontmatter: std::collections::BTreeMap::new(),
2190                    archived: false,
2191                    entered_at: None,
2192                    source: None,
2193                };
2194                // T035: entry metadata through the same seam every other entry
2195                // uses. The queue's age is measured from `entered_at`, so an issue
2196                // that entered the board through an import has to record that it
2197                // did — and `import` is the origin data-model §2 documents for it.
2198                //
2199                // The **landing column** decides, not `mapping.routed`: `routed` is a
2200                // policy notion, so under `--triage-policy remote` (or a default whose
2201                // remote-mapped column happens to be the entry column) an imported
2202                // issue landed in the inbox with no stamp and the queue reported
2203                // `unknown` for an issue it was listing. On a project with no
2204                // `[triage]` section the shared rule stamps nothing, which is the
2205                // outcome the old guard was reaching for.
2206                crate::triage::record_entry_if_inbox(config, &mut issue, "import")?;
2207                match tool {
2208                    Tool::GitLab => issue.gitlab_id = Some(remote.id.clone()),
2209                    Tool::Linear => issue.linear_id = Some(remote.id.clone()),
2210                }
2211                // WP07 T031: a **column check** on the incoming status, and only
2212                // that. `remote_to_local_status` already lands on a column the board
2213                // configures — the service's completion column, or a name match, or
2214                // the first — so this is the belt to that braces, and it means an
2215                // import can never create a file in a column the board lacks. The
2216                // create path performs the same check (WP05), so this is belt *and*
2217                // braces for the remote-sourced case.
2218                //
2219                // Not a transition evaluation: a new issue is not leaving anywhere, so
2220                // the terminal lock has nothing to say. Importing an issue directly
2221                // into a terminal column is legitimate.
2222                let (_, resolution) = crate::workflow::active_resolution(config);
2223                if resolution.resolve(issue.status.display()).is_none() {
2224                    // Reported, not written. A skipped item is visible and the rest of
2225                    // the batch still lands.
2226                    writeln!(
2227                        out,
2228                        "skipped {tool} issue {} \"{}\": `{}` is not a column on board `{}`",
2229                        remote.id, remote.title, issue.status, resolution.board
2230                    )?;
2231                    report.skipped += 1;
2232                    continue;
2233                }
2234                // One transaction per imported issue (#1580), never one over the
2235                // whole import: each remote issue has its own outcome, and a
2236                // failure on one must not roll back the others. `create` claims a
2237                // number and writes the file, so the claim and the bytes are one
2238                // unit.
2239                let path =
2240                    store.transaction(|| store.create(&mut issue, &remote.description, None))?;
2241                imported.push((remote.id.clone(), issue.number));
2242                located.push((remote.id.clone(), issue.number));
2243                let name = path
2244                    .file_name()
2245                    .map(|n| n.to_string_lossy().to_string())
2246                    .unwrap_or_default();
2247                // T035: the mapping is named in the same line that names the file,
2248                // and `report_clause` is empty exactly when the default policy
2249                // decided nothing — so a legacy project's report is byte-identical
2250                // to what it was, and a project that routed says where the remote
2251                // state asked for and where the issue landed.
2252                let clause = mapping.report_clause();
2253                writeln!(
2254                    out,
2255                    "imported {tool} issue {} \"{}\" as #{} ({}) [{name}]{clause}",
2256                    remote.id, remote.title, issue.number, issue.status
2257                )?;
2258                handled += 1;
2259            }
2260            Some(local) => {
2261                // The payload found its local issue. Recorded before the branches
2262                // below, because the association pass needs to attach a remote
2263                // payload's projects to it whether or not this run also overwrote
2264                // its fields — "kept" is a statement about title and body, not about
2265                // membership.
2266                located.push((remote.id.clone(), local.number));
2267                if is_remote_newer(remote.updated_at.as_deref().unwrap_or(""), &local.updated) {
2268                    let backup = crate::backup::save(&store.issues_dir, &local.filename);
2269                    // Captured before `local` is moved into `overwritten`: the
2270                    // skip path restores the local status, and reading it back off
2271                    // `overwritten` after the assignment would restore the status the
2272                    // *incoming* value was about to overwrite.
2273                    let local_status = local.status.clone();
2274                    let mut overwritten = local;
2275                    let keep_created = overwritten.created.clone();
2276                    overwritten.title = remote.title.clone();
2277                    overwritten.body = remote.description.clone();
2278                    overwritten.labels = remote.labels.clone();
2279                    let incoming = Status::new(remote_to_local_status(tool, &remote.state, config));
2280                    // WP07 T031: validate the incoming status as a **transition**,
2281                    // because this *is* one — an existing issue moving columns.
2282                    //
2283                    // **Skip-and-report**, which is the disposition the design settles
2284                    // (T031 step 3). The alternative, apply-and-report, would make a
2285                    // board's policy non-binding for the one path nobody watches, and
2286                    // a remote whose states the board forbids would quietly win.
2287                    // Skipping keeps the local file truthful and puts the conflict in
2288                    // the report where a human will see it. Recorded here rather than
2289                    // in `data-model.md` §8, which is a protected path for this lane.
2290                    let (_, resolution) = crate::workflow::active_resolution(config);
2291                    let transition = crate::workflow::validate_transition(
2292                        &overwritten,
2293                        incoming.display(),
2294                        &resolution,
2295                        config,
2296                        None,
2297                    );
2298                    if let crate::workflow::TransitionOutcome::Rejected(diagnostic) = transition {
2299                        // The status is left alone; the other fields still import,
2300                        // because they are not what the policy governs. The file is
2301                        // rewritten, so this is a **partial** import — reported as such
2302                        // rather than as a plain overwrite.
2303                        overwritten.status = local_status.clone();
2304                        overwritten.title = remote.title.clone();
2305                        overwritten.body = remote.description.clone();
2306                        overwritten.labels = remote.labels.clone();
2307                        if let Some(a) = &remote.assignee {
2308                            overwritten.assignee = Some(a.clone());
2309                        }
2310                        overwritten.created = keep_created;
2311                        overwritten.bump_updated();
2312                        let filename = overwritten.filename.clone();
2313                        // One transaction for the item's corpus write (#1580): the
2314                        // `update` writes the file and may then reconcile its
2315                        // location, so those two mutations are one unit. The
2316                        // backup copy above is deliberately out-of-corpus and is
2317                        // not part of the journaled set.
2318                        store.transaction(|| store.update(&filename, &overwritten))?;
2319                        writeln!(
2320                            out,
2321                            "overwrote {tool} issue {} \"{}\" without its status: {diagnostic} \
2322                             (kept {})",
2323                            remote.id, overwritten.title, overwritten.status
2324                        )?;
2325                        report.skipped += 1;
2326                        handled += 1;
2327                        continue;
2328                    }
2329                    overwritten.status = incoming;
2330                    if let Some(a) = &remote.assignee {
2331                        overwritten.assignee = Some(a.clone());
2332                    }
2333                    overwritten.created = keep_created;
2334                    overwritten.bump_updated();
2335                    let filename = overwritten.filename.clone();
2336
2337                    // An import that moves a locally-blocked issue into the
2338                    // board's completion column goes through the same blocked-move
2339                    // policy as every other surface -- WP04 T016 step 6 names
2340                    // "any bulk or import path" explicitly, and an import that
2341                    // can end an issue in the completion column with open
2342                    // blockers is the sharpest form of that.
2343                    //
2344                    // It **records** the acknowledgement rather than refusing, and
2345                    // the asymmetry is deliberate. There is no interactive
2346                    // confirmation available mid-import, and refusing would make
2347                    // `tix import` unusable whenever one issue in a batch carries
2348                    // a stale blocker -- turning a stale local edge into a
2349                    // permanently un-syncable board. The user ran the import;
2350                    // that is the decision, and the event exists to preserve it.
2351                    // Where a human is present to answer, refusing is right
2352                    // (`tix move`, `tix edit --status`, `move_issue`); here the
2353                    // honest record is that the import did it.
2354                    let guard = crate::commands::mv::blocked_move_guard(
2355                        config,
2356                        store,
2357                        &overwritten,
2358                        overwritten.status.display(),
2359                    )?;
2360                    if let Some(warning) = guard.warning() {
2361                        overwritten.history.append_now(
2362                            &crate::storage::actor_identity(),
2363                            crate::model::HistoryEventKind::BlockedMoveAcknowledged,
2364                            &format!("imported to {}: {warning}", overwritten.status.display()),
2365                        );
2366                    }
2367                    store.transaction(|| store.update(&filename, &overwritten))?;
2368                    let backup_label = backup
2369                        .as_ref()
2370                        .map(|p| p.display().to_string())
2371                        .unwrap_or_else(|| "none".to_string());
2372                    writeln!(
2373                        out,
2374                        "overwrote {tool} issue {} \"{}\" (remote newer; backup at {backup_label})",
2375                        remote.id, overwritten.title
2376                    )?;
2377                    handled += 1;
2378                } else {
2379                    writeln!(
2380                        out,
2381                        "kept {tool} issue {} \"{}\" (local updated later or equal)",
2382                        remote.id, remote.title
2383                    )?;
2384                }
2385            }
2386        }
2387    }
2388    restore_imported_parents(store, tool, &claims, &imported, &mut report, out)?;
2389    apply_imported_projects(
2390        store,
2391        config,
2392        tool,
2393        &project_payloads,
2394        &located,
2395        &mut report,
2396        out,
2397    )?;
2398    apply_imported_epics(
2399        store,
2400        config,
2401        tool,
2402        &epic_payloads,
2403        &located,
2404        &mut report,
2405        out,
2406    )?;
2407    writeln!(out, "imported {handled} {tool} issue(s)")?;
2408    report.handled = handled;
2409    Ok(report)
2410}
2411
2412/// Restore the parent references an import's payload reported.
2413///
2414/// A **second pass**, because a payload names a parent by *remote* id and the
2415/// parent may well be imported after its child. Setting the field while building
2416/// each issue would therefore succeed or fail depending on the order the remote
2417/// happened to return, which is the order-dependence NFR-003 forbids.
2418///
2419/// Every write goes through [`crate::hierarchy::set_parent`] rather than a field
2420/// assignment, so an imported reference obeys the same rules as a CLI write: it is
2421/// validated before anything is written, and a refusal leaves the file
2422/// byte-identical. T032's note is explicit — "an import that would create a cycle
2423/// is a defect, not a special case" — and the service is what decides that, not
2424/// this loop.
2425///
2426/// `claims` is `(child remote id, parent remote id)` and `imported` is
2427/// `(remote id, local number)`. A pair where either side does not resolve becomes a
2428/// diagnostic rather than a write: a reference to an issue this run did not import
2429/// is exactly the unresolved-parent state WP05 added a finding for, and
2430/// manufacturing it here would be this loop's own defect.
2431fn restore_imported_parents(
2432    store: &Store,
2433    tool: Tool,
2434    claims: &[(String, String)],
2435    imported: &[(String, u32)],
2436    report: &mut SyncReport,
2437    out: &mut dyn Write,
2438) -> Result<()> {
2439    for (child_remote, parent_remote) in claims {
2440        let child = imported
2441            .iter()
2442            .find(|(id, _)| id == child_remote)
2443            .map(|(_, number)| *number);
2444        let parent = imported
2445            .iter()
2446            .find(|(id, _)| id == parent_remote)
2447            .map(|(_, number)| *number);
2448        let (Some(child), Some(parent)) = (child, parent) else {
2449            report.note_parent(
2450                MappedParent::Unsupported(RemoteMappingDiagnostic {
2451                    relation_id: format!("{child_remote}->{parent_remote}"),
2452                    field: parent_field(tool).to_string(),
2453                    tool,
2454                    message: format!(
2455                        "remote issue {child_remote} reports parent {parent_remote}, \
2456                         which this import did not create — it may be closed, \
2457                         filtered out, or in another project; no local reference is \
2458                         written, so the issue stays parentless rather than \
2459                         unresolved"
2460                    ),
2461                }),
2462                out,
2463            )?;
2464            continue;
2465        };
2466        // Through `Store::set_parent`, which resolves, validates with the
2467        // hierarchy service, writes, and records the one History entry — the
2468        // same path `tix edit --parent` takes, so an imported reference is
2469        // indistinguishable from a typed one. A refusal is reported and nothing is
2470        // written.
2471        let (child_sel, parent_sel) = (child.to_string(), parent.to_string());
2472        match store.set_parent(&child_sel, &parent_sel) {
2473            Ok(_) => writeln!(out, "restored parent of {tool} issue #{child} to #{parent}")?,
2474            Err(e) => {
2475                writeln!(
2476                    out,
2477                    "could not restore the parent of {tool} issue #{child}: {e}; the \
2478                     reference was not written"
2479                )?;
2480                report.diagnostics.push(RemoteMappingDiagnostic {
2481                    relation_id: format!("{child}->{parent}"),
2482                    field: parent_field(tool).to_string(),
2483                    tool,
2484                    message: format!(
2485                        "the remote reports #{child} under #{parent}, but the hierarchy \
2486                         service refused it: {e}"
2487                    ),
2488                });
2489            }
2490        }
2491    }
2492    Ok(())
2493}
2494
2495/// Apply the project associations an import's payloads reported (WP08 T037).
2496///
2497/// A **second pass**, like [`restore_imported_parents`], because the association
2498/// belongs to an issue that must exist locally first and because the order a
2499/// remote happened to list its issues in must not decide the outcome (T037 step
2500/// 4). Every payload that found a local issue is in `located`, so an issue created
2501/// *after* its sibling still gets its memberships.
2502///
2503/// ## Every write goes through the project service
2504///
2505/// [`Store::associate`] is the same path `tix project add` will take, so
2506/// uniqueness, reference validation and the one History entry per accepted change
2507/// are the service's decisions rather than a second membership rule here. An
2508/// already-present association is the service's idempotent no-op: it writes
2509/// nothing, appends nothing, and leaves the canonical file byte-stable — which is
2510/// what makes a repeated import a true no-op (T037 step 3).
2511///
2512/// ## Matching is by id, and only by id
2513///
2514/// `resolve_project_id` also accepts a project *name*, because a human selects
2515/// projects by name. An import must not: a name is a display label (C-002), so a
2516/// remote that renames a project — or a local registry whose name happens to
2517/// equal the remote's id — would resolve to the wrong project, or to a second
2518/// association for a membership that is already there. The registry is therefore
2519/// consulted by **key** before the service is called, which both enforces
2520/// id-only matching and makes the refusal diagnosable instead of an
2521/// `unknown_project` error string.
2522///
2523/// ## An unknown id is diagnosed, never written
2524///
2525/// Writing the association would manufacture a dangling reference — one per
2526/// issue, per run, for a project the registry has never heard of: the "missing
2527/// reference storm" T037 step 4 names. The remote keeps the association, the
2528/// local file is untouched, and the diagnostic names the field, the tool and the
2529/// id (C-005). The registry itself is **never** written: the local
2530/// `[projects.<id>]` section is this repository's configuration (C-001) and not
2531/// the remote's to rewrite, which is also why there is no window in which a
2532/// partially written registry could exist.
2533///
2534/// A payload the import skipped — an issue whose remote state is not a column on
2535/// the board — was already reported on its own line and has no local issue to
2536/// attach a membership to, so it is passed over here rather than diagnosed twice.
2537fn apply_imported_projects(
2538    store: &Store,
2539    config: &Config,
2540    tool: Tool,
2541    payloads: &[(String, Vec<RemoteProject>)],
2542    located: &[(String, u32)],
2543    report: &mut SyncReport,
2544    out: &mut dyn Write,
2545) -> Result<()> {
2546    if payloads.is_empty() {
2547        return Ok(());
2548    }
2549    // Resolved once, with a **no-op** warning sink: this is the same resolution the
2550    // adapters use, and quoting the project name the remote reported is the one
2551    // place remote output reaches a diagnostic, so it has to be scrubbed. The sink
2552    // is silent because `token::resolve` already warned on the adapter's behalf for
2553    // this run — warning twice per import would be noise, not extra safety.
2554    let secret = token::resolve(tool, config, &token::RealEnv, &mut |_| {})
2555        .secret()
2556        .map(str::to_string);
2557    for (remote_id, projects) in payloads {
2558        let Some(local_number) = located
2559            .iter()
2560            .find(|(id, _)| id == remote_id)
2561            .map(|(_, number)| *number)
2562        else {
2563            continue;
2564        };
2565        for project in projects {
2566            let project_id = match map_project_from_remote(tool, project) {
2567                MappedProject::Remote { value, .. } => value,
2568                MappedProject::Unsupported(mut diagnostic) => {
2569                    scrub_diagnostic(&mut diagnostic, secret.as_deref());
2570                    report.note_project(MappedProject::Unsupported(diagnostic), out)?;
2571                    continue;
2572                }
2573            };
2574            if !config.projects.contains_key(project_id.as_str()) {
2575                let mut diagnostic = RemoteMappingDiagnostic {
2576                    relation_id: format!("{local_number}->{project_id}"),
2577                    field: project_field(tool).to_string(),
2578                    tool,
2579                    message: format!(
2580                        "the remote reports #{local_number} in project `{project_id}`{}, \
2581                         which this repository's registry does not declare; nothing was \
2582                         written — the association stays on the remote and no missing \
2583                         reference was created locally",
2584                        project
2585                            .name
2586                            .as_deref()
2587                            .map(|name| format!(" (`{name}`)"))
2588                            .unwrap_or_default()
2589                    ),
2590                };
2591                scrub_diagnostic(&mut diagnostic, secret.as_deref());
2592                report.note_project(MappedProject::Unsupported(diagnostic), out)?;
2593                continue;
2594            }
2595            match store.associate(&local_number.to_string(), &project_id) {
2596                Ok(applied) if applied.changed => {
2597                    writeln!(
2598                        out,
2599                        "associated {tool} issue #{local_number} with {}",
2600                        crate::projects::association_detail(&project_id, true)
2601                    )?;
2602                }
2603                // Already associated: the service's no-op, so nothing was written and
2604                // no History entry was appended. Silent on purpose — a report line per
2605                // unchanged association would make every re-import look like work.
2606                Ok(_) => {}
2607                Err(error) => {
2608                    let mut diagnostic = RemoteMappingDiagnostic {
2609                        relation_id: format!("{local_number}->{project_id}"),
2610                        field: project_field(tool).to_string(),
2611                        tool,
2612                        message: format!(
2613                            "the remote reports #{local_number} in project `{project_id}`, \
2614                             but the project service refused the association: {error}; the \
2615                             local association list is unchanged"
2616                        ),
2617                    };
2618                    scrub_diagnostic(&mut diagnostic, secret.as_deref());
2619                    report.note_project(MappedProject::Unsupported(diagnostic), out)?;
2620                }
2621            }
2622        }
2623    }
2624    Ok(())
2625}
2626
2627/// Apply the epic references an import's payloads reported (WP08, mission
2628/// `epics-work-items-01M3BYW4`).
2629///
2630/// A **second pass**, like [`apply_imported_projects`], because the reference
2631/// belongs to an issue that must exist locally first and because the order a remote
2632/// happened to list its issues in must not decide the outcome. Every payload that
2633/// found a local issue is in `located`, so an issue created *after* its sibling
2634/// still gets its references.
2635///
2636/// ## Every write goes through the epic service
2637///
2638/// [`Store::reference_epic`] is the same path `tix edit --add-epic` takes, so
2639/// uniqueness, reference validation and the one History entry per accepted change
2640/// are the service's decisions rather than a second reference rule here. An
2641/// already-present reference is the service's idempotent no-op: it writes nothing,
2642/// appends nothing, and leaves the canonical file byte-stable — which is what makes
2643/// a repeated import a true no-op (T039 step 3).
2644///
2645/// ## Matching is by id, and only by id
2646///
2647/// `resolve_epic_id` also accepts an epic *name*, because a human selects epics by
2648/// name. An import must not: a name is a display label (C-003), so a remote that
2649/// renames an epic — or a local registry whose name happens to equal the remote's id
2650/// — would resolve to the wrong epic, or to a second reference for a reference that
2651/// is already there. The registry is therefore consulted by **key** before the
2652/// service is called, which both enforces id-only matching and makes the refusal
2653/// diagnosable instead of an `unknown_epic` error string.
2654///
2655/// ## An unknown id is diagnosed, never written
2656///
2657/// Writing the reference would manufacture a dangling reference — one per issue, per
2658/// run, for an epic the registry has never heard of: the "missing reference storm"
2659/// T039 step 4 names. The remote keeps the reference, the local file is untouched,
2660/// and the diagnostic names the field, the tool and the id (C-007). The registry
2661/// itself is **never** written: the local `[epics.<id>]` section is this repository's
2662/// configuration (C-004) and not the remote's to rewrite, which is also why there is
2663/// no window in which a partially written registry could exist.
2664///
2665/// A payload the import skipped — an issue whose remote state is not a column on the
2666/// board — was already reported on its own line and has no local issue to attach a
2667/// reference to, so it is passed over here rather than diagnosed twice.
2668fn apply_imported_epics(
2669    store: &Store,
2670    config: &Config,
2671    tool: Tool,
2672    payloads: &[(String, Vec<RemoteEpic>)],
2673    located: &[(String, u32)],
2674    report: &mut SyncReport,
2675    out: &mut dyn Write,
2676) -> Result<()> {
2677    if payloads.is_empty() {
2678        return Ok(());
2679    }
2680    // Resolved once, with a **no-op** warning sink: the same resolution the adapters
2681    // use, and quoting the epic name the remote reported is the one place remote
2682    // output reaches a diagnostic, so it has to be scrubbed. The sink is silent
2683    // because `token::resolve` already warned on the adapter's behalf for this run.
2684    let secret = token::resolve(tool, config, &token::RealEnv, &mut |_| {})
2685        .secret()
2686        .map(str::to_string);
2687    for (remote_id, epics) in payloads {
2688        let Some(local_number) = located
2689            .iter()
2690            .find(|(id, _)| id == remote_id)
2691            .map(|(_, number)| *number)
2692        else {
2693            continue;
2694        };
2695        for epic in epics {
2696            let epic_id = match map_epic_from_remote(tool, epic) {
2697                MappedEpic::Remote { value, .. } => value,
2698                MappedEpic::Unsupported(mut diagnostic) => {
2699                    scrub_diagnostic(&mut diagnostic, secret.as_deref());
2700                    report.note_epic(MappedEpic::Unsupported(diagnostic), out)?;
2701                    continue;
2702                }
2703            };
2704            if !config.epics.contains_key(epic_id.as_str()) {
2705                let mut diagnostic = RemoteMappingDiagnostic {
2706                    relation_id: format!("{local_number}->{epic_id}"),
2707                    field: EPIC_FIELD.to_string(),
2708                    tool,
2709                    message: format!(
2710                        "the remote reports #{local_number} in epic `{epic_id}`{}, which \
2711                         this repository's registry does not declare; nothing was written \
2712                         — the reference stays on the remote and no missing reference \
2713                         was created locally",
2714                        epic.name
2715                            .as_deref()
2716                            .map(|name| format!(" (`{name}`)"))
2717                            .unwrap_or_default()
2718                    ),
2719                };
2720                scrub_diagnostic(&mut diagnostic, secret.as_deref());
2721                report.note_epic(MappedEpic::Unsupported(diagnostic), out)?;
2722                continue;
2723            }
2724            match store.reference_epic(&local_number.to_string(), &epic_id) {
2725                Ok(applied) if applied.changed => {
2726                    writeln!(
2727                        out,
2728                        "referenced {tool} issue #{local_number} with {}",
2729                        crate::epics::reference_detail(&epic_id, true)
2730                    )?;
2731                }
2732                // Already referenced: the service's no-op, so nothing was written and
2733                // no History entry was appended. Silent on purpose — a report line per
2734                // unchanged reference would make every re-import look like work.
2735                Ok(_) => {}
2736                Err(error) => {
2737                    let mut diagnostic = RemoteMappingDiagnostic {
2738                        relation_id: format!("{local_number}->{epic_id}"),
2739                        field: EPIC_FIELD.to_string(),
2740                        tool,
2741                        message: format!(
2742                            "the remote reports #{local_number} in epic `{epic_id}`, but \
2743                             the epic service refused the reference: {error}; the local \
2744                             reference list is unchanged"
2745                        ),
2746                    };
2747                    scrub_diagnostic(&mut diagnostic, secret.as_deref());
2748                    report.note_epic(MappedEpic::Unsupported(diagnostic), out)?;
2749                }
2750            }
2751        }
2752    }
2753    Ok(())
2754}
2755
2756/// Whether a computed **remote** status conveys "done" in that tracker's own
2757/// vocabulary.
2758///
2759/// Deliberately **not** the completion decision. `closed` is GitLab's word and
2760/// `Done` is Linear's; neither is a local column name, so this cannot be a
2761/// category question, and routing it through the service would mean asking the
2762/// service about a string from a different vocabulary.
2763///
2764/// It used to decide *local* completion at the export call sites, by mapping the
2765/// local status out to a remote string and testing that — a round trip that can
2766/// disagree with the category rule on any board with a category_map. Those call
2767/// sites now ask \`workflow::is_completed\` about the local issue (C-004), and this
2768/// function keeps only the narrower job it can actually do: telling an adapter
2769/// whether its own status update already closed the remote issue.
2770fn remote_ref(tool: Tool, issue: &Issue) -> Option<&str> {
2771    match tool {
2772        Tool::GitLab => issue.gitlab_id.as_deref(),
2773        Tool::Linear => issue.linear_id.as_deref(),
2774    }
2775}
2776
2777/// Build and configure the adapter for `tool`: resolves the token with env
2778/// precedence, validates the `[export]` target section, and returns the
2779/// tool-specific adapter wired to a [`ProcessRunner`].
2780fn adapter_for(tool: Tool, config: &Config) -> Result<Box<dyn TrackerAdapter>> {
2781    // The contract's warning starts with "warning:" — no `tix:` prefix.
2782    let mut warn = |w: &str| eprintln!("{w}");
2783    let resolution = token::resolve(tool, config, &token::RealEnv, &mut warn);
2784    let secret = resolution.secret().map(str::to_string);
2785    match tool {
2786        Tool::GitLab => {
2787            let rc = config.export.gitlab.as_ref().ok_or_else(|| {
2788                TixError::Config("no `[export.gitlab]` section in tix.toml — run `tix init --with gitlab` or edit tix.toml".to_string())
2789            })?;
2790            let project = rc.project.clone().ok_or_else(|| {
2791                TixError::Config("`[export.gitlab].project` is not set — add `project = \"acme/tix\"` to tix.toml".to_string())
2792            })?;
2793            Ok(Box::new(glab::GitLabAdapter::new(
2794                ProcessRunner,
2795                secret,
2796                rc.base_url.clone(),
2797                project,
2798            )))
2799        }
2800        Tool::Linear => {
2801            let rc = config.export.linear.as_ref().ok_or_else(|| {
2802                TixError::Config("no `[export.linear]` section in tix.toml — run `tix init --with linear` or edit tix.toml".to_string())
2803            })?;
2804            let team = rc.team.clone().ok_or_else(|| {
2805                TixError::Config(
2806                    "`[export.linear].team` is not set — add `team = \"Eng\"` to tix.toml"
2807                        .to_string(),
2808                )
2809            })?;
2810            Ok(Box::new(linear::LinearAdapter::new(
2811                ProcessRunner,
2812                secret,
2813                team,
2814            )))
2815        }
2816    }
2817}
2818
2819#[cfg(test)]
2820mod tests {
2821    use super::*;
2822
2823    #[test]
2824    fn target_parsing_accepts_only_the_two_contract_targets() {
2825        assert_eq!(Tool::from_target("gitlab").unwrap(), Tool::GitLab);
2826        assert_eq!(Tool::from_target("linear").unwrap(), Tool::Linear);
2827        assert!(Tool::from_target("github").is_err());
2828        assert_eq!(Tool::GitLab.token_var(), "GITLAB_TOKEN");
2829        assert_eq!(Tool::Linear.token_var(), "LINEAR_TOKEN");
2830        assert_eq!(Tool::GitLab.binary(), "glab");
2831        assert_eq!(Tool::Linear.binary(), "linear-cli");
2832    }
2833
2834    #[test]
2835    fn gitlab_status_mapping() {
2836        let config = Config::default();
2837        assert_eq!(
2838            local_to_remote_status(Tool::GitLab, "todo", &config),
2839            "opened"
2840        );
2841        assert_eq!(
2842            local_to_remote_status(Tool::GitLab, "done", &config),
2843            "closed"
2844        );
2845        assert_eq!(
2846            remote_to_local_status(Tool::GitLab, "opened", &config),
2847            "todo"
2848        );
2849        assert_eq!(
2850            remote_to_local_status(Tool::GitLab, "closed", &config),
2851            "done"
2852        );
2853        assert_eq!(
2854            remote_to_local_status(Tool::GitLab, "anything-else", &config),
2855            "todo"
2856        );
2857    }
2858
2859    #[test]
2860    fn linear_status_mapping() {
2861        let config = Config::default();
2862        assert_eq!(
2863            local_to_remote_status(Tool::Linear, "todo", &config),
2864            "todo"
2865        );
2866        assert_eq!(
2867            local_to_remote_status(Tool::Linear, "in review", &config),
2868            "todo"
2869        );
2870        assert_eq!(
2871            local_to_remote_status(Tool::Linear, "done", &config),
2872            "Done"
2873        );
2874        assert_eq!(
2875            remote_to_local_status(Tool::Linear, "In Progress", &config),
2876            "in progress"
2877        );
2878        assert_eq!(
2879            remote_to_local_status(Tool::Linear, "Done", &config),
2880            "done"
2881        );
2882        assert_eq!(
2883            remote_to_local_status(Tool::Linear, "Unknown", &config),
2884            "todo"
2885        );
2886    }
2887
2888    #[test]
2889    fn days_from_civil_roundtrips_civil_from_days() {
2890        // spot-check independent epoch anchors (known values)
2891        assert_eq!(days_from_civil(1970, 1, 1), 0);
2892        assert_eq!(days_from_civil(2000, 1, 1), 10957);
2893        assert_eq!(days_from_civil(1970, 1, 2), 1);
2894        assert_eq!(days_from_civil(2026, 5, 11), 20584);
2895    }
2896
2897    #[test]
2898    fn date_to_epoch_days_parses_only_yyyymmdd() {
2899        assert_eq!(date_to_epoch_days("1970-01-01"), Some(0));
2900        assert_eq!(date_to_epoch_days("2026-05-11"), Some(20_584));
2901        assert_eq!(date_to_epoch_days("not-a-date"), None);
2902        assert_eq!(date_to_epoch_days("2026-13-01"), None);
2903        assert_eq!(date_to_epoch_days("2026-05-11T10:00:00"), None);
2904    }
2905
2906    #[test]
2907    fn iso_to_epoch_secs_accepts_contract_timestamp_shapes() {
2908        let day = days_from_civil(2026, 5, 11) * 86_400;
2909        assert_eq!(iso_to_epoch_secs("2026-05-11T00:00:00Z"), Some(day));
2910        assert_eq!(iso_to_epoch_secs("2026-05-11 00:00:00"), Some(day));
2911        assert_eq!(iso_to_epoch_secs("2026-05-11T00:00:00.123Z"), Some(day));
2912        assert_eq!(
2913            iso_to_epoch_secs("2026-05-11T00:00:00+02:00"),
2914            Some(day - 7200)
2915        );
2916        assert_eq!(
2917            iso_to_epoch_secs("2026-05-11T00:00:00-05:30"),
2918            Some(day + 19_800)
2919        );
2920        assert_eq!(
2921            iso_to_epoch_secs("2026-05-11T10:30:00Z"),
2922            Some(day + 37_800)
2923        );
2924        assert_eq!(iso_to_epoch_secs("garbage"), None);
2925    }
2926
2927    #[test]
2928    fn remote_newer_wins_only_when_strictly_after_local_end_of_day() {
2929        // local `updated` = 2026-05-11 is interpreted as end of day.
2930        assert!(is_remote_newer("2026-05-12T00:00:00Z", "2026-05-11"));
2931        assert!(!is_remote_newer("2026-05-11 13:00:00", "2026-05-11"));
2932        assert!(!is_remote_newer("2026-05-11T23:59:59Z", "2026-05-11"));
2933        assert!(!is_remote_newer("2026-05-10T23:59:59Z", "2026-05-11"));
2934        // unparseable remote timestamp keeps the local issue
2935        assert!(!is_remote_newer("not-a-time", "2026-05-11"));
2936    }
2937
2938    #[test]
2939    fn scrub_replaces_secret_everywhere_and_preserves_text() {
2940        assert_eq!(
2941            scrub_secret("token=sekrit end", "sekrit"),
2942            "token=<token> end"
2943        );
2944        assert_eq!(
2945            scrub_secret("token=sekrit token=sekrit", "sekrit"),
2946            "token=<token> token=<token>"
2947        );
2948        assert_eq!(scrub_secret("no secret here", "sekrit"), "no secret here");
2949        assert_eq!(scrub_secret("empty secret", ""), "empty secret");
2950    }
2951
2952    // ---- WP07 T029: the relationship mapping seam ----
2953
2954    fn rel(kind: crate::relationships::RelationKind) -> crate::relationships::Relationship {
2955        let mut r = crate::relationships::Relationship::new(1, 2, kind, "Ship it");
2956        r.id = "rel_TEST".to_string();
2957        r
2958    }
2959
2960    /// Every relationship either maps or diagnoses, and the diagnostic
2961    /// distinguishes the reasons (T029 step 5, re-pointed once Linear's
2962    /// `relations add` was verified).
2963    ///
2964    /// - `blocks`/`blocked_by` for **Linear** — expressible in the model and
2965    ///   drivable from the CLI, so they **map**, carrying the target's remote
2966    ///   identifier.
2967    /// - `blocks`/`blocked_by` for **GitLab** — expressible in the model but the
2968    ///   CLI is **verified to have no relation command at all** (tix #1354), so it
2969    ///   diagnoses and says *that*, never "not verified".
2970    /// - `related`/`duplicates` — no remote field at all.
2971    #[test]
2972    fn every_relationship_maps_only_where_the_wire_is_verified_and_otherwise_diagnoses() {
2973        for tool in [Tool::GitLab, Tool::Linear] {
2974            for kind in [
2975                crate::relationships::RelationKind::Blocks,
2976                crate::relationships::RelationKind::BlockedBy,
2977                crate::relationships::RelationKind::Related,
2978                crate::relationships::RelationKind::Duplicates,
2979            ] {
2980                let model_supports = matches!(
2981                    kind,
2982                    crate::relationships::RelationKind::Blocks
2983                        | crate::relationships::RelationKind::BlockedBy
2984                );
2985                match map_relation_to_remote(tool, &rel(kind), Some("ENG-42")) {
2986                    MappedRelation::Remote { field, value } => {
2987                        assert!(
2988                            model_supports && RELATION_WIRE_VERIFIED.contains(&tool),
2989                            "{tool}/{kind} mapped, so the tool must model the kind and \
2990                             be on the verified wire"
2991                        );
2992                        assert_ne!(field, "");
2993                        if tool == Tool::Linear {
2994                            assert_eq!(
2995                                value, "ENG-42",
2996                                "Linear sends the target's remote identifier"
2997                            );
2998                        } else {
2999                            assert!(value.contains('#'));
3000                        }
3001                    }
3002                    MappedRelation::Unsupported(d) => {
3003                        assert!(
3004                            !RELATION_WIRE_VERIFIED.contains(&tool) || !model_supports,
3005                            "{tool}/{kind} is on the verified wire and modelled, so it \
3006                             must map, not diagnose"
3007                        );
3008                        assert_eq!(d.tool, tool, "the diagnostic names the tool");
3009                        assert_eq!(d.relation_id, "rel_TEST");
3010                        assert!(
3011                            !d.field.is_empty(),
3012                            "the diagnostic must name the specific field: {d}"
3013                        );
3014                        assert!(d.to_string().contains(&d.field), "and print it: {d}");
3015                        if model_supports {
3016                            assert!(
3017                                RELATION_CLI_ABSENT.contains(&tool)
3018                                    && d.message.contains("exposes no issue-relation command")
3019                                    && !d.message.contains("not verified"),
3020                                "{tool}/{kind}: a model-supported kind on an unverified \
3021                                 tool must name the real reason; a CLI verified to have \
3022                                 no relation command must say so, not 'not verified': {d}"
3023                            );
3024                        } else {
3025                            assert!(
3026                                !d.message.contains("not verified"),
3027                                "{tool}/{kind}: a kind with no remote field must not \
3028                                 blame an unverified verb: {d}"
3029                            );
3030                        }
3031                    }
3032                }
3033            }
3034        }
3035    }
3036
3037    /// A blocking edge whose target has no remote reference is **reported, never
3038    /// guessed** (C-005): Linear's `relations add` needs the target's remote
3039    /// identifier, and inventing one would be worse than a diagnostic.
3040    #[test]
3041    fn a_relation_without_a_remote_target_is_reported_not_guessed() {
3042        let MappedRelation::Unsupported(d) = map_relation_to_remote(
3043            Tool::Linear,
3044            &rel(crate::relationships::RelationKind::Blocks),
3045            None,
3046        ) else {
3047            panic!("a missing target must not map");
3048        };
3049        assert_eq!(d.tool, Tool::Linear);
3050        assert_eq!(d.field, "blocks");
3051        assert!(
3052            d.message.contains("#2") && d.message.contains("not been exported"),
3053            "the diagnostic names the target and why: {d}"
3054        );
3055    }
3056
3057    /// The forward and reverse directions agree about the verified list.
3058    ///
3059    /// The reverse direction must **not** invent an edge from a payload the forward
3060    /// direction would never have written — otherwise an import could create a
3061    /// relationship the export never made, which is the mirror image of the silent
3062    /// drop the contract forbids. So it maps exactly when the wire is verified.
3063    #[test]
3064    fn the_reverse_direction_maps_exactly_when_the_wire_is_verified() {
3065        for tool in [Tool::GitLab, Tool::Linear] {
3066            let field = match tool {
3067                Tool::GitLab => "blocking",
3068                Tool::Linear => "blocks",
3069            };
3070            match map_relation_from_remote(tool, field, "blocks #2", "rel_TEST") {
3071                MappedRelation::Remote { .. } => assert!(
3072                    RELATION_WIRE_VERIFIED.contains(&tool),
3073                    "{tool}: the reverse direction mapped, so the wire must be verified"
3074                ),
3075                MappedRelation::Unsupported(d) => {
3076                    assert!(
3077                        !RELATION_WIRE_VERIFIED.contains(&tool),
3078                        "{tool}: the wire is verified, so the reverse direction must map"
3079                    );
3080                    assert_eq!(d.tool, tool);
3081                    assert_ne!(d.relation_id, "");
3082                }
3083            }
3084        }
3085    }
3086
3087    /// A remote payload the export could not have written is **ignored with a
3088    /// diagnostic**, never turned into a local edge.
3089    ///
3090    /// The alternative — coercing an unrecognised field to a default kind — would
3091    /// let an import invent a relationship the export never made, which is the
3092    /// mirror image of silently dropping a local one.
3093    #[test]
3094    fn an_unrecognised_remote_relation_field_never_becomes_a_local_edge() {
3095        for tool in [Tool::GitLab, Tool::Linear] {
3096            for (field, value) in [
3097                ("parent", "#2"),
3098                ("blocks", "not-a-number"),
3099                ("whatever", ""),
3100            ] {
3101                match map_relation_from_remote(tool, field, value, "rel_X") {
3102                    MappedRelation::Remote { .. } => {
3103                        panic!("{tool}: `{field}={value}` must not become a local edge")
3104                    }
3105                    MappedRelation::Unsupported(d) => {
3106                        assert_eq!(d.tool, tool);
3107                        assert_eq!(d.field, field, "the diagnostic names the field");
3108                    }
3109                }
3110            }
3111        }
3112    }
3113
3114    /// The two adapters cannot drift: the seam is one function, keyed on `Tool`
3115    /// (T033 step 5, and T029's note).
3116    ///
3117    /// Asserted by construction — a single `map_relation_to_remote` serves both —
3118    /// and pinned here so adding a tracker-specific branch to one of them is a
3119    /// red test rather than a silent divergence.
3120    #[test]
3121    fn both_trackers_share_one_mapping_seam() {
3122        let source = include_str!("mod.rs");
3123        let fns = [
3124            "pub fn map_relation_to_remote",
3125            "pub fn map_relation_from_remote",
3126        ];
3127        for f in fns {
3128            assert_eq!(
3129                source.matches(&format!("{f}(")).count(),
3130                1,
3131                "{f} must be *defined* exactly once, in this module, for both \
3132                 trackers — a doc comment naming it is not a second implementation"
3133            );
3134        }
3135        // And neither adapter file may define its own copy. `include_str!` needs a
3136        // literal, so the pair is spelled out rather than looped over.
3137        for (name, text) in [
3138            ("glab.rs", include_str!("glab.rs")),
3139            ("linear.rs", include_str!("linear.rs")),
3140        ] {
3141            assert!(
3142                !text.contains("fn map_relation_to_remote")
3143                    && !text.contains("fn map_relation_from_remote"),
3144                "{name} must not define its own mapping seam; the shared one is \\
3145                 keyed on `Tool` precisely so the two cannot diverge"
3146            );
3147        }
3148    }
3149
3150    /// A `SyncReport` records the count and the diagnostics together, so a caller
3151    /// cannot report success while quietly dropping a relationship.
3152    #[test]
3153    fn a_sync_report_carries_both_the_count_and_the_diagnostics() {
3154        let mut report = SyncReport::default();
3155        let mut sink: Vec<u8> = Vec::new();
3156        report
3157            .note(
3158                map_relation_to_remote(
3159                    Tool::GitLab,
3160                    &rel(crate::relationships::RelationKind::Related),
3161                    None,
3162                ),
3163                &mut sink,
3164            )
3165            .unwrap();
3166        report
3167            .note(
3168                map_relation_to_remote(
3169                    Tool::GitLab,
3170                    &rel(crate::relationships::RelationKind::Blocks),
3171                    None,
3172                ),
3173                &mut sink,
3174            )
3175            .unwrap();
3176        // Both are diagnostics: GitLab is not wire-verified, so the mapped
3177        // branch is unreachable and the count cannot be 1.
3178        let expected = if RELATION_WIRE_VERIFIED.contains(&Tool::GitLab) {
3179            1
3180        } else {
3181            2
3182        };
3183        assert_eq!(
3184            report.diagnostics.len(),
3185            expected,
3186            "the report records every relation the tool cannot carry"
3187        );
3188        assert_eq!(report.handled, 0, "no issues were handled here");
3189        // It is on the human stream too, so the count and the output agree.
3190        let text = String::from_utf8(sink).unwrap();
3191        assert!(
3192            text.contains("cannot represent"),
3193            "the stream says so: {text}"
3194        );
3195    }
3196    // ---- WP07 T032/T033: completion comes from the service, nowhere else ----
3197
3198    /// A board whose `category_map` puts `completed` on `shipped` while
3199    /// `done_column` is `done` — so the two disagree, and a name comparison picks the
3200    /// wrong one. Every assertion below is vacuous on the default board, which is why
3201    /// this fixture exists rather than `Config::default()`.
3202    fn split_completion_config() -> Config {
3203        Config {
3204            kanban: crate::config::KanbanSection {
3205                configurations: indexmap::IndexMap::from([(
3206                    "default".to_string(),
3207                    crate::config::BoardConfiguration {
3208                        columns: vec!["todo".into(), "shipped".into(), "done".into()],
3209                        done_column: Some("done".to_string()),
3210                        category_map: indexmap::IndexMap::from([
3211                            ("todo".to_string(), "unstarted".to_string()),
3212                            ("shipped".to_string(), "completed".to_string()),
3213                            ("done".to_string(), "active".to_string()),
3214                        ]),
3215                        ..Default::default()
3216                    },
3217                )]),
3218            },
3219            ..Config::default()
3220        }
3221    }
3222
3223    #[test]
3224    fn t032_a_remote_closed_state_maps_to_the_services_completion_column() {
3225        // Not to `done_column`. The remote's `closed` must round-trip to the column
3226        // the *service* calls complete, or export and import disagree with each other
3227        // and with the board.
3228        let config = split_completion_config();
3229        assert_eq!(
3230            remote_to_local_status(Tool::GitLab, "closed", &config),
3231            "shipped",
3232            "a closed GitLab issue belongs in the column that is actually complete"
3233        );
3234        assert_eq!(
3235            remote_to_local_status(Tool::Linear, "Done", &config),
3236            "shipped"
3237        );
3238    }
3239
3240    #[test]
3241    fn t032_export_closes_the_column_the_service_calls_complete() {
3242        // The other direction. A name comparison exported `shipped` as *open* and
3243        // closed `done` instead — the tracker and the board disagreeing about the same
3244        // issue in both directions at once.
3245        let config = split_completion_config();
3246        assert_eq!(
3247            local_to_remote_status(Tool::GitLab, "shipped", &config),
3248            "closed"
3249        );
3250        assert_eq!(
3251            local_to_remote_status(Tool::GitLab, "done", &config),
3252            "opened"
3253        );
3254        assert_eq!(
3255            local_to_remote_status(Tool::Linear, "shipped", &config),
3256            "Done"
3257        );
3258    }
3259
3260    #[test]
3261    fn t032_a_closed_remote_state_mapped_to_a_canceled_column_is_not_complete() {
3262        // T033 step 4. `canceled` is terminal but **not** complete (contract §4), so
3263        // an export that sends a `canceled` column as closed would be claiming the
3264        // work is finished when it was abandoned. The local side is the service's
3265        // answer, so it says no.
3266        let mut config = split_completion_config();
3267        config
3268            .kanban
3269            .configurations
3270            .get_mut("default")
3271            .unwrap()
3272            .category_map
3273            .insert("done".to_string(), "canceled".to_string());
3274        let board = config.default_config();
3275        // `shipped` is still `completed`, so it still exports as closed…
3276        assert_eq!(
3277            local_to_remote_status(Tool::GitLab, "shipped", &config),
3278            "closed"
3279        );
3280        // …and `done` is now `canceled`, which is terminal and not complete, so it does
3281        // not. The remote has only two states, so the honest outcome is that `canceled`
3282        // cannot be represented distinctly — and the local definition is what decides,
3283        // not the remote's vocabulary.
3284        assert!(
3285            !is_done_column(board, "done", &config),
3286            "a canceled column is not locally complete"
3287        );
3288    }
3289
3290    #[test]
3291    fn t032_the_existing_round_trips_are_unchanged() {
3292        // T032 step 5: this is a refactor of *where the decision comes from*, not a
3293        // protocol change. The default board's behaviour must be identical.
3294        let config = Config::default();
3295        assert_eq!(
3296            local_to_remote_status(Tool::GitLab, "done", &config),
3297            "closed"
3298        );
3299        assert_eq!(
3300            local_to_remote_status(Tool::GitLab, "todo", &config),
3301            "opened"
3302        );
3303        assert_eq!(
3304            remote_to_local_status(Tool::GitLab, "closed", &config),
3305            "done"
3306        );
3307        assert_eq!(
3308            remote_to_local_status(Tool::Linear, "Done", &config),
3309            "done"
3310        );
3311        // "In Progress" case-insensitively matches the board's `in progress` column,
3312        // and a name match wins over the first column. The first draft of this
3313        // assertion expected `todo` — which is the *first* column, and therefore the
3314        // answer when no name matches. It was asserting the opposite rule to the one
3315        // it claimed to pin.
3316        assert_eq!(
3317            remote_to_local_status(Tool::Linear, "In Progress", &config),
3318            "in progress",
3319            "a name match wins over the first column"
3320        );
3321        assert_eq!(
3322            remote_to_local_status(Tool::Linear, "No Such State", &config),
3323            "todo",
3324            "and with no match at all, the first column"
3325        );
3326    }
3327
3328    #[test]
3329    fn t032_the_sync_seam_holds_no_local_completion_definition() {
3330        // T032 step 4, as a structural guard. The module header says a second local
3331        // definition is a C-004 defect, and this is what keeps that from being
3332        // "improved" back in: the seam may only *translate* a remote state word, and
3333        // the completion test may only come from the service.
3334        let source = include_str!("mod.rs");
3335        // Assembled from fragments so this assertion does not match **itself**: the
3336        // first version spelled the forbidden name out in full, so the very line
3337        // asserting its absence satisfied the check. That is the grep equivalent of a
3338        // test that proves itself, and it was found because the test went red while
3339        // the function was, in fact, absent.
3340        let forbidden = ["fn remote", "status_is_done"].concat();
3341        assert!(
3342            !source.contains(&forbidden),
3343            "the dead second definition is removed and must not come back"
3344        );
3345        // The only local completion question in the module is the service call.
3346        let uses_service = source.matches("WorkflowCategory::is_complete").count();
3347        assert!(
3348            uses_service >= 2,
3349            "both `is_done_column` and `completion_column` must ask the service, found \
3350             {uses_service} uses"
3351        );
3352    }
3353
3354    // ------------------------------------------------------------------
3355    // WP08 (mission first-class-projects-01M3BYBW) — project associations
3356    // ------------------------------------------------------------------
3357
3358    /// A representable association maps, and the reverse direction is
3359    /// **local**-shaped: the frontmatter key and the id, not a copy of the tool's
3360    /// field name (T035 step 1).
3361    #[test]
3362    fn t035_a_project_association_maps_back_to_its_local_id() {
3363        for tool in [Tool::GitLab, Tool::Linear] {
3364            let remote = RemoteProject {
3365                id: "p-1".to_string(),
3366                name: Some("One".to_string()),
3367            };
3368            match map_project_from_remote(tool, &remote) {
3369                MappedProject::Remote { field, value } => {
3370                    assert_eq!(field, crate::projects::PROJECTS_KEY);
3371                    assert_eq!(value, "p-1", "the id, never the remote's name");
3372                }
3373                MappedProject::Unsupported(d) => {
3374                    panic!("{tool}: a reported id must map back: {d}")
3375                }
3376            }
3377        }
3378    }
3379
3380    /// Every association diagnoses on both trackers, and the diagnostic names the
3381    /// **field**, the **tool** and the issue — the three things that make it
3382    /// actionable rather than a shrug (T035 step 6, C-005).
3383    ///
3384    /// Written so it still holds if a tracker joins `PROJECT_WIRE_VERIFIED`: the
3385    /// mapped branch is then the assertion, not the diagnostic's.
3386    #[test]
3387    fn t035_every_project_association_diagnoses_with_its_field_its_tool_and_its_reason() {
3388        for tool in [Tool::GitLab, Tool::Linear] {
3389            match map_project_to_remote(tool, 3, "p-7") {
3390                MappedProject::Remote { field, value } => {
3391                    assert!(
3392                        PROJECT_WIRE_VERIFIED.contains(&tool),
3393                        "{tool}: an association mapped without a verified membership field"
3394                    );
3395                    assert_eq!(field, project_field(tool));
3396                    assert_eq!(value, "p-7");
3397                }
3398                MappedProject::Unsupported(d) => {
3399                    assert_eq!(d.tool, tool, "the diagnostic names the tool");
3400                    assert_eq!(d.field, project_field(tool), "and the field");
3401                    assert_eq!(
3402                        d.relation_id, "3->p-7",
3403                        "and the issue and the association it is about"
3404                    );
3405                    let text = d.to_string();
3406                    for needle in [d.field.as_str(), "p-7", "#3"] {
3407                        assert!(text.contains(needle), "`{needle}` missing from: {text}");
3408                    }
3409                    // Neither CLI has a membership verb to wait for, so the message
3410                    // must not promise one — the same distinction
3411                    // `RELATION_CLI_ABSENT` draws.
3412                    assert!(
3413                        !d.message.contains("not verified"),
3414                        "{tool}: there is no membership verb to verify: {text}"
3415                    );
3416                }
3417            }
3418        }
3419        // The reasons are per tool because the two CLIs differ, and they say which.
3420        assert!(project_absence_reason(Tool::GitLab).contains("--project"));
3421        assert!(project_absence_reason(Tool::Linear).contains("one `project`"));
3422        assert_ne!(
3423            project_absence_reason(Tool::GitLab),
3424            project_absence_reason(Tool::Linear)
3425        );
3426    }
3427
3428    /// An association with no id is a repair job, not a remote capability gap, and
3429    /// the local entry is not quietly sent as somebody else's project.
3430    #[test]
3431    fn t035_an_association_with_no_id_is_diagnosed_and_kept() {
3432        for tool in [Tool::GitLab, Tool::Linear] {
3433            match map_project_to_remote(tool, 4, "   ") {
3434                MappedProject::Remote { .. } => panic!("{tool}: nothing to send"),
3435                MappedProject::Unsupported(d) => {
3436                    assert_eq!(d.tool, tool);
3437                    assert_eq!(d.field, project_field(tool));
3438                    assert!(d.message.contains("no id"), "{}", d.message);
3439                    assert!(
3440                        d.message.contains("kept locally"),
3441                        "and says the entry survives: {}",
3442                        d.message
3443                    );
3444                }
3445            }
3446        }
3447    }
3448
3449    /// A payload's references are read **in order**, in every shape the two trackers
3450    /// are known to use, and an entry that cannot yield an id is still reported by
3451    /// name instead of being dropped (T035 step 1, NFR-002 for the order).
3452    #[test]
3453    fn t035_remote_project_reading_keeps_order_and_accepts_every_known_shape() {
3454        let list: serde_json::Value =
3455            serde_json::from_str(r#"["p-1", {"id": "p-2", "name": "Two"}, 3]"#).unwrap();
3456        assert_eq!(
3457            remote_projects(Some(&list)),
3458            vec![
3459                RemoteProject {
3460                    id: "p-1".into(),
3461                    name: None
3462                },
3463                RemoteProject {
3464                    id: "p-2".into(),
3465                    name: Some("Two".into())
3466                },
3467                RemoteProject {
3468                    id: "3".into(),
3469                    name: None
3470                },
3471            ],
3472            "the payload's order is the list's order"
3473        );
3474        // Linear's single `project`, under any of the id spellings.
3475        for raw in [
3476            r#"{"identifier": "p-9"}"#,
3477            r#"{"project_id": "p-9"}"#,
3478            r#""p-9""#,
3479        ] {
3480            let value: serde_json::Value = serde_json::from_str(raw).unwrap();
3481            assert_eq!(
3482                remote_projects(Some(&value)),
3483                vec![RemoteProject {
3484                    id: "p-9".into(),
3485                    name: None
3486                }],
3487                "{raw}"
3488            );
3489        }
3490        // A named entry with no id survives, so the import can report it.
3491        let named: serde_json::Value =
3492            serde_json::from_str(r#"[{"name": "Unidentified"}]"#).unwrap();
3493        assert_eq!(
3494            remote_projects(Some(&named)),
3495            vec![RemoteProject {
3496                id: String::new(),
3497                name: Some("Unidentified".into())
3498            }]
3499        );
3500        // Absent, null, and shapes carrying nothing at all yield no references.
3501        assert_eq!(remote_projects(None), [] as [crate::sync::RemoteProject; 0]);
3502        assert_eq!(
3503            remote_projects(Some(&serde_json::Value::Null)),
3504            [] as [crate::sync::RemoteProject; 0]
3505        );
3506        assert_eq!(
3507            remote_projects(Some(&serde_json::json!([true, false]))),
3508            [] as [crate::sync::RemoteProject; 0]
3509        );
3510    }
3511
3512    /// An entry the remote named but did not identify cannot be matched on, and the
3513    /// diagnostic quotes the name so a reader can see which project it meant.
3514    #[test]
3515    fn t035_a_remote_project_with_no_id_is_diagnosed_never_guessed_at() {
3516        for tool in [Tool::GitLab, Tool::Linear] {
3517            let remote = RemoteProject {
3518                id: "  ".to_string(),
3519                name: Some("Named but unidentified".to_string()),
3520            };
3521            match map_project_from_remote(tool, &remote) {
3522                MappedProject::Remote { .. } => {
3523                    panic!("{tool}: an absent id cannot become a local association")
3524                }
3525                MappedProject::Unsupported(d) => {
3526                    assert_eq!(d.tool, tool);
3527                    assert_eq!(d.field, project_field(tool));
3528                    assert!(d.message.contains("Named but unidentified"));
3529                    assert!(d.message.contains("no id"));
3530                }
3531            }
3532        }
3533    }
3534
3535    /// A diagnostic built from **remote output** is scrubbed before it is reported
3536    /// (T036 step 6). The project name is the remote text this seam quotes, which is
3537    /// exactly how a credential could reach a report.
3538    #[test]
3539    fn t036_a_project_diagnostic_built_from_remote_output_is_scrubbed() {
3540        let mut diagnostic = RemoteMappingDiagnostic {
3541            relation_id: "1->lin-secret".to_string(),
3542            field: LINEAR_PROJECT_FIELD.to_string(),
3543            tool: Tool::Linear,
3544            message: "the remote reports #1 in project `p-1` (`lin-secret`)".to_string(),
3545        };
3546        scrub_diagnostic(&mut diagnostic, Some("lin-secret"));
3547        assert!(
3548            diagnostic.message.contains("<token>"),
3549            "{}",
3550            diagnostic.message
3551        );
3552        assert!(!diagnostic.message.contains("lin-secret"));
3553        assert!(!diagnostic.relation_id.contains("lin-secret"));
3554
3555        // With no secret configured there is nothing to redact, and the diagnostic is
3556        // reported unchanged rather than withheld.
3557        let mut untouched = RemoteMappingDiagnostic {
3558            relation_id: "1->p-1".to_string(),
3559            field: LINEAR_PROJECT_FIELD.to_string(),
3560            tool: Tool::Linear,
3561            message: "no secret here".to_string(),
3562        };
3563        let expected = untouched.clone();
3564        scrub_diagnostic(&mut untouched, None);
3565        assert_eq!(untouched, expected);
3566    }
3567
3568    /// Neither tracker sends a membership, both adapters implement the same seam,
3569    /// and the seam itself is defined once (T036 steps 3 and 5, T038 step 6).
3570    ///
3571    /// Structural assertions on purpose: the compiler already requires `set_projects`
3572    /// on both adapters (it is a required trait method), and these keep a
3573    /// tracker-specific mapping from being added to one adapter file later.
3574    #[test]
3575    fn t036_no_tracker_sends_a_membership_and_both_adapters_share_the_seam() {
3576        for tool in [Tool::GitLab, Tool::Linear] {
3577            assert!(!PROJECT_WIRE_VERIFIED.contains(&tool));
3578            assert!(matches!(
3579                map_project_to_remote(tool, 1, "p-1"),
3580                MappedProject::Unsupported(_)
3581            ));
3582        }
3583        let source = include_str!("mod.rs");
3584        for f in [
3585            "pub fn map_project_to_remote",
3586            "pub fn map_project_from_remote",
3587        ] {
3588            assert_eq!(
3589                source.matches(&format!("{f}(")).count(),
3590                1,
3591                "{f} must be *defined* exactly once, in this module, for both trackers"
3592            );
3593        }
3594        for (name, text) in [
3595            ("glab.rs", include_str!("glab.rs")),
3596            ("linear.rs", include_str!("linear.rs")),
3597        ] {
3598            assert!(
3599                !text.contains("fn map_project_to_remote")
3600                    && !text.contains("fn map_project_from_remote"),
3601                "{name} must not define its own mapping seam"
3602            );
3603            assert!(
3604                text.contains("fn set_projects"),
3605                "{name} must implement the shared membership seam"
3606            );
3607        }
3608    }
3609
3610    /// The report carries a project diagnostic with the count, on the stream and in
3611    /// the returned value, so a caller cannot report success while dropping one.
3612    #[test]
3613    fn t037_a_report_records_a_project_diagnostic_and_prints_the_field() {
3614        let mut report = SyncReport::default();
3615        let mut sink: Vec<u8> = Vec::new();
3616        report
3617            .note_project(map_project_to_remote(Tool::GitLab, 2, "p-1"), &mut sink)
3618            .unwrap();
3619        assert_eq!(report.diagnostics.len(), 1);
3620        assert_eq!(report.handled, 0);
3621        let text = String::from_utf8(sink).unwrap();
3622        assert!(text.contains("gitlab: projects"), "{text}");
3623        assert!(text.contains("p-1"), "{text}");
3624        assert_eq!(report.diagnostics[0].field, GITLAB_PROJECT_FIELD);
3625    }
3626
3627    // ---- WP08 T037–T040: the epic-reference mapping seam ----------------
3628
3629    /// A reported epic id maps back to the local reference key and id, on both
3630    /// trackers (T037 step 1, C-003).
3631    #[test]
3632    fn t037_an_epic_reference_maps_back_to_its_local_id() {
3633        for tool in [Tool::GitLab, Tool::Linear] {
3634            let remote = RemoteEpic {
3635                id: "e-1".to_string(),
3636                name: Some("One".to_string()),
3637            };
3638            match map_epic_from_remote(tool, &remote) {
3639                MappedEpic::Remote { field, value } => {
3640                    assert_eq!(field, crate::epics::EPICS_KEY);
3641                    assert_eq!(value, "e-1", "the id, never the remote's name");
3642                }
3643                MappedEpic::Unsupported(d) => {
3644                    panic!("{tool}: a reported id must map back: {d}")
3645                }
3646            }
3647        }
3648    }
3649
3650    /// Every epic reference diagnoses on both trackers, and the diagnostic names the
3651    /// **field**, the **tool** and the issue — the three things that make it
3652    /// actionable rather than a shrug (T037 step 6, C-007).
3653    ///
3654    /// Written so it still holds if a tracker joins `EPIC_WIRE_VERIFIED`: the mapped
3655    /// branch is then the assertion, not the diagnostic's.
3656    #[test]
3657    fn t037_every_epic_reference_diagnoses_with_its_field_its_tool_and_its_reason() {
3658        for tool in [Tool::GitLab, Tool::Linear] {
3659            match map_epic_to_remote(tool, 3, "e-7") {
3660                MappedEpic::Remote { field, value } => {
3661                    assert!(
3662                        EPIC_WIRE_VERIFIED.contains(&tool),
3663                        "{tool}: a reference mapped without a verified epic field"
3664                    );
3665                    assert_eq!(field, EPIC_FIELD);
3666                    assert_eq!(value, "e-7");
3667                }
3668                MappedEpic::Unsupported(d) => {
3669                    assert_eq!(d.tool, tool, "the diagnostic names the tool");
3670                    assert_eq!(d.field, EPIC_FIELD, "and the field");
3671                    assert_eq!(
3672                        d.relation_id, "3->e-7",
3673                        "and the issue and the reference it is about"
3674                    );
3675                    let text = d.to_string();
3676                    for needle in [d.field.as_str(), "e-7", "#3"] {
3677                        assert!(text.contains(needle), "`{needle}` missing from: {text}");
3678                    }
3679                    // Neither CLI has an epic verb to wait for, so the message must
3680                    // not promise one.
3681                    assert!(
3682                        !d.message.contains("not verified"),
3683                        "{tool}: there is no epic verb to verify: {text}"
3684                    );
3685                }
3686            }
3687        }
3688        // The reasons are per tool because the two CLIs differ, and they say which.
3689        assert!(epic_absence_reason(Tool::GitLab).contains("epic_iid"));
3690        assert!(epic_absence_reason(Tool::Linear).contains("project"));
3691        assert_ne!(
3692            epic_absence_reason(Tool::GitLab),
3693            epic_absence_reason(Tool::Linear)
3694        );
3695    }
3696
3697    /// A reference with no id is a repair job, not a remote capability gap, and the
3698    /// local entry is not quietly sent as somebody else's epic.
3699    #[test]
3700    fn t037_an_epic_reference_with_no_id_is_diagnosed_and_kept() {
3701        for tool in [Tool::GitLab, Tool::Linear] {
3702            match map_epic_to_remote(tool, 4, "   ") {
3703                MappedEpic::Remote { .. } => panic!("{tool}: nothing to send"),
3704                MappedEpic::Unsupported(d) => {
3705                    assert_eq!(d.tool, tool);
3706                    assert_eq!(d.field, EPIC_FIELD);
3707                    assert!(d.message.contains("no id"), "{}", d.message);
3708                    assert!(
3709                        d.message.contains("kept locally"),
3710                        "and says the entry survives: {}",
3711                        d.message
3712                    );
3713                }
3714            }
3715        }
3716    }
3717
3718    /// A payload's references are read **in order**, in every shape the two trackers
3719    /// are known to use, and an entry that cannot yield an id is still reported by
3720    /// name instead of being dropped (T037 step 1, NFR-002 for the order).
3721    #[test]
3722    fn t037_remote_epic_reading_keeps_order_and_accepts_every_known_shape() {
3723        let list: serde_json::Value =
3724            serde_json::from_str(r#"["e-1", {"id": "e-2", "name": "Two"}, 3]"#).unwrap();
3725        assert_eq!(
3726            remote_epics(Some(&list)),
3727            vec![
3728                RemoteEpic {
3729                    id: "e-1".into(),
3730                    name: None
3731                },
3732                RemoteEpic {
3733                    id: "e-2".into(),
3734                    name: Some("Two".into())
3735                },
3736                RemoteEpic {
3737                    id: "3".into(),
3738                    name: None
3739                },
3740            ],
3741            "the payload's order is the list's order"
3742        );
3743        // A single reference, under any of the id spellings.
3744        for raw in [
3745            r#"{"identifier": "e-9"}"#,
3746            r#"{"epic_id": "e-9"}"#,
3747            r#""e-9""#,
3748        ] {
3749            let value: serde_json::Value = serde_json::from_str(raw).unwrap();
3750            assert_eq!(
3751                remote_epics(Some(&value)),
3752                vec![RemoteEpic {
3753                    id: "e-9".into(),
3754                    name: None
3755                }],
3756                "{raw}"
3757            );
3758        }
3759        // A named entry with no id survives, so the import can report it.
3760        let named: serde_json::Value =
3761            serde_json::from_str(r#"[{"name": "Unidentified"}]"#).unwrap();
3762        assert_eq!(
3763            remote_epics(Some(&named)),
3764            vec![RemoteEpic {
3765                id: String::new(),
3766                name: Some("Unidentified".into())
3767            }]
3768        );
3769        // Absent, null, and shapes carrying nothing at all yield no references.
3770        assert_eq!(remote_epics(None), [] as [crate::sync::RemoteEpic; 0]);
3771        assert_eq!(
3772            remote_epics(Some(&serde_json::Value::Null)),
3773            [] as [crate::sync::RemoteEpic; 0]
3774        );
3775        assert_eq!(
3776            remote_epics(Some(&serde_json::json!([true, false]))),
3777            [] as [crate::sync::RemoteEpic; 0]
3778        );
3779    }
3780
3781    /// An entry the remote named but did not identify cannot be matched on, and the
3782    /// diagnostic quotes the name so a reader can see which epic it meant.
3783    #[test]
3784    fn t037_a_remote_epic_with_no_id_is_diagnosed_never_guessed_at() {
3785        for tool in [Tool::GitLab, Tool::Linear] {
3786            let remote = RemoteEpic {
3787                id: "  ".to_string(),
3788                name: Some("Named but unidentified".to_string()),
3789            };
3790            match map_epic_from_remote(tool, &remote) {
3791                MappedEpic::Remote { .. } => {
3792                    panic!("{tool}: an absent id cannot become a local reference")
3793                }
3794                MappedEpic::Unsupported(d) => {
3795                    assert_eq!(d.tool, tool);
3796                    assert_eq!(d.field, EPIC_FIELD);
3797                    assert!(d.message.contains("Named but unidentified"));
3798                    assert!(d.message.contains("no id"));
3799                }
3800            }
3801        }
3802    }
3803
3804    /// A diagnostic built from **remote output** is scrubbed before it is reported
3805    /// (T038 step 6). The epic name is the remote text this seam quotes, which is
3806    /// exactly how a credential could reach a report.
3807    #[test]
3808    fn t038_a_remote_epic_diagnostic_is_scrubbed() {
3809        let mut diagnostic = RemoteMappingDiagnostic {
3810            relation_id: "1->lin-secret".to_string(),
3811            field: EPIC_FIELD.to_string(),
3812            tool: Tool::Linear,
3813            message: "the remote reports #1 in epic `e-1` (`lin-secret`)".to_string(),
3814        };
3815        scrub_diagnostic(&mut diagnostic, Some("lin-secret"));
3816        assert!(
3817            diagnostic.message.contains("<token>"),
3818            "{}",
3819            diagnostic.message
3820        );
3821        assert!(!diagnostic.message.contains("lin-secret"));
3822        assert!(!diagnostic.relation_id.contains("lin-secret"));
3823    }
3824
3825    /// Neither tracker sends an epic reference, both adapters implement the same
3826    /// seam, and the seam itself is defined once (T038 step 6).
3827    ///
3828    /// Structural assertions on purpose: the compiler already requires `set_epics` on
3829    /// both adapters (it is a required trait method), and these keep a
3830    /// tracker-specific mapping from being added to one adapter file later.
3831    #[test]
3832    fn t038_no_tracker_sends_an_epic_reference_and_both_adapters_share_the_seam() {
3833        for tool in [Tool::GitLab, Tool::Linear] {
3834            assert!(!EPIC_WIRE_VERIFIED.contains(&tool));
3835            assert!(matches!(
3836                map_epic_to_remote(tool, 1, "e-1"),
3837                MappedEpic::Unsupported(_)
3838            ));
3839        }
3840        let source = include_str!("mod.rs");
3841        for f in ["pub fn map_epic_to_remote", "pub fn map_epic_from_remote"] {
3842            assert_eq!(
3843                source.matches(&format!("{f}(")).count(),
3844                1,
3845                "{f} must be *defined* exactly once, in this module, for both trackers"
3846            );
3847        }
3848        for (name, text) in [
3849            ("glab.rs", include_str!("glab.rs")),
3850            ("linear.rs", include_str!("linear.rs")),
3851        ] {
3852            assert!(
3853                !text.contains("fn map_epic_to_remote")
3854                    && !text.contains("fn map_epic_from_remote"),
3855                "{name} must not define its own mapping seam"
3856            );
3857            assert!(
3858                text.contains("fn set_epics"),
3859                "{name} must implement the shared epic seam"
3860            );
3861        }
3862    }
3863
3864    /// The report carries an epic diagnostic with the count, on the stream and in the
3865    /// returned value, so a caller cannot report success while dropping one.
3866    #[test]
3867    fn t038_a_report_records_an_epic_diagnostic_and_prints_the_field() {
3868        let mut report = SyncReport::default();
3869        let mut sink: Vec<u8> = Vec::new();
3870        report
3871            .note_epic(map_epic_to_remote(Tool::GitLab, 2, "e-1"), &mut sink)
3872            .unwrap();
3873        assert_eq!(report.diagnostics.len(), 1);
3874        assert_eq!(report.handled, 0);
3875        let text = String::from_utf8(sink).unwrap();
3876        assert!(text.contains("gitlab: epics"), "{text}");
3877        assert!(text.contains("e-1"), "{text}");
3878        assert_eq!(report.diagnostics[0].field, EPIC_FIELD);
3879    }
3880}