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