Skip to main content

tix/sync/
mod.rs

1//! External tracker sync: orchestration for `tix export` / `tix import`,
2//! the [`Tool`] selector, the [`CommandRunner`] spawn seam, and the pure
3//! mapping / conflict-resolution rules.
4//!
5//! WP05 wires the CLI to `glab` (GitLab) and `linear-cli` (Linear) under
6//! `contracts/external-tools.md`. Tokens are resolved through
7//! [`token::resolve`] with env precedence, injected only as an env pair (never
8//! argv), and every error/report surface scrubs the secret as `<token>`.
9//!
10//! The orchestration lives here so it is testable at module granularity by
11//! pointing PATH at fake tool scripts; [`export`] / [`import`] are pure in the
12//! sense that they take the store, tool, config and an output sink, and the
13//! only externally visible side effects are local issue files, `tix.toml`,
14//! the tool subprocesses, and what is written to `out`.
15
16use std::io::Write;
17
18use crate::config::{BoardConfiguration, Config};
19use crate::error::{Result, TixError};
20use crate::model::{Issue, Status};
21use crate::storage::Store;
22
23pub mod glab;
24pub mod linear;
25pub mod token;
26
27/// The supported external tracker, selected via `--target gitlab|linear`.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Tool {
30    GitLab,
31    Linear,
32}
33
34impl Tool {
35    #[must_use]
36    pub fn from_target(target: &str) -> Result<Tool> {
37        match target {
38            "gitlab" => Ok(Tool::GitLab),
39            "linear" => Ok(Tool::Linear),
40            other => Err(TixError::InvalidInput(format!(
41                "unknown --target '{other}' — use gitlab or linear"
42            ))),
43        }
44    }
45
46    /// The token env var that wins over any `tix.toml` token.
47    #[must_use]
48    pub fn token_var(self) -> &'static str {
49        match self {
50            Tool::GitLab => "GITLAB_TOKEN",
51            Tool::Linear => "LINEAR_TOKEN",
52        }
53    }
54
55    /// The binary shelled out to.
56    #[must_use]
57    pub fn binary(self) -> &'static str {
58        match self {
59            Tool::GitLab => "glab",
60            Tool::Linear => "linear-cli",
61        }
62    }
63
64    #[must_use]
65    pub fn as_str(self) -> &'static str {
66        match self {
67            Tool::GitLab => "gitlab",
68            Tool::Linear => "linear",
69        }
70    }
71}
72
73impl std::fmt::Display for Tool {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        f.write_str(self.as_str())
76    }
77}
78
79/// The captured result of a spawned external tool.
80///
81/// `ProcessRunner` waits synchronously on the child (never kills it), so a
82/// blocking tool blocks the sync — the contract's supported "wait" model for
83/// `glab`/`linear-cli`.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct CommandOutcome {
86    pub success: bool,
87    pub status: Option<i32>,
88    pub stdout: String,
89    pub stderr: String,
90}
91
92impl CommandOutcome {
93    /// The human-readable output chosen for error reporting: stderr when the
94    /// tool failed and produced any, otherwise stdout.
95    #[must_use]
96    pub fn text(&self) -> &str {
97        if self.success {
98            &self.stdout
99        } else if !self.stderr.trim().is_empty() {
100            &self.stderr
101        } else {
102            &self.stdout
103        }
104    }
105}
106
107/// Spawn seam: production uses [`ProcessRunner`]; tests inject a
108/// `FakeRunner` that records argv/env without spawning anything.
109pub trait CommandRunner: Send {
110    fn run(
111        &self,
112        program: &str,
113        args: &[String],
114        env: &[(String, String)],
115    ) -> Result<CommandOutcome>;
116
117    /// Downcasting hook so tests can inspect recorded calls on a fake.
118    fn as_any(&self) -> &dyn std::any::Any;
119}
120
121/// Production runner: `std::process::Command` with a synchronous output wait.
122///
123/// A missing binary surfaces as [`TixError::External`] with the contract's
124/// exit-3 wording (`contracts/external-tools.md`: "missing binary ... exit 3").
125pub struct ProcessRunner;
126
127impl CommandRunner for ProcessRunner {
128    fn run(
129        &self,
130        program: &str,
131        args: &[String],
132        env: &[(String, String)],
133    ) -> Result<CommandOutcome> {
134        use std::process::Command;
135        let mut cmd = Command::new(program);
136        cmd.args(args);
137        for (key, value) in env {
138            cmd.env(key, value);
139        }
140        let output = cmd.output().map_err(|e| {
141            if e.kind() == std::io::ErrorKind::NotFound {
142                TixError::External(not_found_message(program))
143            } else {
144                TixError::External(format!("`{program}` failed to start: {e}"))
145            }
146        })?;
147        Ok(CommandOutcome {
148            success: output.status.success(),
149            status: output.status.code(),
150            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
151            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
152        })
153    }
154
155    fn as_any(&self) -> &dyn std::any::Any {
156        self
157    }
158}
159
160/// Common surface both tracker adapters implement so the orchestration is
161/// tool-agnostic.
162///
163/// `status` is the *remote* state/label already computed by the pure mapping
164/// helpers (e.g. `closed`, `Done`, or the first work column). GitLab conveys
165/// "done" via a separate close call ([`TrackerAdapter::mark_done`]); Linear
166/// sends `--status Done` directly ([`TrackerAdapter::done_via_status`]).
167pub trait TrackerAdapter {
168    fn tool(&self) -> Tool;
169
170    /// Create a remote issue and return its remote id (GitLab `iid`, Linear
171    /// `identifier`).
172    fn create_issue(
173        &mut self,
174        title: &str,
175        body: &str,
176        labels: &[String],
177        assignee: Option<&str>,
178        priority: Option<&str>,
179        status: &str,
180    ) -> Result<String>;
181
182    /// Update an existing remote issue in place.
183    #[allow(clippy::too_many_arguments)]
184    fn update_issue(
185        &mut self,
186        remote_ref: &str,
187        title: &str,
188        body: &str,
189        labels: &[String],
190        assignee: Option<&str>,
191        priority: Option<&str>,
192        status: &str,
193    ) -> Result<()>;
194
195    /// Convey "done" (GitLab: `issue close <id>`). Only invoked when
196    /// `done_via_status` is false.
197    fn mark_done(&mut self, remote_ref: &str) -> Result<()>;
198
199    /// List open remote issues (the import source). Closed/done issues are
200    /// excluded.
201    fn list_open(&mut self) -> Result<Vec<RemoteIssue>>;
202
203    /// Linear sets done through the status flag; GitLab needs a separate
204    /// close call.
205    fn done_via_status(&self) -> bool;
206}
207
208/// A remote issue parsed from the tracker's JSON list output.
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct RemoteIssue {
211    /// Remote id (GitLab `iid`, Linear `identifier`).
212    pub id: String,
213    pub title: String,
214    pub description: String,
215    pub labels: Vec<String>,
216    /// Remote state (`closed` / `Done` / a column label, ...).
217    pub state: String,
218    /// ISO-8601 `updated_at`; `None` when the tool omitted it.
219    pub updated_at: Option<String>,
220    /// Best-effort single assignee (bare name, no `@`).
221    pub assignee: Option<String>,
222}
223
224/// Map a local column to the remote state/label sent to the tracker
225/// (`contracts/external-tools.md` mapping table).
226///
227/// - GitLab: done → `closed`, anything else → `opened`.
228/// - Linear: done → `Done`, otherwise the first non-done column label.
229#[must_use]
230pub fn local_to_remote_status(tool: Tool, local_status: &str, config: &Config) -> String {
231    let board = config.default_config();
232    match tool {
233        Tool::GitLab => {
234            if is_done_column(board, local_status) {
235                "closed".into()
236            } else {
237                "opened".into()
238            }
239        }
240        Tool::Linear => {
241            if is_done_column(board, local_status) {
242                "Done".into()
243            } else {
244                first_work_column(board).to_string()
245            }
246        }
247    }
248}
249
250/// Map a remote state/label back onto a local column. Closed/Done maps to
251/// the effective done column; anything else lands in the column whose name
252/// matches (case-insensitive), else the first column — per the contract's
253/// "whose name matches, else into the first column".
254#[must_use]
255pub fn remote_to_local_status(tool: Tool, remote_status: &str, config: &Config) -> String {
256    let board = config.default_config();
257    let done = board.effective_done_column().unwrap_or("done").to_string();
258    match tool {
259        Tool::GitLab => {
260            if remote_status == "closed" {
261                done
262            } else {
263                board
264                    .columns
265                    .first()
266                    .cloned()
267                    .unwrap_or_else(|| "todo".into())
268            }
269        }
270        Tool::Linear => {
271            if remote_status == "Done" {
272                done
273            } else {
274                board
275                    .columns
276                    .iter()
277                    .find(|c| c.eq_ignore_ascii_case(remote_status))
278                    .cloned()
279                    .unwrap_or_else(|| {
280                        board
281                            .columns
282                            .first()
283                            .cloned()
284                            .unwrap_or_else(|| "todo".into())
285                    })
286            }
287        }
288    }
289}
290
291fn is_done_column(board: &BoardConfiguration, local_status: &str) -> bool {
292    board.effective_done_column() == Some(local_status)
293}
294
295fn first_work_column(board: &BoardConfiguration) -> &str {
296    let done = board.effective_done_column().unwrap_or("done");
297    board
298        .columns
299        .iter()
300        .find(|c| c.as_str() != done)
301        .map_or(done, String::as_str)
302}
303
304/// The inverse of `model::civil_from_days` (Howard Hinnant's
305/// `days_from_civil`): calendar date → days since the Unix epoch.
306#[must_use]
307pub fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
308    let y = if m <= 2 { y - 1 } else { y };
309    let era = if y >= 0 { y } else { y - 399 } / 400;
310    let yoe = y - era * 400; // [0, 399]
311    let mp = (m + 9) % 12; // [0, 11]
312    let doy = i64::from((153 * mp + 2) / 5 + d - 1); // [0, 365]
313    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
314    era * 146_097 + doe - 719_468
315}
316
317/// Parse `YYYY-MM-DD` into days since the Unix epoch.
318#[must_use]
319pub fn date_to_epoch_days(ymd: &str) -> Option<i64> {
320    let mut parts = ymd.trim().split('-');
321    let y: i64 = parts.next()?.parse().ok()?;
322    let m: u32 = parts.next()?.parse().ok()?;
323    let d: u32 = parts.next()?.parse().ok()?;
324    if parts.next().is_some() {
325        return None;
326    }
327    if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
328        return None;
329    }
330    Some(days_from_civil(y, m, d))
331}
332
333/// Parse an ISO-8601 remote timestamp into epoch seconds. Accepts
334/// `YYYY-MM-DDTHH:MM:SS` (the `T` may be a space) with optional fractional
335/// seconds and a `Z` or `±HH:MM` zone.
336#[must_use]
337pub fn iso_to_epoch_secs(input: &str) -> Option<i64> {
338    let (date, time) = input.trim().split_once(['T', ' '])?;
339    let days = date_to_epoch_days(date)?;
340    let (time, zone) = split_zone(time);
341    let mut hms = time.split(':');
342    let hh: i64 = hms.next()?.parse().ok()?;
343    let mm: i64 = hms.next()?.parse().ok()?;
344    let ss_part = hms.next()?;
345    let whole: i64 = ss_part.split('.').next()?.parse().ok()?;
346    if !(0..=23).contains(&hh) || !(0..=59).contains(&mm) || !(0..=59).contains(&whole) {
347        return None;
348    }
349    let mut secs = days * 86_400 + hh * 3600 + mm * 60 + whole;
350    if let Some((zh, zm)) = zone {
351        secs -= zh * 3600 + zm * 60;
352    }
353    Some(secs)
354}
355
356/// Split `HH:MM:SS[.frac][zone]` into the bare time and an optional
357/// `(hours, minutes)` zone offset (already sign-adjusted).
358fn split_zone(time: &str) -> (&str, Option<(i64, i64)>) {
359    if time.ends_with('Z') || time.ends_with('z') {
360        return (&time[..time.len() - 1], Some((0, 0)));
361    }
362    match time.rfind(['+', '-']) {
363        Some(i) if i > 0 => {
364            let (bare, zone) = time.split_at(i);
365            let sign = if zone.starts_with('-') { -1i64 } else { 1i64 };
366            let digits = &zone[1..];
367            let (zh, zm) = if let Some((h, m)) = digits.split_once(':') {
368                (h.parse::<i64>().ok(), m.parse::<i64>().ok())
369            } else if digits.len() == 2 {
370                (digits.parse::<i64>().ok(), Some(0))
371            } else if digits.len() >= 3 {
372                let (h, m) = digits.split_at(digits.len() - 2);
373                (h.parse::<i64>().ok(), m.parse::<i64>().ok())
374            } else {
375                (None, None)
376            };
377            match (zh, zm) {
378                (Some(zh), Some(zm)) => (bare, Some((sign * zh, sign * zm))),
379                _ => (bare, None),
380            }
381        }
382        Some(_) | None => (time, None),
383    }
384}
385
386/// The conflict rule: the remote wins when its `updated_at` is strictly later
387/// than the local issue's `updated` (a `YYYY-MM-DD` interpreted as end of
388/// day). A remote timestamp that cannot be parsed counts as *not newer*, so
389/// the local issue is kept.
390#[must_use]
391pub fn is_remote_newer(remote_updated_at: &str, local_updated: &str) -> bool {
392    let Some(remote) = iso_to_epoch_secs(remote_updated_at) else {
393        return false;
394    };
395    let local_end_of_day = date_to_epoch_days(local_updated).map_or(0, |d| d * 86_400 + 86_399);
396    remote > local_end_of_day
397}
398
399/// Redact a resolved secret from text flowing through error/report surfaces.
400#[must_use]
401pub fn scrub_secret(text: &str, secret: &str) -> String {
402    if secret.is_empty() {
403        return text.to_string();
404    }
405    text.replace(secret, "<token>")
406}
407
408/// The contract's missing-binary wording (`contracts/external-tools.md`):
409/// exit code 3 with `required tool not found: <tool> — install from <source>`.
410fn not_found_message(program: &str) -> String {
411    match program {
412        "glab" => "required tool not found: glab — install from https://gitlab.com/gitlab-org/cli"
413            .to_string(),
414        _ => format!("required tool not found: {program} — install it and retry"),
415    }
416}
417
418/// `tix export`: mirror local issues to the remote tracker, recording the
419/// remote id back into the local `gitlab_id`/`linear_id` so a re-run updates
420/// instead of creating duplicates (idempotency, FR-Remote-sync).
421///
422/// Returns the number of issues handled (created or updated — `close` on an
423/// updated GitLab done issue is not counted separately).
424pub fn export(
425    store: &Store,
426    tool: Tool,
427    config: &Config,
428    dry_run: bool,
429    out: &mut dyn Write,
430) -> Result<usize> {
431    let mut adapter = adapter_for(store, tool, config)?;
432
433    if dry_run {
434        for issue in store.list()? {
435            match remote_ref(tool, &issue) {
436                Some(r) => {
437                    writeln!(out, "would update {tool} issue {r} \"{}\"", issue.title)?;
438                }
439                None => {
440                    writeln!(out, "would create {tool} issue \"{}\"", issue.title)?;
441                }
442            }
443        }
444        writeln!(out, "dry run — {tool} CLI was not invoked")?;
445        return Ok(store.list()?.len());
446    }
447
448    let mut handled = 0;
449    for mut issue in store.list()? {
450        let remote_status = local_to_remote_status(tool, issue.status.display(), config);
451        let labels: Vec<String> = issue.labels.clone();
452        let assignee = issue.assignee.as_deref();
453        let priority = issue.priority.to_string();
454        match remote_ref(tool, &issue) {
455            Some(r) => {
456                let r = r.to_string();
457                adapter.update_issue(
458                    &r,
459                    &issue.title,
460                    &issue.body,
461                    &labels,
462                    assignee,
463                    Some(&priority),
464                    &remote_status,
465                )?;
466                if !adapter.done_via_status() && remote_status_is_done(&remote_status) {
467                    adapter.mark_done(&r)?;
468                }
469                writeln!(out, "updated {tool} issue {r} \"{}\"", issue.title)?;
470            }
471            None => {
472                let id = adapter.create_issue(
473                    &issue.title,
474                    &issue.body,
475                    &labels,
476                    assignee,
477                    Some(&priority),
478                    &remote_status,
479                )?;
480                if !adapter.done_via_status() && remote_status_is_done(&remote_status) {
481                    adapter.mark_done(&id)?;
482                }
483                match tool {
484                    Tool::GitLab => issue.gitlab_id = Some(id.clone()),
485                    Tool::Linear => issue.linear_id = Some(id.clone()),
486                }
487                store.update(&issue.filename, &issue)?;
488                writeln!(
489                    out,
490                    "created {tool} issue {id} \"{}\" (remote id recorded)",
491                    issue.title
492                )?;
493            }
494        }
495        handled += 1;
496    }
497    writeln!(out, "exported {handled} issue(s) to {tool}")?;
498    Ok(handled)
499}
500
501/// `tix import`: pull open remote issues into local files.
502///
503/// Conflict resolution: a remote issue that already tracks a local one skips
504/// the local file when the local `updated` is later or equal; when the remote
505/// `updated_at` is newer the local file is overwritten (fields + body, keeping
506/// `created`), with the previous file first copied to `<filename>.bak`.
507///
508/// Returns the number of local files created or overwritten.
509pub fn import(store: &Store, tool: Tool, config: &Config, out: &mut dyn Write) -> Result<usize> {
510    let mut adapter = adapter_for(store, tool, config)?;
511    let remote_issues = adapter.list_open()?;
512    let mut handled = 0;
513    for remote in remote_issues {
514        let tracked = store
515            .list()?
516            .into_iter()
517            .find(|i| remote_ref(tool, i) == Some(remote.id.as_str()));
518        match tracked {
519            None => {
520                let mut issue = Issue {
521                    number: 0,
522                    title: remote.title.clone(),
523                    status: Status::new(remote_to_local_status(tool, &remote.state, config)),
524                    labels: remote.labels.clone(),
525                    priority: crate::model::Priority::Medium,
526                    assignee: remote.assignee.clone(),
527                    estimate: None,
528                    commit: None,
529                    gitlab_id: None,
530                    linear_id: None,
531                    mission: None,
532                    task: None,
533                    links: vec![],
534                    files: vec![],
535                    created: crate::model::today(),
536                    updated: crate::model::today(),
537                    filename: String::new(),
538                    body: String::new(),
539                };
540                match tool {
541                    Tool::GitLab => issue.gitlab_id = Some(remote.id.clone()),
542                    Tool::Linear => issue.linear_id = Some(remote.id.clone()),
543                }
544                let path = store.create(&mut issue, &remote.description, None)?;
545                let name = path
546                    .file_name()
547                    .map(|n| n.to_string_lossy().to_string())
548                    .unwrap_or_default();
549                writeln!(
550                    out,
551                    "imported {tool} issue {} \"{}\" as #{} ({}) [{name}]",
552                    remote.id, remote.title, issue.number, issue.status
553                )?;
554                handled += 1;
555            }
556            Some(local) => {
557                if is_remote_newer(remote.updated_at.as_deref().unwrap_or(""), &local.updated) {
558                    let backup = format!("{}.bak", local.filename);
559                    std::fs::copy(
560                        store.issues_dir.join(&local.filename),
561                        store.issues_dir.join(&backup),
562                    )?;
563                    let mut overwritten = local;
564                    let keep_created = overwritten.created.clone();
565                    overwritten.title = remote.title.clone();
566                    overwritten.body = remote.description.clone();
567                    overwritten.labels = remote.labels.clone();
568                    overwritten.status =
569                        Status::new(remote_to_local_status(tool, &remote.state, config));
570                    if let Some(a) = &remote.assignee {
571                        overwritten.assignee = Some(a.clone());
572                    }
573                    overwritten.created = keep_created;
574                    overwritten.bump_updated();
575                    let filename = overwritten.filename.clone();
576                    store.update(&filename, &overwritten)?;
577                    writeln!(
578                        out,
579                        "overwrote {tool} issue {} \"{}\" (remote newer; backup at {backup})",
580                        remote.id, overwritten.title
581                    )?;
582                    handled += 1;
583                } else {
584                    writeln!(
585                        out,
586                        "kept {tool} issue {} \"{}\" (local updated later or equal)",
587                        remote.id, remote.title
588                    )?;
589                }
590            }
591        }
592    }
593    writeln!(out, "imported {handled} {tool} issue(s)")?;
594    Ok(handled)
595}
596
597/// Whether the computed remote status conveys "done".
598pub fn remote_status_is_done(status: &str) -> bool {
599    status == "closed" || status == "Done"
600}
601
602fn remote_ref(tool: Tool, issue: &Issue) -> Option<&str> {
603    match tool {
604        Tool::GitLab => issue.gitlab_id.as_deref(),
605        Tool::Linear => issue.linear_id.as_deref(),
606    }
607}
608
609/// Build and configure the adapter for `tool`: resolves the token with env
610/// precedence, validates the `[export]` target section, and returns the
611/// tool-specific adapter wired to a [`ProcessRunner`].
612fn adapter_for(store: &Store, tool: Tool, config: &Config) -> Result<Box<dyn TrackerAdapter>> {
613    let _ = store;
614    // The contract's warning starts with "warning:" — no `tix:` prefix.
615    let mut warn = |w: &str| eprintln!("{w}");
616    let resolution = token::resolve(tool, config, &token::RealEnv, &mut warn);
617    let secret = resolution.secret().map(str::to_string);
618    match tool {
619        Tool::GitLab => {
620            let rc = config.export.gitlab.as_ref().ok_or_else(|| {
621                TixError::Config("no `[export.gitlab]` section in tix.toml — run `tix init --with gitlab` or edit tix.toml".to_string())
622            })?;
623            let project = rc.project.clone().ok_or_else(|| {
624                TixError::Config("`[export.gitlab].project` is not set — add `project = \"acme/tix\"` to tix.toml".to_string())
625            })?;
626            Ok(Box::new(glab::GitLabAdapter::new(
627                ProcessRunner,
628                secret,
629                rc.base_url.clone(),
630                project,
631            )))
632        }
633        Tool::Linear => {
634            let rc = config.export.linear.as_ref().ok_or_else(|| {
635                TixError::Config("no `[export.linear]` section in tix.toml — run `tix init --with linear` or edit tix.toml".to_string())
636            })?;
637            let team = rc.team.clone().ok_or_else(|| {
638                TixError::Config(
639                    "`[export.linear].team` is not set — add `team = \"Eng\"` to tix.toml"
640                        .to_string(),
641                )
642            })?;
643            Ok(Box::new(linear::LinearAdapter::new(
644                ProcessRunner,
645                secret,
646                team,
647            )))
648        }
649    }
650}
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655
656    #[test]
657    fn target_parsing_accepts_only_the_two_contract_targets() {
658        assert_eq!(Tool::from_target("gitlab").unwrap(), Tool::GitLab);
659        assert_eq!(Tool::from_target("linear").unwrap(), Tool::Linear);
660        assert!(Tool::from_target("github").is_err());
661        assert_eq!(Tool::GitLab.token_var(), "GITLAB_TOKEN");
662        assert_eq!(Tool::Linear.token_var(), "LINEAR_TOKEN");
663        assert_eq!(Tool::GitLab.binary(), "glab");
664        assert_eq!(Tool::Linear.binary(), "linear-cli");
665    }
666
667    #[test]
668    fn gitlab_status_mapping() {
669        let config = Config::default();
670        assert_eq!(
671            local_to_remote_status(Tool::GitLab, "todo", &config),
672            "opened"
673        );
674        assert_eq!(
675            local_to_remote_status(Tool::GitLab, "done", &config),
676            "closed"
677        );
678        assert_eq!(
679            remote_to_local_status(Tool::GitLab, "opened", &config),
680            "todo"
681        );
682        assert_eq!(
683            remote_to_local_status(Tool::GitLab, "closed", &config),
684            "done"
685        );
686        assert_eq!(
687            remote_to_local_status(Tool::GitLab, "anything-else", &config),
688            "todo"
689        );
690    }
691
692    #[test]
693    fn linear_status_mapping() {
694        let config = Config::default();
695        assert_eq!(
696            local_to_remote_status(Tool::Linear, "todo", &config),
697            "todo"
698        );
699        assert_eq!(
700            local_to_remote_status(Tool::Linear, "in review", &config),
701            "todo"
702        );
703        assert_eq!(
704            local_to_remote_status(Tool::Linear, "done", &config),
705            "Done"
706        );
707        assert_eq!(
708            remote_to_local_status(Tool::Linear, "In Progress", &config),
709            "in progress"
710        );
711        assert_eq!(
712            remote_to_local_status(Tool::Linear, "Done", &config),
713            "done"
714        );
715        assert_eq!(
716            remote_to_local_status(Tool::Linear, "Unknown", &config),
717            "todo"
718        );
719    }
720
721    #[test]
722    fn days_from_civil_roundtrips_civil_from_days() {
723        // spot-check independent epoch anchors (known values)
724        assert_eq!(days_from_civil(1970, 1, 1), 0);
725        assert_eq!(days_from_civil(2000, 1, 1), 10957);
726        assert_eq!(days_from_civil(1970, 1, 2), 1);
727        assert_eq!(days_from_civil(2026, 5, 11), 20584);
728    }
729
730    #[test]
731    fn date_to_epoch_days_parses_only_yyyymmdd() {
732        assert_eq!(date_to_epoch_days("1970-01-01"), Some(0));
733        assert_eq!(date_to_epoch_days("2026-05-11"), Some(20_584));
734        assert_eq!(date_to_epoch_days("not-a-date"), None);
735        assert_eq!(date_to_epoch_days("2026-13-01"), None);
736        assert_eq!(date_to_epoch_days("2026-05-11T10:00:00"), None);
737    }
738
739    #[test]
740    fn iso_to_epoch_secs_accepts_contract_timestamp_shapes() {
741        let day = days_from_civil(2026, 5, 11) * 86_400;
742        assert_eq!(iso_to_epoch_secs("2026-05-11T00:00:00Z"), Some(day));
743        assert_eq!(iso_to_epoch_secs("2026-05-11 00:00:00"), Some(day));
744        assert_eq!(iso_to_epoch_secs("2026-05-11T00:00:00.123Z"), Some(day));
745        assert_eq!(
746            iso_to_epoch_secs("2026-05-11T00:00:00+02:00"),
747            Some(day - 7200)
748        );
749        assert_eq!(
750            iso_to_epoch_secs("2026-05-11T00:00:00-05:30"),
751            Some(day + 19_800)
752        );
753        assert_eq!(
754            iso_to_epoch_secs("2026-05-11T10:30:00Z"),
755            Some(day + 37_800)
756        );
757        assert_eq!(iso_to_epoch_secs("garbage"), None);
758    }
759
760    #[test]
761    fn remote_newer_wins_only_when_strictly_after_local_end_of_day() {
762        // local `updated` = 2026-05-11 is interpreted as end of day.
763        assert!(is_remote_newer("2026-05-12T00:00:00Z", "2026-05-11"));
764        assert!(!is_remote_newer("2026-05-11 13:00:00", "2026-05-11"));
765        assert!(!is_remote_newer("2026-05-11T23:59:59Z", "2026-05-11"));
766        assert!(!is_remote_newer("2026-05-10T23:59:59Z", "2026-05-11"));
767        // unparseable remote timestamp keeps the local issue
768        assert!(!is_remote_newer("not-a-time", "2026-05-11"));
769    }
770
771    #[test]
772    fn scrub_replaces_secret_everywhere_and_preserves_text() {
773        assert_eq!(
774            scrub_secret("token=sekrit end", "sekrit"),
775            "token=<token> end"
776        );
777        assert_eq!(
778            scrub_secret("token=sekrit token=sekrit", "sekrit"),
779            "token=<token> token=<token>"
780        );
781        assert_eq!(scrub_secret("no secret here", "sekrit"), "no secret here");
782        assert_eq!(scrub_secret("empty secret", ""), "empty secret");
783    }
784
785    #[test]
786    fn remote_status_is_done_matches_both_tools() {
787        assert!(remote_status_is_done("closed"));
788        assert!(remote_status_is_done("Done"));
789        assert!(!remote_status_is_done("opened"));
790        assert!(!remote_status_is_done("In Progress"));
791    }
792}