tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! The startup tips the TUI shows (tix #1632).
//!
//! The tips themselves live in `src/templates/tips/`, one TOML file per
//! category, and are **compiled into the binary** with [`include_str!`]
//! (concatenated in filename order): a shipped `tix` needs no files on disk,
//! and the file a contributor edits is the file the modal shows. This module
//! owns the parse and the lookup; the modal itself is [`crate::ui::tips`], and
//! the on/off setting is [`crate::config::Config::tips_on_startup`].
//!
//! A malformed embedded file is not allowed to take the session down: [`all`]
//! falls back to no tips (and thus no modal) rather than panicking, and the
//! `embedded_tips_are_well_formed_and_plentiful` test is what makes a malformed
//! file a test failure instead of a silently empty dialog.

use std::sync::LazyLock;

/// The embedded tips, one file per category, concatenated in filename order
/// (the order the modal presents them).
const TIPS_TOML: &str = concat!(
    include_str!("templates/tips/getting-started.toml"),
    include_str!("templates/tips/tui-navigation.toml"),
    include_str!("templates/tips/the-board.toml"),
    include_str!("templates/tips/the-detail-pane.toml"),
    include_str!("templates/tips/relationships-and-parents.toml"),
    include_str!("templates/tips/cycles-milestones-rollover.toml"),
    include_str!("templates/tips/cycle-schedules.toml"),
    include_str!("templates/tips/recurring-issues.toml"),
    include_str!("templates/tips/triage-inbox.toml"),
    include_str!("templates/tips/projects-epics-roadmap.toml"),
    include_str!("templates/tips/health-and-stats.toml"),
    include_str!("templates/tips/saved-views-and-filters.toml"),
    include_str!("templates/tips/templates.toml"),
    include_str!("templates/tips/work-in-progress-limits.toml"),
    include_str!("templates/tips/workflow-categories.toml"),
    include_str!("templates/tips/batch-editing.toml"),
    include_str!("templates/tips/archiving.toml"),
    include_str!("templates/tips/external-sync.toml"),
    include_str!("templates/tips/mcp-and-agents.toml"),
    include_str!("templates/tips/configuration-and-storage.toml"),
    include_str!("templates/tips/about-this-dialog.toml"),
);

/// The file's shape: one table per category, each with a list of tips.
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
struct TipsFile {
    #[serde(default)]
    categories: Vec<Category>,
}

#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
struct Category {
    name: String,
    #[serde(default)]
    tips: Vec<String>,
}

/// One tip, flattened with the category it was declared under.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tip {
    /// The category's display name, as the modal's heading prints it.
    pub category: String,
    /// The tip's text, verbatim from `tips.toml`.
    pub text: String,
}

static TIPS: LazyLock<Vec<Tip>> = LazyLock::new(|| parse(TIPS_TOML).unwrap_or_default());

/// Every tip, flattened in file order (categories in declaration order, and each
/// category's tips in order).
///
/// Parsed once per process. An unparsable embedded file yields an empty slice, so
/// the TUI simply shows no tips rather than failing to start.
#[must_use]
pub fn all() -> &'static [Tip] {
    &TIPS
}

/// Parse the tips file's text into the flattened tip list.
///
/// Exposed rather than private so the test can pin the embedded file's shape
/// without going through the process-wide cache.
///
/// # Errors
///
/// Returns the TOML diagnostic when `text` is not a well-formed tips file.
pub fn parse(text: &str) -> std::result::Result<Vec<Tip>, String> {
    let file: TipsFile = toml::from_str(text).map_err(|e| e.to_string())?;
    let mut tips = Vec::new();
    for category in file.categories {
        for text in category.tips {
            tips.push(Tip {
                category: category.name.clone(),
                text,
            });
        }
    }
    Ok(tips)
}

/// A pseudo-random starting index below `len`.
///
/// Seeded from the wall clock and mixed, so opening the TUI twice in the same
/// second (the clock's resolution on some platforms) does not show the same tip
/// twice. `len == 0` yields `0` — the caller shows no tip in that case anyway, and
/// this keeps the function total.
#[must_use]
pub fn random_index(len: usize, seed: u128) -> usize {
    if len == 0 {
        return 0;
    }
    // A 64-bit finalizer (splitmix64's): cheap, and enough to decorrelate the
    // low bits a modulo reads.
    let mut x = seed as u64;
    x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
    x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
    x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
    x ^= x >> 31;
    (x % len as u64) as usize
}

/// `random_index` for the current wall clock.
#[must_use]
pub fn random_start(len: usize) -> usize {
    let seed = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map_or(0, |d| d.as_nanos());
    random_index(len, seed)
}

#[cfg(test)]
mod tests {
    #[allow(clippy::wildcard_imports)]
    use super::*;

    #[test]
    fn embedded_tips_are_well_formed_and_plentiful() {
        let tips = parse(TIPS_TOML).expect("tips.toml must parse");
        assert!(
            tips.len() >= 100,
            "tix #1632 asks for at least 100 tips, found {}",
            tips.len()
        );
        let categories: std::collections::BTreeSet<&str> =
            tips.iter().map(|t| t.category.as_str()).collect();
        assert!(
            categories.len() >= 10,
            "the tips must be categorized, found {} categories",
            categories.len()
        );
        for tip in &tips {
            assert!(!tip.text.trim().is_empty(), "a tip is blank");
            assert!(
                !tip.category.trim().is_empty(),
                "a tip (\"{}\") has no category",
                tip.text
            );
        }
        let unique: std::collections::BTreeSet<&str> =
            tips.iter().map(|t| t.text.as_str()).collect();
        assert_eq!(unique.len(), tips.len(), "a tip is duplicated");
    }

    #[test]
    fn all_is_populated_from_the_embedded_file() {
        assert_eq!(all().len(), parse(TIPS_TOML).unwrap().len());
    }

    #[test]
    fn parse_rejects_a_malformed_file() {
        assert!(parse("categories = 3").is_err());
    }

    #[test]
    fn an_empty_category_list_yields_no_tips() {
        assert_eq!(parse("").unwrap(), [] as [crate::tips::Tip; 0]);
    }

    #[test]
    fn random_index_is_in_range_and_total_at_zero() {
        assert_eq!(random_index(0, 12345), 0);
        for seed in 0..1000u128 {
            let index = random_index(7, seed);
            assert!(index < 7, "index {index} out of range");
        }
        assert!(random_start(10) < 10);
    }

    #[test]
    fn random_index_spreads_its_seeds() {
        // Consecutive nanosecond stamps must not all land on the same tip.
        let bucket: std::collections::BTreeSet<usize> =
            (0..64u128).map(|seed| random_index(16, seed)).collect();
        assert!(
            bucket.len() > 4,
            "the seed mix is not spreading: {bucket:?}"
        );
    }
}