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::storage::Store;

/// `tix link <a> <b>` (D2): symmetrically link two issues by selector.
/// Both issues must exist; linking an issue to itself is `InvalidInput`.
pub fn run(store: &Store, a: &str, b: &str) -> Result<()> {
    let (issue_a, issue_b) = store.link(a, b)?;
    println!("Linked #{} ↔ #{}", issue_a.number, issue_b.number);
    Ok(())
}

/// `tix unlink <a> <b>` (D2): symmetrically remove the link between two
/// issues by selector. A pair that was never linked is a no-op.
pub fn unlink_cli(store: &Store, a: &str, b: &str) -> Result<()> {
    let (issue_a, issue_b) = store.unlink(a, b)?;
    println!("Unlinked #{} ↔ #{}", issue_a.number, issue_b.number);
    Ok(())
}

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

    #[test]
    fn link_cli_links_two_issues_by_number() {
        let (tmp, store) = make_store();
        let _ = tmp;
        let a = seed(&store, "A");
        let b = seed(&store, "B");
        run(&store, &a.number.to_string(), &b.number.to_string()).unwrap();
        let reread_a = store.get(IssueSelector::Number(a.number)).unwrap();
        let reread_b = store.get(IssueSelector::Number(b.number)).unwrap();
        assert_eq!(reread_a.links, [b.number.to_string()]);
        assert_eq!(reread_b.links, [a.number.to_string()]);
    }

    #[test]
    fn link_cli_errors_on_self_link() {
        let (tmp, store) = make_store();
        let _ = tmp;
        let a = seed(&store, "A");
        let err = run(&store, &a.number.to_string(), &a.number.to_string()).unwrap_err();
        assert!(matches!(err, crate::error::TixError::InvalidInput(_)));
    }

    #[test]
    fn unlink_cli_removes_symmetric_link() {
        let (tmp, store) = make_store();
        let _ = tmp;
        let a = seed(&store, "A");
        let b = seed(&store, "B");
        run(&store, &a.number.to_string(), &b.number.to_string()).unwrap();
        unlink_cli(&store, &a.number.to_string(), &b.number.to_string()).unwrap();
        let reread_a = store.get(IssueSelector::Number(a.number)).unwrap();
        let reread_b = store.get(IssueSelector::Number(b.number)).unwrap();
        assert!(reread_a.links.is_empty());
        assert!(reread_b.links.is_empty());
    }

    #[test]
    fn unlink_cli_never_linked_pair_is_noop() {
        let (tmp, store) = make_store();
        let _ = tmp;
        let a = seed(&store, "A");
        let b = seed(&store, "B");
        unlink_cli(&store, &a.number.to_string(), &b.number.to_string()).unwrap();
        let reread_a = store.get(IssueSelector::Number(a.number)).unwrap();
        assert!(reread_a.links.is_empty());
    }
}