tix-tracker 0.3.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
use crate::error::Result;
use crate::model::Issue;
use crate::storage::Store;

/// `tix list`: a parseable table (number, title, status, priority) with
/// columns separated by exactly two spaces; the header row is always first
/// and an empty result prints the header only (`contracts/cli.md`).
pub fn run(store: &Store, status: Option<&str>) -> Result<()> {
    let issues = query(store, status)?;

    let num_width = 6usize;
    let title_width = issues
        .iter()
        .map(|i| i.title.chars().count())
        .chain([5])
        .max()
        .unwrap()
        .min(40);
    let status_width = issues
        .iter()
        .map(|i| i.status.display().chars().count())
        .chain([6])
        .max()
        .unwrap()
        .min(16);
    let priority_width = issues
        .iter()
        .map(|i| i.priority.to_string().chars().count())
        .chain([8])
        .max()
        .unwrap()
        .min(12);

    let row = |number: String, title: String, status: String, priority: String| {
        format!(
            "{number:<num_width$}  {title:<title_width$}  {status:<status_width$}  {priority:<priority_width$}"
        )
    };

    println!(
        "{}",
        row(
            "NUM".into(),
            "TITLE".into(),
            "STATUS".into(),
            "PRIORITY".into()
        )
    );
    for issue in &issues {
        println!(
            "{}",
            row(
                issue.number.to_string(),
                clip(&issue.title, title_width),
                clip(issue.status.display(), status_width),
                clip(&issue.priority.to_string(), priority_width)
            )
        );
    }
    Ok(())
}

/// The list core (WP03): every issue, optionally filtered by exactly one
/// status string (same `Status::is` filter the CLI applied).
///
/// This is what MCP's `list_issues` tool (WP04) calls. Side-effect policy
/// (C-005): no printing here; the CLI renders the table in [`run`]. The
/// ordering is the store's canonical filename-ascending sort (stable).
pub fn query(store: &Store, status: Option<&str>) -> Result<Vec<Issue>> {
    let mut issues = store.list()?;
    if let Some(col) = status {
        issues.retain(|i| i.status.is(col));
    }
    Ok(issues)
}

fn clip(value: &str, width: usize) -> String {
    value.chars().take(width).collect()
}

#[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 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, number: u32, title: &str, status: &str) -> Issue {
        let mut issue = Issue {
            number,
            title: title.to_string(),
            status: Status::new(status.to_string()),
            labels: vec![],
            priority: Priority::default(),
            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 = format!("alice/2026-01-01-{}.md", crate::slug::slugify(title));
        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
        fs::write(
            store.issues_dir.join(&issue.filename),
            issue.to_file_contents(),
        )
        .unwrap();
        issue
    }

    #[test]
    fn query_without_filter_returns_every_issue() {
        let (tmp, store) = make_store();
        let _ = tmp;
        seed(&store, 1, "First", "todo");
        seed(&store, 2, "Second", "done");
        let issues = query(&store, None).unwrap();
        assert_eq!(issues.len(), 2);
        assert_eq!(issues[0].number, 1);
        assert_eq!(issues[1].number, 2);
    }

    #[test]
    fn query_filters_by_status_exactly_as_the_cli_does() {
        let (tmp, store) = make_store();
        let _ = tmp;
        seed(&store, 1, "Todo one", "todo");
        seed(&store, 2, "Todo two", "todo");
        seed(&store, 3, "Done one", "done");
        let todos = query(&store, Some("todo")).unwrap();
        assert_eq!(
            todos.iter().map(|i| i.number).collect::<Vec<_>>(),
            vec![1, 2]
        );
        let done = query(&store, Some("done")).unwrap();
        assert_eq!(done.len(), 1);
        assert_eq!(done[0].number, 3);
        let none = query(&store, Some("in review")).unwrap();
        assert!(none.is_empty(), "no in-review issues seeded");
    }
}