Skip to main content

tix/sync/
adapters.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4/// The supported external tracker, selected via `--target gitlab|linear`.
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum Tool {
7    GitLab,
8    Linear,
9}
10
11impl Tool {
12    #[must_use]
13    pub fn from_target(target: &str) -> Result<Tool> {
14        match target {
15            "gitlab" => Ok(Tool::GitLab),
16            "linear" => Ok(Tool::Linear),
17            other => Err(TixError::InvalidInput(format!(
18                "unknown --target '{other}' — use gitlab or linear"
19            ))),
20        }
21    }
22
23    /// The token env var that wins over any `tix.toml` token — the tool's
24    /// **primary** name, which is what the `tix.toml` warning and the injection
25    /// tests name.
26    #[must_use]
27    pub fn token_var(self) -> &'static str {
28        match self {
29            Tool::GitLab => "GITLAB_TOKEN",
30            Tool::Linear => "LINEAR_TOKEN",
31        }
32    }
33
34    /// Every environment variable that can carry this tool's token, in
35    /// precedence order.
36    ///
37    /// `token_var` is the primary name tix documents; the extras are the names
38    /// the **tool itself** already reads, so a user who has the tracker
39    /// configured does not have to export a second, tix-specific variable. The
40    /// canonical `linear-cli` reads `LINEAR_API_KEY` (its help shows
41    /// `printf '%s\n' "$LINEAR_API_KEY" | linear config set-key`), so that is
42    /// accepted after tix's own `LINEAR_TOKEN`.
43    #[must_use]
44    pub fn token_vars(self) -> &'static [&'static str] {
45        match self {
46            Tool::GitLab => &["GITLAB_TOKEN"],
47            Tool::Linear => &["LINEAR_TOKEN", "LINEAR_API_KEY"],
48        }
49    }
50
51    /// The binary shelled out to.
52    #[must_use]
53    pub fn binary(self) -> &'static str {
54        match self {
55            Tool::GitLab => "glab",
56            Tool::Linear => "linear-cli",
57        }
58    }
59
60    #[must_use]
61    pub fn as_str(self) -> &'static str {
62        match self {
63            Tool::GitLab => "gitlab",
64            Tool::Linear => "linear",
65        }
66    }
67}
68
69impl std::fmt::Display for Tool {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        f.write_str(self.as_str())
72    }
73}
74
75/// The captured result of a spawned external tool.
76///
77/// `ProcessRunner` waits synchronously on the child (never kills it), so a
78/// blocking tool blocks the sync — the contract's supported "wait" model for
79/// `glab`/`linear-cli`.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct CommandOutcome {
82    pub success: bool,
83    pub status: Option<i32>,
84    pub stdout: String,
85    pub stderr: String,
86}
87
88impl CommandOutcome {
89    /// The human-readable output chosen for error reporting: stderr when the
90    /// tool failed and produced any, otherwise stdout.
91    #[must_use]
92    pub fn text(&self) -> &str {
93        if self.success {
94            &self.stdout
95        } else if !self.stderr.trim().is_empty() {
96            &self.stderr
97        } else {
98            &self.stdout
99        }
100    }
101}
102
103/// Production runner: `std::process::Command` with a synchronous output wait.
104///
105/// A missing binary surfaces as [`TixError::External`] with the contract's
106/// exit-3 wording (`contracts/external-tools.md`: "missing binary ... exit 3").
107pub struct ProcessRunner;
108
109impl CommandRunner for ProcessRunner {
110    fn run(
111        &self,
112        program: &str,
113        args: &[String],
114        env: &[(String, String)],
115    ) -> Result<CommandOutcome> {
116        use std::process::Command;
117        let mut cmd = Command::new(program);
118        cmd.args(args);
119        for (key, value) in env {
120            cmd.env(key, value);
121        }
122        let output = cmd.output().map_err(|e| {
123            if e.kind() == std::io::ErrorKind::NotFound {
124                TixError::External(not_found_message(program))
125            } else {
126                TixError::External(format!("`{program}` failed to start: {e}"))
127            }
128        })?;
129        Ok(CommandOutcome {
130            success: output.status.success(),
131            status: output.status.code(),
132            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
133            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
134        })
135    }
136
137    fn as_any(&self) -> &dyn std::any::Any {
138        self
139    }
140}
141
142/// A remote issue parsed from the tracker's JSON list output.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct RemoteIssue {
145    /// Remote id (GitLab `iid`, Linear `identifier`).
146    pub id: String,
147    pub title: String,
148    pub description: String,
149    pub labels: Vec<String>,
150    /// Remote state (`closed` / `Done` / a column label, ...).
151    pub state: String,
152    /// ISO-8601 `updated_at`; `None` when the tool omitted it.
153    pub updated_at: Option<String>,
154    /// Best-effort single assignee (bare name, no `@`).
155    pub assignee: Option<String>,
156    /// The remote id of this issue's parent, when the remote reported one.
157    ///
158    /// A **remote** id, not a local number, because it is read from a remote
159    /// payload and the two id spaces are not interchangeable. It is resolved to a
160    /// local number by `local_parent_number` before anything is written, and
161    /// `None` — rather than a guess — when it cannot be resolved.
162    pub parent: Option<String>,
163    /// The project references the remote reported for this issue (WP08).
164    ///
165    /// Read from the payload, in the order the payload listed them, and matched
166    /// against the local registry by **id** before anything is written. An empty
167    /// vector is the answer for a payload that reported none, which is every
168    /// GitLab payload today: nothing in this build ever *sends* a membership, so
169    /// this is only ever what the remote side already said.
170    pub projects: Vec<RemoteProject>,
171    /// The epic references the remote reported for this issue (WP08, mission
172    /// `epics-work-items-01M3BYW4`).
173    ///
174    /// Read from the payload, in the order the payload listed them, and matched
175    /// against the local registry by **id** before anything is written. An empty
176    /// vector is the answer for a payload that reported none — and for both trackers
177    /// today nothing in this build ever *sends* one (`EPIC_WIRE_VERIFIED` is empty),
178    /// so this is only ever what the remote already said.
179    pub epics: Vec<RemoteEpic>,
180}
181
182/// What one export or import run did, and what it could not do.
183///
184/// Returned rather than only written to the caller's `Write`, so a caller can
185/// surface the diagnostics without scraping its own output — and so the count and
186/// the diagnostics cannot disagree. Both entry points had `Result<usize>`; a count
187/// alone cannot express "three issues synced, one relationship could not be".
188#[derive(Debug, Clone, Default, PartialEq, Eq)]
189pub struct SyncReport {
190    /// Issues created, updated, or overwritten.
191    pub handled: usize,
192    /// Local data the remote could not represent — relationships, parent
193    /// references, health values, project associations, and epic references — in
194    /// encounter order.
195    pub diagnostics: Vec<RemoteMappingDiagnostic>,
196    /// Items whose **status** the transition policy refused (WP07 T031).
197    ///
198    /// Counted separately from `handled` because an import can do both: an issue
199    /// whose title and body imported fine while its status was refused still counts
200    /// as handled — the file was written — and also as skipped, because a column did
201    /// not move. A single number cannot express that, and the report line it backs
202    /// (`overwrote … without its status`) is the one a user has to be able to find.
203    pub skipped: usize,
204    /// Whether diagnostics are written beside the report lines.
205    ///
206    /// Import's historical surface includes them in the text report. Remote
207    /// export keeps them separate so the CLI can put warnings on stderr and use
208    /// the exit status for a partial mirror.
209    report_diagnostics: bool,
210}
211
212impl SyncReport {
213    /// Ask the note methods to write diagnostics beside report lines.
214    pub(crate) fn diagnostics_in_report(mut self) -> Self {
215        self.report_diagnostics = true;
216        self
217    }
218
219    /// Record one unrepresentable relationship for the caller to present.
220    pub(super) fn note(
221        &mut self,
222        mapped: MappedRelation,
223        out: &mut dyn Write,
224    ) -> std::io::Result<()> {
225        if let MappedRelation::Unsupported(diagnostic) = mapped {
226            if self.report_diagnostics {
227                writeln!(out, "{diagnostic}")?;
228            }
229            self.diagnostics.push(diagnostic);
230        }
231        Ok(())
232    }
233
234    /// The same, for a **parent** reference.
235    ///
236    /// Its own method rather than coercing a `MappedParent` into a
237    /// `MappedRelation`: a parent is not a relationship, and the first cut did
238    /// exactly that with an empty `field` and an empty `value` on the remote arm
239    /// — which reads as though something were being sent when nothing is. The
240    /// coercion compiled, produced the right output, and was wrong in a way only
241    /// reading the type would have caught.
242    pub(super) fn note_parent(
243        &mut self,
244        mapped: MappedParent,
245        out: &mut dyn Write,
246    ) -> std::io::Result<()> {
247        if let MappedParent::Unsupported(diagnostic) = mapped {
248            if self.report_diagnostics {
249                writeln!(out, "{diagnostic}")?;
250            }
251            self.diagnostics.push(diagnostic);
252        }
253        Ok(())
254    }
255
256    /// The same, for a **project association**.
257    ///
258    /// Its own method for the reason [`SyncReport::note_parent`] gives: a project
259    /// membership is not a relationship, and coercing a `MappedProject` into a
260    /// `MappedRelation` would put a project id where a relationship id belongs.
261    /// The three methods share the diagnostic type and the count, which is the
262    /// vocabulary that matters.
263    pub(super) fn note_project(
264        &mut self,
265        mapped: MappedProject,
266        out: &mut dyn Write,
267    ) -> std::io::Result<()> {
268        if let MappedProject::Unsupported(diagnostic) = mapped {
269            if self.report_diagnostics {
270                writeln!(out, "{diagnostic}")?;
271            }
272            self.diagnostics.push(diagnostic);
273        }
274        Ok(())
275    }
276
277    /// The same, for an **epic reference** (WP08, mission
278    /// `epics-work-items-01M3BYW4`).
279    ///
280    /// Its own method for the reason [`SyncReport::note_project`] gives: an epic
281    /// reference is not a relationship, and coercing a `MappedEpic` into a
282    /// `MappedRelation` would put an epic id where a relationship id belongs. The
283    /// four methods share the diagnostic type and the count, which is the vocabulary
284    /// that matters.
285    pub(super) fn note_epic(
286        &mut self,
287        mapped: MappedEpic,
288        out: &mut dyn Write,
289    ) -> std::io::Result<()> {
290        if let MappedEpic::Unsupported(diagnostic) = mapped {
291            if self.report_diagnostics {
292                writeln!(out, "{diagnostic}")?;
293            }
294            self.diagnostics.push(diagnostic);
295        }
296        Ok(())
297    }
298}
299
300/// The record emitted when a remote tracker cannot hold a piece of local data —
301/// a relation (FR-005, C-005, `data-model.md` §6), a parent reference, a health
302/// value, a project association (WP08, mission `first-class-projects-01M3BYBW`),
303/// or an epic reference (WP08, mission `epics-work-items-01M3BYW4`).
304///
305/// The four fields are fixed by the data model. `field` is the important one: it
306/// is what makes the diagnostic actionable rather than a shrug, and
307/// `data-model.md` §6 requires it for exactly that reason — "so the message can
308/// name it".
309///
310/// One type for all of them because the vocabulary matters more than the noun: a
311/// reader of `tix export` output learns one shape ("tool: field cannot represent
312/// this local data (subject) — reason") and does not have to recognise which kind
313/// of local data each line is about. The **subject** in `relation_id` is whatever
314/// the diagnostic is about — a relationship id, an `issue->parent` pair, a health
315/// scope, an `issue->project` pair, or an `issue->epic` pair.
316#[derive(Debug, Clone, PartialEq, Eq)]
317pub struct RemoteMappingDiagnostic {
318    /// The local item the diagnostic is about: a relationship id, an
319    /// `issue->parent` pair, a health scope, or an `issue->project` pair.
320    pub relation_id: String,
321    /// The specific unsupported remote field or relation name.
322    pub field: String,
323    /// Which adapter produced the diagnostic.
324    pub tool: Tool,
325    /// Human-readable text, safe for CLI output, the TUI, and lint.
326    pub message: String,
327}
328
329impl std::fmt::Display for RemoteMappingDiagnostic {
330    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
331        // "this local data", not "this relationship": the type now carries project
332        // associations and health values too, and a line that called an association
333        // a relationship would send the reader looking for an edge that is not there.
334        write!(
335            f,
336            "{}: {} cannot represent this local data ({}) — {}",
337            self.tool.as_str(),
338            self.field,
339            self.relation_id,
340            self.message
341        )
342    }
343}
344
345/// The outcome of mapping one local relationship for one tracker: either a
346/// remote representation, or a diagnostic naming why there is none.
347#[derive(Debug, Clone, PartialEq, Eq)]
348pub enum MappedRelation {
349    /// The remote representation to send: the relation `field` (the remote
350    /// relation kind, e.g. `blocks`) and the `value` the adapter needs. For a
351    /// relation verb taking two endpoints (Linear's `relations add`) the `value`
352    /// is the **target's remote identifier** (`ENG-42`); a tracker whose verb
353    /// carries the target inside a field renders its own value there.
354    Remote { field: String, value: String },
355    /// The tracker cannot express this relation. The **local** relation is
356    /// untouched — C-005 is that a relation the remote cannot hold still survives.
357    Unsupported(RemoteMappingDiagnostic),
358}
359
360/// What one parent reference maps to on the wire.
361#[derive(Debug, Clone, PartialEq, Eq)]
362pub enum MappedParent {
363    /// The remote value to send, as a field/value pair like a relation.
364    Remote { field: String, value: String },
365    /// The adapter cannot carry it. The **local** reference is untouched, which is
366    /// the same contract the relationship mapper follows (C-005): a reference the
367    /// remote cannot hold still survives here.
368    Unsupported(RemoteMappingDiagnostic),
369}
370
371/// Map one local parent reference to its remote representation, or say why not.
372///
373/// Pure: no adapter, no I/O, no configuration, and shared by both trackers so a
374/// future one cannot be added with a divergent contract (T033).
375#[must_use]
376pub fn map_parent_to_remote(tool: Tool, issue_number: u32, parent: Option<u32>) -> MappedParent {
377    let Some(parent) = parent else {
378        // No reference is not a mapping problem. Reporting it would put a
379        // diagnostic on every parentless issue in the repository, which is every
380        // diagnostic being ignored.
381        return MappedParent::Remote {
382            field: parent_field(tool).to_string(),
383            value: String::new(),
384        };
385    };
386    let field = parent_field(tool);
387    if PARENT_WIRE_VERIFIED.contains(&tool) {
388        return MappedParent::Remote {
389            field: field.to_string(),
390            value: format!("#{parent}"),
391        };
392    }
393    MappedParent::Unsupported(RemoteMappingDiagnostic {
394        relation_id: format!("{issue_number}->{parent}"),
395        field: field.to_string(),
396        tool,
397        message: format!(
398            "#{issue_number}'s parent #{parent} is expressible in {}'s model as \
399             `{field}`, but the CLI verb that would carry it is not verified, so \
400             nothing is sent; the local reference is kept",
401            tool.as_str()
402        ),
403    })
404}
405
406/// The field an adapter would carry a parent in.
407pub(super) fn parent_field(tool: Tool) -> &'static str {
408    match tool {
409        Tool::GitLab => GITLAB_PARENT_FIELD,
410        Tool::Linear => LINEAR_PARENT_FIELD,
411    }
412}
413
414/// Parse the project references one remote issue reported.
415///
416/// **Pure and total**, in the shape [`crate::sync::mod`'s parent parser] is: an
417/// unrecognised payload yields no references — which the import then reports
418/// nothing about — rather than a deserialisation failure that aborts the whole
419/// list. Accepted, for both trackers, because the field's exact shape is a claim
420/// about a program this repository cannot run:
421///
422/// - an array of ids (`"projects": ["p-1", …]`) or of objects;
423/// - a single id, or a single object (`"project": {"id": …}`);
424/// - an object's id under `id`, `identifier`, `key`, or `project_id`, as a
425///   string or a number (`iid`-style numeric ids are real on the wire);
426/// - its name under `name`, `title`, or `display_name`, as a string.
427///
428/// **Entry order is preserved**, which is what keeps a round trip byte-stable
429/// (NFR-002): reordering here would reorder the local list.
430///
431/// An entry with a name but no id is **kept** with an empty id, so the import can
432/// report it by name instead of dropping it in silence; an entry with neither is
433/// not a reference at all and yields nothing.
434#[must_use]
435pub fn remote_projects(value: Option<&serde_json::Value>) -> Vec<RemoteProject> {
436    fn id_of(value: &serde_json::Value) -> String {
437        match value {
438            serde_json::Value::String(s) => s.clone(),
439            serde_json::Value::Number(n) => n.to_string(),
440            _ => String::new(),
441        }
442    }
443    fn one(value: &serde_json::Value) -> Option<RemoteProject> {
444        match value {
445            serde_json::Value::String(_) | serde_json::Value::Number(_) => Some(RemoteProject {
446                id: id_of(value),
447                name: None,
448            }),
449            serde_json::Value::Object(map) => {
450                let id = ["id", "identifier", "key", "project_id"]
451                    .iter()
452                    .find_map(|key| map.get(*key))
453                    .map(id_of)
454                    .unwrap_or_default();
455                let name = ["name", "title", "display_name"]
456                    .iter()
457                    .find_map(|key| map.get(*key))
458                    .and_then(serde_json::Value::as_str)
459                    .map(str::to_string);
460                if id.trim().is_empty() && name.is_none() {
461                    return None;
462                }
463                Some(RemoteProject { id, name })
464            }
465            _ => None,
466        }
467    }
468    let Some(value) = value else {
469        return Vec::new();
470    };
471    match value {
472        serde_json::Value::Array(items) => items.iter().filter_map(one).collect(),
473        other => one(other).into_iter().collect(),
474    }
475}
476
477/// The field an adapter would carry project associations in.
478pub(super) fn project_field(tool: Tool) -> &'static str {
479    match tool {
480        Tool::GitLab => GITLAB_PROJECT_FIELD,
481        Tool::Linear => LINEAR_PROJECT_FIELD,
482    }
483}
484
485/// What one local project association maps to on the wire.
486#[derive(Debug, Clone, PartialEq, Eq)]
487pub enum MappedProject {
488    /// The remote value to send, as a field/value pair like a relation.
489    Remote { field: String, value: String },
490    /// The adapter cannot carry it. The **local** association is untouched — C-005
491    /// is that a membership the remote cannot hold still survives.
492    Unsupported(RemoteMappingDiagnostic),
493}
494
495/// Map one local project association to its remote representation, or say why not.
496///
497/// Pure: no adapter, no I/O, no configuration. Both trackers share it, so a future
498/// one cannot be added with a divergent contract — and the representability
499/// decision lives in exactly one place instead of once per adapter.
500///
501/// `issue_number` appears in the message because the diagnostic has to be
502/// actionable: the association list can be long, and "project `p-3` cannot be
503/// sent" without the issue is a shrug.
504#[must_use]
505pub fn map_project_to_remote(tool: Tool, issue_number: u32, project_id: &str) -> MappedProject {
506    let field = project_field(tool);
507    let id = project_id.trim();
508    if id.is_empty() {
509        // Not a representability question: there is nothing to send, and an empty
510        // entry in the list is a repair job rather than a remote capability gap.
511        return MappedProject::Unsupported(RemoteMappingDiagnostic {
512            relation_id: format!("{issue_number}->"),
513            field: field.to_string(),
514            tool,
515            message: format!(
516                "issue #{issue_number} carries a project association with no id, so there \
517                 is nothing to send; the entry is kept locally — dissociating the empty \
518                 reference, or editing the issue's project list, clears it"
519            ),
520        });
521    }
522    if PROJECT_WIRE_VERIFIED.contains(&tool) {
523        return MappedProject::Remote {
524            field: field.to_string(),
525            value: id.to_string(),
526        };
527    }
528    MappedProject::Unsupported(RemoteMappingDiagnostic {
529        relation_id: format!("{issue_number}->{id}"),
530        field: field.to_string(),
531        tool,
532        message: format!(
533            "#{issue_number}'s membership of project `{id}` has no {} field to travel in \
534             — {} — so nothing is sent and the local association is kept",
535            tool.as_str(),
536            project_absence_reason(tool)
537        ),
538    })
539}
540
541/// Why a tracker's CLI cannot carry a project membership, per tool.
542///
543/// A **permanent** claim about the CLI the adapter drives, not "not yet
544/// verified", and the wording says so for the same reason `RELATION_CLI_ABSENT`
545/// does: a reader told "the verb is unverified" will wait for it, and there is
546/// nothing to wait for. The two reasons differ, so the message does.
547pub(super) fn project_absence_reason(tool: Tool) -> &'static str {
548    match tool {
549        Tool::GitLab => {
550            "`glab` names one owning project (`--project`) and exposes no membership field"
551        }
552        Tool::Linear => {
553            "Linear's issue carries one `project` and no list, so a second \
554                         membership has nowhere to go"
555        }
556    }
557}
558
559/// Map one remote project reference back onto a local association id, or say why
560/// not.
561///
562/// The reverse value is **local-shaped**, like [`map_relation_from_remote`]'s: the
563/// frontmatter key the association lives under ([`crate::projects::PROJECTS_KEY`])
564/// and the id, not a copy of the tool's field name — echoing the field back would
565/// make the same association read differently per tracker, which is the opposite
566/// of a shared seam.
567///
568/// Not gated on [`PROJECT_WIRE_VERIFIED`], and the module header records why: an
569/// id is validated against the local registry before anything is written, so this
570/// direction cannot invent local data the way an unverified relation field could.
571#[must_use]
572pub fn map_project_from_remote(tool: Tool, remote: &RemoteProject) -> MappedProject {
573    let id = remote.id.trim();
574    if id.is_empty() {
575        return MappedProject::Unsupported(RemoteMappingDiagnostic {
576            relation_id: remote.name.clone().unwrap_or_default(),
577            field: project_field(tool).to_string(),
578            tool,
579            message: format!(
580                "the remote reported a project it named `{}` but gave no id, and an id is \
581                 the only thing a local association can match on; nothing was associated \
582                 and the local list is unchanged",
583                remote.name.as_deref().unwrap_or("")
584            ),
585        });
586    }
587    MappedProject::Remote {
588        field: crate::projects::PROJECTS_KEY.to_string(),
589        value: id.to_string(),
590    }
591}
592
593/// Redact the resolved secret from a diagnostic's message.
594///
595/// The diagnostics this module builds itself are made of local ids and field
596/// names. The **import** builds four — a reference it cannot read, a project the
597/// registry does not declare, an epic the registry does not declare, and a service
598/// refusal — and the first three quote the name a remote payload reported. Remote
599/// output is the one input that can carry a credential, which is why the import's
600/// diagnostics go through here, using the same [`scrub_secret`] the adapters use on
601/// their errors.
602pub(super) fn scrub_diagnostic(diagnostic: &mut RemoteMappingDiagnostic, secret: Option<&str>) {
603    if let Some(secret) = secret {
604        diagnostic.message = scrub_secret(&diagnostic.message, secret);
605        diagnostic.relation_id = scrub_secret(&diagnostic.relation_id, secret);
606    }
607}
608
609/// Parse the epic references one remote issue reported.
610///
611/// **Pure and total**, in the shape [`remote_projects`] is: an unrecognised payload
612/// yields no references — which the import then reports nothing about — rather than
613/// a deserialisation failure that aborts the whole list. Accepted, for both
614/// trackers, because the field's exact shape is a claim about a program this
615/// repository cannot run:
616///
617/// - an array of ids (`"epics": ["e-1", …]`) or of objects;
618/// - a single id, or a single object (`"epic": {"id": …}`);
619/// - an object's id under `id`, `identifier`, `key`, or `epic_id`, as a string or a
620///   number (numeric ids are real on the wire);
621/// - its name under `name`, `title`, or `display_name`, as a string.
622///
623/// **Entry order is preserved**, which is what keeps a round trip byte-stable
624/// (NFR-002): reordering here would reorder the local list. An entry with a name
625/// but no id is **kept** with an empty id, so the import can report it by name
626/// instead of dropping it in silence; an entry with neither is not a reference at
627/// all and yields nothing.
628#[must_use]
629pub fn remote_epics(value: Option<&serde_json::Value>) -> Vec<RemoteEpic> {
630    fn id_of(value: &serde_json::Value) -> String {
631        match value {
632            serde_json::Value::String(s) => s.clone(),
633            serde_json::Value::Number(n) => n.to_string(),
634            _ => String::new(),
635        }
636    }
637    fn one(value: &serde_json::Value) -> Option<RemoteEpic> {
638        match value {
639            serde_json::Value::String(_) | serde_json::Value::Number(_) => Some(RemoteEpic {
640                id: id_of(value),
641                name: None,
642            }),
643            serde_json::Value::Object(map) => {
644                let id = ["id", "identifier", "key", "epic_id"]
645                    .iter()
646                    .find_map(|key| map.get(*key))
647                    .map(id_of)
648                    .unwrap_or_default();
649                let name = ["name", "title", "display_name"]
650                    .iter()
651                    .find_map(|key| map.get(*key))
652                    .and_then(serde_json::Value::as_str)
653                    .map(str::to_string);
654                if id.trim().is_empty() && name.is_none() {
655                    return None;
656                }
657                Some(RemoteEpic { id, name })
658            }
659            _ => None,
660        }
661    }
662    let Some(value) = value else {
663        return Vec::new();
664    };
665    match value {
666        serde_json::Value::Array(items) => items.iter().filter_map(one).collect(),
667        other => one(other).into_iter().collect(),
668    }
669}
670
671/// What one local epic reference maps to on the wire.
672#[derive(Debug, Clone, PartialEq, Eq)]
673pub enum MappedEpic {
674    /// The remote value to send, as a field/value pair like a relation.
675    Remote { field: String, value: String },
676    /// The adapter cannot carry it. The **local** reference is untouched — C-007 is
677    /// that a reference the remote cannot hold still survives.
678    Unsupported(RemoteMappingDiagnostic),
679}
680
681/// Map one local epic reference to its remote representation, or say why not.
682///
683/// Pure: no adapter, no I/O, no configuration. Both trackers share it, so a future
684/// one cannot be added with a divergent contract — and the representability decision
685/// lives in exactly one place instead of once per adapter.
686///
687/// `issue_number` appears in the message because the diagnostic has to be
688/// actionable: the reference list can be long, and "epic `e-3` cannot be sent"
689/// without the issue is a shrug.
690#[must_use]
691pub fn map_epic_to_remote(tool: Tool, issue_number: u32, epic_id: &str) -> MappedEpic {
692    let field = EPIC_FIELD;
693    let id = epic_id.trim();
694    if id.is_empty() {
695        // Not a representability question: there is nothing to send, and an empty
696        // entry in the list is a repair job rather than a remote capability gap.
697        return MappedEpic::Unsupported(RemoteMappingDiagnostic {
698            relation_id: format!("{issue_number}->"),
699            field: field.to_string(),
700            tool,
701            message: format!(
702                "issue #{issue_number} carries an epic reference with no id, so there is \
703                 nothing to send; the entry is kept locally — dereferencing the empty \
704                 reference, or editing the issue's epic list, clears it"
705            ),
706        });
707    }
708    if EPIC_WIRE_VERIFIED.contains(&tool) {
709        return MappedEpic::Remote {
710            field: field.to_string(),
711            value: id.to_string(),
712        };
713    }
714    MappedEpic::Unsupported(RemoteMappingDiagnostic {
715        relation_id: format!("{issue_number}->{id}"),
716        field: field.to_string(),
717        tool,
718        message: format!(
719            "#{issue_number}'s reference to epic `{id}` has no {} field to travel in \
720             — {} — so nothing is sent and the local reference is kept",
721            tool.as_str(),
722            epic_absence_reason(tool)
723        ),
724    })
725}
726
727/// Why a tracker's CLI cannot carry an epic membership, per tool.
728///
729/// A **permanent** claim about the CLI the adapter drives, not "not yet verified",
730/// and the wording says so for the same reason `PROJECT_WIRE_VERIFIED`'s reason
731/// does: a reader told "the verb is unverified" will wait for it, and there is
732/// nothing to wait for. The two reasons differ, so the message does.
733pub(super) fn epic_absence_reason(tool: Tool) -> &'static str {
734    match tool {
735        Tool::GitLab => {
736            "`glab` names at most one epic per issue (`epic_iid`) and exposes no flag \
737             to send one, so a membership list has nowhere to go"
738        }
739        Tool::Linear => {
740            "Linear has no epic level at all — the nearest concept is a project — so an \
741             epic reference has nowhere to go"
742        }
743    }
744}
745
746/// Map one remote epic reference back onto a local reference id, or say why not.
747///
748/// The reverse value is **local-shaped**, like [`map_project_from_remote`]'s: the
749/// frontmatter key the reference lives under ([`crate::epics::EPICS_KEY`]) and the
750/// id, not a copy of the tool's field name — echoing the field back would make the
751/// same reference read differently per tracker, which is the opposite of a shared
752/// seam.
753///
754/// Not gated on [`EPIC_WIRE_VERIFIED`], and the module header records why: an id is
755/// validated against the local registry before anything is written, so this
756/// direction cannot invent local data the way an unverified relation field could.
757#[must_use]
758pub fn map_epic_from_remote(tool: Tool, remote: &RemoteEpic) -> MappedEpic {
759    let id = remote.id.trim();
760    if id.is_empty() {
761        return MappedEpic::Unsupported(RemoteMappingDiagnostic {
762            relation_id: remote.name.clone().unwrap_or_default(),
763            field: EPIC_FIELD.to_string(),
764            tool,
765            message: format!(
766                "the remote reported an epic it named `{}` but gave no id, and an id is \
767                 the only thing a local reference can match on; nothing was referenced \
768                 and the local list is unchanged",
769                remote.name.as_deref().unwrap_or("")
770            ),
771        });
772    }
773    MappedEpic::Remote {
774        field: crate::epics::EPICS_KEY.to_string(),
775        value: id.to_string(),
776    }
777}
778
779/// What one local health value maps to on the wire.
780#[derive(Debug, Clone, PartialEq, Eq)]
781pub enum MappedHealth {
782    /// The remote value to send, as a field/value pair like a relation.
783    Remote { field: String, value: String },
784    /// The adapter cannot carry it. The **local** value is untouched, which is the
785    /// same contract the relationship and parent mappers follow (C-008): a value the
786    /// remote cannot hold still survives here, and a remote that lacks the field can
787    /// never clear it.
788    Unsupported(RemoteMappingDiagnostic),
789}
790
791/// Map one local health value to its remote representation, or say why not (T022,
792/// T023).
793///
794/// Pure: no adapter, no I/O, no configuration. Shared by both trackers so a future
795/// one cannot be added with a divergent contract, and the representability table
796/// lives in exactly one place.
797///
798/// Only an **issue**-scoped value has a remote home: a rollup belongs to this
799/// repository's memberships, and a remote that knew nothing of the parent would carry
800/// a number the local side derived. A non-issue scope is therefore reported, never
801/// silently sent as if it were the issue's own health.
802#[must_use]
803pub fn map_health_to_remote(
804    tool: Tool,
805    scope: &crate::health::HealthScope,
806    state: crate::health::HealthState,
807) -> MappedHealth {
808    let field = health_field(tool);
809    if HEALTH_MODEL_ABSENT.contains(&tool) {
810        return MappedHealth::Unsupported(RemoteMappingDiagnostic {
811            relation_id: scope.as_str(),
812            field: field.to_string(),
813            tool,
814            message: format!(
815                "{}'s issue model has no health field, so `{}` stays local; a remote that \
816                 cannot carry the value never clears it",
817                tool.as_str(),
818                state.as_str()
819            ),
820        });
821    }
822    let crate::health::HealthScope::Issue(number) = scope else {
823        return MappedHealth::Unsupported(RemoteMappingDiagnostic {
824            relation_id: scope.as_str(),
825            field: field.to_string(),
826            tool,
827            message: format!(
828                "`{}` is a derived value for {}, which has no remote representation; only \
829                 an issue's own health can be sent, so the local value is kept",
830                state.as_str(),
831                scope
832            ),
833        });
834    };
835    if HEALTH_WIRE_VERIFIED.contains(&tool) {
836        return MappedHealth::Remote {
837            field: field.to_string(),
838            value: state.as_str().to_string(),
839        };
840    }
841    MappedHealth::Unsupported(RemoteMappingDiagnostic {
842        relation_id: format!("issue:{number}"),
843        field: field.to_string(),
844        tool,
845        message: format!(
846            "issue #{number}'s `{}` is expressible in {}'s model as `{field}`, but the CLI \
847             verb that would carry it is not verified, so nothing is sent; the local value \
848             is kept",
849            state.as_str(),
850            tool.as_str()
851        ),
852    })
853}
854
855/// The field an adapter carries an issue's health in.
856pub(super) fn health_field(tool: Tool) -> &'static str {
857    match tool {
858        Tool::GitLab => GITLAB_HEALTH_FIELD,
859        Tool::Linear => LINEAR_HEALTH_FIELD,
860    }
861}
862
863/// What one local initiative maps to on the wire.
864#[derive(Debug, Clone, PartialEq, Eq)]
865pub enum MappedInitiative {
866    /// The remote value to send, as a field/value pair like a relation. A verified
867    /// adapter would put the **remote reference** in `value`; until one exists this
868    /// arm is unreachable, exactly as [`MappedParent::Remote`] is.
869    Remote { field: String, value: String },
870    /// The target cannot carry the record. The **local** record is untouched,
871    /// which is the contract every other mapper follows (C-005): a record the
872    /// remote cannot hold still survives, and a remote that lacks the field can
873    /// never clear it.
874    Unsupported(RemoteMappingDiagnostic),
875}
876
877/// Map one local initiative to its remote representation, or say why not (T034,
878/// T035).
879///
880/// Pure: no adapter, no I/O, no configuration. Shared by both trackers so a future
881/// one cannot be added with a divergent contract — and the representability
882/// decision lives in exactly one place instead of once per adapter.
883///
884/// `initiative_id` is the **stable** id (C-002), never a display name: it is the
885/// subject the diagnostic names, and the value a verified adapter would send.
886#[must_use]
887pub fn map_initiative_to_remote(tool: Tool, initiative_id: &str) -> MappedInitiative {
888    let field = INITIATIVE_FIELD;
889    if INITIATIVE_WIRE_VERIFIED.contains(&tool) {
890        return MappedInitiative::Remote {
891            field: field.to_string(),
892            value: initiative_id.to_string(),
893        };
894    }
895    MappedInitiative::Unsupported(RemoteMappingDiagnostic {
896        relation_id: initiative_id.to_string(),
897        field: field.to_string(),
898        tool,
899        message: initiative_absence_reason(tool).to_string(),
900    })
901}
902
903/// Why a tracker's CLI cannot carry a local initiative, per tool.
904///
905/// The two reasons differ and the message must not blur them: GitLab's is a
906/// **permanent** "the CLI has no such command", while Linear's is "the verb exists
907/// but cannot hold the record's fields". A reader told the wrong one would either
908/// wait for a command that will never appear or look for a flag that is not there.
909pub(super) fn initiative_absence_reason(tool: Tool) -> &'static str {
910    if INITIATIVE_CLI_ABSENT.contains(&tool) {
911        return "the CLI exposes no initiative command at all, so the whole local record is \
912                kept; a remote that cannot hold an initiative never clears one";
913    }
914    "the CLI's `initiatives` verbs take only a name, a description and a status — they \
915     cannot carry `owner`, `priority`, `labels`, `target_date`, or the typed \
916     project/epic/milestone associations — so sending those three fields would drop the \
917     rest; the whole local record is kept instead"
918}
919
920/// Map one local relationship to its remote representation, or say why not.
921///
922/// Pure: no adapter, no I/O, no configuration. Both trackers share it, so a future
923/// one cannot be added with a divergent contract (T033) — and the representability
924/// table lives in exactly one place instead of once per adapter.
925///
926/// `target` is the **target issue's remote reference**, if it is known: Linear's
927/// `relations add` needs the target's remote identifier (`ENG-42`), and an edge
928/// whose target has not been exported yet cannot be sent. Passing `None` therefore
929/// yields a diagnostic that names the target and says so — never a guessed
930/// identifier (C-005: the local edge survives). Trackers whose verb does not need a
931/// second endpoint (or none of whose verbs are verified) ignore `target`.
932#[must_use]
933pub fn map_relation_to_remote(
934    tool: Tool,
935    relation: &crate::relationships::Relationship,
936    target: Option<&str>,
937) -> MappedRelation {
938    use crate::relationships::RelationKind;
939    let (field, value) = match tool {
940        Tool::GitLab => (GITLAB_BLOCKING_FIELD, GITLAB_BLOCKING_VALUE),
941        Tool::Linear => (LINEAR_BLOCKING_FIELD, LINEAR_BLOCKING_VALUE),
942    };
943    // The tracker's *model* can express a blocking edge — and the adapter drives a
944    // CLI whose verb is verified only for the tools on `RELATION_WIRE_VERIFIED`.
945    let model_supports = matches!(
946        relation.kind,
947        RelationKind::Blocks | RelationKind::BlockedBy
948    );
949    if model_supports && RELATION_WIRE_VERIFIED.contains(&tool) {
950        let value = match tool {
951            // Linear's `relations add` takes the target's **remote identifier** as
952            // its second endpoint. Without one there is nothing to send, and a
953            // guessed identifier would be worse than a diagnostic.
954            Tool::Linear => match target {
955                Some(target) => target.to_string(),
956                None => {
957                    return MappedRelation::Unsupported(RemoteMappingDiagnostic {
958                        relation_id: relation.id.clone(),
959                        field: field.to_string(),
960                        tool,
961                        message: format!(
962                            "`{}` targets #{}, which has not been exported yet, so its \
963                             remote identifier is unknown and the edge cannot be sent; \
964                             the local edge is kept",
965                            relation.kind.as_str(),
966                            relation.to
967                        ),
968                    });
969                }
970            },
971            // GitLab is never on `RELATION_WIRE_VERIFIED` (its CLI has no relation
972            // command at all), so this arm is unreachable today; its rendering is
973            // left exactly as it was rather than "fixed" into an unverified shape.
974            Tool::GitLab => format!("{value} #{}", relation.to),
975        };
976        return MappedRelation::Remote {
977            field: field.to_string(),
978            value,
979        };
980    }
981    if model_supports {
982        // Expressible in the model, not drivable from the CLI. A *different* claim
983        // from "unsupported", and the message says which — a reader who is told
984        // "GitLab cannot express this" will not go looking for the verb. And for a
985        // CLI verified to have no relation command at all, the message must not say
986        // "not verified": there is nothing to verify (tix #1354).
987        let reason = if RELATION_CLI_ABSENT.contains(&tool) {
988            format!(
989                "the {} CLI exposes no issue-relation command, so it cannot be sent",
990                tool.as_str()
991            )
992        } else {
993            "the CLI verb that would carry it is not verified, so nothing is sent".to_string()
994        };
995        return MappedRelation::Unsupported(RemoteMappingDiagnostic {
996            relation_id: relation.id.clone(),
997            field: field.to_string(),
998            tool,
999            message: format!(
1000                "`{}` is expressible in {}'s model as `{field}`, but {reason}; the local \
1001                 edge is kept",
1002                relation.kind.as_str(),
1003                tool.as_str()
1004            ),
1005        });
1006    }
1007    MappedRelation::Unsupported(RemoteMappingDiagnostic {
1008        relation_id: relation.id.clone(),
1009        field: field.to_string(),
1010        tool,
1011        message: format!(
1012            "`{}` is a local-only relation; {} has no field for it, so the local \
1013             edge is kept and the remote one is not created",
1014            relation.kind.as_str(),
1015            tool.as_str()
1016        ),
1017    })
1018}
1019
1020/// The inverse: read a remote relation back to a local kind and target.
1021///
1022/// Symmetric with [`map_relation_to_remote`] by construction — the blocking field
1023/// maps back to `blocks`, and nothing else maps back at all. A remote payload
1024/// carrying a field this function does not recognise yields a diagnostic rather
1025/// than a default, so an import cannot invent a relation the export never made.
1026#[must_use]
1027pub fn map_relation_from_remote(
1028    tool: Tool,
1029    field: &str,
1030    value: &str,
1031    relation_id: &str,
1032) -> MappedRelation {
1033    let expected = match tool {
1034        Tool::GitLab => GITLAB_BLOCKING_FIELD,
1035        Tool::Linear => LINEAR_BLOCKING_FIELD,
1036    };
1037    // Gated the same way as the forward direction: without a verified verb there is
1038    // no payload this could have received, and coercing one would invent a
1039    // relationship the export never made.
1040    if field == expected && RELATION_WIRE_VERIFIED.contains(&tool) {
1041        // The value this module writes is `"{kind} #{n}"`, so the target is the
1042        // **last** whitespace-separated token. Taking the first reads the kind word
1043        // and fails — which is what an earlier version did, and it made the round
1044        // trip look asymmetric when only the parser was.
1045        if let Some(target) = value
1046            .split_whitespace()
1047            .last()
1048            .and_then(|t| t.strip_prefix('#'))
1049            .and_then(|t| t.parse::<u32>().ok())
1050        {
1051            // The reverse value is **local**-shaped — the canonical kind spelling
1052            // and a `#`-prefixed number — not a copy of the remote field name.
1053            // GitLab's field is `blocking` and Linear's is `blocks`; echoing the
1054            // field back would make the round trip return different text per
1055            // tracker for the same relationship, which is the opposite of
1056            // symmetric.
1057            return MappedRelation::Remote {
1058                field: "kind".to_string(),
1059                value: format!("blocks #{target}"),
1060            };
1061        }
1062        return MappedRelation::Unsupported(RemoteMappingDiagnostic {
1063            relation_id: relation_id.to_string(),
1064            field: field.to_string(),
1065            tool,
1066            message: format!(
1067                "`{value}` is not a target issue number, so the remote relation \
1068                 cannot be read back; no local edge was created"
1069            ),
1070        });
1071    }
1072    MappedRelation::Unsupported(RemoteMappingDiagnostic {
1073        relation_id: relation_id.to_string(),
1074        field: field.to_string(),
1075        tool,
1076        message: format!(
1077            "`{field}` is not a relation field {} writes, so it is ignored; the \
1078             local relationship set is unchanged",
1079            tool.as_str()
1080        ),
1081    })
1082}
1083
1084/// Map a local column to the remote state/label sent to the tracker
1085/// (`contracts/external-tools.md` mapping table).
1086///
1087/// - GitLab: done → `closed`, anything else → `opened`.
1088/// - Linear: done → `Done`, otherwise the first non-done column label.
1089#[must_use]
1090pub fn local_to_remote_status(tool: Tool, local_status: &str, config: &Config) -> String {
1091    let board = config.default_config();
1092    match tool {
1093        Tool::GitLab => {
1094            if is_done_column(board, local_status, config) {
1095                "closed".into()
1096            } else {
1097                "opened".into()
1098            }
1099        }
1100        Tool::Linear => {
1101            if is_done_column(board, local_status, config) {
1102                "Done".into()
1103            } else {
1104                first_work_column(board, config).to_string()
1105            }
1106        }
1107    }
1108}
1109
1110/// Map a remote state/label back onto a local column. Closed/Done maps to
1111/// the effective done column; anything else lands in the column whose name
1112/// matches (case-insensitive), else the first column — per the contract's
1113/// "whose name matches, else into the first column".
1114#[must_use]
1115pub fn remote_to_local_status(tool: Tool, remote_status: &str, config: &Config) -> String {
1116    let board = config.default_config();
1117    // WP07 T032: the *service's* completion column, not `done_column`. See
1118    // `completion_column` for why choosing one local column is this module's business
1119    // while the meaning of "complete" is not.
1120    let done = completion_column(board, config);
1121    match tool {
1122        Tool::GitLab => {
1123            if remote_status == "closed" {
1124                done
1125            } else {
1126                board
1127                    .columns
1128                    .first()
1129                    .cloned()
1130                    .unwrap_or_else(|| "todo".into())
1131            }
1132        }
1133        Tool::Linear => {
1134            if remote_status == "Done" {
1135                done
1136            } else {
1137                board
1138                    .columns
1139                    .iter()
1140                    .find(|c| c.eq_ignore_ascii_case(remote_status))
1141                    .cloned()
1142                    .unwrap_or_else(|| {
1143                        board
1144                            .columns
1145                            .first()
1146                            .cloned()
1147                            .unwrap_or_else(|| "todo".into())
1148                    })
1149            }
1150        }
1151    }
1152}
1153
1154/// Whether `local_status` is the board's **completion** column, by the service.
1155///
1156/// WP07 T032. This was `board.effective_done_column() == Some(local_status)` — a
1157/// column-*name* comparison, which is a second local definition of "done" and exactly
1158/// what C-004 forbids. It agreed with the service on a board with no
1159/// `category_map` — every existing configuration — and disagreed on precisely the
1160/// boards this mission exists to support: a board mapping `shipped` to `completed`
1161/// exports `shipped` as *open* and leaves `done_column` (mapped to `active`) as
1162/// *closed*.
1163///
1164/// `sync/mod.rs` is out of this mission's map; the edit is the completion
1165/// reconciliation T032 names, and the rationale is recorded here and in
1166/// `decisions/WP07-verification.md`.
1167pub(super) fn is_done_column(
1168    board: &BoardConfiguration,
1169    local_status: &str,
1170    config: &Config,
1171) -> bool {
1172    let resolution =
1173        crate::workflow::resolution(config, board, crate::workflow::active_board_name(config));
1174    resolution
1175        .resolve(local_status)
1176        .is_some_and(crate::workflow::WorkflowCategory::is_complete)
1177}
1178
1179/// The board's completion **column**, chosen by the service rather than by
1180/// `done_column`.
1181///
1182/// The remote trackers have a two-state notion of "closed", so the export path has to
1183/// pick one local column to send as closed. That is a **remote-translation** concern
1184/// and belongs here; deciding what "complete" *means* is not, and is the service's.
1185///
1186/// Preferring the column the service calls complete means a board whose completion
1187/// category sits on a column other than `done_column` still round-trips: export
1188/// closes the column that is genuinely complete, and import brings it back to that
1189/// same column rather than to `done_column`.
1190pub(super) fn completion_column(board: &BoardConfiguration, config: &Config) -> String {
1191    // WP09: this was a private re-implementation — the same "first complete column,
1192    // else `done_column`, else `done`" rule the service now owns, duplicated here so
1193    // that `first_work_column` and `remote_to_local_status` could each have a total
1194    // `String`. It is the module's own doc comment, written in WP07, that says a
1195    // second local definition is a C-004 defect, so the duplicate had to go.
1196    crate::workflow::completion_column_or_default(board, config)
1197}
1198
1199/// The first column that is **not** the board's completion column, or the
1200/// completion column when every column is complete.
1201///
1202/// WP09: "not the done column" is a question about **completion**, so it is asked of
1203/// the service rather than of `done_column`. On a board whose `category_map` puts
1204/// `completed` on `shipped` while `done_column` is `done`, the old version returned
1205/// `todo` as the first *work* column when `shipped` is the first column that is
1206/// genuinely finished — so an export labelled a shipped issue as work in progress.
1207pub(super) fn first_work_column<'a>(board: &'a BoardConfiguration, config: &Config) -> &'a str {
1208    // The lifetime is `board`'s, and the `done` fallback cannot borrow from `config`
1209    // — so the completion column is compared as a `String` and the fallback is a
1210    // literal. The alternative, returning `&'a str` from a local, is what forces the
1211    // old `unwrap_or("done")` to exist at all.
1212    let done = crate::workflow::completion_column_or_default(board, config);
1213    board
1214        .columns
1215        .iter()
1216        .find(|c| c.as_str() != done)
1217        .map_or("done", String::as_str)
1218}
1219
1220/// The inverse of `model::civil_from_days` (Howard Hinnant's
1221/// `days_from_civil`): calendar date → days since the Unix epoch.
1222#[must_use]
1223pub fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
1224    let y = if m <= 2 { y - 1 } else { y };
1225    let era = if y >= 0 { y } else { y - 399 } / 400;
1226    let yoe = y - era * 400; // [0, 399]
1227    let mp = (m + 9) % 12; // [0, 11]
1228    let doy = i64::from((153 * mp + 2) / 5 + d - 1); // [0, 365]
1229    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
1230    era * 146_097 + doe - 719_468
1231}
1232
1233/// Parse `YYYY-MM-DD` into days since the Unix epoch.
1234#[must_use]
1235pub fn date_to_epoch_days(ymd: &str) -> Option<i64> {
1236    let mut parts = ymd.trim().split('-');
1237    let y: i64 = parts.next()?.parse().ok()?;
1238    let m: u32 = parts.next()?.parse().ok()?;
1239    let d: u32 = parts.next()?.parse().ok()?;
1240    if parts.next().is_some() {
1241        return None;
1242    }
1243    if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
1244        return None;
1245    }
1246    Some(days_from_civil(y, m, d))
1247}
1248
1249/// Parse an ISO-8601 remote timestamp into epoch seconds. Accepts
1250/// `YYYY-MM-DDTHH:MM:SS` (the `T` may be a space) with optional fractional
1251/// seconds and a `Z` or `±HH:MM` zone.
1252#[must_use]
1253pub fn iso_to_epoch_secs(input: &str) -> Option<i64> {
1254    let (date, time) = input.trim().split_once(['T', ' '])?;
1255    let days = date_to_epoch_days(date)?;
1256    let (time, zone) = split_zone(time);
1257    let mut hms = time.split(':');
1258    let hh: i64 = hms.next()?.parse().ok()?;
1259    let mm: i64 = hms.next()?.parse().ok()?;
1260    let ss_part = hms.next()?;
1261    let whole: i64 = ss_part.split('.').next()?.parse().ok()?;
1262    if !(0..=23).contains(&hh) || !(0..=59).contains(&mm) || !(0..=59).contains(&whole) {
1263        return None;
1264    }
1265    let mut secs = days * 86_400 + hh * 3600 + mm * 60 + whole;
1266    if let Some((zh, zm)) = zone {
1267        secs -= zh * 3600 + zm * 60;
1268    }
1269    Some(secs)
1270}
1271
1272/// Split `HH:MM:SS[.frac][zone]` into the bare time and an optional
1273/// `(hours, minutes)` zone offset (already sign-adjusted).
1274pub(super) fn split_zone(time: &str) -> (&str, Option<(i64, i64)>) {
1275    if time.ends_with('Z') || time.ends_with('z') {
1276        return (&time[..time.len() - 1], Some((0, 0)));
1277    }
1278    match time.rfind(['+', '-']) {
1279        Some(i) if i > 0 => {
1280            let (bare, zone) = time.split_at(i);
1281            let sign = if zone.starts_with('-') { -1i64 } else { 1i64 };
1282            let digits = &zone[1..];
1283            let (zh, zm) = if let Some((h, m)) = digits.split_once(':') {
1284                (h.parse::<i64>().ok(), m.parse::<i64>().ok())
1285            } else if digits.len() == 2 {
1286                (digits.parse::<i64>().ok(), Some(0))
1287            } else if digits.len() >= 3 {
1288                let (h, m) = digits.split_at(digits.len() - 2);
1289                (h.parse::<i64>().ok(), m.parse::<i64>().ok())
1290            } else {
1291                (None, None)
1292            };
1293            match (zh, zm) {
1294                (Some(zh), Some(zm)) => (bare, Some((sign * zh, sign * zm))),
1295                _ => (bare, None),
1296            }
1297        }
1298        Some(_) | None => (time, None),
1299    }
1300}
1301
1302/// The conflict rule: the remote wins when its `updated_at` is strictly later
1303/// than the local issue's `updated` (a `YYYY-MM-DD` interpreted as end of
1304/// day). A remote timestamp that cannot be parsed counts as *not newer*, so
1305/// the local issue is kept.
1306#[must_use]
1307pub fn is_remote_newer(remote_updated_at: &str, local_updated: &str) -> bool {
1308    let Some(remote) = iso_to_epoch_secs(remote_updated_at) else {
1309        return false;
1310    };
1311    let local_end_of_day = date_to_epoch_days(local_updated).map_or(0, |d| d * 86_400 + 86_399);
1312    remote > local_end_of_day
1313}
1314
1315/// Redact a resolved secret from text flowing through error/report surfaces.
1316#[must_use]
1317pub fn scrub_secret(text: &str, secret: &str) -> String {
1318    if secret.is_empty() {
1319        return text.to_string();
1320    }
1321    text.replace(secret, "<token>")
1322}
1323
1324/// The contract's missing-binary wording (`contracts/external-tools.md`):
1325/// exit code 3 with `required tool not found: <tool> — install from <source>`.
1326pub(super) fn not_found_message(program: &str) -> String {
1327    match program {
1328        "glab" => "required tool not found: glab — install from https://gitlab.com/gitlab-org/cli"
1329            .to_string(),
1330        _ => format!("required tool not found: {program} — install it and retry"),
1331    }
1332}
1333
1334/// Build and configure the adapter for `tool`: resolves the token with env
1335/// precedence, validates the `[export]` target section, and returns the
1336/// tool-specific adapter wired to a [`ProcessRunner`].
1337pub(super) fn adapter_for(tool: Tool, config: &Config) -> Result<Box<dyn TrackerAdapter>> {
1338    // The contract's warning starts with "warning:" — no `tix:` prefix.
1339    let mut warn = |w: &str| eprintln!("{w}");
1340    let resolution = token::resolve(tool, config, &token::RealEnv, &mut warn);
1341    let secret = resolution.secret().map(str::to_string);
1342    match tool {
1343        Tool::GitLab => {
1344            let rc = config.export.gitlab.as_ref().ok_or_else(|| {
1345                TixError::Config("no `[export.gitlab]` section in tix.toml — run `tix init --with gitlab` or edit tix.toml".to_string())
1346            })?;
1347            let project = rc.project.clone().ok_or_else(|| {
1348                TixError::Config("`[export.gitlab].project` is not set — add `project = \"acme/tix\"` to tix.toml".to_string())
1349            })?;
1350            Ok(Box::new(glab::GitLabAdapter::new(
1351                ProcessRunner,
1352                secret,
1353                rc.base_url.clone(),
1354                project,
1355            )))
1356        }
1357        Tool::Linear => {
1358            let rc = config.export.linear.as_ref().ok_or_else(|| {
1359                TixError::Config("no `[export.linear]` section in tix.toml — run `tix init --with linear` or edit tix.toml".to_string())
1360            })?;
1361            let team = rc.team.clone().ok_or_else(|| {
1362                TixError::Config(
1363                    "`[export.linear].team` is not set — add `team = \"Eng\"` to tix.toml"
1364                        .to_string(),
1365                )
1366            })?;
1367            Ok(Box::new(linear::LinearAdapter::new(
1368                ProcessRunner,
1369                secret,
1370                team,
1371            )))
1372        }
1373    }
1374}