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