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::io::Write;
45
46use crate::config::{BoardConfiguration, Config};
47use crate::error::{Result, TixError};
48use crate::model::{Issue, Status};
49use crate::storage::Store;
50
51pub mod glab;
52pub mod linear;
53pub mod token;
54
55/// The supported external tracker, selected via `--target gitlab|linear`.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum Tool {
58    GitLab,
59    Linear,
60}
61
62impl Tool {
63    #[must_use]
64    pub fn from_target(target: &str) -> Result<Tool> {
65        match target {
66            "gitlab" => Ok(Tool::GitLab),
67            "linear" => Ok(Tool::Linear),
68            other => Err(TixError::InvalidInput(format!(
69                "unknown --target '{other}' — use gitlab or linear"
70            ))),
71        }
72    }
73
74    /// The token env var that wins over any `tix.toml` token.
75    #[must_use]
76    pub fn token_var(self) -> &'static str {
77        match self {
78            Tool::GitLab => "GITLAB_TOKEN",
79            Tool::Linear => "LINEAR_TOKEN",
80        }
81    }
82
83    /// The binary shelled out to.
84    #[must_use]
85    pub fn binary(self) -> &'static str {
86        match self {
87            Tool::GitLab => "glab",
88            Tool::Linear => "linear-cli",
89        }
90    }
91
92    #[must_use]
93    pub fn as_str(self) -> &'static str {
94        match self {
95            Tool::GitLab => "gitlab",
96            Tool::Linear => "linear",
97        }
98    }
99}
100
101impl std::fmt::Display for Tool {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        f.write_str(self.as_str())
104    }
105}
106
107/// The captured result of a spawned external tool.
108///
109/// `ProcessRunner` waits synchronously on the child (never kills it), so a
110/// blocking tool blocks the sync — the contract's supported "wait" model for
111/// `glab`/`linear-cli`.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct CommandOutcome {
114    pub success: bool,
115    pub status: Option<i32>,
116    pub stdout: String,
117    pub stderr: String,
118}
119
120impl CommandOutcome {
121    /// The human-readable output chosen for error reporting: stderr when the
122    /// tool failed and produced any, otherwise stdout.
123    #[must_use]
124    pub fn text(&self) -> &str {
125        if self.success {
126            &self.stdout
127        } else if !self.stderr.trim().is_empty() {
128            &self.stderr
129        } else {
130            &self.stdout
131        }
132    }
133}
134
135/// Spawn seam: production uses [`ProcessRunner`]; tests inject a
136/// `FakeRunner` that records argv/env without spawning anything.
137pub trait CommandRunner: Send {
138    fn run(
139        &self,
140        program: &str,
141        args: &[String],
142        env: &[(String, String)],
143    ) -> Result<CommandOutcome>;
144
145    /// Downcasting hook so tests can inspect recorded calls on a fake.
146    fn as_any(&self) -> &dyn std::any::Any;
147}
148
149/// Production runner: `std::process::Command` with a synchronous output wait.
150///
151/// A missing binary surfaces as [`TixError::External`] with the contract's
152/// exit-3 wording (`contracts/external-tools.md`: "missing binary ... exit 3").
153pub struct ProcessRunner;
154
155impl CommandRunner for ProcessRunner {
156    fn run(
157        &self,
158        program: &str,
159        args: &[String],
160        env: &[(String, String)],
161    ) -> Result<CommandOutcome> {
162        use std::process::Command;
163        let mut cmd = Command::new(program);
164        cmd.args(args);
165        for (key, value) in env {
166            cmd.env(key, value);
167        }
168        let output = cmd.output().map_err(|e| {
169            if e.kind() == std::io::ErrorKind::NotFound {
170                TixError::External(not_found_message(program))
171            } else {
172                TixError::External(format!("`{program}` failed to start: {e}"))
173            }
174        })?;
175        Ok(CommandOutcome {
176            success: output.status.success(),
177            status: output.status.code(),
178            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
179            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
180        })
181    }
182
183    fn as_any(&self) -> &dyn std::any::Any {
184        self
185    }
186}
187
188/// Common surface both tracker adapters implement so the orchestration is
189/// tool-agnostic.
190///
191/// `status` is the *remote* state/label already computed by the pure mapping
192/// helpers (e.g. `closed`, `Done`, or the first work column). GitLab conveys
193/// "done" via a separate close call ([`TrackerAdapter::mark_done`]); Linear
194/// sends `--status Done` directly ([`TrackerAdapter::done_via_status`]).
195pub trait TrackerAdapter {
196    fn tool(&self) -> Tool;
197
198    /// Create a remote issue and return its remote id (GitLab `iid`, Linear
199    /// `identifier`).
200    fn create_issue(
201        &mut self,
202        title: &str,
203        body: &str,
204        labels: &[String],
205        assignee: Option<&str>,
206        priority: Option<&str>,
207        status: &str,
208    ) -> Result<String>;
209
210    /// Update an existing remote issue in place.
211    #[allow(clippy::too_many_arguments)]
212    fn update_issue(
213        &mut self,
214        remote_ref: &str,
215        title: &str,
216        body: &str,
217        labels: &[String],
218        assignee: Option<&str>,
219        priority: Option<&str>,
220        status: &str,
221    ) -> Result<()>;
222
223    /// Convey "done" (GitLab: `issue close <id>`). Only invoked when
224    /// `done_via_status` is false.
225    fn mark_done(&mut self, remote_ref: &str) -> Result<()>;
226
227    /// List open remote issues (the import source). Closed/done issues are
228    /// excluded.
229    fn list_open(&mut self) -> Result<Vec<RemoteIssue>>;
230
231    /// Linear sets done through the status flag; GitLab needs a separate
232    /// close call.
233    fn done_via_status(&self) -> bool;
234
235    /// Send the relationships this tracker can express, after the issue itself
236    /// exists (WP07 T030/T031).
237    ///
238    /// **Required, not defaulted.** A default no-op would let a tracker be added
239    /// that silently never syncs relationships — the failure mode is a local
240    /// relation that appears to sync and does not, which is the same silent-loss
241    /// shape as a dropped edge. Two implementors is a small price for making the
242    /// omission a compile error.
243    ///
244    /// The relationships arrive already filtered by
245    /// [`map_relation_to_remote`], so an adapter never decides representability:
246    /// it sends what the shared seam says it can send, and reports the rest. A
247    /// `blocks` edge reaches here; a `related` edge never does.
248    fn set_relations(&mut self, remote_ref: &str, relations: &[MappedRelation]) -> Result<()>;
249}
250
251/// A remote issue parsed from the tracker's JSON list output.
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct RemoteIssue {
254    /// Remote id (GitLab `iid`, Linear `identifier`).
255    pub id: String,
256    pub title: String,
257    pub description: String,
258    pub labels: Vec<String>,
259    /// Remote state (`closed` / `Done` / a column label, ...).
260    pub state: String,
261    /// ISO-8601 `updated_at`; `None` when the tool omitted it.
262    pub updated_at: Option<String>,
263    /// Best-effort single assignee (bare name, no `@`).
264    pub assignee: Option<String>,
265    /// The remote id of this issue's parent, when the remote reported one.
266    ///
267    /// A **remote** id, not a local number, because it is read from a remote
268    /// payload and the two id spaces are not interchangeable. It is resolved to a
269    /// local number by `local_parent_number` before anything is written, and
270    /// `None` — rather than a guess — when it cannot be resolved.
271    pub parent: Option<String>,
272}
273
274/// What one export or import run did, and what it could not do.
275///
276/// Returned rather than only written to the caller's `Write`, so a caller can
277/// surface the diagnostics without scraping its own output — and so the count and
278/// the diagnostics cannot disagree. Both entry points had `Result<usize>`; a count
279/// alone cannot express "three issues synced, one relationship could not be".
280#[derive(Debug, Clone, Default, PartialEq, Eq)]
281pub struct SyncReport {
282    /// Issues created, updated, or overwritten.
283    pub handled: usize,
284    /// Relationships the remote could not represent, in encounter order.
285    pub diagnostics: Vec<RemoteMappingDiagnostic>,
286    /// Items whose **status** the transition policy refused (WP07 T031).
287    ///
288    /// Counted separately from `handled` because an import can do both: an issue
289    /// whose title and body imported fine while its status was refused still counts
290    /// as handled — the file was written — and also as skipped, because a column did
291    /// not move. A single number cannot express that, and the report line it backs
292    /// (`overwrote … without its status`) is the one a user has to be able to find.
293    pub skipped: usize,
294}
295
296impl SyncReport {
297    /// Record one unrepresentable relationship: keep the diagnostic for the
298    /// caller, and say so on the human-facing stream too.
299    fn note(&mut self, mapped: MappedRelation, out: &mut dyn Write) -> std::io::Result<()> {
300        if let MappedRelation::Unsupported(diagnostic) = mapped {
301            writeln!(out, "{diagnostic}")?;
302            self.diagnostics.push(diagnostic);
303        }
304        Ok(())
305    }
306
307    /// The same, for a **parent** reference.
308    ///
309    /// Its own method rather than coercing a `MappedParent` into a
310    /// `MappedRelation`: a parent is not a relationship, and the first cut did
311    /// exactly that with an empty `field` and an empty `value` on the remote arm
312    /// — which reads as though something were being sent when nothing is. The
313    /// coercion compiled, produced the right output, and was wrong in a way only
314    /// reading the type would have caught.
315    fn note_parent(&mut self, mapped: MappedParent, out: &mut dyn Write) -> std::io::Result<()> {
316        if let MappedParent::Unsupported(diagnostic) = mapped {
317            writeln!(out, "{diagnostic}")?;
318            self.diagnostics.push(diagnostic);
319        }
320        Ok(())
321    }
322}
323
324/// The record emitted when a remote tracker cannot represent a local relation
325/// (FR-005, C-005, `data-model.md` §6).
326///
327/// The four fields are fixed by the data model. `field` is the important one: it
328/// is what makes the diagnostic actionable rather than a shrug, and
329/// `data-model.md` §6 requires it for exactly that reason — "so the message can
330/// name it".
331#[derive(Debug, Clone, PartialEq, Eq)]
332pub struct RemoteMappingDiagnostic {
333    /// The local relationship the diagnostic is about.
334    pub relation_id: String,
335    /// The specific unsupported remote field or relation name.
336    pub field: String,
337    /// Which adapter produced the diagnostic.
338    pub tool: Tool,
339    /// Human-readable text, safe for CLI output, the TUI, and lint.
340    pub message: String,
341}
342
343impl std::fmt::Display for RemoteMappingDiagnostic {
344    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
345        write!(
346            f,
347            "{}: {} cannot represent this relationship ({}) — {}",
348            self.tool.as_str(),
349            self.field,
350            self.relation_id,
351            self.message
352        )
353    }
354}
355
356/// The outcome of mapping one local relationship for one tracker: either a
357/// remote representation, or a diagnostic naming why there is none.
358#[derive(Debug, Clone, PartialEq, Eq)]
359pub enum MappedRelation {
360    /// The remote field/value pair to send.
361    Remote { field: String, value: String },
362    /// The tracker cannot express this relation. The **local** relation is
363    /// untouched — C-005 is that a relation the remote cannot hold still survives.
364    Unsupported(RemoteMappingDiagnostic),
365}
366
367/// The remote fields each tracker can express for a typed relation, and the one it
368/// maps every blocking edge onto.
369///
370/// This is the whole of the representability decision, and it is deliberately a
371/// table keyed on `(Tool, RelationKind)` rather than logic spread across the two
372/// adapters. Two consequences worth stating:
373///
374/// - **It is derived from what the adapters can actually send**, not from a guess
375///   about their APIs. `glab issue update --blocking` and Linear's
376///   `relations.create` are the only blocking affordances either CLI exposes here,
377///   so `blocks` maps and nothing else does.
378/// - **`blocks` maps; `related`, `duplicates`, and `blocked_by` do not.** Both
379///   trackers' issue-update CLIs carry a title, body, labels, assignee, and state —
380///   and no field for "this issue is related to that one". Guessing otherwise
381///   would mean writing a title into a field, which is not a mapping.
382///
383/// `blocked_by` is the inverse of `blocks`, not a third thing, so it is reported
384/// against the same remote field: naming `blocking` for it would be true but would
385/// send the reader looking for a relation the tracker does not have.
386///
387/// ## Nothing is on the verified list yet
388///
389/// The mapping above knows what each tracker's *model* can express — GitLab and
390/// Linear both have blocking relations in their APIs — but the adapters drive a
391/// **CLI**, and whether `glab issue update --blocking` or a `linear-cli` relation
392/// subcommand exists is a claim about a program this repository cannot run. The
393/// fake `linear-cli` in `tests/bin/` models the tool as issue-only, and a fake
394/// written before this work is better evidence of the surface than an assumption
395/// made after it.
396///
397/// Sending an unverified command line is not a conservative default; it is a
398/// production failure with a confusing message. So the seam maps, and the adapters
399/// send nothing until a verb is verified. T030's instruction is explicit: "do
400/// **not** invent remote capabilities: the mapping must reflect what the adapter
401/// can actually express."
402///
403/// **So `tix export` currently exports no relationships at all**, and every
404/// relationship produces a `RemoteMappingDiagnostic` — the contract's answer for
405/// exactly this case (C-005: emitted, never resolved by deletion; the local
406/// relation survives). The follow-up is narrow: verify both verbs against the real
407/// CLIs, add the tool to this list, and implement its `set_relations`.
408const RELATION_WIRE_VERIFIED: &[Tool] = &[];
409
410// ---------------------------------------------------------------------------
411// WP08 — the parent reference across a sync round trip
412// ---------------------------------------------------------------------------
413
414/// The CLI verbs that would carry a parent reference, per adapter.
415///
416/// **Empty, and for the same reason `RELATION_WIRE_VERIFIED` is.** Both trackers'
417/// *models* have a hierarchy — GitLab has epics and child issues, Linear has
418/// parent issues — but the adapters drive a CLI, and whether `glab issue create
419/// --parent` or `linear-cli issue create --parent` exists is a claim about a
420/// program this repository cannot run. The fakes in `tests/bin/` were written
421/// before this work and accept no parent flag at all, which is better evidence of
422/// the surface than an assumption made after it.
423///
424/// So the seam maps, and nothing is sent until a verb is verified. What *does*
425/// work today — and is what the round trip covers — is that the reference
426/// survives: the local parent is retained, the mapping is reported, and an import
427/// restores a parent that came from the remote. Flipping a tool on here is a
428/// one-line change plus its adapter implementation, once the verb is confirmed by
429/// hand.
430const PARENT_WIRE_VERIFIED: &[Tool] = &[];
431
432/// The remote field each adapter would carry a parent in, if its verb existed.
433///
434/// Present so the diagnostic can *name* the thing that is missing, rather than
435/// saying only that something is. A reader told "GitLab cannot express this" will
436/// not go looking for the flag.
437const GITLAB_PARENT_FIELD: &str = "parent";
438const LINEAR_PARENT_FIELD: &str = "parent";
439
440/// What one parent reference maps to on the wire.
441#[derive(Debug, Clone, PartialEq, Eq)]
442pub enum MappedParent {
443    /// The remote value to send, as a field/value pair like a relation.
444    Remote { field: String, value: String },
445    /// The adapter cannot carry it. The **local** reference is untouched, which is
446    /// the same contract the relationship mapper follows (C-005): a reference the
447    /// remote cannot hold still survives here.
448    Unsupported(RemoteMappingDiagnostic),
449}
450
451/// Map one local parent reference to its remote representation, or say why not.
452///
453/// Pure: no adapter, no I/O, no configuration, and shared by both trackers so a
454/// future one cannot be added with a divergent contract (T033).
455#[must_use]
456pub fn map_parent_to_remote(tool: Tool, issue_number: u32, parent: Option<u32>) -> MappedParent {
457    let Some(parent) = parent else {
458        // No reference is not a mapping problem. Reporting it would put a
459        // diagnostic on every parentless issue in the repository, which is every
460        // diagnostic being ignored.
461        return MappedParent::Remote {
462            field: parent_field(tool).to_string(),
463            value: String::new(),
464        };
465    };
466    let field = parent_field(tool);
467    if PARENT_WIRE_VERIFIED.contains(&tool) {
468        return MappedParent::Remote {
469            field: field.to_string(),
470            value: format!("#{parent}"),
471        };
472    }
473    MappedParent::Unsupported(RemoteMappingDiagnostic {
474        relation_id: format!("{issue_number}->{parent}"),
475        field: field.to_string(),
476        tool,
477        message: format!(
478            "#{issue_number}'s parent #{parent} is expressible in {}'s model as \
479             `{field}`, but the CLI verb that would carry it is not verified, so \
480             nothing is sent; the local reference is kept",
481            tool.as_str()
482        ),
483    })
484}
485
486/// The field an adapter would carry a parent in.
487fn parent_field(tool: Tool) -> &'static str {
488    match tool {
489        Tool::GitLab => GITLAB_PARENT_FIELD,
490        Tool::Linear => LINEAR_PARENT_FIELD,
491    }
492}
493
494const GITLAB_BLOCKING_FIELD: &str = "blocking";
495const GITLAB_BLOCKING_VALUE: &str = "blocks";
496const LINEAR_BLOCKING_FIELD: &str = "blocks";
497const LINEAR_BLOCKING_VALUE: &str = "blocks";
498
499/// Map one local relationship to its remote representation, or say why not.
500///
501/// Pure: no adapter, no I/O, no configuration. Both trackers share it, so a future
502/// one cannot be added with a divergent contract (T033) — and the representability
503/// table lives in exactly one place instead of once per adapter.
504#[must_use]
505pub fn map_relation_to_remote(
506    tool: Tool,
507    relation: &crate::relationships::Relationship,
508) -> MappedRelation {
509    use crate::relationships::RelationKind;
510    let (field, value) = match tool {
511        Tool::GitLab => (GITLAB_BLOCKING_FIELD, GITLAB_BLOCKING_VALUE),
512        Tool::Linear => (LINEAR_BLOCKING_FIELD, LINEAR_BLOCKING_VALUE),
513    };
514    // The tracker's *model* can express a blocking edge — but the adapter drives a
515    // CLI, and no relation verb is verified against either one yet. See
516    // `RELATION_WIRE_VERIFIED`.
517    let model_supports = matches!(
518        relation.kind,
519        RelationKind::Blocks | RelationKind::BlockedBy
520    );
521    if model_supports && RELATION_WIRE_VERIFIED.contains(&tool) {
522        return MappedRelation::Remote {
523            field: field.to_string(),
524            value: format!("{value} #{}", relation.to),
525        };
526    }
527    if model_supports {
528        // Expressible in the model, not drivable from the CLI. A *different* claim
529        // from "unsupported", and the message says which — a reader who is told
530        // "GitLab cannot express this" will not go looking for the verb.
531        return MappedRelation::Unsupported(RemoteMappingDiagnostic {
532            relation_id: relation.id.clone(),
533            field: field.to_string(),
534            tool,
535            message: format!(
536                "`{}` is expressible in {}'s model as `{field}`, but the CLI verb \\
537                 that would carry it is not verified, so nothing is sent; the local \\
538                 edge is kept",
539                relation.kind.as_str(),
540                tool.as_str()
541            ),
542        });
543    }
544    MappedRelation::Unsupported(RemoteMappingDiagnostic {
545        relation_id: relation.id.clone(),
546        field: field.to_string(),
547        tool,
548        message: format!(
549            "`{}` is a local-only relation; {} has no field for it, so the local \
550             edge is kept and the remote one is not created",
551            relation.kind.as_str(),
552            tool.as_str()
553        ),
554    })
555}
556
557/// The inverse: read a remote relation back to a local kind and target.
558///
559/// Symmetric with [`map_relation_to_remote`] by construction — the blocking field
560/// maps back to `blocks`, and nothing else maps back at all. A remote payload
561/// carrying a field this function does not recognise yields a diagnostic rather
562/// than a default, so an import cannot invent a relation the export never made.
563#[must_use]
564pub fn map_relation_from_remote(
565    tool: Tool,
566    field: &str,
567    value: &str,
568    relation_id: &str,
569) -> MappedRelation {
570    let expected = match tool {
571        Tool::GitLab => GITLAB_BLOCKING_FIELD,
572        Tool::Linear => LINEAR_BLOCKING_FIELD,
573    };
574    // Gated the same way as the forward direction: without a verified verb there is
575    // no payload this could have received, and coercing one would invent a
576    // relationship the export never made.
577    if field == expected && RELATION_WIRE_VERIFIED.contains(&tool) {
578        // The value this module writes is `"{kind} #{n}"`, so the target is the
579        // **last** whitespace-separated token. Taking the first reads the kind word
580        // and fails — which is what an earlier version did, and it made the round
581        // trip look asymmetric when only the parser was.
582        if let Some(target) = value
583            .split_whitespace()
584            .last()
585            .and_then(|t| t.strip_prefix('#'))
586            .and_then(|t| t.parse::<u32>().ok())
587        {
588            // The reverse value is **local**-shaped — the canonical kind spelling
589            // and a `#`-prefixed number — not a copy of the remote field name.
590            // GitLab's field is `blocking` and Linear's is `blocks`; echoing the
591            // field back would make the round trip return different text per
592            // tracker for the same relationship, which is the opposite of
593            // symmetric.
594            return MappedRelation::Remote {
595                field: "kind".to_string(),
596                value: format!("blocks #{target}"),
597            };
598        }
599        return MappedRelation::Unsupported(RemoteMappingDiagnostic {
600            relation_id: relation_id.to_string(),
601            field: field.to_string(),
602            tool,
603            message: format!(
604                "`{value}` is not a target issue number, so the remote relation \
605                 cannot be read back; no local edge was created"
606            ),
607        });
608    }
609    MappedRelation::Unsupported(RemoteMappingDiagnostic {
610        relation_id: relation_id.to_string(),
611        field: field.to_string(),
612        tool,
613        message: format!(
614            "`{field}` is not a relation field {} writes, so it is ignored; the \
615             local relationship set is unchanged",
616            tool.as_str()
617        ),
618    })
619}
620
621/// Map a local column to the remote state/label sent to the tracker
622/// (`contracts/external-tools.md` mapping table).
623///
624/// - GitLab: done → `closed`, anything else → `opened`.
625/// - Linear: done → `Done`, otherwise the first non-done column label.
626#[must_use]
627pub fn local_to_remote_status(tool: Tool, local_status: &str, config: &Config) -> String {
628    let board = config.default_config();
629    match tool {
630        Tool::GitLab => {
631            if is_done_column(board, local_status, config) {
632                "closed".into()
633            } else {
634                "opened".into()
635            }
636        }
637        Tool::Linear => {
638            if is_done_column(board, local_status, config) {
639                "Done".into()
640            } else {
641                first_work_column(board, config).to_string()
642            }
643        }
644    }
645}
646
647/// Map a remote state/label back onto a local column. Closed/Done maps to
648/// the effective done column; anything else lands in the column whose name
649/// matches (case-insensitive), else the first column — per the contract's
650/// "whose name matches, else into the first column".
651#[must_use]
652pub fn remote_to_local_status(tool: Tool, remote_status: &str, config: &Config) -> String {
653    let board = config.default_config();
654    // WP07 T032: the *service's* completion column, not `done_column`. See
655    // `completion_column` for why choosing one local column is this module's business
656    // while the meaning of "complete" is not.
657    let done = completion_column(board, config);
658    match tool {
659        Tool::GitLab => {
660            if remote_status == "closed" {
661                done
662            } else {
663                board
664                    .columns
665                    .first()
666                    .cloned()
667                    .unwrap_or_else(|| "todo".into())
668            }
669        }
670        Tool::Linear => {
671            if remote_status == "Done" {
672                done
673            } else {
674                board
675                    .columns
676                    .iter()
677                    .find(|c| c.eq_ignore_ascii_case(remote_status))
678                    .cloned()
679                    .unwrap_or_else(|| {
680                        board
681                            .columns
682                            .first()
683                            .cloned()
684                            .unwrap_or_else(|| "todo".into())
685                    })
686            }
687        }
688    }
689}
690
691/// Whether `local_status` is the board's **completion** column, by the service.
692///
693/// WP07 T032. This was `board.effective_done_column() == Some(local_status)` — a
694/// column-*name* comparison, which is a second local definition of "done" and exactly
695/// what C-004 forbids. It agreed with the service on a board with no
696/// `category_map` — every existing configuration — and disagreed on precisely the
697/// boards this mission exists to support: a board mapping `shipped` to `completed`
698/// exports `shipped` as *open* and leaves `done_column` (mapped to `active`) as
699/// *closed*.
700///
701/// `sync/mod.rs` is out of this mission's map; the edit is the completion
702/// reconciliation T032 names, and the rationale is recorded here and in
703/// `decisions/WP07-verification.md`.
704fn is_done_column(board: &BoardConfiguration, local_status: &str, config: &Config) -> bool {
705    let resolution =
706        crate::workflow::resolution(config, board, crate::workflow::active_board_name(config));
707    resolution
708        .resolve(local_status)
709        .is_some_and(crate::workflow::WorkflowCategory::is_complete)
710}
711
712/// The board's completion **column**, chosen by the service rather than by
713/// `done_column`.
714///
715/// The remote trackers have a two-state notion of "closed", so the export path has to
716/// pick one local column to send as closed. That is a **remote-translation** concern
717/// and belongs here; deciding what "complete" *means* is not, and is the service's.
718///
719/// Preferring the column the service calls complete means a board whose completion
720/// category sits on a column other than `done_column` still round-trips: export
721/// closes the column that is genuinely complete, and import brings it back to that
722/// same column rather than to `done_column`.
723fn completion_column(board: &BoardConfiguration, config: &Config) -> String {
724    // WP09: this was a private re-implementation — the same "first complete column,
725    // else `done_column`, else `done`" rule the service now owns, duplicated here so
726    // that `first_work_column` and `remote_to_local_status` could each have a total
727    // `String`. It is the module's own doc comment, written in WP07, that says a
728    // second local definition is a C-004 defect, so the duplicate had to go.
729    crate::workflow::completion_column_or_default(board, config)
730}
731
732/// The first column that is **not** the board's completion column, or the
733/// completion column when every column is complete.
734///
735/// WP09: "not the done column" is a question about **completion**, so it is asked of
736/// the service rather than of `done_column`. On a board whose `category_map` puts
737/// `completed` on `shipped` while `done_column` is `done`, the old version returned
738/// `todo` as the first *work* column when `shipped` is the first column that is
739/// genuinely finished — so an export labelled a shipped issue as work in progress.
740fn first_work_column<'a>(board: &'a BoardConfiguration, config: &Config) -> &'a str {
741    // The lifetime is `board`'s, and the `done` fallback cannot borrow from `config`
742    // — so the completion column is compared as a `String` and the fallback is a
743    // literal. The alternative, returning `&'a str` from a local, is what forces the
744    // old `unwrap_or("done")` to exist at all.
745    let done = crate::workflow::completion_column_or_default(board, config);
746    board
747        .columns
748        .iter()
749        .find(|c| c.as_str() != done)
750        .map_or("done", String::as_str)
751}
752
753/// The inverse of `model::civil_from_days` (Howard Hinnant's
754/// `days_from_civil`): calendar date → days since the Unix epoch.
755#[must_use]
756pub fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
757    let y = if m <= 2 { y - 1 } else { y };
758    let era = if y >= 0 { y } else { y - 399 } / 400;
759    let yoe = y - era * 400; // [0, 399]
760    let mp = (m + 9) % 12; // [0, 11]
761    let doy = i64::from((153 * mp + 2) / 5 + d - 1); // [0, 365]
762    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
763    era * 146_097 + doe - 719_468
764}
765
766/// Parse `YYYY-MM-DD` into days since the Unix epoch.
767#[must_use]
768pub fn date_to_epoch_days(ymd: &str) -> Option<i64> {
769    let mut parts = ymd.trim().split('-');
770    let y: i64 = parts.next()?.parse().ok()?;
771    let m: u32 = parts.next()?.parse().ok()?;
772    let d: u32 = parts.next()?.parse().ok()?;
773    if parts.next().is_some() {
774        return None;
775    }
776    if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
777        return None;
778    }
779    Some(days_from_civil(y, m, d))
780}
781
782/// Parse an ISO-8601 remote timestamp into epoch seconds. Accepts
783/// `YYYY-MM-DDTHH:MM:SS` (the `T` may be a space) with optional fractional
784/// seconds and a `Z` or `±HH:MM` zone.
785#[must_use]
786pub fn iso_to_epoch_secs(input: &str) -> Option<i64> {
787    let (date, time) = input.trim().split_once(['T', ' '])?;
788    let days = date_to_epoch_days(date)?;
789    let (time, zone) = split_zone(time);
790    let mut hms = time.split(':');
791    let hh: i64 = hms.next()?.parse().ok()?;
792    let mm: i64 = hms.next()?.parse().ok()?;
793    let ss_part = hms.next()?;
794    let whole: i64 = ss_part.split('.').next()?.parse().ok()?;
795    if !(0..=23).contains(&hh) || !(0..=59).contains(&mm) || !(0..=59).contains(&whole) {
796        return None;
797    }
798    let mut secs = days * 86_400 + hh * 3600 + mm * 60 + whole;
799    if let Some((zh, zm)) = zone {
800        secs -= zh * 3600 + zm * 60;
801    }
802    Some(secs)
803}
804
805/// Split `HH:MM:SS[.frac][zone]` into the bare time and an optional
806/// `(hours, minutes)` zone offset (already sign-adjusted).
807fn split_zone(time: &str) -> (&str, Option<(i64, i64)>) {
808    if time.ends_with('Z') || time.ends_with('z') {
809        return (&time[..time.len() - 1], Some((0, 0)));
810    }
811    match time.rfind(['+', '-']) {
812        Some(i) if i > 0 => {
813            let (bare, zone) = time.split_at(i);
814            let sign = if zone.starts_with('-') { -1i64 } else { 1i64 };
815            let digits = &zone[1..];
816            let (zh, zm) = if let Some((h, m)) = digits.split_once(':') {
817                (h.parse::<i64>().ok(), m.parse::<i64>().ok())
818            } else if digits.len() == 2 {
819                (digits.parse::<i64>().ok(), Some(0))
820            } else if digits.len() >= 3 {
821                let (h, m) = digits.split_at(digits.len() - 2);
822                (h.parse::<i64>().ok(), m.parse::<i64>().ok())
823            } else {
824                (None, None)
825            };
826            match (zh, zm) {
827                (Some(zh), Some(zm)) => (bare, Some((sign * zh, sign * zm))),
828                _ => (bare, None),
829            }
830        }
831        Some(_) | None => (time, None),
832    }
833}
834
835/// The conflict rule: the remote wins when its `updated_at` is strictly later
836/// than the local issue's `updated` (a `YYYY-MM-DD` interpreted as end of
837/// day). A remote timestamp that cannot be parsed counts as *not newer*, so
838/// the local issue is kept.
839#[must_use]
840pub fn is_remote_newer(remote_updated_at: &str, local_updated: &str) -> bool {
841    let Some(remote) = iso_to_epoch_secs(remote_updated_at) else {
842        return false;
843    };
844    let local_end_of_day = date_to_epoch_days(local_updated).map_or(0, |d| d * 86_400 + 86_399);
845    remote > local_end_of_day
846}
847
848/// Redact a resolved secret from text flowing through error/report surfaces.
849#[must_use]
850pub fn scrub_secret(text: &str, secret: &str) -> String {
851    if secret.is_empty() {
852        return text.to_string();
853    }
854    text.replace(secret, "<token>")
855}
856
857/// The contract's missing-binary wording (`contracts/external-tools.md`):
858/// exit code 3 with `required tool not found: <tool> — install from <source>`.
859fn not_found_message(program: &str) -> String {
860    match program {
861        "glab" => "required tool not found: glab — install from https://gitlab.com/gitlab-org/cli"
862            .to_string(),
863        _ => format!("required tool not found: {program} — install it and retry"),
864    }
865}
866
867/// `tix export`: mirror local issues to the remote tracker, recording the
868/// remote id back into the local `gitlab_id`/`linear_id` so a re-run updates
869/// instead of creating duplicates (idempotency, FR-Remote-sync).
870///
871/// Returns the number of issues handled (created or updated — `close` on an
872/// updated GitLab done issue is not counted separately).
873pub fn export(
874    store: &Store,
875    tool: Tool,
876    config: &Config,
877    dry_run: bool,
878    out: &mut dyn Write,
879) -> Result<SyncReport> {
880    let mut adapter = adapter_for(tool, config)?;
881    let mut report = SyncReport::default();
882    // Built **once** for the whole run, which is the service's rule: resolution is
883    // not rebuilt per issue. See `workflow::resolution`.
884    let resolution = crate::workflow::resolution(config, config.default_config(), "default");
885
886    if dry_run {
887        for issue in store.list()? {
888            match remote_ref(tool, &issue) {
889                Some(r) => {
890                    writeln!(out, "would update {tool} issue {r} \"{}\"", issue.title)?;
891                }
892                None => {
893                    writeln!(out, "would create {tool} issue \"{}\"", issue.title)?;
894                }
895            }
896        }
897        writeln!(out, "dry run — {tool} CLI was not invoked")?;
898        report.handled = store.list()?.len();
899        // The dry run reports the same diagnostics a real run would, so
900        // "preview then commit" is not the only way to find out that a
901        // relationship cannot leave the machine.
902        for issue in store.list()? {
903            for rel in store.relationships_for(issue.number)? {
904                report.note(map_relation_to_remote(tool, &rel), out)?;
905            }
906            // And the parent, so a dry run surfaces exactly what a real run would.
907            if issue.parent.is_some() {
908                report.note_parent(map_parent_to_remote(tool, issue.number, issue.parent), out)?;
909            }
910        }
911        return Ok(report);
912    }
913
914    let mut handled = 0;
915    for mut issue in store.list()? {
916        let remote_status = local_to_remote_status(tool, issue.status.display(), config);
917        let labels: Vec<String> = issue.labels.clone();
918        let assignee = issue.assignee.as_deref();
919        let priority = issue.priority.to_string();
920        match remote_ref(tool, &issue) {
921            Some(r) => {
922                let r = r.to_string();
923                adapter.update_issue(
924                    &r,
925                    &issue.title,
926                    &issue.body,
927                    &labels,
928                    assignee,
929                    Some(&priority),
930                    &remote_status,
931                )?;
932                if !adapter.done_via_status() && crate::workflow::is_completed(&issue, &resolution)
933                {
934                    adapter.mark_done(&r)?;
935                }
936                writeln!(out, "updated {tool} issue {r} \"{}\"", issue.title)?;
937            }
938            None => {
939                let id = adapter.create_issue(
940                    &issue.title,
941                    &issue.body,
942                    &labels,
943                    assignee,
944                    Some(&priority),
945                    &remote_status,
946                )?;
947                if !adapter.done_via_status() && crate::workflow::is_completed(&issue, &resolution)
948                {
949                    adapter.mark_done(&id)?;
950                }
951                match tool {
952                    Tool::GitLab => issue.gitlab_id = Some(id.clone()),
953                    Tool::Linear => issue.linear_id = Some(id.clone()),
954                }
955                store.update(&issue.filename, &issue)?;
956                writeln!(
957                    out,
958                    "created {tool} issue {id} \"{}\" (remote id recorded)",
959                    issue.title
960                )?;
961            }
962        }
963        // The parent reference is offered to the same shared mapper, on the same
964        // "after the issue" footing as a relationship: a parent only means
965        // something once the parent itself exists remotely. Nothing is sent while
966        // `PARENT_WIRE_VERIFIED` is empty, so this reports rather than acts — and
967        // the local reference is untouched either way.
968        report.note_parent(map_parent_to_remote(tool, issue.number, issue.parent), out)?;
969
970        // Relationships go **after** the issue, because an edge is only meaningful
971        // once both endpoints exist remotely. Each is offered to the shared mapper
972        // first: what it accepts is sent to the adapter, what it does not becomes a
973        // diagnostic naming the field. The adapter never decides representability.
974        let remote = match remote_ref(tool, &issue) {
975            Some(r) => r.to_string(),
976            None => match tool {
977                Tool::GitLab => issue.gitlab_id.clone(),
978                Tool::Linear => issue.linear_id.clone(),
979            }
980            .unwrap_or_default(),
981        };
982        if !remote.is_empty() {
983            let mut sendable = Vec::new();
984            for rel in store.relationships_for(issue.number)? {
985                match map_relation_to_remote(tool, &rel) {
986                    MappedRelation::Remote { field, value } => {
987                        writeln!(out, "exported {tool} {field} {value} ({})", rel.id)?;
988                        sendable.push(MappedRelation::Remote { field, value });
989                    }
990                    unsupported @ MappedRelation::Unsupported(_) => {
991                        report.note(unsupported, out)?
992                    }
993                }
994            }
995            adapter.set_relations(&remote, &sendable)?;
996        }
997        handled += 1;
998    }
999    report.handled = handled;
1000    writeln!(
1001        out,
1002        "exported {handled} issue(s) to {tool} ({} relationship(s) not representable)",
1003        report.diagnostics.len()
1004    )?;
1005    Ok(report)
1006}
1007
1008/// `tix import`: pull open remote issues into local files.
1009///
1010/// Conflict resolution: a remote issue that already tracks a local one skips
1011/// the local file when the local `updated` is later or equal; when the remote
1012/// `updated_at` is newer the local file is overwritten (fields + body, keeping
1013/// `created`), with the previous file first copied to `<filename>.bak`.
1014///
1015/// `policy` is the explicit triage-entry policy (WP07 T034). It decides **only
1016/// where a newly created file lands and what the report says about it**: the
1017/// conflict rules above, remote ids, and adapter behaviour are untouched, and
1018/// the overwrite path never consults it (T036).
1019///
1020/// Returns the number of local files created or overwritten.
1021pub fn import(
1022    store: &Store,
1023    tool: Tool,
1024    config: &Config,
1025    out: &mut dyn Write,
1026    policy: crate::triage::import_policy::ImportPolicy,
1027) -> Result<SyncReport> {
1028    let mut adapter = adapter_for(tool, config)?;
1029    import_with_adapter(store, tool, config, out, policy, adapter.as_mut())
1030}
1031
1032/// [`import`] with the adapter injected — the [`CommandRunner`] pattern, so the
1033/// orchestration under test is the real one and the remote side is a fake that
1034/// returns canned [`RemoteIssue`]s without spawning a process (WP07 T037).
1035///
1036/// `command`s like `tix import` still go through [`import`]; this entry point
1037/// exists so a test can hold every input of the triage policy in one place —
1038/// config, policy, and the remote payloads — instead of installing a fake
1039/// binary on `PATH` and running the compiled CLI to reach it.
1040pub fn import_with_adapter(
1041    store: &Store,
1042    tool: Tool,
1043    config: &Config,
1044    out: &mut dyn Write,
1045    policy: crate::triage::import_policy::ImportPolicy,
1046    adapter: &mut dyn TrackerAdapter,
1047) -> Result<SyncReport> {
1048    // Resolved **before** the loop: a policy this project cannot represent
1049    // refuses the whole import, not the third item of it.
1050    let router = crate::triage::import_policy::resolve(config, policy)?;
1051    let remote_issues = adapter.list_open()?;
1052    let mut report = SyncReport::default();
1053    let mut handled = 0;
1054    // Remote ids imported this run, paired with the local number each one landed
1055    // on. A parent is named by a **remote** id in the payload, so the reference can
1056    // only be restored after the parent itself has been created — which is why
1057    // this is a second pass rather than something set while building each issue.
1058    let mut imported: Vec<(String, u32)> = Vec::new();
1059    // `(child remote id, parent remote id)` for every payload that named one.
1060    let mut claims: Vec<(String, String)> = Vec::new();
1061    for remote in remote_issues.clone() {
1062        if let Some(parent) = remote.parent.clone() {
1063            claims.push((remote.id.clone(), parent));
1064        }
1065        let tracked = store
1066            .list()?
1067            .into_iter()
1068            .find(|i| remote_ref(tool, i) == Some(remote.id.as_str()));
1069        match tracked {
1070            None => {
1071                // WP07 T034/T035: the placement decision is made here, once, from
1072                // the remote state and the configured entry column — before any
1073                // byte of the file exists. `requested` comes from this module's
1074                // own mapper rather than being re-derived by the policy, so the
1075                // two answers can be compared in the report instead of guessed at.
1076                let requested = remote_to_local_status(tool, &remote.state, config);
1077                let mapping = router.place(&remote.state, &requested);
1078                let mut issue = Issue {
1079                    number: 0,
1080                    title: remote.title.clone(),
1081                    status: Status::new(mapping.landed.clone()),
1082                    labels: remote.labels.clone(),
1083                    priority: crate::model::Priority::Medium,
1084                    assignee: remote.assignee.clone(),
1085                    estimate: None,
1086                    commit: None,
1087                    gitlab_id: None,
1088                    linear_id: None,
1089                    mission: None,
1090                    task: None,
1091                    relationships: vec![],
1092                    related_issues: vec![],
1093                    files: vec![],
1094                    cycles: None,
1095                    milestones: None,
1096                    reviewer: None,
1097                    start_date: None,
1098                    due_date: None,
1099                    created: crate::model::today(),
1100                    type_: None,
1101                    updated: crate::model::today(),
1102                    filename: String::new(),
1103                    body: String::new(),
1104                    history: crate::model::History::new(),
1105                    has_retired_links_key: false,
1106                    parent: None,
1107                    extra_frontmatter: std::collections::BTreeMap::new(),
1108                    archived: false,
1109                    entered_at: None,
1110                    source: None,
1111                };
1112                // T035: entry metadata through the same seam every other entry
1113                // uses. The queue's age is measured from `entered_at`, so an issue
1114                // that entered the board through an import has to record that it
1115                // did — and `import` is the origin data-model §2 documents for it.
1116                //
1117                // The **landing column** decides, not `mapping.routed`: `routed` is a
1118                // policy notion, so under `--triage-policy remote` (or a default whose
1119                // remote-mapped column happens to be the entry column) an imported
1120                // issue landed in the inbox with no stamp and the queue reported
1121                // `unknown` for an issue it was listing. On a project with no
1122                // `[triage]` section the shared rule stamps nothing, which is the
1123                // outcome the old guard was reaching for.
1124                crate::triage::record_entry_if_inbox(config, &mut issue, "import")?;
1125                match tool {
1126                    Tool::GitLab => issue.gitlab_id = Some(remote.id.clone()),
1127                    Tool::Linear => issue.linear_id = Some(remote.id.clone()),
1128                }
1129                // WP07 T031: a **column check** on the incoming status, and only
1130                // that. `remote_to_local_status` already lands on a column the board
1131                // configures — the service's completion column, or a name match, or
1132                // the first — so this is the belt to that braces, and it means an
1133                // import can never create a file in a column the board lacks. The
1134                // create path performs the same check (WP05), so this is belt *and*
1135                // braces for the remote-sourced case.
1136                //
1137                // Not a transition evaluation: a new issue is not leaving anywhere, so
1138                // the terminal lock has nothing to say. Importing an issue directly
1139                // into a terminal column is legitimate.
1140                let (_, resolution) = crate::workflow::active_resolution(config);
1141                if resolution.resolve(issue.status.display()).is_none() {
1142                    // Reported, not written. A skipped item is visible and the rest of
1143                    // the batch still lands.
1144                    writeln!(
1145                        out,
1146                        "skipped {tool} issue {} \"{}\": `{}` is not a column on board `{}`",
1147                        remote.id, remote.title, issue.status, resolution.board
1148                    )?;
1149                    report.skipped += 1;
1150                    continue;
1151                }
1152                let path = store.create(&mut issue, &remote.description, None)?;
1153                imported.push((remote.id.clone(), issue.number));
1154                let name = path
1155                    .file_name()
1156                    .map(|n| n.to_string_lossy().to_string())
1157                    .unwrap_or_default();
1158                // T035: the mapping is named in the same line that names the file,
1159                // and `report_clause` is empty exactly when the default policy
1160                // decided nothing — so a legacy project's report is byte-identical
1161                // to what it was, and a project that routed says where the remote
1162                // state asked for and where the issue landed.
1163                let clause = mapping.report_clause();
1164                writeln!(
1165                    out,
1166                    "imported {tool} issue {} \"{}\" as #{} ({}) [{name}]{clause}",
1167                    remote.id, remote.title, issue.number, issue.status
1168                )?;
1169                handled += 1;
1170            }
1171            Some(local) => {
1172                if is_remote_newer(remote.updated_at.as_deref().unwrap_or(""), &local.updated) {
1173                    let backup = format!("{}.bak", local.filename);
1174                    std::fs::copy(
1175                        store.issues_dir.join(&local.filename),
1176                        store.issues_dir.join(&backup),
1177                    )?;
1178                    // Captured before `local` is moved into `overwritten`: the
1179                    // skip path restores the local status, and reading it back off
1180                    // `overwritten` after the assignment would restore the status the
1181                    // *incoming* value was about to overwrite.
1182                    let local_status = local.status.clone();
1183                    let mut overwritten = local;
1184                    let keep_created = overwritten.created.clone();
1185                    overwritten.title = remote.title.clone();
1186                    overwritten.body = remote.description.clone();
1187                    overwritten.labels = remote.labels.clone();
1188                    let incoming = Status::new(remote_to_local_status(tool, &remote.state, config));
1189                    // WP07 T031: validate the incoming status as a **transition**,
1190                    // because this *is* one — an existing issue moving columns.
1191                    //
1192                    // **Skip-and-report**, which is the disposition the design settles
1193                    // (T031 step 3). The alternative, apply-and-report, would make a
1194                    // board's policy non-binding for the one path nobody watches, and
1195                    // a remote whose states the board forbids would quietly win.
1196                    // Skipping keeps the local file truthful and puts the conflict in
1197                    // the report where a human will see it. Recorded here rather than
1198                    // in `data-model.md` §8, which is a protected path for this lane.
1199                    let (_, resolution) = crate::workflow::active_resolution(config);
1200                    let transition = crate::workflow::validate_transition(
1201                        &overwritten,
1202                        incoming.display(),
1203                        &resolution,
1204                        config,
1205                        None,
1206                    );
1207                    if let crate::workflow::TransitionOutcome::Rejected(diagnostic) = transition {
1208                        // The status is left alone; the other fields still import,
1209                        // because they are not what the policy governs. The file is
1210                        // rewritten, so this is a **partial** import — reported as such
1211                        // rather than as a plain overwrite.
1212                        overwritten.status = local_status.clone();
1213                        overwritten.title = remote.title.clone();
1214                        overwritten.body = remote.description.clone();
1215                        overwritten.labels = remote.labels.clone();
1216                        if let Some(a) = &remote.assignee {
1217                            overwritten.assignee = Some(a.clone());
1218                        }
1219                        overwritten.created = keep_created;
1220                        overwritten.bump_updated();
1221                        let filename = overwritten.filename.clone();
1222                        store.update(&filename, &overwritten)?;
1223                        writeln!(
1224                            out,
1225                            "overwrote {tool} issue {} \"{}\" without its status: {diagnostic} \
1226                             (kept {})",
1227                            remote.id, overwritten.title, overwritten.status
1228                        )?;
1229                        report.skipped += 1;
1230                        handled += 1;
1231                        continue;
1232                    }
1233                    overwritten.status = incoming;
1234                    if let Some(a) = &remote.assignee {
1235                        overwritten.assignee = Some(a.clone());
1236                    }
1237                    overwritten.created = keep_created;
1238                    overwritten.bump_updated();
1239                    let filename = overwritten.filename.clone();
1240
1241                    // An import that moves a locally-blocked issue into the
1242                    // board's completion column goes through the same blocked-move
1243                    // policy as every other surface -- WP04 T016 step 6 names
1244                    // "any bulk or import path" explicitly, and an import that
1245                    // can end an issue in the completion column with open
1246                    // blockers is the sharpest form of that.
1247                    //
1248                    // It **records** the acknowledgement rather than refusing, and
1249                    // the asymmetry is deliberate. There is no interactive
1250                    // confirmation available mid-import, and refusing would make
1251                    // `tix import` unusable whenever one issue in a batch carries
1252                    // a stale blocker -- turning a stale local edge into a
1253                    // permanently un-syncable board. The user ran the import;
1254                    // that is the decision, and the event exists to preserve it.
1255                    // Where a human is present to answer, refusing is right
1256                    // (`tix move`, `tix edit --status`, `move_issue`); here the
1257                    // honest record is that the import did it.
1258                    let guard = crate::commands::mv::blocked_move_guard(
1259                        config,
1260                        store,
1261                        &overwritten,
1262                        overwritten.status.display(),
1263                    )?;
1264                    if let Some(warning) = guard.warning() {
1265                        overwritten.history.append_now(
1266                            &crate::storage::actor_identity(),
1267                            crate::model::HistoryEventKind::BlockedMoveAcknowledged,
1268                            &format!("imported to {}: {warning}", overwritten.status.display()),
1269                        );
1270                    }
1271                    store.update(&filename, &overwritten)?;
1272                    writeln!(
1273                        out,
1274                        "overwrote {tool} issue {} \"{}\" (remote newer; backup at {backup})",
1275                        remote.id, overwritten.title
1276                    )?;
1277                    handled += 1;
1278                } else {
1279                    writeln!(
1280                        out,
1281                        "kept {tool} issue {} \"{}\" (local updated later or equal)",
1282                        remote.id, remote.title
1283                    )?;
1284                }
1285            }
1286        }
1287    }
1288    restore_imported_parents(store, tool, &claims, &imported, &mut report, out)?;
1289    writeln!(out, "imported {handled} {tool} issue(s)")?;
1290    report.handled = handled;
1291    Ok(report)
1292}
1293
1294/// Restore the parent references an import's payload reported.
1295///
1296/// A **second pass**, because a payload names a parent by *remote* id and the
1297/// parent may well be imported after its child. Setting the field while building
1298/// each issue would therefore succeed or fail depending on the order the remote
1299/// happened to return, which is the order-dependence NFR-003 forbids.
1300///
1301/// Every write goes through [`crate::hierarchy::set_parent`] rather than a field
1302/// assignment, so an imported reference obeys the same rules as a CLI write: it is
1303/// validated before anything is written, and a refusal leaves the file
1304/// byte-identical. T032's note is explicit — "an import that would create a cycle
1305/// is a defect, not a special case" — and the service is what decides that, not
1306/// this loop.
1307///
1308/// `claims` is `(child remote id, parent remote id)` and `imported` is
1309/// `(remote id, local number)`. A pair where either side does not resolve becomes a
1310/// diagnostic rather than a write: a reference to an issue this run did not import
1311/// is exactly the unresolved-parent state WP05 added a finding for, and
1312/// manufacturing it here would be this loop's own defect.
1313fn restore_imported_parents(
1314    store: &Store,
1315    tool: Tool,
1316    claims: &[(String, String)],
1317    imported: &[(String, u32)],
1318    report: &mut SyncReport,
1319    out: &mut dyn Write,
1320) -> Result<()> {
1321    for (child_remote, parent_remote) in claims {
1322        let child = imported
1323            .iter()
1324            .find(|(id, _)| id == child_remote)
1325            .map(|(_, number)| *number);
1326        let parent = imported
1327            .iter()
1328            .find(|(id, _)| id == parent_remote)
1329            .map(|(_, number)| *number);
1330        let (Some(child), Some(parent)) = (child, parent) else {
1331            report.note_parent(
1332                MappedParent::Unsupported(RemoteMappingDiagnostic {
1333                    relation_id: format!("{child_remote}->{parent_remote}"),
1334                    field: parent_field(tool).to_string(),
1335                    tool,
1336                    message: format!(
1337                        "remote issue {child_remote} reports parent {parent_remote}, \
1338                         which this import did not create — it may be closed, \
1339                         filtered out, or in another project; no local reference is \
1340                         written, so the issue stays parentless rather than \
1341                         unresolved"
1342                    ),
1343                }),
1344                out,
1345            )?;
1346            continue;
1347        };
1348        // Through `Store::set_parent`, which resolves, validates with the
1349        // hierarchy service, writes, and records the one History entry — the
1350        // same path `tix edit --parent` takes, so an imported reference is
1351        // indistinguishable from a typed one. A refusal is reported and nothing is
1352        // written.
1353        let (child_sel, parent_sel) = (child.to_string(), parent.to_string());
1354        match store.set_parent(&child_sel, &parent_sel) {
1355            Ok(_) => writeln!(out, "restored parent of {tool} issue #{child} to #{parent}")?,
1356            Err(e) => {
1357                writeln!(
1358                    out,
1359                    "could not restore the parent of {tool} issue #{child}: {e}; the \
1360                     reference was not written"
1361                )?;
1362                report.diagnostics.push(RemoteMappingDiagnostic {
1363                    relation_id: format!("{child}->{parent}"),
1364                    field: parent_field(tool).to_string(),
1365                    tool,
1366                    message: format!(
1367                        "the remote reports #{child} under #{parent}, but the hierarchy \
1368                         service refused it: {e}"
1369                    ),
1370                });
1371            }
1372        }
1373    }
1374    Ok(())
1375}
1376
1377/// Whether a computed **remote** status conveys "done" in that tracker's own
1378/// vocabulary.
1379///
1380/// Deliberately **not** the completion decision. `closed` is GitLab's word and
1381/// `Done` is Linear's; neither is a local column name, so this cannot be a
1382/// category question, and routing it through the service would mean asking the
1383/// service about a string from a different vocabulary.
1384///
1385/// It used to decide *local* completion at the export call sites, by mapping the
1386/// local status out to a remote string and testing that — a round trip that can
1387/// disagree with the category rule on any board with a category_map. Those call
1388/// sites now ask \`workflow::is_completed\` about the local issue (C-004), and this
1389/// function keeps only the narrower job it can actually do: telling an adapter
1390/// whether its own status update already closed the remote issue.
1391fn remote_ref(tool: Tool, issue: &Issue) -> Option<&str> {
1392    match tool {
1393        Tool::GitLab => issue.gitlab_id.as_deref(),
1394        Tool::Linear => issue.linear_id.as_deref(),
1395    }
1396}
1397
1398/// Build and configure the adapter for `tool`: resolves the token with env
1399/// precedence, validates the `[export]` target section, and returns the
1400/// tool-specific adapter wired to a [`ProcessRunner`].
1401fn adapter_for(tool: Tool, config: &Config) -> Result<Box<dyn TrackerAdapter>> {
1402    // The contract's warning starts with "warning:" — no `tix:` prefix.
1403    let mut warn = |w: &str| eprintln!("{w}");
1404    let resolution = token::resolve(tool, config, &token::RealEnv, &mut warn);
1405    let secret = resolution.secret().map(str::to_string);
1406    match tool {
1407        Tool::GitLab => {
1408            let rc = config.export.gitlab.as_ref().ok_or_else(|| {
1409                TixError::Config("no `[export.gitlab]` section in tix.toml — run `tix init --with gitlab` or edit tix.toml".to_string())
1410            })?;
1411            let project = rc.project.clone().ok_or_else(|| {
1412                TixError::Config("`[export.gitlab].project` is not set — add `project = \"acme/tix\"` to tix.toml".to_string())
1413            })?;
1414            Ok(Box::new(glab::GitLabAdapter::new(
1415                ProcessRunner,
1416                secret,
1417                rc.base_url.clone(),
1418                project,
1419            )))
1420        }
1421        Tool::Linear => {
1422            let rc = config.export.linear.as_ref().ok_or_else(|| {
1423                TixError::Config("no `[export.linear]` section in tix.toml — run `tix init --with linear` or edit tix.toml".to_string())
1424            })?;
1425            let team = rc.team.clone().ok_or_else(|| {
1426                TixError::Config(
1427                    "`[export.linear].team` is not set — add `team = \"Eng\"` to tix.toml"
1428                        .to_string(),
1429                )
1430            })?;
1431            Ok(Box::new(linear::LinearAdapter::new(
1432                ProcessRunner,
1433                secret,
1434                team,
1435            )))
1436        }
1437    }
1438}
1439
1440#[cfg(test)]
1441mod tests {
1442    use super::*;
1443
1444    #[test]
1445    fn target_parsing_accepts_only_the_two_contract_targets() {
1446        assert_eq!(Tool::from_target("gitlab").unwrap(), Tool::GitLab);
1447        assert_eq!(Tool::from_target("linear").unwrap(), Tool::Linear);
1448        assert!(Tool::from_target("github").is_err());
1449        assert_eq!(Tool::GitLab.token_var(), "GITLAB_TOKEN");
1450        assert_eq!(Tool::Linear.token_var(), "LINEAR_TOKEN");
1451        assert_eq!(Tool::GitLab.binary(), "glab");
1452        assert_eq!(Tool::Linear.binary(), "linear-cli");
1453    }
1454
1455    #[test]
1456    fn gitlab_status_mapping() {
1457        let config = Config::default();
1458        assert_eq!(
1459            local_to_remote_status(Tool::GitLab, "todo", &config),
1460            "opened"
1461        );
1462        assert_eq!(
1463            local_to_remote_status(Tool::GitLab, "done", &config),
1464            "closed"
1465        );
1466        assert_eq!(
1467            remote_to_local_status(Tool::GitLab, "opened", &config),
1468            "todo"
1469        );
1470        assert_eq!(
1471            remote_to_local_status(Tool::GitLab, "closed", &config),
1472            "done"
1473        );
1474        assert_eq!(
1475            remote_to_local_status(Tool::GitLab, "anything-else", &config),
1476            "todo"
1477        );
1478    }
1479
1480    #[test]
1481    fn linear_status_mapping() {
1482        let config = Config::default();
1483        assert_eq!(
1484            local_to_remote_status(Tool::Linear, "todo", &config),
1485            "todo"
1486        );
1487        assert_eq!(
1488            local_to_remote_status(Tool::Linear, "in review", &config),
1489            "todo"
1490        );
1491        assert_eq!(
1492            local_to_remote_status(Tool::Linear, "done", &config),
1493            "Done"
1494        );
1495        assert_eq!(
1496            remote_to_local_status(Tool::Linear, "In Progress", &config),
1497            "in progress"
1498        );
1499        assert_eq!(
1500            remote_to_local_status(Tool::Linear, "Done", &config),
1501            "done"
1502        );
1503        assert_eq!(
1504            remote_to_local_status(Tool::Linear, "Unknown", &config),
1505            "todo"
1506        );
1507    }
1508
1509    #[test]
1510    fn days_from_civil_roundtrips_civil_from_days() {
1511        // spot-check independent epoch anchors (known values)
1512        assert_eq!(days_from_civil(1970, 1, 1), 0);
1513        assert_eq!(days_from_civil(2000, 1, 1), 10957);
1514        assert_eq!(days_from_civil(1970, 1, 2), 1);
1515        assert_eq!(days_from_civil(2026, 5, 11), 20584);
1516    }
1517
1518    #[test]
1519    fn date_to_epoch_days_parses_only_yyyymmdd() {
1520        assert_eq!(date_to_epoch_days("1970-01-01"), Some(0));
1521        assert_eq!(date_to_epoch_days("2026-05-11"), Some(20_584));
1522        assert_eq!(date_to_epoch_days("not-a-date"), None);
1523        assert_eq!(date_to_epoch_days("2026-13-01"), None);
1524        assert_eq!(date_to_epoch_days("2026-05-11T10:00:00"), None);
1525    }
1526
1527    #[test]
1528    fn iso_to_epoch_secs_accepts_contract_timestamp_shapes() {
1529        let day = days_from_civil(2026, 5, 11) * 86_400;
1530        assert_eq!(iso_to_epoch_secs("2026-05-11T00:00:00Z"), Some(day));
1531        assert_eq!(iso_to_epoch_secs("2026-05-11 00:00:00"), Some(day));
1532        assert_eq!(iso_to_epoch_secs("2026-05-11T00:00:00.123Z"), Some(day));
1533        assert_eq!(
1534            iso_to_epoch_secs("2026-05-11T00:00:00+02:00"),
1535            Some(day - 7200)
1536        );
1537        assert_eq!(
1538            iso_to_epoch_secs("2026-05-11T00:00:00-05:30"),
1539            Some(day + 19_800)
1540        );
1541        assert_eq!(
1542            iso_to_epoch_secs("2026-05-11T10:30:00Z"),
1543            Some(day + 37_800)
1544        );
1545        assert_eq!(iso_to_epoch_secs("garbage"), None);
1546    }
1547
1548    #[test]
1549    fn remote_newer_wins_only_when_strictly_after_local_end_of_day() {
1550        // local `updated` = 2026-05-11 is interpreted as end of day.
1551        assert!(is_remote_newer("2026-05-12T00:00:00Z", "2026-05-11"));
1552        assert!(!is_remote_newer("2026-05-11 13:00:00", "2026-05-11"));
1553        assert!(!is_remote_newer("2026-05-11T23:59:59Z", "2026-05-11"));
1554        assert!(!is_remote_newer("2026-05-10T23:59:59Z", "2026-05-11"));
1555        // unparseable remote timestamp keeps the local issue
1556        assert!(!is_remote_newer("not-a-time", "2026-05-11"));
1557    }
1558
1559    #[test]
1560    fn scrub_replaces_secret_everywhere_and_preserves_text() {
1561        assert_eq!(
1562            scrub_secret("token=sekrit end", "sekrit"),
1563            "token=<token> end"
1564        );
1565        assert_eq!(
1566            scrub_secret("token=sekrit token=sekrit", "sekrit"),
1567            "token=<token> token=<token>"
1568        );
1569        assert_eq!(scrub_secret("no secret here", "sekrit"), "no secret here");
1570        assert_eq!(scrub_secret("empty secret", ""), "empty secret");
1571    }
1572
1573    // ---- WP07 T029: the relationship mapping seam ----
1574
1575    fn rel(kind: crate::relationships::RelationKind) -> crate::relationships::Relationship {
1576        let mut r = crate::relationships::Relationship::new(1, 2, kind, "Ship it");
1577        r.id = "rel_TEST".to_string();
1578        r
1579    }
1580
1581    /// Every relationship diagnoses today, and the diagnostic distinguishes the two
1582    /// reasons (T029 step 5).
1583    ///
1584    /// Both trackers' *models* can express a blocking edge; neither *CLI* verb is
1585    /// verified, so nothing is sent. The two reasons are different claims and the
1586    /// message says which, because a reader told "GitLab cannot express this" will
1587    /// not go looking for the verb:
1588    ///
1589    /// - `blocks`/`blocked_by` — expressible in the model, no verified CLI verb
1590    /// - `related`/`duplicates` — no remote field at all
1591    ///
1592    /// Written so it still holds once a tool joins `RELATION_WIRE_VERIFIED`: the
1593    /// `blocks` case then takes the mapped branch instead.
1594    #[test]
1595    fn every_relationship_diagnoses_with_its_field_its_tool_and_its_reason() {
1596        for tool in [Tool::GitLab, Tool::Linear] {
1597            for kind in [
1598                crate::relationships::RelationKind::Blocks,
1599                crate::relationships::RelationKind::BlockedBy,
1600                crate::relationships::RelationKind::Related,
1601                crate::relationships::RelationKind::Duplicates,
1602            ] {
1603                let model_supports = matches!(
1604                    kind,
1605                    crate::relationships::RelationKind::Blocks
1606                        | crate::relationships::RelationKind::BlockedBy
1607                );
1608                match map_relation_to_remote(tool, &rel(kind)) {
1609                    MappedRelation::Remote { field, value } => {
1610                        assert!(
1611                            !RELATION_WIRE_VERIFIED.contains(&tool),
1612                            "{tool}/{kind} mapped, so the wire must be verified"
1613                        );
1614                        assert!(!field.is_empty() && value.contains('#'));
1615                    }
1616                    MappedRelation::Unsupported(d) => {
1617                        assert_eq!(d.tool, tool, "the diagnostic names the tool");
1618                        assert_eq!(d.relation_id, "rel_TEST");
1619                        assert!(
1620                            !d.field.is_empty(),
1621                            "the diagnostic must name the specific field: {d}"
1622                        );
1623                        assert!(d.to_string().contains(&d.field), "and print it: {d}");
1624                        if model_supports {
1625                            assert!(
1626                                d.message.contains("not verified"),
1627                                "{tool}/{kind}: a model-supported kind must say the \
1628                                 verb is unverified, not that the model lacks it: {d}"
1629                            );
1630                        } else {
1631                            assert!(
1632                                !d.message.contains("not verified"),
1633                                "{tool}/{kind}: a kind with no remote field must not \
1634                                 blame an unverified verb: {d}"
1635                            );
1636                        }
1637                    }
1638                }
1639            }
1640        }
1641    }
1642
1643    /// The forward and reverse directions agree about the verified list.
1644    ///
1645    /// Symmetry used to be asserted as a round trip, which cannot run while the list
1646    /// is empty. What matters while it is empty is that the reverse direction does
1647    /// **not** invent an edge from a payload the forward direction would never have
1648    /// written — otherwise an import could create a relationship the export never
1649    /// made, which is the mirror image of the silent drop the contract forbids.
1650    #[test]
1651    fn neither_direction_maps_without_a_verified_verb() {
1652        for tool in [Tool::GitLab, Tool::Linear] {
1653            let field = match tool {
1654                Tool::GitLab => "blocking",
1655                Tool::Linear => "blocks",
1656            };
1657            match map_relation_from_remote(tool, field, "blocks #2", "rel_TEST") {
1658                MappedRelation::Remote { .. } => assert!(
1659                    !RELATION_WIRE_VERIFIED.contains(&tool),
1660                    "{tool}: the reverse direction mapped, so the wire must be verified"
1661                ),
1662                MappedRelation::Unsupported(d) => {
1663                    assert_eq!(d.tool, tool);
1664                    assert!(!d.relation_id.is_empty());
1665                }
1666            }
1667        }
1668    }
1669
1670    /// A remote payload the export could not have written is **ignored with a
1671    /// diagnostic**, never turned into a local edge.
1672    ///
1673    /// The alternative — coercing an unrecognised field to a default kind — would
1674    /// let an import invent a relationship the export never made, which is the
1675    /// mirror image of silently dropping a local one.
1676    #[test]
1677    fn an_unrecognised_remote_relation_field_never_becomes_a_local_edge() {
1678        for tool in [Tool::GitLab, Tool::Linear] {
1679            for (field, value) in [
1680                ("parent", "#2"),
1681                ("blocks", "not-a-number"),
1682                ("whatever", ""),
1683            ] {
1684                match map_relation_from_remote(tool, field, value, "rel_X") {
1685                    MappedRelation::Remote { .. } => {
1686                        panic!("{tool}: `{field}={value}` must not become a local edge")
1687                    }
1688                    MappedRelation::Unsupported(d) => {
1689                        assert_eq!(d.tool, tool);
1690                        assert_eq!(d.field, field, "the diagnostic names the field");
1691                    }
1692                }
1693            }
1694        }
1695    }
1696
1697    /// The two adapters cannot drift: the seam is one function, keyed on `Tool`
1698    /// (T033 step 5, and T029's note).
1699    ///
1700    /// Asserted by construction — a single `map_relation_to_remote` serves both —
1701    /// and pinned here so adding a tracker-specific branch to one of them is a
1702    /// red test rather than a silent divergence.
1703    #[test]
1704    fn both_trackers_share_one_mapping_seam() {
1705        let source = include_str!("mod.rs");
1706        let fns = [
1707            "pub fn map_relation_to_remote",
1708            "pub fn map_relation_from_remote",
1709        ];
1710        for f in fns {
1711            assert_eq!(
1712                source.matches(&format!("{f}(")).count(),
1713                1,
1714                "{f} must be *defined* exactly once, in this module, for both \
1715                 trackers — a doc comment naming it is not a second implementation"
1716            );
1717        }
1718        // And neither adapter file may define its own copy. `include_str!` needs a
1719        // literal, so the pair is spelled out rather than looped over.
1720        for (name, text) in [
1721            ("glab.rs", include_str!("glab.rs")),
1722            ("linear.rs", include_str!("linear.rs")),
1723        ] {
1724            assert!(
1725                !text.contains("fn map_relation_to_remote")
1726                    && !text.contains("fn map_relation_from_remote"),
1727                "{name} must not define its own mapping seam; the shared one is \\
1728                 keyed on `Tool` precisely so the two cannot diverge"
1729            );
1730        }
1731    }
1732
1733    /// A `SyncReport` records the count and the diagnostics together, so a caller
1734    /// cannot report success while quietly dropping a relationship.
1735    #[test]
1736    fn a_sync_report_carries_both_the_count_and_the_diagnostics() {
1737        let mut report = SyncReport::default();
1738        let mut sink: Vec<u8> = Vec::new();
1739        report
1740            .note(
1741                map_relation_to_remote(
1742                    Tool::GitLab,
1743                    &rel(crate::relationships::RelationKind::Related),
1744                ),
1745                &mut sink,
1746            )
1747            .unwrap();
1748        report
1749            .note(
1750                map_relation_to_remote(
1751                    Tool::GitLab,
1752                    &rel(crate::relationships::RelationKind::Blocks),
1753                ),
1754                &mut sink,
1755            )
1756            .unwrap();
1757        // Both are diagnostics today: nothing is wire-verified, so the mapped
1758        // branch is not reachable and the count cannot be 1.
1759        let expected = if RELATION_WIRE_VERIFIED.contains(&Tool::GitLab) {
1760            1
1761        } else {
1762            2
1763        };
1764        assert_eq!(
1765            report.diagnostics.len(),
1766            expected,
1767            "the report records every relation the tool cannot carry"
1768        );
1769        assert_eq!(report.handled, 0, "no issues were handled here");
1770        // It is on the human stream too, so the count and the output agree.
1771        let text = String::from_utf8(sink).unwrap();
1772        assert!(
1773            text.contains("cannot represent"),
1774            "the stream says so: {text}"
1775        );
1776    }
1777    // ---- WP07 T032/T033: completion comes from the service, nowhere else ----
1778
1779    /// A board whose `category_map` puts `completed` on `shipped` while
1780    /// `done_column` is `done` — so the two disagree, and a name comparison picks the
1781    /// wrong one. Every assertion below is vacuous on the default board, which is why
1782    /// this fixture exists rather than `Config::default()`.
1783    fn split_completion_config() -> Config {
1784        Config {
1785            kanban: crate::config::KanbanSection {
1786                configurations: indexmap::IndexMap::from([(
1787                    "default".to_string(),
1788                    crate::config::BoardConfiguration {
1789                        columns: vec!["todo".into(), "shipped".into(), "done".into()],
1790                        done_column: Some("done".to_string()),
1791                        category_map: indexmap::IndexMap::from([
1792                            ("todo".to_string(), "unstarted".to_string()),
1793                            ("shipped".to_string(), "completed".to_string()),
1794                            ("done".to_string(), "active".to_string()),
1795                        ]),
1796                        ..Default::default()
1797                    },
1798                )]),
1799            },
1800            ..Config::default()
1801        }
1802    }
1803
1804    #[test]
1805    fn t032_a_remote_closed_state_maps_to_the_services_completion_column() {
1806        // Not to `done_column`. The remote's `closed` must round-trip to the column
1807        // the *service* calls complete, or export and import disagree with each other
1808        // and with the board.
1809        let config = split_completion_config();
1810        assert_eq!(
1811            remote_to_local_status(Tool::GitLab, "closed", &config),
1812            "shipped",
1813            "a closed GitLab issue belongs in the column that is actually complete"
1814        );
1815        assert_eq!(
1816            remote_to_local_status(Tool::Linear, "Done", &config),
1817            "shipped"
1818        );
1819    }
1820
1821    #[test]
1822    fn t032_export_closes_the_column_the_service_calls_complete() {
1823        // The other direction. A name comparison exported `shipped` as *open* and
1824        // closed `done` instead — the tracker and the board disagreeing about the same
1825        // issue in both directions at once.
1826        let config = split_completion_config();
1827        assert_eq!(
1828            local_to_remote_status(Tool::GitLab, "shipped", &config),
1829            "closed"
1830        );
1831        assert_eq!(
1832            local_to_remote_status(Tool::GitLab, "done", &config),
1833            "opened"
1834        );
1835        assert_eq!(
1836            local_to_remote_status(Tool::Linear, "shipped", &config),
1837            "Done"
1838        );
1839    }
1840
1841    #[test]
1842    fn t032_a_closed_remote_state_mapped_to_a_canceled_column_is_not_complete() {
1843        // T033 step 4. `canceled` is terminal but **not** complete (contract §4), so
1844        // an export that sends a `canceled` column as closed would be claiming the
1845        // work is finished when it was abandoned. The local side is the service's
1846        // answer, so it says no.
1847        let mut config = split_completion_config();
1848        config
1849            .kanban
1850            .configurations
1851            .get_mut("default")
1852            .unwrap()
1853            .category_map
1854            .insert("done".to_string(), "canceled".to_string());
1855        let board = config.default_config();
1856        // `shipped` is still `completed`, so it still exports as closed…
1857        assert_eq!(
1858            local_to_remote_status(Tool::GitLab, "shipped", &config),
1859            "closed"
1860        );
1861        // …and `done` is now `canceled`, which is terminal and not complete, so it does
1862        // not. The remote has only two states, so the honest outcome is that `canceled`
1863        // cannot be represented distinctly — and the local definition is what decides,
1864        // not the remote's vocabulary.
1865        assert!(
1866            !is_done_column(board, "done", &config),
1867            "a canceled column is not locally complete"
1868        );
1869    }
1870
1871    #[test]
1872    fn t032_the_existing_round_trips_are_unchanged() {
1873        // T032 step 5: this is a refactor of *where the decision comes from*, not a
1874        // protocol change. The default board's behaviour must be identical.
1875        let config = Config::default();
1876        assert_eq!(
1877            local_to_remote_status(Tool::GitLab, "done", &config),
1878            "closed"
1879        );
1880        assert_eq!(
1881            local_to_remote_status(Tool::GitLab, "todo", &config),
1882            "opened"
1883        );
1884        assert_eq!(
1885            remote_to_local_status(Tool::GitLab, "closed", &config),
1886            "done"
1887        );
1888        assert_eq!(
1889            remote_to_local_status(Tool::Linear, "Done", &config),
1890            "done"
1891        );
1892        // "In Progress" case-insensitively matches the board's `in progress` column,
1893        // and a name match wins over the first column. The first draft of this
1894        // assertion expected `todo` — which is the *first* column, and therefore the
1895        // answer when no name matches. It was asserting the opposite rule to the one
1896        // it claimed to pin.
1897        assert_eq!(
1898            remote_to_local_status(Tool::Linear, "In Progress", &config),
1899            "in progress",
1900            "a name match wins over the first column"
1901        );
1902        assert_eq!(
1903            remote_to_local_status(Tool::Linear, "No Such State", &config),
1904            "todo",
1905            "and with no match at all, the first column"
1906        );
1907    }
1908
1909    #[test]
1910    fn t032_the_sync_seam_holds_no_local_completion_definition() {
1911        // T032 step 4, as a structural guard. The module header says a second local
1912        // definition is a C-004 defect, and this is what keeps that from being
1913        // "improved" back in: the seam may only *translate* a remote state word, and
1914        // the completion test may only come from the service.
1915        let source = include_str!("mod.rs");
1916        // Assembled from fragments so this assertion does not match **itself**: the
1917        // first version spelled the forbidden name out in full, so the very line
1918        // asserting its absence satisfied the check. That is the grep equivalent of a
1919        // test that proves itself, and it was found because the test went red while
1920        // the function was, in fact, absent.
1921        let forbidden = ["fn remote", "status_is_done"].concat();
1922        assert!(
1923            !source.contains(&forbidden),
1924            "the dead second definition is removed and must not come back"
1925        );
1926        // The only local completion question in the module is the service call.
1927        let uses_service = source.matches("WorkflowCategory::is_complete").count();
1928        assert!(
1929            uses_service >= 2,
1930            "both `is_done_column` and `completion_column` must ask the service, found \\
1931             {uses_service} uses"
1932        );
1933    }
1934}