tix-tracker 0.12.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};
use crate::triage::import_policy::ImportPolicy;

/// The import orchestration with an explicit triage-entry policy (WP07 T036).
///
/// One entry point, not two: the flag that supplies `policy` lives in
/// `src/cli.rs` and the CLI passes it through ([`crate::commands::dispatch`]), and
/// the MCP tool reaches the same core with its own arguments. A `run(...)`
/// convenience that always used `ImportPolicy::default()` used to sit here for the
/// "caller that has not been wired yet"; there is no such caller, and a second
/// entry point that silently drops the policy is the drift this module exists to
/// avoid (tix's post-merge mission review, RISK-1).
pub fn run_with_policy(
    config: &Config,
    store: &Store,
    target: &str,
    policy: ImportPolicy,
) -> Result<()> {
    let report = run_core_with_policy(config, store, Some(target), policy)?;
    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> {
    run_core_with_policy(config, store, target, ImportPolicy::default())
}

/// [`run_core`] with an explicit triage-entry policy (WP07 T034).
///
/// `target` validation is unchanged and runs **before** the policy is resolved,
/// so a bad `--target` still reports what users of an older `tix` expect and
/// never a triage diagnostic (T036).
pub fn run_core_with_policy(
    config: &Config,
    store: &Store,
    target: Option<&str>,
    policy: ImportPolicy,
) -> Result<String> {
    let target = target
        .ok_or_else(|| TixError::InvalidInput("missing --target — use gitlab or linear".into()))?;
    // `speckit` is a local reader, not a remote tracker: it has no `Tool`, no
    // token and no subprocess, so it branches before the tracker validation.
    if target == "speckit" {
        return crate::speckit::import_core(config, store, policy);
    }
    // `beads` is a local tracker reached through the `bd` CLI, not an HTTP API
    // with a `Tool` adapter, so it branches here for the same reason `speckit`
    // does. It never opens the Dolt database.
    if target == "beads" {
        return import_beads(config, store);
    }
    let tool = Tool::from_target(target)?;
    let mut report: Vec<u8> = Vec::new();
    let outcome = sync::import(store, tool, config, &mut report, policy)?;
    let handled = outcome.handled;
    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"))
}

/// `tix import --target beads`: pull a beads workspace into local files.
///
/// Reads the workspace through `bd export` and never opens the Dolt database. Each
/// bead becomes its own transaction, exactly as the GitLab/Linear import does it,
/// and a bead that names an existing issue overwrites it only when the bead is
/// newer. The id → number pairing for beads-originated issues is kept in the
/// project's beads state file, and the local issue is never left half-written: a
/// row whose status names no column on this board is reported and skipped.
fn import_beads(config: &Config, store: &Store) -> Result<String> {
    use crate::sync::beads;

    beads::ensure_writes_allowed(config)?;
    let root =
        Config::project_root(&store.issues_dir).unwrap_or_else(|| std::path::PathBuf::from("."));
    let mut state = beads::load_state(&root);
    let rows = beads::fetch(&crate::sync::ProcessRunner, config)?;
    let resolution = crate::workflow::resolution(config, config.default_config(), "default");
    let report = beads::import_into(store, config, &resolution, &rows, &mut state)?;
    beads::save_state(&root, &state)?;

    let mut out = String::new();
    for diagnostic in &report.diagnostics {
        out.push_str(&format!("{diagnostic}\n"));
    }
    if report.created + report.updated == 0 {
        out.push_str("nothing to import\n");
    } else {
        out.push_str(&format!(
            "imported {} bead(s): {} created{}, {} updated, {} skipped\n",
            rows.len(),
            report.created,
            if report.foreign > 0 {
                format!(" ({} beads-originated)", report.foreign)
            } else {
                String::new()
            },
            report.updated,
            report.skipped
        ));
    }
    Ok(out)
}

#[cfg(test)]
mod tests {
    #[allow(clippy::wildcard_imports)]
    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}"
        );
    }

    /// `beads` is a **local** target reached through the `bd` CLI, not a remote
    /// `Tool`, so the dispatch gate must not validate it against
    /// `Tool::from_target` — which is exactly what it used to do, refusing
    /// `tix import --target beads` with the unknown-tracker error before the
    /// import path could branch to it.
    ///
    /// The configured binary is pointed at a name that cannot exist, so the
    /// assertion is about the **gate** and nothing else: whether a real `bd`
    /// happens to be installed here cannot make this slow, and cannot make it
    /// pass or fail differently. Reaching a "binary not found" error *is* the
    /// proof that the gate let the target through.
    #[test]
    fn the_beads_target_survives_the_dispatch_gate() {
        let (tmp, store) = make_store();
        let _ = tmp;
        let mut config = store.config.clone();
        config.beads.binary = "tix-no-such-bd-binary".to_string();
        let command = crate::cli::Command::Import {
            target: "beads".to_string(),
            triage_policy: None,
        };
        let err = crate::commands::dispatch(&command, &config, &store)
            .expect_err("a `bd` that does not exist must fail");
        assert!(
            !err.to_string().contains("unknown --target"),
            "`beads` must not be refused as an unknown tracker: {err}"
        );
    }

    /// The same gate for the export surface, which also has to reach the beads
    /// branch **before** `Tool::from_target`. `--dry-run` keeps it offline: the
    /// plan is pure and nothing is pushed, so this is deterministic.
    #[test]
    fn the_beads_export_target_survives_the_dispatch_gate() {
        let (tmp, store) = make_store();
        let _ = tmp;
        let command = crate::cli::Command::Export {
            target: "beads".to_string(),
            dry_run: true,
            project: None,
            cycle: None,
            output: None,
            verbose: false,
        };
        crate::commands::dispatch(&command, &store.config, &store)
            .expect("`tix export --target beads --dry-run` must be accepted");
    }
}