1use 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#[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 pub fn token_var(self) -> &'static str {
47 match self {
48 Tool::GitLab => "GITLAB_TOKEN",
49 Tool::Linear => "LINEAR_TOKEN",
50 }
51 }
52
53 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#[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 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
102pub trait CommandRunner: Send {
105 fn run(
106 &self,
107 program: &str,
108 args: &[String],
109 env: &[(String, String)],
110 ) -> Result<CommandOutcome>;
111
112 fn as_any(&self) -> &dyn std::any::Any;
114}
115
116pub 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
155pub trait TrackerAdapter {
163 fn tool(&self) -> Tool;
164
165 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 #[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 fn mark_done(&mut self, remote_ref: &str) -> Result<()>;
193
194 fn list_open(&mut self) -> Result<Vec<RemoteIssue>>;
197
198 fn done_via_status(&self) -> bool;
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
205pub struct RemoteIssue {
206 pub id: String,
208 pub title: String,
209 pub description: String,
210 pub labels: Vec<String>,
211 pub state: String,
213 pub updated_at: Option<String>,
215 pub assignee: Option<String>,
217}
218
219pub 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
244pub 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
298pub 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; let mp = (m + 9) % 12; let doy = ((153 * mp + 2) / 5 + d - 1) as i64; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146_097 + doe - 719_468
308}
309
310pub 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
325pub 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
347fn 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
377pub 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
391pub 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
399fn 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
409pub 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
492pub 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
586pub 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
598fn adapter_for(store: &Store, tool: Tool, config: &Config) -> Result<Box<dyn TrackerAdapter>> {
602 let _ = store;
603 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 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 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 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}