tix-tracker 0.3.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! `tix export --target <gitlab|linear> [--dry-run] [--project <p>]`:
//! mirror local issues to an external tracker, recording the remote id back
//! into `gitlab_id`/`linear_id` so a re-run updates instead of duplicating
//! (`contracts/external-tools.md`).

use crate::config::Config;
use crate::error::{Result, TixError};
use crate::storage::Store;
use crate::sync::{self, Tool};

/// Run the export orchestration, printing the captured report to stdout
/// (the CLI keeps stdout clean and machine-parseable; warnings go to stderr
/// via `sync::token::resolve`).
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(())
}

/// The export core (WP03): run the export orchestration and return the full
/// stdout report as a `String` — including the `nothing to export` line on an
/// empty store, so the MCP `export_issues` tool (WP04) can mirror CLI stdout
/// byte-for-byte (NFR-005).
///
/// The BAIL-early config gates (`no [export.gitlab] section`, missing
/// `project`) and their exact messages live in `sync`, and are surfaced here
/// untouched (C-006). Side-effect policy (C-005): the report is captured into
/// a buffer, never printed; the CLI renders it in [`run`] (which also keeps
/// `token::resolve` warnings on stderr as today).
///
/// `target` is `None` for a missing required arg — caught by the tool layer
/// before it reaches here per the MCP schema — and surfaces as the same
/// `unknown --target`-style `InvalidInput` error class (C-006).
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"))
}

/// Honor a `--project`/`--team` CLI override on a cloned config so the
/// on-disk `tix.toml` stays untouched.
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;
        // Drop the gitlab config the helper seeded: no binary is needed for
        // the BAIL gate to fire before any capture.
        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}"
        );
    }
}