Skip to main content

mps/
ref_resolver.rs

1//! Bidirectional epoch-ref ↔ human-ref translator.
2//!
3//! Mirrors Ruby's `MPS::RefResolver` exactly.
4//!
5//! Human ref format:
6//!   Top-level:  `{type}-{n}`           e.g. `task-1`, `note-2`, `mps-1`
7//!   Nested:     `{parent_human}.{idx}` e.g. `mps-1.1`, `task-2.3`
8//!
9//! The resolver is ephemeral — instantiated per-request from a parsed elements map.
10
11use crate::elements::{Element, ElementKind};
12use indexmap::IndexMap;
13use std::collections::HashMap;
14
15pub struct RefResolver {
16    epoch_to_human: HashMap<String, String>,
17    human_to_epoch: HashMap<String, String>,
18}
19
20impl RefResolver {
21    /// Build a resolver from a parsed elements map (as produced by `parser::parse_file`).
22    pub fn new(elements: &IndexMap<String, Element>) -> Self {
23        let mut r = RefResolver {
24            epoch_to_human: HashMap::new(),
25            human_to_epoch: HashMap::new(),
26        };
27        r.build_maps(elements);
28        r
29    }
30
31    /// Return the human ref for an epoch ref, or None if not mapped.
32    pub fn to_human(&self, epoch_ref: &str) -> Option<&str> {
33        self.epoch_to_human.get(epoch_ref).map(|s| s.as_str())
34    }
35
36    /// Return the epoch ref for a human ref, or None if not mapped.
37    pub fn to_epoch(&self, human_ref: &str) -> Option<&str> {
38        self.human_to_epoch.get(human_ref).map(|s| s.as_str())
39    }
40
41    /// Resolve either form. Human refs are translated; epoch refs are validated and returned as-is.
42    #[allow(dead_code)]
43    pub fn resolve<'a>(&'a self, ref_str: &'a str) -> Option<&'a str> {
44        if let Some(epoch) = self.human_to_epoch.get(ref_str) {
45            return Some(epoch.as_str());
46        }
47        if self.epoch_to_human.contains_key(ref_str) {
48            return Some(ref_str);
49        }
50        None
51    }
52
53    fn build_maps(&mut self, elements: &IndexMap<String, Element>) {
54        if elements.is_empty() {
55            return;
56        }
57
58        // Sort by ref-path parts numerically (mirrors Ruby's sort_by { k.split(".").map(&:to_i) })
59        let mut sorted: Vec<(&String, &Element)> = elements.iter().collect();
60        sorted.sort_by(|(a, _), (b, _)| {
61            let a_parts: Vec<u64> = a.split('.').filter_map(|s| s.parse().ok()).collect();
62            let b_parts: Vec<u64> = b.split('.').filter_map(|s| s.parse().ok()).collect();
63            a_parts.cmp(&b_parts)
64        });
65
66        // root_depth = number of segments in the synthetic root wrapper ref (always 1: just the epoch)
67        let root_depth = sorted
68            .first()
69            .map(|(k, _)| k.split('.').count())
70            .unwrap_or(1);
71        let mut type_counters: HashMap<String, usize> = HashMap::new();
72
73        for (epoch_ref, el) in &sorted {
74            let seg_count = epoch_ref.split('.').count();
75            let depth = seg_count - root_depth; // 0 = root, 1 = top-level, 2+ = nested
76
77            if depth == 0 {
78                continue;
79            } // skip synthetic root @mps wrapper
80
81            let human = if depth == 1 {
82                // Top-level: skip Unknown elements (mirrors Ruby behaviour)
83                if el.kind() == ElementKind::Unknown {
84                    continue;
85                }
86                let type_name = el.sign().to_string();
87                let n = type_counters.entry(type_name.clone()).or_insert(0);
88                *n += 1;
89                format!("{}-{}", type_name, n)
90            } else {
91                // Nested: parent_epoch is all segments except the last
92                let parts: Vec<&str> = epoch_ref.splitn(seg_count, '.').collect();
93                let parent_epoch = parts[..parts.len() - 1].join(".");
94                let child_idx = parts[parts.len() - 1];
95                match self.epoch_to_human.get(&parent_epoch) {
96                    Some(parent_human) => format!("{}.{}", parent_human, child_idx),
97                    None => continue, // parent not mapped → skip
98                }
99            };
100
101            self.epoch_to_human
102                .insert(epoch_ref.to_string(), human.clone());
103            self.human_to_epoch.insert(human, epoch_ref.to_string());
104        }
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::parser;
112
113    fn parse(content: &str) -> IndexMap<String, Element> {
114        let wrapped = format!("@mps[]{{\n{}\n}}", content);
115        parser::parse_wrapped(&wrapped, 20260101).unwrap()
116    }
117
118    #[test]
119    fn test_empty_elements() {
120        let els = parse("");
121        let r = RefResolver::new(&els);
122        // Only root wrapper — nothing mapped (depth 0 skipped)
123        assert!(r.epoch_to_human.is_empty());
124    }
125
126    #[test]
127    fn test_top_level_task() {
128        let els = parse("@task[work]{ Do thing }");
129        let r = RefResolver::new(&els);
130        assert_eq!(r.to_human("20260101.1"), Some("task-1"));
131        assert_eq!(r.to_epoch("task-1"), Some("20260101.1"));
132    }
133
134    #[test]
135    fn test_sequential_types() {
136        let els = parse("@task{ A }\n@note{ B }\n@task{ C }");
137        let r = RefResolver::new(&els);
138        assert_eq!(r.to_human("20260101.1"), Some("task-1"));
139        assert_eq!(r.to_human("20260101.2"), Some("note-1"));
140        assert_eq!(r.to_human("20260101.3"), Some("task-2"));
141    }
142
143    #[test]
144    fn test_nested_inside_mps() {
145        let els = parse("@mps{\n  @task{ Nested }\n  @note{ also }\n}");
146        let r = RefResolver::new(&els);
147        // mps-1 is the @mps group
148        assert_eq!(r.to_human("20260101.1"), Some("mps-1"));
149        // task inside = mps-1.1, note inside = mps-1.2
150        assert_eq!(r.to_human("20260101.1.1"), Some("mps-1.1"));
151        assert_eq!(r.to_human("20260101.1.2"), Some("mps-1.2"));
152    }
153
154    #[test]
155    fn test_resolve_accepts_both_forms() {
156        let els = parse("@task{ A }");
157        let r = RefResolver::new(&els);
158        assert_eq!(r.resolve("task-1"), Some("20260101.1"));
159        assert_eq!(r.resolve("20260101.1"), Some("20260101.1"));
160        assert_eq!(r.resolve("bogus"), None);
161    }
162
163    #[test]
164    fn test_unknown_element_skipped() {
165        let els = parse("@widget{ unknown }\n@task{ real }");
166        let r = RefResolver::new(&els);
167        // widget at .1 should be skipped; task at .2 should be task-1
168        assert!(r.to_human("20260101.1").is_none());
169        assert_eq!(r.to_human("20260101.2"), Some("task-1"));
170    }
171
172    #[test]
173    fn test_roundtrip() {
174        let els = parse("@task[work]{ A }\n@note{ B }");
175        let r = RefResolver::new(&els);
176        for epoch_ref in r.epoch_to_human.keys() {
177            let human = r.to_human(epoch_ref).unwrap();
178            let back = r.to_epoch(human).unwrap();
179            assert_eq!(back, epoch_ref.as_str());
180        }
181    }
182}