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}