use std::io::Write;
use crate::config::{BoardConfiguration, Config};
use crate::error::{Result, TixError};
use crate::model::{Issue, Status};
use crate::storage::Store;
pub mod glab;
pub mod linear;
pub mod token;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tool {
GitLab,
Linear,
}
impl Tool {
#[must_use]
pub fn from_target(target: &str) -> Result<Tool> {
match target {
"gitlab" => Ok(Tool::GitLab),
"linear" => Ok(Tool::Linear),
other => Err(TixError::InvalidInput(format!(
"unknown --target '{other}' — use gitlab or linear"
))),
}
}
#[must_use]
pub fn token_var(self) -> &'static str {
match self {
Tool::GitLab => "GITLAB_TOKEN",
Tool::Linear => "LINEAR_TOKEN",
}
}
#[must_use]
pub fn binary(self) -> &'static str {
match self {
Tool::GitLab => "glab",
Tool::Linear => "linear-cli",
}
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Tool::GitLab => "gitlab",
Tool::Linear => "linear",
}
}
}
impl std::fmt::Display for Tool {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandOutcome {
pub success: bool,
pub status: Option<i32>,
pub stdout: String,
pub stderr: String,
}
impl CommandOutcome {
#[must_use]
pub fn text(&self) -> &str {
if self.success {
&self.stdout
} else if !self.stderr.trim().is_empty() {
&self.stderr
} else {
&self.stdout
}
}
}
pub trait CommandRunner: Send {
fn run(
&self,
program: &str,
args: &[String],
env: &[(String, String)],
) -> Result<CommandOutcome>;
fn as_any(&self) -> &dyn std::any::Any;
}
pub struct ProcessRunner;
impl CommandRunner for ProcessRunner {
fn run(
&self,
program: &str,
args: &[String],
env: &[(String, String)],
) -> Result<CommandOutcome> {
use std::process::Command;
let mut cmd = Command::new(program);
cmd.args(args);
for (key, value) in env {
cmd.env(key, value);
}
let output = cmd.output().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
TixError::External(not_found_message(program))
} else {
TixError::External(format!("`{program}` failed to start: {e}"))
}
})?;
Ok(CommandOutcome {
success: output.status.success(),
status: output.status.code(),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
})
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
pub trait TrackerAdapter {
fn tool(&self) -> Tool;
fn create_issue(
&mut self,
title: &str,
body: &str,
labels: &[String],
assignee: Option<&str>,
priority: Option<&str>,
status: &str,
) -> Result<String>;
#[allow(clippy::too_many_arguments)]
fn update_issue(
&mut self,
remote_ref: &str,
title: &str,
body: &str,
labels: &[String],
assignee: Option<&str>,
priority: Option<&str>,
status: &str,
) -> Result<()>;
fn mark_done(&mut self, remote_ref: &str) -> Result<()>;
fn list_open(&mut self) -> Result<Vec<RemoteIssue>>;
fn done_via_status(&self) -> bool;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteIssue {
pub id: String,
pub title: String,
pub description: String,
pub labels: Vec<String>,
pub state: String,
pub updated_at: Option<String>,
pub assignee: Option<String>,
}
#[must_use]
pub fn local_to_remote_status(tool: Tool, local_status: &str, config: &Config) -> String {
let board = config.default_config();
match tool {
Tool::GitLab => {
if is_done_column(board, local_status) {
"closed".into()
} else {
"opened".into()
}
}
Tool::Linear => {
if is_done_column(board, local_status) {
"Done".into()
} else {
first_work_column(board).to_string()
}
}
}
}
#[must_use]
pub fn remote_to_local_status(tool: Tool, remote_status: &str, config: &Config) -> String {
let board = config.default_config();
let done = board.effective_done_column().unwrap_or("done").to_string();
match tool {
Tool::GitLab => {
if remote_status == "closed" {
done
} else {
board
.columns
.first()
.cloned()
.unwrap_or_else(|| "todo".into())
}
}
Tool::Linear => {
if remote_status == "Done" {
done
} else {
board
.columns
.iter()
.find(|c| c.eq_ignore_ascii_case(remote_status))
.cloned()
.unwrap_or_else(|| {
board
.columns
.first()
.cloned()
.unwrap_or_else(|| "todo".into())
})
}
}
}
}
fn is_done_column(board: &BoardConfiguration, local_status: &str) -> bool {
board.effective_done_column() == Some(local_status)
}
fn first_work_column(board: &BoardConfiguration) -> &str {
let done = board.effective_done_column().unwrap_or("done");
board
.columns
.iter()
.find(|c| c.as_str() != done)
.map_or(done, String::as_str)
}
#[must_use]
pub fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
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
}
#[must_use]
pub fn date_to_epoch_days(ymd: &str) -> Option<i64> {
let mut parts = ymd.trim().split('-');
let y: i64 = parts.next()?.parse().ok()?;
let m: u32 = parts.next()?.parse().ok()?;
let d: u32 = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return None;
}
Some(days_from_civil(y, m, d))
}
#[must_use]
pub fn iso_to_epoch_secs(input: &str) -> Option<i64> {
let (date, time) = input.trim().split_once(['T', ' '])?;
let days = date_to_epoch_days(date)?;
let (time, zone) = split_zone(time);
let mut hms = time.split(':');
let hh: i64 = hms.next()?.parse().ok()?;
let mm: i64 = hms.next()?.parse().ok()?;
let ss_part = hms.next()?;
let whole: i64 = ss_part.split('.').next()?.parse().ok()?;
if !(0..=23).contains(&hh) || !(0..=59).contains(&mm) || !(0..=59).contains(&whole) {
return None;
}
let mut secs = days * 86_400 + hh * 3600 + mm * 60 + whole;
if let Some((zh, zm)) = zone {
secs -= zh * 3600 + zm * 60;
}
Some(secs)
}
fn split_zone(time: &str) -> (&str, Option<(i64, i64)>) {
if time.ends_with('Z') || time.ends_with('z') {
return (&time[..time.len() - 1], Some((0, 0)));
}
match time.rfind(['+', '-']) {
Some(i) if i > 0 => {
let (bare, zone) = time.split_at(i);
let sign = if zone.starts_with('-') { -1i64 } else { 1i64 };
let digits = &zone[1..];
let (zh, zm) = if let Some((h, m)) = digits.split_once(':') {
(h.parse::<i64>().ok(), m.parse::<i64>().ok())
} else if digits.len() == 2 {
(digits.parse::<i64>().ok(), Some(0))
} else if digits.len() >= 3 {
let (h, m) = digits.split_at(digits.len() - 2);
(h.parse::<i64>().ok(), m.parse::<i64>().ok())
} else {
(None, None)
};
match (zh, zm) {
(Some(zh), Some(zm)) => (bare, Some((sign * zh, sign * zm))),
_ => (bare, None),
}
}
Some(_) | None => (time, None),
}
}
#[must_use]
pub fn is_remote_newer(remote_updated_at: &str, local_updated: &str) -> bool {
let Some(remote) = iso_to_epoch_secs(remote_updated_at) else {
return false;
};
let local_end_of_day = date_to_epoch_days(local_updated).map_or(0, |d| d * 86_400 + 86_399);
remote > local_end_of_day
}
#[must_use]
pub fn scrub_secret(text: &str, secret: &str) -> String {
if secret.is_empty() {
return text.to_string();
}
text.replace(secret, "<token>")
}
fn not_found_message(program: &str) -> String {
match program {
"glab" => "required tool not found: glab — install from https://gitlab.com/gitlab-org/cli"
.to_string(),
_ => format!("required tool not found: {program} — install it and retry"),
}
}
pub fn export(
store: &Store,
tool: Tool,
config: &Config,
dry_run: bool,
out: &mut dyn Write,
) -> Result<usize> {
let mut adapter = adapter_for(store, tool, config)?;
if dry_run {
for issue in store.list()? {
match remote_ref(tool, &issue) {
Some(r) => {
writeln!(out, "would update {tool} issue {r} \"{}\"", issue.title)?;
}
None => {
writeln!(out, "would create {tool} issue \"{}\"", issue.title)?;
}
}
}
writeln!(out, "dry run — {tool} CLI was not invoked")?;
return Ok(store.list()?.len());
}
let mut handled = 0;
for mut issue in store.list()? {
let remote_status = local_to_remote_status(tool, issue.status.display(), config);
let labels: Vec<String> = issue.labels.clone();
let assignee = issue.assignee.as_deref();
let priority = issue.priority.to_string();
match remote_ref(tool, &issue) {
Some(r) => {
let r = r.to_string();
adapter.update_issue(
&r,
&issue.title,
&issue.body,
&labels,
assignee,
Some(&priority),
&remote_status,
)?;
if !adapter.done_via_status() && remote_status_is_done(&remote_status) {
adapter.mark_done(&r)?;
}
writeln!(out, "updated {tool} issue {r} \"{}\"", issue.title)?;
}
None => {
let id = adapter.create_issue(
&issue.title,
&issue.body,
&labels,
assignee,
Some(&priority),
&remote_status,
)?;
if !adapter.done_via_status() && remote_status_is_done(&remote_status) {
adapter.mark_done(&id)?;
}
match tool {
Tool::GitLab => issue.gitlab_id = Some(id.clone()),
Tool::Linear => issue.linear_id = Some(id.clone()),
}
store.update(&issue.filename, &issue)?;
writeln!(
out,
"created {tool} issue {id} \"{}\" (remote id recorded)",
issue.title
)?;
}
}
handled += 1;
}
writeln!(out, "exported {handled} issue(s) to {tool}")?;
Ok(handled)
}
pub fn import(store: &Store, tool: Tool, config: &Config, out: &mut dyn Write) -> Result<usize> {
let mut adapter = adapter_for(store, tool, config)?;
let remote_issues = adapter.list_open()?;
let mut handled = 0;
for remote in remote_issues {
let tracked = store
.list()?
.into_iter()
.find(|i| remote_ref(tool, i) == Some(remote.id.as_str()));
match tracked {
None => {
let mut issue = Issue {
number: 0,
title: remote.title.clone(),
status: Status::new(remote_to_local_status(tool, &remote.state, config)),
labels: remote.labels.clone(),
priority: crate::model::Priority::Medium,
assignee: remote.assignee.clone(),
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
links: vec![],
files: vec![],
created: crate::model::today(),
updated: crate::model::today(),
filename: String::new(),
body: String::new(),
};
match tool {
Tool::GitLab => issue.gitlab_id = Some(remote.id.clone()),
Tool::Linear => issue.linear_id = Some(remote.id.clone()),
}
let path = store.create(&mut issue, &remote.description, None)?;
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
writeln!(
out,
"imported {tool} issue {} \"{}\" as #{} ({}) [{name}]",
remote.id, remote.title, issue.number, issue.status
)?;
handled += 1;
}
Some(local) => {
if is_remote_newer(remote.updated_at.as_deref().unwrap_or(""), &local.updated) {
let backup = format!("{}.bak", local.filename);
std::fs::copy(
store.issues_dir.join(&local.filename),
store.issues_dir.join(&backup),
)?;
let mut overwritten = local;
let keep_created = overwritten.created.clone();
overwritten.title = remote.title.clone();
overwritten.body = remote.description.clone();
overwritten.labels = remote.labels.clone();
overwritten.status =
Status::new(remote_to_local_status(tool, &remote.state, config));
if let Some(a) = &remote.assignee {
overwritten.assignee = Some(a.clone());
}
overwritten.created = keep_created;
overwritten.bump_updated();
let filename = overwritten.filename.clone();
store.update(&filename, &overwritten)?;
writeln!(
out,
"overwrote {tool} issue {} \"{}\" (remote newer; backup at {backup})",
remote.id, overwritten.title
)?;
handled += 1;
} else {
writeln!(
out,
"kept {tool} issue {} \"{}\" (local updated later or equal)",
remote.id, remote.title
)?;
}
}
}
}
writeln!(out, "imported {handled} {tool} issue(s)")?;
Ok(handled)
}
pub fn remote_status_is_done(status: &str) -> bool {
status == "closed" || status == "Done"
}
fn remote_ref(tool: Tool, issue: &Issue) -> Option<&str> {
match tool {
Tool::GitLab => issue.gitlab_id.as_deref(),
Tool::Linear => issue.linear_id.as_deref(),
}
}
fn adapter_for(store: &Store, tool: Tool, config: &Config) -> Result<Box<dyn TrackerAdapter>> {
let _ = store;
let mut warn = |w: &str| eprintln!("{w}");
let resolution = token::resolve(tool, config, &token::RealEnv, &mut warn);
let secret = resolution.secret().map(str::to_string);
match tool {
Tool::GitLab => {
let rc = config.export.gitlab.as_ref().ok_or_else(|| {
TixError::Config("no `[export.gitlab]` section in tix.toml — run `tix init --with gitlab` or edit tix.toml".to_string())
})?;
let project = rc.project.clone().ok_or_else(|| {
TixError::Config("`[export.gitlab].project` is not set — add `project = \"acme/tix\"` to tix.toml".to_string())
})?;
Ok(Box::new(glab::GitLabAdapter::new(
ProcessRunner,
secret,
rc.base_url.clone(),
project,
)))
}
Tool::Linear => {
let rc = config.export.linear.as_ref().ok_or_else(|| {
TixError::Config("no `[export.linear]` section in tix.toml — run `tix init --with linear` or edit tix.toml".to_string())
})?;
let team = rc.team.clone().ok_or_else(|| {
TixError::Config(
"`[export.linear].team` is not set — add `team = \"Eng\"` to tix.toml"
.to_string(),
)
})?;
Ok(Box::new(linear::LinearAdapter::new(
ProcessRunner,
secret,
team,
)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn target_parsing_accepts_only_the_two_contract_targets() {
assert_eq!(Tool::from_target("gitlab").unwrap(), Tool::GitLab);
assert_eq!(Tool::from_target("linear").unwrap(), Tool::Linear);
assert!(Tool::from_target("github").is_err());
assert_eq!(Tool::GitLab.token_var(), "GITLAB_TOKEN");
assert_eq!(Tool::Linear.token_var(), "LINEAR_TOKEN");
assert_eq!(Tool::GitLab.binary(), "glab");
assert_eq!(Tool::Linear.binary(), "linear-cli");
}
#[test]
fn gitlab_status_mapping() {
let config = Config::default();
assert_eq!(
local_to_remote_status(Tool::GitLab, "todo", &config),
"opened"
);
assert_eq!(
local_to_remote_status(Tool::GitLab, "done", &config),
"closed"
);
assert_eq!(
remote_to_local_status(Tool::GitLab, "opened", &config),
"todo"
);
assert_eq!(
remote_to_local_status(Tool::GitLab, "closed", &config),
"done"
);
assert_eq!(
remote_to_local_status(Tool::GitLab, "anything-else", &config),
"todo"
);
}
#[test]
fn linear_status_mapping() {
let config = Config::default();
assert_eq!(
local_to_remote_status(Tool::Linear, "todo", &config),
"todo"
);
assert_eq!(
local_to_remote_status(Tool::Linear, "in review", &config),
"todo"
);
assert_eq!(
local_to_remote_status(Tool::Linear, "done", &config),
"Done"
);
assert_eq!(
remote_to_local_status(Tool::Linear, "In Progress", &config),
"in progress"
);
assert_eq!(
remote_to_local_status(Tool::Linear, "Done", &config),
"done"
);
assert_eq!(
remote_to_local_status(Tool::Linear, "Unknown", &config),
"todo"
);
}
#[test]
fn days_from_civil_roundtrips_civil_from_days() {
assert_eq!(days_from_civil(1970, 1, 1), 0);
assert_eq!(days_from_civil(2000, 1, 1), 10957);
assert_eq!(days_from_civil(1970, 1, 2), 1);
assert_eq!(days_from_civil(2026, 5, 11), 20584);
}
#[test]
fn date_to_epoch_days_parses_only_yyyymmdd() {
assert_eq!(date_to_epoch_days("1970-01-01"), Some(0));
assert_eq!(date_to_epoch_days("2026-05-11"), Some(20_584));
assert_eq!(date_to_epoch_days("not-a-date"), None);
assert_eq!(date_to_epoch_days("2026-13-01"), None);
assert_eq!(date_to_epoch_days("2026-05-11T10:00:00"), None);
}
#[test]
fn iso_to_epoch_secs_accepts_contract_timestamp_shapes() {
let day = days_from_civil(2026, 5, 11) * 86_400;
assert_eq!(iso_to_epoch_secs("2026-05-11T00:00:00Z"), Some(day));
assert_eq!(iso_to_epoch_secs("2026-05-11 00:00:00"), Some(day));
assert_eq!(iso_to_epoch_secs("2026-05-11T00:00:00.123Z"), Some(day));
assert_eq!(
iso_to_epoch_secs("2026-05-11T00:00:00+02:00"),
Some(day - 7200)
);
assert_eq!(
iso_to_epoch_secs("2026-05-11T00:00:00-05:30"),
Some(day + 19_800)
);
assert_eq!(
iso_to_epoch_secs("2026-05-11T10:30:00Z"),
Some(day + 37_800)
);
assert_eq!(iso_to_epoch_secs("garbage"), None);
}
#[test]
fn remote_newer_wins_only_when_strictly_after_local_end_of_day() {
assert!(is_remote_newer("2026-05-12T00:00:00Z", "2026-05-11"));
assert!(!is_remote_newer("2026-05-11 13:00:00", "2026-05-11"));
assert!(!is_remote_newer("2026-05-11T23:59:59Z", "2026-05-11"));
assert!(!is_remote_newer("2026-05-10T23:59:59Z", "2026-05-11"));
assert!(!is_remote_newer("not-a-time", "2026-05-11"));
}
#[test]
fn scrub_replaces_secret_everywhere_and_preserves_text() {
assert_eq!(
scrub_secret("token=sekrit end", "sekrit"),
"token=<token> end"
);
assert_eq!(
scrub_secret("token=sekrit token=sekrit", "sekrit"),
"token=<token> token=<token>"
);
assert_eq!(scrub_secret("no secret here", "sekrit"), "no secret here");
assert_eq!(scrub_secret("empty secret", ""), "empty secret");
}
#[test]
fn remote_status_is_done_matches_both_tools() {
assert!(remote_status_is_done("closed"));
assert!(remote_status_is_done("Done"));
assert!(!remote_status_is_done("opened"));
assert!(!remote_status_is_done("In Progress"));
}
}