tix-tracker 0.2.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! `tix import --target <gitlab|linear>`: pull open remote issues into local
//! issue files, resolving id conflicts (`gitlab_id`/`linear_id`) by the
//! newer-`updated` rule with a `.bak` backup on overwrite
//! (`contracts/external-tools.md`).

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

/// Run the import orchestration, printing the captured report to stdout.
pub fn run(config: &Config, store: &Store, target: &str) -> Result<()> {
    let report = run_core(config, store, Some(target))?;
    print!("{report}");
    Ok(())
}

/// The import core (WP03): run the import orchestration and return the full
/// stdout report as a `String` — including the `nothing to import` line on an
/// empty result, so the MCP `import_issues` tool (WP04) can mirror CLI stdout
/// byte-for-byte (NFR-005).
///
/// The BAIL-early config gates 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`].
///
/// `target` mirrors the MCP tool schema's required `target` arg; a missing one
/// is a structured `InvalidInput` error of the same class as the CLI's target
/// validation (C-006).
pub fn run_core(config: &Config, store: &Store, target: Option<&str>) -> Result<String> {
    let target = target
        .ok_or_else(|| TixError::InvalidInput("missing --target — use gitlab or linear".into()))?;
    let tool = Tool::from_target(target)?;
    let mut report: Vec<u8> = Vec::new();
    let handled = sync::import(store, tool, config, &mut report)?;
    if handled == 0 {
        report.extend_from_slice(b"nothing to import\n");
    }
    Ok(String::from_utf8(report).expect("sync reports contain only UTF-8 text"))
}

#[cfg(test)]
mod tests {
    use super::*;
    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 config = crate::config::Config {
            issues_dir: dir.clone(),
            ..crate::config::Config::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 run_core_missing_export_section_is_the_frozen_config_error() {
        let (tmp, store) = make_store();
        let _ = tmp;
        // No `[export.*]` section: adapter_for BAILs before any binary is
        // invoked and before any report line is captured (C-006 message).
        let err = run_core(&store.config, &store, Some("gitlab")).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_unknown_target_is_the_frozen_error() {
        let (tmp, store) = make_store();
        let _ = tmp;
        let err = run_core(&store.config, &store, Some("foobar")).unwrap_err();
        assert_eq!(
            err.to_string(),
            "invalid input: unknown --target 'foobar' — use gitlab or linear"
        );
    }

    #[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).unwrap_err();
        assert!(
            matches!(err, TixError::InvalidInput(_)),
            "missing target must be a structured InvalidInput error, got {err}"
        );
    }
}