tix-tracker 0.2.0

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

/// `tix view <issue>`: print the full issue (frontmatter + `---` + body)
/// verbatim to stdout. No trailing newline beyond what the issue file
/// contains.
pub fn run(store: &Store, raw: &str) -> Result<()> {
    let issue = get(store, raw)?;
    print!("{}", issue.to_file_contents());
    Ok(())
}

/// The view core (WP03): resolve `<issue>` and load the [`Issue`] record.
///
/// This is what MCP's `get_issue` tool (WP04) calls. Side-effect policy
/// (C-005): the core never reads `stdin`/writes `stdout`; the CLI printer in
/// [`run`] renders the record. Selector resolution and ambiguity handling
/// reuse `commands/mod.rs` unchanged (Directive 024).
pub fn get(store: &Store, raw: &str) -> Result<Issue> {
    let selector = parse_selector(store, raw)?;
    store.get(selector)
}

#[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.join("alice")).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) -> Issue {
        let mut issue = Issue {
            number,
            title: title.to_string(),
            status: Status::new("todo"),
            labels: vec![],
            priority: Priority::default(),
            assignee: Some("alice".to_string()),
            estimate: None,
            commit: None,
            gitlab_id: None,
            linear_id: None,
            mission: None,
            task: None,
            created: "2026-01-01".to_string(),
            updated: "2026-01-01".to_string(),
            filename: String::new(),
            body: "the body".to_string(),
        };
        issue.filename = format!("alice/2026-01-01-{}.md", crate::slug::slugify(title));
        fs::write(
            store.issues_dir.join(&issue.filename),
            issue.to_file_contents(),
        )
        .unwrap();
        issue
    }

    #[test]
    fn get_returns_the_full_record() {
        let (tmp, store) = make_store();
        let _ = tmp;
        let seeded = seed(&store, 1, "Get me");
        let issue = get(&store, &seeded.number.to_string()).unwrap();
        assert_eq!(issue.title, "Get me");
        assert_eq!(issue.body, "the body");
    }

    #[test]
    fn get_selector_miss_is_not_found() {
        let (tmp, store) = make_store();
        let _ = tmp;
        let err = get(&store, "999").unwrap_err();
        assert!(
            matches!(err, crate::error::TixError::NotFound(_)),
            "missing issue must surface as NotFound, got {err}"
        );
    }

    #[test]
    fn get_ambiguous_selector_is_an_error() {
        let (tmp, store) = make_store();
        let _ = tmp;
        seed(&store, 1, "Shared title");
        seed(&store, 2, "Shared title other");
        // A bare filename fragment matches nothing; a title fragment matching
        // two issues is ambiguous per contracts/cli.md.
        let err = get(&store, "Shared").unwrap_err();
        assert!(
            matches!(err, crate::error::TixError::InvalidInput(_)),
            "ambiguous selector must be an InvalidInput error, got {err}"
        );
    }
}