Skip to main content

nika_engine/binding/
mention.rs

1//! Mention Module - @reference parsing for Chat-as-DAG
2//!
3//! Parses @mentions in chat messages and converts them to BindingSpec bindings.
4//!
5//! # Syntax
6//!
7//! | Pattern | Description |
8//! |---------|-------------|
9//! | `@N` | Reference message #N (msg-001) |
10//! | `@last` | Reference the last message |
11//! | `@all` | Reference all previous messages |
12//! | `@N..M` | Reference messages N through M (range) |
13//! | `//` | Parallel marker (no dependency on previous message) |
14//!
15//! # Example
16//!
17//! ```text
18//! @1 USER: Analyze this file
19//! @2 ASSISTANT: Here is the analysis...
20//! @3 USER: Translate @2 to French    ◄── Reference @2
21//! @4 USER: // Independent task       ◄── Parallel (no edge from @3)
22//! ```
23
24use regex::Regex;
25use serde::{Deserialize, Serialize};
26use std::fmt;
27use std::sync::LazyLock;
28
29use super::{BindingEntry, BindingSpec};
30
31/// A reference to a previous chat message.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub enum Mention {
34    /// @N - specific message number (1-indexed)
35    Number(u32),
36    /// @last - most recent message
37    Last,
38    /// @all - all previous messages
39    All,
40    /// @N..M - range of messages (inclusive)
41    Range { start: u32, end: u32 },
42}
43
44impl fmt::Display for Mention {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        match self {
47            Mention::Number(n) => write!(f, "@{}", n),
48            Mention::Last => write!(f, "@last"),
49            Mention::All => write!(f, "@all"),
50            Mention::Range { start, end } => write!(f, "@{}..{}", start, end),
51        }
52    }
53}
54
55// ═══════════════════════════════════════════════════════════════════════════════
56// Task 2.3: parse_mentions() Regex Parser
57// ═══════════════════════════════════════════════════════════════════════════════
58
59/// Compiled regex for @mention parsing.
60///
61/// Pattern matches @ preceded by whitespace, punctuation, or start of string.
62/// This prevents false positives like emails (user@123.com).
63///
64/// Capture groups:
65/// - Group 1,2: Range @N..M (start, end)
66/// - Group 3: Number @N
67/// - Group 4: Keyword (last|all)
68static MENTION_REGEX: LazyLock<Regex> = LazyLock::new(|| {
69    // Match @ at start of string or after whitespace/punctuation
70    // Range must be checked before number (longer match wins)
71    Regex::new(r"(?:^|[\s\[\](),:;])@(?:(\d+)\.\.(\d+)|(\d+)|(last|all))")
72        .expect("Invalid mention regex")
73});
74
75/// Parse @mentions from text.
76///
77/// Supports:
78/// - `@N` - Reference message N (1-indexed display, 0 is valid but resolves empty)
79/// - `@N..M` - Range of messages from N to M (inclusive)
80/// - `@last` - Most recent message
81/// - `@all` - All messages
82///
83/// Does NOT match emails (user@123.com) due to prefix requirement.
84///
85/// # Examples
86///
87/// ```
88/// use nika::binding::mention::{parse_mentions, Mention};
89///
90/// let mentions = parse_mentions("Look at @1 and @2");
91/// assert_eq!(mentions, vec![Mention::Number(1), Mention::Number(2)]);
92///
93/// let mentions = parse_mentions("Continue from @last");
94/// assert_eq!(mentions, vec![Mention::Last]);
95/// ```
96pub fn parse_mentions(text: &str) -> Vec<Mention> {
97    let mut mentions = Vec::new();
98
99    for cap in MENTION_REGEX.captures_iter(text) {
100        if let (Some(start), Some(end)) = (cap.get(1), cap.get(2)) {
101            // Range: @N..M
102            let Ok(start) = start.as_str().parse::<u32>() else {
103                continue;
104            };
105            let Ok(end) = end.as_str().parse::<u32>() else {
106                continue;
107            };
108            mentions.push(Mention::Range { start, end });
109        } else if let Some(num) = cap.get(3) {
110            // Number: @N
111            let Ok(n) = num.as_str().parse::<u32>() else {
112                continue;
113            };
114            mentions.push(Mention::Number(n));
115        } else if let Some(keyword) = cap.get(4) {
116            // Keyword: @last or @all
117            match keyword.as_str() {
118                "last" => mentions.push(Mention::Last),
119                "all" => mentions.push(Mention::All),
120                _ => {}
121            }
122        }
123    }
124
125    mentions
126}
127
128// ═══════════════════════════════════════════════════════════════════════════════
129// Task 2.7: Parallel Marker Detection
130// ═══════════════════════════════════════════════════════════════════════════════
131
132/// Check if text starts with the parallel marker `//`.
133///
134/// A message starting with `//` indicates it has no dependency on the previous message
135/// (parallel execution). The marker can have leading whitespace.
136///
137/// # Examples
138///
139/// ```
140/// use nika::binding::mention::has_parallel_marker;
141///
142/// assert!(has_parallel_marker("// Independent task"));
143/// assert!(has_parallel_marker("  // Also parallel"));
144/// assert!(!has_parallel_marker("Normal message"));
145/// assert!(!has_parallel_marker("@1 Reference")); // mentions, not parallel
146/// ```
147pub fn has_parallel_marker(text: &str) -> bool {
148    text.trim_start().starts_with("//")
149}
150
151/// Strip the parallel marker from text if present.
152///
153/// Returns the text content after the `//` marker, trimmed.
154/// If no marker, returns the original text unchanged.
155///
156/// # Examples
157///
158/// ```
159/// use nika::binding::mention::strip_parallel_marker;
160///
161/// assert_eq!(strip_parallel_marker("// Task"), "Task");
162/// assert_eq!(strip_parallel_marker("  //  Parallel work"), "Parallel work");
163/// assert_eq!(strip_parallel_marker("Normal message"), "Normal message");
164/// ```
165pub fn strip_parallel_marker(text: &str) -> &str {
166    let trimmed = text.trim_start();
167    if let Some(stripped) = trimmed.strip_prefix("//") {
168        stripped.trim_start()
169    } else {
170        text
171    }
172}
173
174// ═══════════════════════════════════════════════════════════════════════════════
175// Task 2.4-2.6: resolve_mention() - Mention → Message Indices
176// ═══════════════════════════════════════════════════════════════════════════════
177
178/// Resolved message indices from a mention.
179///
180/// All indices are 1-indexed (matching @N syntax).
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum ResolvedMention {
183    /// Single message index
184    Single(u32),
185    /// Multiple message indices (for @all and ranges)
186    Multiple(Vec<u32>),
187    /// Empty resolution (no messages to reference)
188    Empty,
189}
190
191/// Error when resolving a mention.
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub enum MentionResolutionError {
194    /// Referenced message doesn't exist
195    MessageNotFound { index: u32, max: u32 },
196    /// Invalid range (start > end)
197    InvalidRange { start: u32, end: u32 },
198    /// No messages to reference for @last/@all
199    NoMessages,
200}
201
202impl std::fmt::Display for MentionResolutionError {
203    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204        match self {
205            Self::MessageNotFound { index, max } => {
206                write!(f, "Message @{} not found (max: @{})", index, max)
207            }
208            Self::InvalidRange { start, end } => {
209                write!(f, "Invalid range @{}..{} (start > end)", start, end)
210            }
211            Self::NoMessages => write!(f, "No messages to reference"),
212        }
213    }
214}
215
216impl std::error::Error for MentionResolutionError {}
217
218/// Resolve a mention to message indices.
219///
220/// # Arguments
221/// * `mention` - The mention to resolve
222/// * `message_count` - Total number of messages in the chat
223///
224/// # Returns
225/// * `Ok(ResolvedMention)` - Resolved indices (1-indexed)
226/// * `Err(MentionResolutionError)` - Resolution failed
227///
228/// # Examples
229///
230/// ```
231/// use nika::binding::mention::{resolve_mention, Mention, ResolvedMention};
232///
233/// // @last with 3 messages → @3
234/// let result = resolve_mention(&Mention::Last, 3);
235/// assert_eq!(result, Ok(ResolvedMention::Single(3)));
236///
237/// // @2 with 3 messages → @2
238/// let result = resolve_mention(&Mention::Number(2), 3);
239/// assert_eq!(result, Ok(ResolvedMention::Single(2)));
240/// ```
241pub fn resolve_mention(
242    mention: &Mention,
243    message_count: u32,
244) -> Result<ResolvedMention, MentionResolutionError> {
245    match mention {
246        Mention::Last => {
247            if message_count == 0 {
248                Err(MentionResolutionError::NoMessages)
249            } else {
250                Ok(ResolvedMention::Single(message_count))
251            }
252        }
253        Mention::Number(n) => {
254            if *n == 0 || *n > message_count {
255                Err(MentionResolutionError::MessageNotFound {
256                    index: *n,
257                    max: message_count,
258                })
259            } else {
260                Ok(ResolvedMention::Single(*n))
261            }
262        }
263        Mention::All => {
264            if message_count == 0 {
265                Ok(ResolvedMention::Empty)
266            } else {
267                Ok(ResolvedMention::Multiple((1..=message_count).collect()))
268            }
269        }
270        Mention::Range { start, end } => {
271            if start > end {
272                return Err(MentionResolutionError::InvalidRange {
273                    start: *start,
274                    end: *end,
275                });
276            }
277            if *start == 0 || *end > message_count {
278                return Err(MentionResolutionError::MessageNotFound {
279                    index: if *start == 0 { 0 } else { *end },
280                    max: message_count,
281                });
282            }
283            Ok(ResolvedMention::Multiple((*start..=*end).collect()))
284        }
285    }
286}
287
288// ═══════════════════════════════════════════════════════════════════════════════
289// Task 2.8: mentions_to_bindings() - Mentions → BindingSpec
290// ═══════════════════════════════════════════════════════════════════════════════
291
292/// Convert resolved mentions to BindingSpec bindings.
293///
294/// Creates a binding for each referenced message with:
295/// - Alias: `ref_N` where N is the message number (1-indexed)
296/// - Path: `msg-NNN.output` following ChatWorkflow message ID format
297///
298/// # Arguments
299/// * `resolved` - Resolved mention indices
300///
301/// # Returns
302/// BindingSpec with bindings for each referenced message
303///
304/// # Examples
305///
306/// ```
307/// use nika::binding::mention::{mentions_to_bindings, ResolvedMention};
308///
309/// // Single reference @2
310/// let spec = mentions_to_bindings(&ResolvedMention::Single(2));
311/// assert!(spec.contains_key("ref_2"));
312///
313/// // Multiple references @1..3
314/// let spec = mentions_to_bindings(&ResolvedMention::Multiple(vec![1, 2, 3]));
315/// assert!(spec.contains_key("ref_1"));
316/// assert!(spec.contains_key("ref_2"));
317/// assert!(spec.contains_key("ref_3"));
318/// ```
319pub fn mentions_to_bindings(resolved: &ResolvedMention) -> BindingSpec {
320    let mut spec = BindingSpec::default();
321
322    match resolved {
323        ResolvedMention::Single(n) => {
324            let alias = format!("ref_{}", n);
325            let path = format!("msg-{:03}.output", n);
326            spec.insert(alias, BindingEntry::new(path));
327        }
328        ResolvedMention::Multiple(indices) => {
329            for n in indices {
330                let alias = format!("ref_{}", n);
331                let path = format!("msg-{:03}.output", n);
332                spec.insert(alias, BindingEntry::new(path));
333            }
334        }
335        ResolvedMention::Empty => {
336            // No bindings for empty resolution
337        }
338    }
339
340    spec
341}
342
343/// Convert all mentions in text to BindingSpec.
344///
345/// Parses, resolves, and converts mentions in one step.
346///
347/// # Arguments
348/// * `text` - Text containing @mentions
349/// * `message_count` - Current message count for resolution
350///
351/// # Returns
352/// Combined BindingSpec for all mentions in text
353pub fn text_to_bindings(
354    text: &str,
355    message_count: u32,
356) -> Result<BindingSpec, MentionResolutionError> {
357    let mut spec = BindingSpec::default();
358
359    for mention in parse_mentions(text) {
360        let resolved = resolve_mention(&mention, message_count)?;
361        spec.extend(mentions_to_bindings(&resolved));
362    }
363
364    Ok(spec)
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    // ═══════════════════════════════════════════════════════════════════════════
372    // Task 2.1: Module existence tests
373    // ═══════════════════════════════════════════════════════════════════════════
374
375    #[test]
376    fn test_mention_module_exists() {
377        let _: Mention = Mention::Number(1);
378    }
379
380    #[test]
381    fn test_mention_number_stores_value() {
382        let m = Mention::Number(42);
383        if let Mention::Number(n) = m {
384            assert_eq!(n, 42);
385        } else {
386            panic!("Expected Number variant");
387        }
388    }
389
390    #[test]
391    fn test_mention_range_stores_bounds() {
392        let m = Mention::Range { start: 1, end: 5 };
393        if let Mention::Range { start, end } = m {
394            assert_eq!(start, 1);
395            assert_eq!(end, 5);
396        } else {
397            panic!("Expected Range variant");
398        }
399    }
400
401    #[test]
402    fn test_mention_equality() {
403        assert_eq!(Mention::Number(1), Mention::Number(1));
404        assert_ne!(Mention::Number(1), Mention::Number(2));
405        assert_eq!(Mention::Last, Mention::Last);
406        assert_eq!(Mention::All, Mention::All);
407    }
408
409    #[test]
410    fn test_mention_clone() {
411        let m = Mention::Range { start: 1, end: 10 };
412        let cloned = m.clone();
413        assert_eq!(m, cloned);
414    }
415
416    #[test]
417    fn test_mention_serialization() {
418        let m = Mention::Number(5);
419        let json = serde_json::to_string(&m).unwrap();
420        let restored: Mention = serde_json::from_str(&json).unwrap();
421        assert_eq!(m, restored);
422    }
423
424    // ═══════════════════════════════════════════════════════════════════════════
425    // Task 2.2: Display implementation tests
426    // ═══════════════════════════════════════════════════════════════════════════
427
428    #[test]
429    fn test_mention_display_number() {
430        assert_eq!(format!("{}", Mention::Number(1)), "@1");
431        assert_eq!(format!("{}", Mention::Number(42)), "@42");
432        assert_eq!(format!("{}", Mention::Number(999)), "@999");
433    }
434
435    #[test]
436    fn test_mention_display_last() {
437        assert_eq!(format!("{}", Mention::Last), "@last");
438    }
439
440    #[test]
441    fn test_mention_display_all() {
442        assert_eq!(format!("{}", Mention::All), "@all");
443    }
444
445    #[test]
446    fn test_mention_display_range() {
447        assert_eq!(format!("{}", Mention::Range { start: 1, end: 3 }), "@1..3");
448        assert_eq!(
449            format!("{}", Mention::Range { start: 10, end: 20 }),
450            "@10..20"
451        );
452    }
453
454    // ═══════════════════════════════════════════════════════════════════════════
455    // Task 2.3: parse_mentions() tests
456    // ═══════════════════════════════════════════════════════════════════════════
457
458    #[test]
459    fn test_parse_mentions_number() {
460        let text = "Look at @1 and @2";
461        let mentions = parse_mentions(text);
462        assert_eq!(mentions, vec![Mention::Number(1), Mention::Number(2)]);
463    }
464
465    #[test]
466    fn test_parse_mentions_at_start() {
467        let text = "@1 is the first message";
468        let mentions = parse_mentions(text);
469        assert_eq!(mentions, vec![Mention::Number(1)]);
470    }
471
472    #[test]
473    fn test_parse_mentions_last() {
474        let text = "Continue from @last";
475        let mentions = parse_mentions(text);
476        assert_eq!(mentions, vec![Mention::Last]);
477    }
478
479    #[test]
480    fn test_parse_mentions_all() {
481        let text = "Summarize @all";
482        let mentions = parse_mentions(text);
483        assert_eq!(mentions, vec![Mention::All]);
484    }
485
486    #[test]
487    fn test_parse_mentions_range() {
488        let text = "Combine @1..3";
489        let mentions = parse_mentions(text);
490        assert_eq!(mentions, vec![Mention::Range { start: 1, end: 3 }]);
491    }
492
493    #[test]
494    fn test_parse_mentions_mixed() {
495        let text = "Based on @1, @last, and @all";
496        let mentions = parse_mentions(text);
497        assert_eq!(
498            mentions,
499            vec![Mention::Number(1), Mention::Last, Mention::All,]
500        );
501    }
502
503    #[test]
504    fn test_parse_mentions_no_matches() {
505        let text = "No mentions here";
506        let mentions = parse_mentions(text);
507        assert!(mentions.is_empty());
508    }
509
510    #[test]
511    fn test_parse_mentions_after_punctuation() {
512        let text = "See (@1) and [@2]";
513        let mentions = parse_mentions(text);
514        assert_eq!(mentions, vec![Mention::Number(1), Mention::Number(2)]);
515    }
516
517    #[test]
518    fn test_parse_mentions_email_not_matched() {
519        // Emails should NOT be parsed as mentions
520        let text = "Contact user@123.com for help";
521        let mentions = parse_mentions(text);
522        assert!(mentions.is_empty(), "Email should not be parsed as mention");
523    }
524
525    #[test]
526    fn test_parse_mentions_mixed_with_email() {
527        // Standalone @123 should work, but email @456 should not
528        let text = "Check @123 after emailing user@456.com";
529        let mentions = parse_mentions(text);
530        assert_eq!(mentions, vec![Mention::Number(123)]);
531    }
532
533    // ═══════════════════════════════════════════════════════════════════════════
534    // Task 2.4: resolve_mention() for @last
535    // ═══════════════════════════════════════════════════════════════════════════
536
537    #[test]
538    fn test_resolve_last_with_messages() {
539        // @last with 3 messages → @3
540        let result = resolve_mention(&Mention::Last, 3);
541        assert_eq!(result, Ok(ResolvedMention::Single(3)));
542    }
543
544    #[test]
545    fn test_resolve_last_with_one_message() {
546        // @last with 1 message → @1
547        let result = resolve_mention(&Mention::Last, 1);
548        assert_eq!(result, Ok(ResolvedMention::Single(1)));
549    }
550
551    #[test]
552    fn test_resolve_last_with_no_messages() {
553        // @last with 0 messages → error
554        let result = resolve_mention(&Mention::Last, 0);
555        assert_eq!(result, Err(MentionResolutionError::NoMessages));
556    }
557
558    #[test]
559    fn test_resolve_number_valid() {
560        // @2 with 3 messages → @2
561        let result = resolve_mention(&Mention::Number(2), 3);
562        assert_eq!(result, Ok(ResolvedMention::Single(2)));
563    }
564
565    #[test]
566    fn test_resolve_number_first_message() {
567        // @1 with 5 messages → @1
568        let result = resolve_mention(&Mention::Number(1), 5);
569        assert_eq!(result, Ok(ResolvedMention::Single(1)));
570    }
571
572    #[test]
573    fn test_resolve_number_out_of_bounds() {
574        // @5 with 3 messages → error
575        let result = resolve_mention(&Mention::Number(5), 3);
576        assert_eq!(
577            result,
578            Err(MentionResolutionError::MessageNotFound { index: 5, max: 3 })
579        );
580    }
581
582    #[test]
583    fn test_resolve_number_zero() {
584        // @0 is invalid (1-indexed)
585        let result = resolve_mention(&Mention::Number(0), 3);
586        assert_eq!(
587            result,
588            Err(MentionResolutionError::MessageNotFound { index: 0, max: 3 })
589        );
590    }
591
592    // ═══════════════════════════════════════════════════════════════════════════
593    // Task 2.5: resolve_mention() for @all
594    // ═══════════════════════════════════════════════════════════════════════════
595
596    #[test]
597    fn test_resolve_all_with_messages() {
598        // @all with 3 messages → [1, 2, 3]
599        let result = resolve_mention(&Mention::All, 3);
600        assert_eq!(result, Ok(ResolvedMention::Multiple(vec![1, 2, 3])));
601    }
602
603    #[test]
604    fn test_resolve_all_with_one_message() {
605        // @all with 1 message → [1]
606        let result = resolve_mention(&Mention::All, 1);
607        assert_eq!(result, Ok(ResolvedMention::Multiple(vec![1])));
608    }
609
610    #[test]
611    fn test_resolve_all_with_no_messages() {
612        // @all with 0 messages → Empty (not error)
613        let result = resolve_mention(&Mention::All, 0);
614        assert_eq!(result, Ok(ResolvedMention::Empty));
615    }
616
617    // ═══════════════════════════════════════════════════════════════════════════
618    // Task 2.6: resolve_mention() for @N..M range
619    // ═══════════════════════════════════════════════════════════════════════════
620
621    #[test]
622    fn test_resolve_range_valid() {
623        // @1..3 with 5 messages → [1, 2, 3]
624        let result = resolve_mention(&Mention::Range { start: 1, end: 3 }, 5);
625        assert_eq!(result, Ok(ResolvedMention::Multiple(vec![1, 2, 3])));
626    }
627
628    #[test]
629    fn test_resolve_range_single() {
630        // @2..2 with 3 messages → [2]
631        let result = resolve_mention(&Mention::Range { start: 2, end: 2 }, 3);
632        assert_eq!(result, Ok(ResolvedMention::Multiple(vec![2])));
633    }
634
635    #[test]
636    fn test_resolve_range_full() {
637        // @1..3 with 3 messages → [1, 2, 3]
638        let result = resolve_mention(&Mention::Range { start: 1, end: 3 }, 3);
639        assert_eq!(result, Ok(ResolvedMention::Multiple(vec![1, 2, 3])));
640    }
641
642    #[test]
643    fn test_resolve_range_out_of_bounds() {
644        // @1..5 with 3 messages → error
645        let result = resolve_mention(&Mention::Range { start: 1, end: 5 }, 3);
646        assert_eq!(
647            result,
648            Err(MentionResolutionError::MessageNotFound { index: 5, max: 3 })
649        );
650    }
651
652    #[test]
653    fn test_resolve_range_invalid_order() {
654        // @3..1 → error (start > end)
655        let result = resolve_mention(&Mention::Range { start: 3, end: 1 }, 5);
656        assert_eq!(
657            result,
658            Err(MentionResolutionError::InvalidRange { start: 3, end: 1 })
659        );
660    }
661
662    #[test]
663    fn test_resolve_range_zero_start() {
664        // @0..3 → error (0 is invalid)
665        let result = resolve_mention(&Mention::Range { start: 0, end: 3 }, 5);
666        assert_eq!(
667            result,
668            Err(MentionResolutionError::MessageNotFound { index: 0, max: 5 })
669        );
670    }
671
672    #[test]
673    fn test_error_display() {
674        let err = MentionResolutionError::MessageNotFound { index: 5, max: 3 };
675        assert_eq!(format!("{}", err), "Message @5 not found (max: @3)");
676
677        let err = MentionResolutionError::InvalidRange { start: 3, end: 1 };
678        assert_eq!(format!("{}", err), "Invalid range @3..1 (start > end)");
679
680        let err = MentionResolutionError::NoMessages;
681        assert_eq!(format!("{}", err), "No messages to reference");
682    }
683
684    // ═══════════════════════════════════════════════════════════════════════════
685    // Task 2.7: Parallel marker tests
686    // ═══════════════════════════════════════════════════════════════════════════
687
688    #[test]
689    fn test_has_parallel_marker_basic() {
690        assert!(has_parallel_marker("// Independent task"));
691        assert!(has_parallel_marker("//Task"));
692    }
693
694    #[test]
695    fn test_has_parallel_marker_with_whitespace() {
696        assert!(has_parallel_marker("  // Also parallel"));
697        assert!(has_parallel_marker("\t// Tab prefixed"));
698    }
699
700    #[test]
701    fn test_has_parallel_marker_false() {
702        assert!(!has_parallel_marker("Normal message"));
703        assert!(!has_parallel_marker("@1 Reference"));
704        assert!(!has_parallel_marker("/ Single slash"));
705        assert!(!has_parallel_marker(""));
706    }
707
708    #[test]
709    fn test_has_parallel_marker_not_url() {
710        // URLs start with protocol://, not just //
711        assert!(!has_parallel_marker("https://example.com"));
712        assert!(!has_parallel_marker("http://localhost"));
713    }
714
715    #[test]
716    fn test_strip_parallel_marker_basic() {
717        assert_eq!(strip_parallel_marker("// Task"), "Task");
718        assert_eq!(strip_parallel_marker("//Task"), "Task");
719    }
720
721    #[test]
722    fn test_strip_parallel_marker_with_whitespace() {
723        assert_eq!(
724            strip_parallel_marker("  //  Parallel work"),
725            "Parallel work"
726        );
727        assert_eq!(strip_parallel_marker("\t// Tab"), "Tab");
728    }
729
730    #[test]
731    fn test_strip_parallel_marker_no_marker() {
732        assert_eq!(strip_parallel_marker("Normal message"), "Normal message");
733        assert_eq!(strip_parallel_marker("@1 Reference"), "@1 Reference");
734    }
735
736    #[test]
737    fn test_strip_parallel_marker_empty() {
738        assert_eq!(strip_parallel_marker("//"), "");
739        assert_eq!(strip_parallel_marker("//  "), "");
740    }
741
742    // ═══════════════════════════════════════════════════════════════════════════
743    // Task 2.8: mentions_to_bindings() tests
744    // ═══════════════════════════════════════════════════════════════════════════
745
746    #[test]
747    fn test_mentions_to_bindings_single() {
748        let spec = mentions_to_bindings(&ResolvedMention::Single(2));
749        assert_eq!(spec.len(), 1);
750        assert!(spec.contains_key("ref_2"));
751        assert_eq!(spec["ref_2"].path, "msg-002.output");
752    }
753
754    #[test]
755    fn test_mentions_to_bindings_single_large_number() {
756        let spec = mentions_to_bindings(&ResolvedMention::Single(123));
757        assert_eq!(spec.len(), 1);
758        assert!(spec.contains_key("ref_123"));
759        assert_eq!(spec["ref_123"].path, "msg-123.output");
760    }
761
762    #[test]
763    fn test_mentions_to_bindings_multiple() {
764        let spec = mentions_to_bindings(&ResolvedMention::Multiple(vec![1, 2, 3]));
765        assert_eq!(spec.len(), 3);
766        assert_eq!(spec["ref_1"].path, "msg-001.output");
767        assert_eq!(spec["ref_2"].path, "msg-002.output");
768        assert_eq!(spec["ref_3"].path, "msg-003.output");
769    }
770
771    #[test]
772    fn test_mentions_to_bindings_empty() {
773        let spec = mentions_to_bindings(&ResolvedMention::Empty);
774        assert!(spec.is_empty());
775    }
776
777    #[test]
778    fn test_mentions_to_bindings_entry_is_eager() {
779        // Verify bindings are eager (not lazy)
780        let spec = mentions_to_bindings(&ResolvedMention::Single(1));
781        assert!(!spec["ref_1"].lazy);
782        assert!(spec["ref_1"].default.is_none());
783    }
784
785    #[test]
786    fn test_text_to_bindings_simple() {
787        let spec = text_to_bindings("Based on @1", 3).unwrap();
788        assert_eq!(spec.len(), 1);
789        assert!(spec.contains_key("ref_1"));
790    }
791
792    #[test]
793    fn test_text_to_bindings_multiple() {
794        let spec = text_to_bindings("Combine @1 and @2", 3).unwrap();
795        assert_eq!(spec.len(), 2);
796        assert!(spec.contains_key("ref_1"));
797        assert!(spec.contains_key("ref_2"));
798    }
799
800    #[test]
801    fn test_text_to_bindings_with_last() {
802        let spec = text_to_bindings("Continue from @last", 5).unwrap();
803        assert_eq!(spec.len(), 1);
804        assert!(spec.contains_key("ref_5")); // @last resolves to 5
805    }
806
807    #[test]
808    fn test_text_to_bindings_with_range() {
809        let spec = text_to_bindings("Summarize @1..3", 5).unwrap();
810        assert_eq!(spec.len(), 3);
811        assert!(spec.contains_key("ref_1"));
812        assert!(spec.contains_key("ref_2"));
813        assert!(spec.contains_key("ref_3"));
814    }
815
816    #[test]
817    fn test_text_to_bindings_with_all() {
818        let spec = text_to_bindings("Based on @all", 3).unwrap();
819        assert_eq!(spec.len(), 3);
820        assert!(spec.contains_key("ref_1"));
821        assert!(spec.contains_key("ref_2"));
822        assert!(spec.contains_key("ref_3"));
823    }
824
825    #[test]
826    fn test_text_to_bindings_no_mentions() {
827        let spec = text_to_bindings("Just a normal message", 5).unwrap();
828        assert!(spec.is_empty());
829    }
830
831    #[test]
832    fn test_text_to_bindings_error_out_of_bounds() {
833        let result = text_to_bindings("Reference @10", 3);
834        assert!(result.is_err());
835        assert_eq!(
836            result.unwrap_err(),
837            MentionResolutionError::MessageNotFound { index: 10, max: 3 }
838        );
839    }
840
841    #[test]
842    fn test_text_to_bindings_dedup() {
843        // Same reference multiple times should produce one binding
844        let spec = text_to_bindings("See @1 and again @1", 3).unwrap();
845        assert_eq!(spec.len(), 1); // HashMap deduplicates
846        assert!(spec.contains_key("ref_1"));
847    }
848}