Skip to main content

jyn_core/
display.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4//! Short-form ID rendering and input parsing per JOT-002F-4D.
5//!
6//! Full IDs follow joy-core's ADR-027 scheme: `TODO-XXXX-YY`. For display
7//! the tool shows only the middle counter without the acronym prefix,
8//! leading zeros, or the title-hash suffix: `TODO-00A1-EA` -> `#A1`.
9//! When two tasks in the same workspace share a counter (rare, caused by
10//! concurrent adds on different devices before sync), the affected rows
11//! keep the suffix so they remain addressable: `#A1-EA`, `#A1-7F`.
12
13use std::collections::HashMap;
14
15use crate::storage::ACRONYM;
16
17/// Split a full ID `TODO-00A1-EA` into its counter (`00A1`) and optional
18/// title-hash suffix (`EA`). Returns `(counter, suffix)`.
19fn split_full_id(full: &str) -> (&str, Option<&str>) {
20    let without_prefix = full
21        .strip_prefix(&format!("{ACRONYM}-"))
22        .or_else(|| full.strip_prefix(&format!("{}-", ACRONYM.to_lowercase())))
23        .unwrap_or(full);
24    match without_prefix.split_once('-') {
25        Some((counter, suffix)) => (counter, Some(suffix)),
26        None => (without_prefix, None),
27    }
28}
29
30/// Strip leading zeros from a hex counter, keeping at least one digit.
31fn strip_leading_zeros(hex: &str) -> String {
32    let trimmed = hex.trim_start_matches('0');
33    if trimmed.is_empty() {
34        "0".to_string()
35    } else {
36        trimmed.to_string()
37    }
38}
39
40/// Render a short display ID for a single full ID, without disambiguation.
41/// Use `format_ids` when rendering a list where collisions must be handled.
42pub fn short_id(full: &str) -> String {
43    let (counter, _) = split_full_id(full);
44    format!("#{}", strip_leading_zeros(counter))
45}
46
47/// Render short display IDs for a list of full IDs, expanding only the
48/// rows whose counters collide within the list. Returns one string per
49/// input, in the same order.
50pub fn format_ids(full_ids: &[&str]) -> Vec<String> {
51    let mut counter_frequency: HashMap<String, usize> = HashMap::new();
52    let parsed: Vec<(String, Option<String>)> = full_ids
53        .iter()
54        .map(|id| {
55            let (c, s) = split_full_id(id);
56            (c.to_uppercase(), s.map(|s| s.to_uppercase()))
57        })
58        .collect();
59
60    for (counter, _) in &parsed {
61        *counter_frequency.entry(counter.clone()).or_insert(0) += 1;
62    }
63
64    parsed
65        .into_iter()
66        .map(|(counter, suffix)| {
67            let short_counter = strip_leading_zeros(&counter);
68            let ambiguous = counter_frequency.get(&counter).copied().unwrap_or(1) > 1;
69            match (ambiguous, suffix) {
70                (true, Some(sfx)) => format!("#{short_counter}-{sfx}"),
71                _ => format!("#{short_counter}"),
72            }
73        })
74        .collect()
75}
76
77/// Normalize any of `#A1`, `A1`, `a1`, `TODO-00A1`, `TODO-00A1-EA`,
78/// `#A1-EA` to a form `find_task_file` understands (uppercase, with
79/// `TODO-` prefix and 4-digit counter).
80///
81/// Pass-through for anything that does not look like a short or full ID;
82/// callers get the original string back and downstream lookup fails with
83/// the normal "not found" diagnostic.
84pub fn normalize_id_input(raw: &str) -> String {
85    let trimmed = raw.trim().trim_start_matches('#');
86
87    if trimmed.to_uppercase().starts_with(&format!("{ACRONYM}-")) {
88        return trimmed.to_uppercase();
89    }
90
91    let (counter, suffix) = match trimmed.split_once('-') {
92        Some((c, s)) => (c, Some(s)),
93        None => (trimmed, None),
94    };
95
96    let counter_valid =
97        !counter.is_empty() && counter.len() <= 4 && counter.chars().all(|c| c.is_ascii_hexdigit());
98    if !counter_valid {
99        return raw.to_string();
100    }
101
102    let padded = format!("{:0>4}", counter.to_uppercase());
103    match suffix {
104        Some(sfx) if sfx.len() == 2 && sfx.chars().all(|c| c.is_ascii_hexdigit()) => {
105            format!("{ACRONYM}-{padded}-{}", sfx.to_uppercase())
106        }
107        _ => format!("{ACRONYM}-{padded}"),
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn short_id_strips_prefix_suffix_and_leading_zeros() {
117        assert_eq!(short_id("TODO-00A1-EA"), "#A1");
118        assert_eq!(short_id("TODO-0001-7F"), "#1");
119        assert_eq!(short_id("TODO-0110-B3"), "#110");
120        assert_eq!(short_id("TODO-FFFF-00"), "#FFFF");
121    }
122
123    #[test]
124    fn short_id_without_suffix_still_works() {
125        assert_eq!(short_id("TODO-0042"), "#42");
126    }
127
128    #[test]
129    fn format_ids_unique_counters_stay_short() {
130        let ids = vec!["TODO-0001-7F", "TODO-00A1-EA", "TODO-0110-B3"];
131        let out = format_ids(&ids);
132        assert_eq!(out, vec!["#1", "#A1", "#110"]);
133    }
134
135    #[test]
136    fn format_ids_expands_only_colliding_rows() {
137        let ids = vec![
138            "TODO-0001-7F",
139            "TODO-00A1-EA",
140            "TODO-00A1-7F",
141            "TODO-0110-B3",
142        ];
143        let out = format_ids(&ids);
144        assert_eq!(out, vec!["#1", "#A1-EA", "#A1-7F", "#110"]);
145    }
146
147    #[test]
148    fn normalize_accepts_short_form() {
149        assert_eq!(normalize_id_input("#A1"), "TODO-00A1");
150        assert_eq!(normalize_id_input("A1"), "TODO-00A1");
151        assert_eq!(normalize_id_input("a1"), "TODO-00A1");
152        assert_eq!(normalize_id_input("1"), "TODO-0001");
153        assert_eq!(normalize_id_input("110"), "TODO-0110");
154        assert_eq!(normalize_id_input("FFFF"), "TODO-FFFF");
155    }
156
157    #[test]
158    fn normalize_accepts_short_form_with_suffix() {
159        assert_eq!(normalize_id_input("#A1-EA"), "TODO-00A1-EA");
160        assert_eq!(normalize_id_input("a1-ea"), "TODO-00A1-EA");
161    }
162
163    #[test]
164    fn normalize_passes_through_full_form() {
165        assert_eq!(normalize_id_input("TODO-00A1"), "TODO-00A1");
166        assert_eq!(normalize_id_input("todo-00a1-ea"), "TODO-00A1-EA");
167    }
168
169    #[test]
170    fn normalize_passes_through_nonsense() {
171        // Out-of-range hex / non-hex stays untouched; find_task_file will
172        // return a clean "not found" diagnostic.
173        assert_eq!(normalize_id_input("GGGGG"), "GGGGG");
174        assert_eq!(normalize_id_input("12345"), "12345");
175    }
176}