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