tix-tracker 0.3.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Thin dispatch: route a parsed CLI [`Command`] to its per-command handler
//! module. Also owns the shared `<issue>` selector resolution and priority
//! validation that several commands reuse.
//!
//! Selector precedence follows `contracts/cli.md` §Selection grammar: a bare
//! number wins, then a unique filename fragment, then a title fragment that
//! matches exactly one issue. Non-unique matches are a hard error listing
//! the candidates (exit 2 like target-not-found, per the exit-code table).

use std::collections::HashSet;

use crate::cli::Command;
use crate::config::Config;
use crate::error::{Result, TixError};
use crate::model::Priority;
use crate::storage::{IssueSelector, Store};

pub mod agents;
pub mod create;
pub mod delete;
pub mod edit;
pub mod export;
pub mod import;
pub mod init;
pub mod link;
pub mod lint;
pub mod list;
pub mod mv;
pub mod search;
pub mod status;
pub mod view;

/// Message prefix shared with `cli::exit_code_for` so selection ambiguity is
/// routed to the `2` exit bucket alongside target-not-found.
pub const AMBIGUITY_PREFIX: &str = "ambiguous selector";

/// One argument per `tix.toml`-backed field; the flag count mirrors the
/// frozen command surface in `contracts/cli.md`.
#[allow(clippy::too_many_arguments)]
pub fn dispatch(cmd: &Command, config: &Config, store: &Store) -> Result<()> {
    match cmd {
        Command::Init => unreachable!("init is handled before dispatch"),
        Command::InitAgents => unreachable!("init-agents is handled before dispatch"),
        Command::Mcp => unreachable!("mcp is handled before dispatch"),
        Command::Create {
            title,
            body,
            status,
            priority,
            assignee,
            labels,
            links,
            files,
            estimate,
        } => create::run(
            config,
            store,
            title.as_deref(),
            body.as_deref(),
            status.as_deref(),
            priority.as_deref(),
            assignee.as_deref(),
            labels.as_deref(),
            links.as_deref(),
            files.as_deref(),
            estimate.as_deref(),
        ),
        Command::Link { a, b } => link::run(store, a, b),
        Command::Unlink { a, b } => link::unlink_cli(store, a, b),
        Command::Edit {
            issue,
            title,
            body,
            status,
            priority,
            assignee,
            labels,
            links,
            files,
            estimate,
            commit,
        } => edit::run(
            config,
            store,
            issue,
            title.as_deref(),
            body.as_deref(),
            status.as_deref(),
            priority.as_deref(),
            assignee.as_deref(),
            labels.as_deref(),
            links.as_deref(),
            files.as_deref(),
            estimate.as_deref(),
            commit.as_deref(),
        ),
        Command::Move {
            issue,
            column,
            commit,
        } => mv::run(config, store, issue, column, commit.as_deref()),
        Command::View { issue } => view::run(store, issue),
        Command::List { status } => list::run(store, status.as_deref()),
        Command::Status => status::run(config, store),
        Command::Delete { issue, yes } => delete::run(store, issue, *yes),
        Command::Export {
            target,
            dry_run,
            project,
        } => export::run(config, store, target, *dry_run, project.as_deref()),
        Command::Import { target } => import::run(config, store, target),
        Command::Search { string } => search::run(store, string),
        Command::Lint => lint::run(config, store).map(|_| ()),
    }
}

/// Resolve an `<issue>` selector string per `contracts/cli.md` §Selection
/// grammar (number → unique filename fragment → unique title fragment).
pub fn parse_selector(store: &Store, raw: &str) -> Result<IssueSelector> {
    if let Ok(number) = raw.parse::<u32>() {
        return Ok(IssueSelector::Number(number));
    }
    let issues = store.list()?;
    let by_filename: Vec<&String> = issues
        .iter()
        .filter(|i| i.filename.contains(raw))
        .map(|i| &i.filename)
        .collect();
    if by_filename.len() == 1 {
        return Ok(IssueSelector::Filename(by_filename[0].clone()));
    }
    let by_title: Vec<&crate::model::Issue> =
        issues.iter().filter(|i| i.title.contains(raw)).collect();
    if by_title.len() == 1 {
        return Ok(IssueSelector::Filename(by_title[0].filename.clone()));
    }
    if by_filename.len() > 1 || by_title.len() > 1 {
        let mut seen: HashSet<u32> = HashSet::new();
        let candidates: Vec<String> = issues
            .iter()
            .filter(|i| i.filename.contains(raw) || i.title.contains(raw))
            .filter(|i| seen.insert(i.number))
            .map(|i| format!("#{} ({})", i.number, i.filename))
            .collect();
        return Err(TixError::InvalidInput(format!(
            "{AMBIGUITY_PREFIX} '{raw}': {}",
            candidates.join(", ")
        )));
    }
    Err(TixError::NotFound(format!(
        "issue matching '{raw}' by number, filename or title"
    )))
}

/// Strict priority validation for user input: only `low`/`medium`/`high`
/// are accepted (`contracts/cli.md` enumerates exactly these three).
pub fn parse_priority(raw: &str) -> Result<Priority> {
    match raw.to_ascii_lowercase().as_str() {
        "low" => Ok(Priority::Low),
        "medium" => Ok(Priority::Medium),
        "high" => Ok(Priority::High),
        _ => Err(TixError::InvalidInput(format!(
            "invalid priority '{raw}' — use low, medium or high"
        ))),
    }
}

/// Split a comma-separated list, trimming whitespace and dropping empty
/// entries (shared by `labels`, `links` and `files` parsing; D3).
pub fn parse_csv_list(raw: &str) -> Vec<String> {
    raw.split(',')
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .collect()
}

/// Strictly parse a `--links` value into issue numbers. CLI/MCP input is
/// strict (D2): non-numeric entries are refused here, unlike the model's
/// tolerant hand-edit path.
pub fn parse_link_numbers(raw: &str) -> Result<Vec<u32>> {
    let mut out = Vec::new();
    for entry in parse_csv_list(raw) {
        match entry.parse::<u32>() {
            Ok(n) => out.push(n),
            Err(_) => {
                return Err(TixError::InvalidInput(format!(
                    "invalid link '{entry}' — links must be issue numbers"
                )));
            }
        }
    }
    Ok(out)
}

#[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,
            },
        )
    }

    fn write_issue(store: &Store, name: &str, number: u32, title: &str) {
        let contents = format!(
            "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nbody"
        );
        fs::write(store.issues_dir.join(name), contents).unwrap();
    }

    #[test]
    fn selector_bare_number_wins() {
        let (tmp, store) = make_store();
        let _ = tmp;
        write_issue(&store, "2026-01-02-ticket-55.md", 55, "Ticket 55");
        assert_eq!(
            parse_selector(&store, "55").unwrap(),
            IssueSelector::Number(55)
        );
    }

    #[test]
    fn selector_unique_filename_fragment() {
        let (tmp, store) = make_store();
        let _ = tmp;
        write_issue(&store, "2026-05-10-add-docs.md", 1, "Add documentation");
        assert_eq!(
            parse_selector(&store, "add-doc").unwrap(),
            IssueSelector::Filename("2026-05-10-add-docs.md".into())
        );
    }

    #[test]
    fn selector_unique_title_fragment() {
        let (tmp, store) = make_store();
        let _ = tmp;
        write_issue(&store, "2026-01-01-fix-login.md", 1, "Fix login bug");
        assert_eq!(
            parse_selector(&store, "login").unwrap(),
            IssueSelector::Filename("2026-01-01-fix-login.md".into())
        );
    }

    #[test]
    fn selector_unique_filename_fragment_matches_grouped_relative_path() {
        let (tmp, store) = make_store();
        let _ = tmp;
        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
        write_issue(
            &store,
            "alice/2026-05-10-add-docs.md",
            1,
            "Add documentation",
        );
        assert_eq!(
            parse_selector(&store, "add-doc").unwrap(),
            IssueSelector::Filename("alice/2026-05-10-add-docs.md".into())
        );
    }

    #[test]
    fn selector_full_grouped_relative_path_resolves() {
        let (tmp, store) = make_store();
        let _ = tmp;
        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
        write_issue(
            &store,
            "alice/2026-05-10-add-docs.md",
            1,
            "Add documentation",
        );
        assert_eq!(
            parse_selector(&store, "alice/2026-05-10-add-docs.md").unwrap(),
            IssueSelector::Filename("alice/2026-05-10-add-docs.md".into())
        );
    }

    #[test]
    fn selector_ambiguous_lists_candidates() {
        let (tmp, store) = make_store();
        let _ = tmp;
        write_issue(&store, "2026-01-01-a.md", 1, "Fix bug A");
        write_issue(&store, "2026-01-02-b.md", 2, "Fix bug B");
        let err = parse_selector(&store, "bug").unwrap_err();
        assert!(
            matches!(&err, TixError::InvalidInput(m) if m.starts_with(AMBIGUITY_PREFIX)),
            "got {err:?}"
        );
        assert!(err.to_string().contains("#1"));
        assert!(err.to_string().contains("#2"));
    }

    #[test]
    fn selector_missing_is_not_found() {
        let (tmp, store) = make_store();
        let _ = tmp;
        write_issue(&store, "2026-01-01-a.md", 1, "Fix bug A");
        assert!(matches!(
            parse_selector(&store, "zzz-nonexistent").unwrap_err(),
            TixError::NotFound(_)
        ));
    }

    #[test]
    fn priority_accepts_only_three_values() {
        assert_eq!(parse_priority("low").unwrap(), Priority::Low);
        assert_eq!(parse_priority("HIGH").unwrap(), Priority::High);
        assert_eq!(parse_priority("medium").unwrap(), Priority::Medium);
        assert!(parse_priority("urgent").is_err());
        assert!(parse_priority("").is_err());
    }
}