tix-tracker 0.8.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Who the mention store's records belong to, derived from git (design D6).
//!
//! The local user's mention files are the slug files for every candidate body their git
//! identity yields:
//!
//! - the username slug of `user.name` — the same slug `docs/issues/<username>/` uses as its
//!   folder segment;
//! - the slug of the local part of `user.email`;
//! - the full email.
//!
//! A mention targets the local user when its body slug normalizes to one of those. Because the
//! store keys files by the slug of the body, an `@thyseus@pm.me` mention is read from
//! `docs/mentions/thyseus-pm-me.toml` and an `@herbert-maschke` mention from
//! `docs/mentions/herbert-maschke.toml` — and a person mentioned both ways has both files,
//! both of which are read.
//!
//! Best-effort, exactly as [`crate::assignee`] is: no git, or no configured `user.name`, means
//! no candidates, which means no mention is attributed to the local user, and no command fails.

use crate::assignee::{self, AuthorIdentity};
use crate::slug::slugify;

/// The mention bodies that address the local user.
#[must_use]
pub fn local_candidates() -> Vec<String> {
    local_candidates_for("git")
}

/// The spawn seam behind [`local_candidates`]: `program` defaults to `"git"` but tests point it
/// at a fake binary, the same seam [`crate::assignee::git_identity`] uses.
#[must_use]
pub(crate) fn local_candidates_for(program: &str) -> Vec<String> {
    match assignee::git_identity_for(program) {
        Some(identity) => candidates_from(&identity),
        None => Vec::new(),
    }
}

/// The candidate mention bodies for an author identity, deduplicated and lowercased.
#[must_use]
pub fn candidates_from(identity: &AuthorIdentity) -> Vec<String> {
    let mut out: Vec<String> = Vec::new();
    let mut push = |value: &str| {
        let value = value.trim().to_lowercase();
        if !value.is_empty() && !out.contains(&value) {
            out.push(value);
        }
    };
    push(&slugify(&identity.name));
    if let Some(email) = &identity.email {
        let local = email.split('@').next().unwrap_or_default();
        push(&slugify(local));
        push(email);
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use std::os::unix::fs::PermissionsExt;

    /// A fake `git` that answers `config --get user.name` / `user.email` from `values`.
    fn fake_git(
        dir: &std::path::Path,
        name: Option<&str>,
        email: Option<&str>,
    ) -> std::path::PathBuf {
        let script = dir.join("fake-git.sh");
        let name_line =
            name.map_or_else(|| "exit 1".to_string(), |n| format!("printf '%s\\n' '{n}'"));
        let email_line =
            email.map_or_else(|| "exit 1".to_string(), |e| format!("printf '%s\\n' '{e}'"));
        let mut file = std::fs::File::create(&script).unwrap();
        writeln!(
            file,
            "#!/bin/sh\ncase \"$3\" in\n  user.name) {name_line} ;;\n  user.email) {email_line} ;;\nesac\nexit 0"
        )
        .unwrap();
        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
        script
    }

    #[test]
    fn a_full_identity_yields_the_name_slug_the_email_slug_and_the_email() {
        let tmp = tempfile::TempDir::new().unwrap();
        let git = fake_git(tmp.path(), Some("Herbert Maschke"), Some("thyseus@pm.me"));
        let candidates = local_candidates_for(git.to_str().unwrap());
        assert_eq!(
            candidates,
            vec![
                "herbert-maschke".to_string(),
                "thyseus".to_string(),
                "thyseus@pm.me".to_string()
            ]
        );
    }

    #[test]
    fn an_email_only_identity_yields_the_email_breadth() {
        let tmp = tempfile::TempDir::new().unwrap();
        // `git_identity` requires `user.name`: an unset name means no identity at all.
        let git = fake_git(tmp.path(), None, Some("thyseus@pm.me"));
        assert_eq!(
            local_candidates_for(git.to_str().unwrap()),
            [] as [std::string::String; 0]
        );
    }

    #[test]
    fn a_name_that_is_already_a_slug_is_kept_as_is() {
        let identity = AuthorIdentity {
            name: "herbert-maschke".to_string(),
            email: None,
        };
        assert_eq!(
            candidates_from(&identity),
            vec!["herbert-maschke".to_string()]
        );
    }

    #[test]
    fn no_git_means_no_candidates_and_no_error() {
        let tmp = tempfile::TempDir::new().unwrap();
        let git = fake_git(tmp.path(), None, None);
        assert_eq!(
            local_candidates_for(git.to_str().unwrap()),
            [] as [std::string::String; 0]
        );
    }

    #[test]
    fn candidates_are_deduplicated_and_lowercased() {
        let identity = AuthorIdentity {
            name: "Thyseus".to_string(),
            email: Some("Thyseus@PM.ME".to_string()),
        };
        assert_eq!(
            candidates_from(&identity),
            vec!["thyseus".to_string(), "thyseus@pm.me".to_string()]
        );
    }

    #[test]
    fn a_handle_and_an_email_of_one_person_slug_to_different_files() {
        // The store keys by the body's slug, so both candidates are reachable.
        assert_eq!(
            crate::mentions::store::MentionStore::slug("herbert-maschke"),
            "herbert-maschke"
        );
        assert_eq!(
            crate::mentions::store::MentionStore::slug("thyseus@pm.me"),
            "thyseus-pm-me"
        );
    }
}