use crate::config::Config;
use crate::error::{Result, TixError};
use crate::storage::Store;
use crate::sync::{self, Tool};
pub fn run(
config: &Config,
store: &Store,
target: &str,
dry_run: bool,
project_override: Option<&str>,
) -> Result<()> {
let report = run_core(config, store, Some(target), dry_run, project_override)?;
print!("{report}");
Ok(())
}
pub fn run_core(
config: &Config,
store: &Store,
target: Option<&str>,
dry_run: bool,
project_override: Option<&str>,
) -> Result<String> {
let target = target.ok_or_else(|| {
TixError::InvalidInput("missing --target — use gitlab or linear".to_string())
})?;
let tool = Tool::from_target(target)?;
let config = apply_project_override(config, tool, project_override);
let mut report: Vec<u8> = Vec::new();
let handled = sync::export(store, tool, &config, dry_run, &mut report)?;
if handled == 0 {
report.extend_from_slice(b"nothing to export\n");
}
Ok(String::from_utf8(report).expect("sync reports contain only UTF-8 text"))
}
fn apply_project_override(config: &Config, tool: Tool, project: Option<&str>) -> Config {
let Some(project) = project else {
return config.clone();
};
let mut config = config.clone();
match tool {
Tool::GitLab => {
let remote = config.export.gitlab.get_or_insert_with(Default::default);
remote.project = Some(project.to_string());
}
Tool::Linear => {
let remote = config.export.linear.get_or_insert_with(Default::default);
remote.team = Some(project.to_string());
}
}
config
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Issue, Priority, Status};
use std::fs;
use tempfile::TempDir;
fn make_store() -> (TempDir, Store) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let mut config = crate::config::Config {
issues_dir: dir.clone(),
..crate::config::Config::default()
};
config.export.gitlab = Some(crate::config::RemoteConfig {
project: Some("acme/tix".to_string()),
..Default::default()
});
config.save(&config_path).unwrap();
let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
(
tmp,
Store {
issues_dir: dir,
config,
},
)
}
#[test]
fn dry_run_captures_the_report_without_invoking_the_tool() {
let (tmp, store) = make_store();
let _ = tmp;
let mut issue = Issue {
number: 1,
title: "Replicate".to_string(),
status: Status::new("todo"),
labels: vec!["bug".to_string()],
priority: Priority::High,
assignee: Some("alice".to_string()),
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
links: vec![],
files: vec![],
created: "2026-01-01".to_string(),
updated: "2026-01-01".to_string(),
filename: String::new(),
body: "body".to_string(),
};
issue.filename = "alice/2026-01-01-replicate.md".to_string();
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
fs::write(
store.issues_dir.join(&issue.filename),
issue.to_file_contents(),
)
.unwrap();
let report = run_core(&store.config, &store, Some("gitlab"), true, None).unwrap();
assert!(
report.contains("would create gitlab issue \"Replicate\""),
"dry-run report must describe the planned create, got:\n{report}"
);
assert!(
report.contains("dry run — gitlab CLI was not invoked"),
"dry-run never invokes the binary, got:\n{report}"
);
}
#[test]
fn run_core_missing_export_section_is_the_frozen_config_error() {
let (tmp, store) = make_store();
let _ = tmp;
let mut bare = store.config.clone();
bare.export.gitlab = None;
let err = run_core(&bare, &store, Some("gitlab"), false, None).unwrap_err();
assert_eq!(
err.to_string(),
"config error: no `[export.gitlab]` section in tix.toml — run `tix init --with gitlab` or edit tix.toml"
);
}
#[test]
fn run_core_missing_target_is_an_invalid_input_error() {
let (tmp, store) = make_store();
let _ = tmp;
let err = run_core(&store.config, &store, None, true, None).unwrap_err();
assert!(
matches!(err, TixError::InvalidInput(_)),
"missing target must be a structured InvalidInput error, got {err}"
);
}
}