tix-tracker 0.3.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
use std::io::IsTerminal;

use crate::commands::parse_selector;
use crate::config::Config;
use crate::editor::read_tty_line;
use crate::error::{Result, TixError};
use crate::model::{Issue, Status};
use crate::storage::Store;

/// `tix move <issue> <column>` (FR-005): validate the column, and when the
/// target is the `done_column`, prompt for a commit hash on a TTY or accept
/// `--commit <hash>`; moving *out* of done clears the recorded commit.
///
/// The interactive commit prompt (`read_line` on a TTY) is CLI glue
/// (C-005); the done-column rule itself lives in [`apply`], which the MCP
/// `move_issue` tool (WP04) calls directly.
pub fn run(
    config: &Config,
    store: &Store,
    raw: &str,
    column: &str,
    commit: Option<&str>,
) -> Result<()> {
    let board = config.default_config();
    if !board.columns.iter().any(|c| c == column) {
        return Err(TixError::InvalidInput(format!(
            "unknown column '{column}' — valid columns: {}",
            board.columns.join(", ")
        )));
    }
    let commit = if board.effective_done_column() == Some(column) && commit.is_none() {
        if std::io::stdin().is_terminal() {
            let trimmed = read_tty_line("Commit hash (or leave empty): ")?;
            if trimmed.is_empty() {
                None
            } else {
                Some(trimmed)
            }
        } else {
            return Err(TixError::InvalidInput(format!(
                "moving to '{column}' requires a commit hash — pass --commit <hash>"
            )));
        }
    } else {
        commit.map(str::to_string)
    };
    let issue = apply(config, store, raw, column, commit.as_deref())?;
    println!("Moved {} to {column}", issue.filename);
    Ok(())
}

/// The move core (WP02): perform the column change on the issue selected by
/// `raw` and return the updated [`Issue`].
///
/// This is what MCP's `move_issue` tool (WP04) calls. Side-effect policy:
/// never reads `stdin`/writes `stdout` — the done-column commit *prompt*
/// stays in [`run`]; this core only applies the rule:
/// - moving into `done_column` records the passed `commit` (or `None`),
/// - moving out of `done_column` clears `commit` (set to `None`).
///
/// Selector ambiguity/missing resolution is unchanged from the CLI path.
pub fn apply(
    config: &Config,
    store: &Store,
    raw: &str,
    column: &str,
    commit: Option<&str>,
) -> Result<Issue> {
    let board = config.default_config();
    if !board.columns.iter().any(|c| c == column) {
        return Err(TixError::InvalidInput(format!(
            "unknown column '{column}' — valid columns: {}",
            board.columns.join(", ")
        )));
    }
    let selector = parse_selector(store, raw)?;
    let mut issue = store.get(selector)?;

    let done = board.effective_done_column();
    let was_done = done.is_some_and(|d| issue.status.is(d));
    issue.status = Status::new(column.to_string());

    if Some(column) == done {
        issue.commit = commit.map(str::to_string);
    } else if was_done {
        issue.commit = None;
    }

    issue.bump_updated();
    store.update(&issue.filename, &issue)?;
    Ok(issue)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::commands::create::create;
    use crate::model::Issue;
    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 seed(store: &Store) -> Issue {
        create(
            &store.config,
            store,
            "Move me",
            Some("body"),
            None,
            None,
            Some(""),
            None,
            None,
            None,
            None,
        )
        .unwrap()
    }

    #[test]
    fn apply_into_done_records_commit() {
        let (tmp, store) = make_store();
        let _ = tmp;
        let seeded = seed(&store);
        let moved = apply(
            &store.config,
            &store,
            &seeded.number.to_string(),
            "done",
            Some("abc123"),
        )
        .unwrap();
        assert_eq!(moved.status.display(), "done");
        assert_eq!(moved.commit.as_deref(), Some("abc123"));
        assert_eq!(moved.number, seeded.number);
        let reloaded = store
            .get(crate::storage::IssueSelector::Filename(
                moved.filename.clone(),
            ))
            .unwrap();
        assert_eq!(reloaded.commit.as_deref(), Some("abc123"));
    }

    #[test]
    fn apply_out_of_done_clears_commit() {
        let (tmp, store) = make_store();
        let _ = tmp;
        let seeded = seed(&store);
        apply(
            &store.config,
            &store,
            &seeded.number.to_string(),
            "done",
            Some("abc123"),
        )
        .unwrap();
        let moved_out = apply(
            &store.config,
            &store,
            &seeded.number.to_string(),
            "in progress",
            None,
        )
        .unwrap();
        assert_eq!(moved_out.status.display(), "in progress");
        assert_eq!(
            moved_out.commit, None,
            "moving out of done clears the commit"
        );
    }

    #[test]
    fn apply_into_done_without_commit_leaves_none() {
        let (tmp, store) = make_store();
        let _ = tmp;
        let seeded = seed(&store);
        let moved = apply(
            &store.config,
            &store,
            &seeded.number.to_string(),
            "done",
            None,
        )
        .unwrap();
        assert_eq!(moved.status.display(), "done");
        assert_eq!(moved.commit, None);
    }

    #[test]
    fn apply_rejects_unknown_column() {
        let (tmp, store) = make_store();
        let _ = tmp;
        let seeded = seed(&store);
        let err = apply(
            &store.config,
            &store,
            &seeded.number.to_string(),
            "bogus",
            None,
        )
        .unwrap_err();
        assert_eq!(
            err.to_string(),
            "invalid input: unknown column 'bogus' — valid columns: todo, in progress, in review, done"
        );
    }

    #[test]
    fn apply_invalid_selector_is_not_found() {
        let (tmp, store) = make_store();
        let _ = tmp;
        let _ = seed(&store);
        let err = apply(&store.config, &store, "999", "todo", None).unwrap_err();
        assert!(matches!(err, TixError::NotFound(_)));
    }
}