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 #[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 #[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 #[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#[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 #[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
107pub trait CommandRunner: Send {
110 fn run(
111 &self,
112 program: &str,
113 args: &[String],
114 env: &[(String, String)],
115 ) -> Result<CommandOutcome>;
116
117 fn as_any(&self) -> &dyn std::any::Any;
119}
120
121pub 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
160pub trait TrackerAdapter {
168 fn tool(&self) -> Tool;
169
170 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 #[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 fn mark_done(&mut self, remote_ref: &str) -> Result<()>;
198
199 fn list_open(&mut self) -> Result<Vec<RemoteIssue>>;
202
203 fn done_via_status(&self) -> bool;
206}
207
208#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct RemoteIssue {
211 pub id: String,
213 pub title: String,
214 pub description: String,
215 pub labels: Vec<String>,
216 pub state: String,
218 pub updated_at: Option<String>,
220 pub assignee: Option<String>,
222}
223
224#[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#[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#[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; let mp = (m + 9) % 12; let doy = i64::from((153 * mp + 2) / 5 + d - 1); let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146_097 + doe - 719_468
315}
316
317#[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#[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
356fn 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#[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#[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
408fn 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
418pub 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
501pub 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
597pub 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
609fn adapter_for(store: &Store, tool: Tool, config: &Config) -> Result<Box<dyn TrackerAdapter>> {
613 let _ = store;
614 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 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 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 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}