Skip to main content

turbo_vision/views/
help_file.rs

1// (C) 2025 - Enzo Lombardi
2
3//! `HelpFile` - markdown-based help content loader and parser.
4// HelpFile - Markdown-based help file manager
5//
6// Modern alternative to Borland's THelpFile (binary format)
7//
8// Uses markdown files for help content with topic markers:
9// # Topic Name {#topic-id}
10//
11// This provides a maintainable, human-readable alternative to
12// Borland's proprietary binary TPH format.
13
14use std::collections::HashMap;
15use std::fs;
16
17/// Styled text segment for rich text rendering
18#[derive(Debug, Clone, PartialEq)]
19pub enum TextSegment {
20    /// Normal unstyled text
21    Normal(String),
22    /// Bold text (from **text**)
23    Bold(String),
24    /// Italic text (from *text*)
25    Italic(String),
26    /// Inline code (from `text`)
27    Code(String),
28    /// Hyperlink (from [text](#target))
29    Link { text: String, target: String },
30}
31
32impl TextSegment {
33    /// Get the display text for this segment
34    pub fn text(&self) -> &str {
35        match self {
36            TextSegment::Normal(s) => s,
37            TextSegment::Bold(s) => s,
38            TextSegment::Italic(s) => s,
39            TextSegment::Code(s) => s,
40            TextSegment::Link { text, .. } => text,
41        }
42    }
43
44    /// Display width of this segment in terminal cells, measured the way
45    /// `DrawBuffer::move_str` draws it: wide characters take two cells,
46    /// zero-width ones none. Not the byte length, so a `→` or an `é` counts
47    /// once.
48    pub fn width(&self) -> usize {
49        display_width(self.text())
50    }
51
52    /// Get the display length of this segment, in cells; same as
53    /// [`width`](Self::width).
54    pub fn len(&self) -> usize {
55        self.width()
56    }
57
58    /// Check if this segment is empty
59    pub fn is_empty(&self) -> bool {
60        self.text().is_empty()
61    }
62}
63
64/// Width of `s` in terminal cells, one per character except wide (two) and
65/// zero-width (none) ones; matches what `DrawBuffer::move_str` occupies.
66pub(crate) fn display_width(s: &str) -> usize {
67    use unicode_width::UnicodeWidthChar;
68    s.chars().map(|c| c.width().unwrap_or(0)).sum()
69}
70
71/// Prefix drawn before each "See also" entry.
72const SEE_ALSO_PREFIX: &str = "  \u{2192} "; // "  → "
73
74/// Clamp a width to a cross-ref's `u8` length.
75fn ref_len(width: usize) -> u8 {
76    width.min(u8::MAX as usize) as u8
77}
78
79/// Cross-reference link within help content
80/// Matches Borland: `TCrossRef` (helpbase.h)
81#[derive(Debug, Clone)]
82pub struct CrossRef {
83    /// Line number (1-based, matching Borland convention)
84    pub line: i16,
85    /// Column offset within line (0-based)
86    pub offset: i16,
87    /// Length of the link text
88    pub length: u8,
89    /// Target topic ID to navigate to
90    pub target: String,
91}
92
93impl CrossRef {
94    /// Create a new cross-reference
95    pub fn new(line: i16, offset: i16, length: u8, target: String) -> Self {
96        Self {
97            line,
98            offset,
99            length,
100            target,
101        }
102    }
103}
104
105/// Help topic containing title and content
106#[derive(Debug, Clone)]
107pub struct HelpTopic {
108    /// Topic ID (e.g., "file-open", "edit-copy")
109    pub id: String,
110    /// Topic title (from markdown heading)
111    pub title: String,
112    /// Topic content as lines of text
113    pub content: Vec<String>,
114    /// Cross-references to other topics
115    pub links: Vec<String>,
116    /// Titles of the linked topics, by topic id, so "See also" can show
117    /// "Edit Menu" rather than "edit". Filled by [`HelpFile`] once every topic
118    /// is parsed; a link with no entry shows its id.
119    pub link_titles: HashMap<String, String>,
120}
121
122impl HelpTopic {
123    /// Create a new help topic
124    pub fn new(id: String, title: String) -> Self {
125        Self {
126            id,
127            title,
128            content: Vec::new(),
129            links: Vec::new(),
130            link_titles: HashMap::new(),
131        }
132    }
133
134    /// Add a line to the topic content
135    pub fn add_line(&mut self, line: String) {
136        self.content.push(line);
137    }
138
139    /// Add a cross-reference link
140    pub fn add_link(&mut self, topic_id: String) {
141        if !self.links.contains(&topic_id) {
142            self.links.push(topic_id);
143        }
144    }
145
146    /// What a "See also" entry for `id` shows: the linked topic's title, or
147    /// the id when that topic is unknown.
148    pub fn link_label<'a>(&'a self, id: &'a str) -> &'a str {
149        self.link_titles.get(id).map_or(id, String::as_str)
150    }
151
152    /// Get formatted content with line numbers for display
153    pub fn get_formatted_content(&self) -> Vec<String> {
154        let mut lines = vec![format!("═══ {} ═══", self.title), String::new()];
155        lines.extend(self.content.clone());
156
157        if !self.links.is_empty() {
158            lines.push(String::new());
159            lines.push("See also:".to_string());
160            for link in &self.links {
161                lines.push(format!("{SEE_ALSO_PREFIX}{}", self.link_label(link)));
162            }
163        }
164
165        lines
166    }
167
168    /// Get formatted content with cross-references extracted
169    /// Returns (`formatted_lines`, `cross_refs`)
170    /// Cross-refs have line numbers relative to the formatted output (1-based)
171    pub fn get_content_with_refs(&self) -> (Vec<String>, Vec<CrossRef>) {
172        let mut lines = Vec::new();
173        let mut refs = Vec::new();
174
175        // Header line
176        lines.push(format!("═══ {} ═══", self.title));
177        lines.push(String::new());
178
179        // Process content lines, extracting links
180        for content_line in &self.content {
181            let (processed_line, line_refs) =
182                Self::process_line_links(content_line, (lines.len() + 1) as i16);
183            lines.push(processed_line);
184            refs.extend(line_refs);
185        }
186
187        // "See also" section with clickable links
188        if !self.links.is_empty() {
189            lines.push(String::new());
190            lines.push("See also:".to_string());
191            let offset = display_width(SEE_ALSO_PREFIX) as i16;
192            for link in &self.links {
193                let line_num = (lines.len() + 1) as i16;
194                let label = self.link_label(link);
195                refs.push(CrossRef::new(
196                    line_num,
197                    offset,
198                    ref_len(display_width(label)),
199                    link.clone(),
200                ));
201                lines.push(format!("{SEE_ALSO_PREFIX}{label}"));
202            }
203        }
204
205        (lines, refs)
206    }
207
208    /// Process a single line, extracting markdown links [text](#target)
209    /// Returns the display text (with link markers removed) and any cross-refs found
210    fn process_line_links(line: &str, line_num: i16) -> (String, Vec<CrossRef>) {
211        let mut result = String::new();
212        let mut refs = Vec::new();
213        let mut remaining = line;
214
215        while let Some(link_start) = remaining.find('[') {
216            // Add text before the link
217            result.push_str(&remaining[..link_start]);
218
219            // Find the closing bracket and target
220            let after_bracket = &remaining[link_start + 1..];
221            if let Some(text_end) = after_bracket.find("](#") {
222                let link_text = &after_bracket[..text_end];
223                let after_target_start = &after_bracket[text_end + 3..];
224
225                if let Some(target_end) = after_target_start.find(')') {
226                    let target = &after_target_start[..target_end];
227
228                    // Record the cross-reference at current position
229                    let offset = display_width(&result) as i16;
230                    refs.push(CrossRef::new(
231                        line_num,
232                        offset,
233                        ref_len(display_width(link_text)),
234                        target.to_string(),
235                    ));
236
237                    // Add the link text (displayed without markdown syntax)
238                    result.push_str(link_text);
239
240                    // Continue after the link
241                    remaining = &after_target_start[target_end + 1..];
242                    continue;
243                }
244            }
245
246            // Not a valid link, keep the bracket and continue
247            result.push('[');
248            remaining = after_bracket;
249        }
250
251        // Add any remaining text
252        result.push_str(remaining);
253
254        (result, refs)
255    }
256
257    /// Get the number of cross-references in this topic
258    pub fn num_cross_refs(&self) -> usize {
259        let (_, refs) = self.get_content_with_refs();
260        refs.len()
261    }
262
263    /// Parse a line into styled text segments
264    /// Handles: **bold**, *italic*, `code`, and [link](#target)
265    pub fn parse_line_segments(line: &str) -> Vec<TextSegment> {
266        let mut segments = Vec::new();
267        let mut remaining = line;
268        let mut current_text = String::new();
269
270        while !remaining.is_empty() {
271            // Check for bold (**text**)
272            if remaining.starts_with("**") {
273                // Flush any accumulated normal text
274                if !current_text.is_empty() {
275                    segments.push(TextSegment::Normal(std::mem::take(&mut current_text)));
276                }
277
278                if let Some(end) = remaining[2..].find("**") {
279                    let bold_text = &remaining[2..2 + end];
280                    segments.push(TextSegment::Bold(bold_text.to_string()));
281                    remaining = &remaining[2 + end + 2..];
282                    continue;
283                }
284            }
285
286            // Check for code (`text`)
287            if remaining.starts_with('`') {
288                // Flush any accumulated normal text
289                if !current_text.is_empty() {
290                    segments.push(TextSegment::Normal(std::mem::take(&mut current_text)));
291                }
292
293                if let Some(end) = remaining[1..].find('`') {
294                    let code_text = &remaining[1..=end];
295                    segments.push(TextSegment::Code(code_text.to_string()));
296                    remaining = &remaining[1 + end + 1..];
297                    continue;
298                }
299            }
300
301            // Check for link ([text](#target))
302            if remaining.starts_with('[') {
303                // Flush any accumulated normal text
304                if !current_text.is_empty() {
305                    segments.push(TextSegment::Normal(std::mem::take(&mut current_text)));
306                }
307
308                if let Some(text_end) = remaining[1..].find("](#") {
309                    let link_text = &remaining[1..=text_end];
310                    let after_target = &remaining[1 + text_end + 3..];
311
312                    if let Some(target_end) = after_target.find(')') {
313                        let target = &after_target[..target_end];
314                        segments.push(TextSegment::Link {
315                            text: link_text.to_string(),
316                            target: target.to_string(),
317                        });
318                        remaining = &after_target[target_end + 1..];
319                        continue;
320                    }
321                }
322            }
323
324            // Check for italic (*text*) - but not if it's **
325            if remaining.starts_with('*') && !remaining.starts_with("**") {
326                // Flush any accumulated normal text
327                if !current_text.is_empty() {
328                    segments.push(TextSegment::Normal(std::mem::take(&mut current_text)));
329                }
330
331                // Find closing * that isn't **
332                let search = &remaining[1..];
333                let mut found_end = None;
334                let mut pos = 0;
335                while pos < search.len() {
336                    if search[pos..].starts_with('*') && !search[pos..].starts_with("**") {
337                        found_end = Some(pos);
338                        break;
339                    }
340                    pos += 1;
341                }
342
343                if let Some(end) = found_end {
344                    let italic_text = &remaining[1..=end];
345                    segments.push(TextSegment::Italic(italic_text.to_string()));
346                    remaining = &remaining[1 + end + 1..];
347                    continue;
348                }
349            }
350
351            // Not a special marker, accumulate as normal text
352            current_text.push(remaining.chars().next().unwrap());
353            remaining = &remaining[remaining.chars().next().unwrap().len_utf8()..];
354        }
355
356        // Flush any remaining normal text
357        if !current_text.is_empty() {
358            segments.push(TextSegment::Normal(current_text));
359        }
360
361        // Coalesce empty result to single empty Normal
362        if segments.is_empty() {
363            segments.push(TextSegment::Normal(String::new()));
364        }
365
366        segments
367    }
368
369    /// Get content as lines of styled segments for rich text rendering
370    /// Returns (`line_segments`, `cross_refs`) where each line is a `Vec<TextSegment>`
371    pub fn get_styled_content(&self) -> (Vec<Vec<TextSegment>>, Vec<CrossRef>) {
372        let mut all_segments = Vec::new();
373        let mut refs = Vec::new();
374
375        // Header line (just normal text for now)
376        all_segments.push(vec![TextSegment::Normal(format!("═══ {} ═══", self.title))]);
377        all_segments.push(vec![TextSegment::Normal(String::new())]);
378
379        // Process content lines
380        for content_line in &self.content {
381            let segments = Self::parse_line_segments(content_line);
382
383            // Track cross-refs from links
384            let line_num = (all_segments.len() + 1) as i16;
385            let mut offset = 0i16;
386            for seg in &segments {
387                if let TextSegment::Link { text, target } = seg {
388                    refs.push(CrossRef::new(
389                        line_num,
390                        offset,
391                        ref_len(display_width(text)),
392                        target.clone(),
393                    ));
394                }
395                offset += seg.width() as i16;
396            }
397
398            all_segments.push(segments);
399        }
400
401        // "See also" section
402        if !self.links.is_empty() {
403            all_segments.push(vec![TextSegment::Normal(String::new())]);
404            all_segments.push(vec![TextSegment::Normal("See also:".to_string())]);
405            // The link starts where the prefix ends, in cells.
406            let offset = display_width(SEE_ALSO_PREFIX) as i16;
407            for link in &self.links {
408                let line_num = (all_segments.len() + 1) as i16;
409                let label = self.link_label(link).to_string();
410                refs.push(CrossRef::new(
411                    line_num,
412                    offset,
413                    ref_len(display_width(&label)),
414                    link.clone(),
415                ));
416                all_segments.push(vec![
417                    TextSegment::Normal(SEE_ALSO_PREFIX.to_string()),
418                    TextSegment::Link {
419                        text: label,
420                        target: link.clone(),
421                    },
422                ]);
423            }
424        }
425
426        (all_segments, refs)
427    }
428}
429
430/// `HelpFile` - Manages markdown help files
431///
432/// Modern alternative to Borland's `THelpFile`
433pub struct HelpFile {
434    /// File path
435    path: String,
436    /// Topics indexed by ID
437    topics: HashMap<String, HelpTopic>,
438    /// Default topic to show
439    default_topic: Option<String>,
440}
441
442impl HelpFile {
443    /// Create a new help file from a markdown file
444    pub fn new(path: impl AsRef<std::path::Path>) -> std::io::Result<Self> {
445        let path_ref = path.as_ref();
446        let mut help_file = Self {
447            path: path_ref.to_string_lossy().to_string(),
448            topics: HashMap::new(),
449            default_topic: None,
450        };
451
452        help_file.load()?;
453        Ok(help_file)
454    }
455
456    /// Create a help file from an in-memory markdown string
457    pub fn from_content(content: &str) -> Self {
458        let mut help_file = Self {
459            path: String::new(),
460            topics: HashMap::new(),
461            default_topic: None,
462        };
463        help_file.parse_markdown(content);
464        help_file
465    }
466
467    /// Load and parse markdown file
468    fn load(&mut self) -> std::io::Result<()> {
469        let content = fs::read_to_string(&self.path)?;
470        self.parse_markdown(&content);
471        Ok(())
472    }
473
474    /// Parse markdown content into topics
475    fn parse_markdown(&mut self, content: &str) {
476        let mut current_topic: Option<HelpTopic> = None;
477
478        for line in content.lines() {
479            // Check for topic header: # Title {#topic-id}
480            if let Some(topic) = Self::parse_topic_header(line) {
481                // Save previous topic if exists
482                if let Some(topic) = current_topic.take() {
483                    if self.default_topic.is_none() {
484                        self.default_topic = Some(topic.id.clone());
485                    }
486                    self.topics.insert(topic.id.clone(), topic);
487                }
488                current_topic = Some(topic);
489            } else if let Some(ref mut topic) = current_topic {
490                // Record every cross-reference on the line: [Link](#topic-id)
491                for link_id in Self::parse_links(line) {
492                    topic.add_link(link_id);
493                }
494
495                // Add line to current topic (skip empty first line)
496                if !topic.content.is_empty() || !line.trim().is_empty() {
497                    topic.add_line(line.to_string());
498                }
499            }
500        }
501
502        // Save last topic
503        if let Some(topic) = current_topic {
504            if self.default_topic.is_none() {
505                self.default_topic = Some(topic.id.clone());
506            }
507            self.topics.insert(topic.id.clone(), topic);
508        }
509
510        self.resolve_link_titles();
511    }
512
513    /// Record, in every topic, the titles of the topics it links to. Runs
514    /// after parsing, since a link may point at a topic further down.
515    fn resolve_link_titles(&mut self) {
516        let titles: HashMap<String, String> = self
517            .topics
518            .values()
519            .map(|t| (t.id.clone(), t.title.clone()))
520            .collect();
521        for topic in self.topics.values_mut() {
522            topic.link_titles = topic
523                .links
524                .iter()
525                .filter_map(|id| titles.get(id).map(|title| (id.clone(), title.clone())))
526                .collect();
527        }
528    }
529
530    /// Parse topic header: # Title {#topic-id}
531    fn parse_topic_header(line: &str) -> Option<HelpTopic> {
532        let trimmed = line.trim();
533        if !trimmed.starts_with('#') {
534            return None;
535        }
536
537        // Extract topic ID from {#id}
538        if let Some(start) = trimmed.find("{#") {
539            if let Some(end) = trimmed[start..].find('}') {
540                let id = trimmed[start + 2..start + end].to_string();
541                let title = trimmed[1..start].trim().to_string();
542                return Some(HelpTopic::new(id, title));
543            }
544        }
545
546        None
547    }
548
549    /// The targets of the cross-reference links `[Text](#topic-id)` on a
550    /// line, in order. Found the way the inline links are drawn, so the
551    /// "See also" list names exactly the links the text shows; repeats are
552    /// left for `add_link` to drop.
553    fn parse_links(line: &str) -> impl Iterator<Item = String> {
554        HelpTopic::process_line_links(line, 0)
555            .1
556            .into_iter()
557            .map(|r| r.target)
558    }
559
560    /// Get a topic by ID
561    pub fn get_topic(&self, id: &str) -> Option<&HelpTopic> {
562        self.topics.get(id)
563    }
564
565    /// Get the default topic
566    pub fn get_default_topic(&self) -> Option<&HelpTopic> {
567        if let Some(ref id) = self.default_topic {
568            self.get_topic(id)
569        } else {
570            None
571        }
572    }
573
574    /// Get all topic IDs
575    pub fn get_topic_ids(&self) -> Vec<String> {
576        let mut ids: Vec<String> = self.topics.keys().cloned().collect();
577        ids.sort();
578        ids
579    }
580
581    /// Check if a topic exists
582    pub fn has_topic(&self, id: &str) -> bool {
583        self.topics.contains_key(id)
584    }
585
586    /// Get the file path
587    pub fn path(&self) -> &str {
588        &self.path
589    }
590
591    /// Reload the help file from disk
592    pub fn reload(&mut self) -> std::io::Result<()> {
593        self.topics.clear();
594        self.default_topic = None;
595        self.load()
596    }
597}
598
599/// Builder for creating help files with a fluent API.
600pub struct HelpFileBuilder {
601    path: Option<String>,
602}
603
604impl HelpFileBuilder {
605    pub fn new() -> Self {
606        Self { path: None }
607    }
608
609    #[must_use]
610    pub fn path(mut self, path: impl Into<String>) -> Self {
611        self.path = Some(path.into());
612        self
613    }
614
615    pub fn build(self) -> std::io::Result<HelpFile> {
616        let path = self.path.expect("HelpFile path must be set");
617        HelpFile::new(&path)
618    }
619
620    pub fn build_rc(self) -> std::io::Result<std::rc::Rc<std::cell::RefCell<HelpFile>>> {
621        Ok(std::rc::Rc::new(std::cell::RefCell::new(self.build()?)))
622    }
623}
624
625impl Default for HelpFileBuilder {
626    fn default() -> Self {
627        Self::new()
628    }
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634    use std::io::Write;
635    use tempfile::NamedTempFile;
636
637    fn create_test_help_file() -> NamedTempFile {
638        let mut file = NamedTempFile::new().unwrap();
639        writeln!(file, "# Introduction {{#intro}}").unwrap();
640        writeln!(file).unwrap();
641        writeln!(file, "Welcome to the help system!").unwrap();
642        writeln!(file).unwrap();
643        writeln!(file, "For more information, see [File Menu](#file-menu).").unwrap();
644        writeln!(file).unwrap();
645        writeln!(file, "# File Menu {{#file-menu}}").unwrap();
646        writeln!(file).unwrap();
647        writeln!(file, "The File menu contains:").unwrap();
648        writeln!(file, "- Open: Open a file").unwrap();
649        writeln!(file, "- Save: Save the file").unwrap();
650        writeln!(file).unwrap();
651        writeln!(file, "See also [Edit Menu](#edit-menu).").unwrap();
652        writeln!(file).unwrap();
653        writeln!(file, "# Edit Menu {{#edit-menu}}").unwrap();
654        writeln!(file).unwrap();
655        writeln!(file, "The Edit menu contains:").unwrap();
656        writeln!(file, "- Copy: Copy text").unwrap();
657        writeln!(file, "- Paste: Paste text").unwrap();
658        file.flush().unwrap();
659        file
660    }
661
662    #[test]
663    fn every_link_on_a_line_is_recorded() {
664        let help = HelpFile::from_content(
665            "# Two {#two}\n\nSee [A](#a) and [B](#b).\n\n\
666             # Brackets {#brackets}\n\nNotes ([C](#c)) and [D](#d)], then [C](#c) again.\n",
667        );
668        assert_eq!(help.get_topic("two").unwrap().links, ["a", "b"]);
669        // A `)` or `]` right after a link ends it; it is not part of the id,
670        // and a repeated link is recorded once.
671        assert_eq!(help.get_topic("brackets").unwrap().links, ["c", "d"]);
672    }
673
674    #[test]
675    fn test_help_file_load() {
676        let file = create_test_help_file();
677        let help = HelpFile::new(file.path().to_str().unwrap()).unwrap();
678
679        assert_eq!(help.get_topic_ids().len(), 3);
680        assert!(help.has_topic("intro"));
681        assert!(help.has_topic("file-menu"));
682        assert!(help.has_topic("edit-menu"));
683    }
684
685    #[test]
686    fn test_help_topic_content() {
687        let file = create_test_help_file();
688        let help = HelpFile::new(file.path().to_str().unwrap()).unwrap();
689
690        let topic = help.get_topic("intro").unwrap();
691        assert_eq!(topic.title, "Introduction");
692        assert!(!topic.content.is_empty());
693        assert_eq!(topic.links.len(), 1);
694        assert_eq!(topic.links[0], "file-menu");
695    }
696
697    #[test]
698    fn test_default_topic() {
699        let file = create_test_help_file();
700        let help = HelpFile::new(file.path().to_str().unwrap()).unwrap();
701
702        let default = help.get_default_topic().unwrap();
703        assert_eq!(default.id, "intro");
704    }
705
706    #[test]
707    fn test_formatted_content() {
708        let file = create_test_help_file();
709        let help = HelpFile::new(file.path().to_str().unwrap()).unwrap();
710
711        let topic = help.get_topic("file-menu").unwrap();
712        let formatted = topic.get_formatted_content();
713
714        assert!(formatted[0].contains("File Menu"));
715        assert!(formatted.iter().any(|line| line.contains("See also:")));
716    }
717
718    #[test]
719    fn test_cross_references() {
720        let file = create_test_help_file();
721        let help = HelpFile::new(file.path().to_str().unwrap()).unwrap();
722
723        let file_menu = help.get_topic("file-menu").unwrap();
724        assert_eq!(file_menu.links.len(), 1);
725        assert_eq!(file_menu.links[0], "edit-menu");
726    }
727
728    #[test]
729    fn test_get_content_with_refs() {
730        let file = create_test_help_file();
731        let help = HelpFile::new(file.path().to_str().unwrap()).unwrap();
732
733        let intro = help.get_topic("intro").unwrap();
734        let (lines, refs) = intro.get_content_with_refs();
735
736        // Should have header, empty line, content lines, and see also section
737        assert!(lines.len() >= 4);
738        assert!(lines[0].contains("Introduction"));
739
740        // Should have at least one cross-reference (from inline link and see also)
741        assert!(!refs.is_empty());
742
743        // Check that inline link "[File Menu](#file-menu)" was processed
744        // The link should appear in the content without markdown syntax
745        let has_inline_ref = refs.iter().any(|r| r.target == "file-menu");
746        assert!(has_inline_ref, "Should have file-menu cross-ref");
747
748        // Check that cross-ref has valid position
749        let file_menu_ref = refs.iter().find(|r| r.target == "file-menu").unwrap();
750        assert!(file_menu_ref.line > 0, "Line should be positive (1-based)");
751        assert!(file_menu_ref.length > 0, "Length should be positive");
752    }
753
754    #[test]
755    fn test_process_line_links() {
756        // Test the link parsing function directly
757        let line = "See [File Menu](#file-menu) and [Edit](#edit) for details.";
758        let (result, refs) = HelpTopic::process_line_links(line, 5);
759
760        // The result should have the markdown stripped
761        assert_eq!(result, "See File Menu and Edit for details.");
762
763        // Should have two cross-references
764        assert_eq!(refs.len(), 2);
765
766        // First ref: "File Menu" at position 4
767        assert_eq!(refs[0].target, "file-menu");
768        assert_eq!(refs[0].offset, 4);
769        assert_eq!(refs[0].length, 9); // "File Menu"
770        assert_eq!(refs[0].line, 5);
771
772        // Second ref: "Edit" at position 18
773        assert_eq!(refs[1].target, "edit");
774        assert_eq!(refs[1].offset, 18);
775        assert_eq!(refs[1].length, 4); // "Edit"
776    }
777
778    #[test]
779    fn test_reload() {
780        let file = create_test_help_file();
781        let path = file.path().to_str().unwrap().to_string();
782        let mut help = HelpFile::new(&path).unwrap();
783
784        assert_eq!(help.get_topic_ids().len(), 3);
785
786        // Reload should work
787        help.reload().unwrap();
788        assert_eq!(help.get_topic_ids().len(), 3);
789    }
790
791    #[test]
792    fn test_parse_line_segments_bold() {
793        let segments = HelpTopic::parse_line_segments("Press **F1** for help");
794        assert_eq!(segments.len(), 3);
795        assert_eq!(segments[0], TextSegment::Normal("Press ".to_string()));
796        assert_eq!(segments[1], TextSegment::Bold("F1".to_string()));
797        assert_eq!(segments[2], TextSegment::Normal(" for help".to_string()));
798    }
799
800    #[test]
801    fn test_parse_line_segments_italic() {
802        let segments = HelpTopic::parse_line_segments("Choose *File > Open* from menu");
803        assert_eq!(segments.len(), 3);
804        assert_eq!(segments[0], TextSegment::Normal("Choose ".to_string()));
805        assert_eq!(segments[1], TextSegment::Italic("File > Open".to_string()));
806        assert_eq!(segments[2], TextSegment::Normal(" from menu".to_string()));
807    }
808
809    #[test]
810    fn test_parse_line_segments_code() {
811        let segments = HelpTopic::parse_line_segments("Use `Ctrl+C` to copy");
812        assert_eq!(segments.len(), 3);
813        assert_eq!(segments[0], TextSegment::Normal("Use ".to_string()));
814        assert_eq!(segments[1], TextSegment::Code("Ctrl+C".to_string()));
815        assert_eq!(segments[2], TextSegment::Normal(" to copy".to_string()));
816    }
817
818    #[test]
819    fn test_parse_line_segments_link() {
820        let segments = HelpTopic::parse_line_segments("See [File Menu](#file-menu) for details");
821        assert_eq!(segments.len(), 3);
822        assert_eq!(segments[0], TextSegment::Normal("See ".to_string()));
823        assert_eq!(
824            segments[1],
825            TextSegment::Link {
826                text: "File Menu".to_string(),
827                target: "file-menu".to_string(),
828            }
829        );
830        assert_eq!(segments[2], TextSegment::Normal(" for details".to_string()));
831    }
832
833    #[test]
834    fn test_parse_line_segments_mixed() {
835        let segments = HelpTopic::parse_line_segments("Press **F1** or `?` for [Help](#help)");
836        assert_eq!(segments.len(), 6);
837        assert_eq!(segments[0], TextSegment::Normal("Press ".to_string()));
838        assert_eq!(segments[1], TextSegment::Bold("F1".to_string()));
839        assert_eq!(segments[2], TextSegment::Normal(" or ".to_string()));
840        assert_eq!(segments[3], TextSegment::Code("?".to_string()));
841        assert_eq!(segments[4], TextSegment::Normal(" for ".to_string()));
842        assert_eq!(
843            segments[5],
844            TextSegment::Link {
845                text: "Help".to_string(),
846                target: "help".to_string(),
847            }
848        );
849    }
850
851    #[test]
852    fn test_parse_line_segments_plain() {
853        let segments = HelpTopic::parse_line_segments("Just plain text");
854        assert_eq!(segments.len(), 1);
855        assert_eq!(
856            segments[0],
857            TextSegment::Normal("Just plain text".to_string())
858        );
859    }
860
861    #[test]
862    fn see_also_shows_the_target_title_and_falls_back_to_the_id() {
863        let help = HelpFile::from_content(
864            "# Intro {#intro}\n\nSee [Edit](#edit).\nAnd [Gone](#missing).\n\n# Edit Menu {#edit}\n\nText.\n",
865        );
866        let intro = help.get_topic("intro").unwrap();
867        assert_eq!(intro.links, vec!["edit".to_string(), "missing".to_string()]);
868        let (lines, refs) = intro.get_styled_content();
869        let n = lines.len();
870        assert_eq!(
871            lines[n - 2][1],
872            TextSegment::Link {
873                text: "Edit Menu".to_string(),
874                target: "edit".to_string(),
875            },
876            "the linked topic's title"
877        );
878        assert_eq!(
879            lines[n - 1][1],
880            TextSegment::Link {
881                text: "missing".to_string(),
882                target: "missing".to_string(),
883            },
884            "an unknown topic shows its id"
885        );
886        let last = refs.last().unwrap();
887        assert_eq!((last.target.as_str(), last.length), ("missing", 7));
888        let edit = &refs[refs.len() - 2];
889        assert_eq!((edit.target.as_str(), edit.length), ("edit", 9));
890    }
891
892    #[test]
893    fn test_parse_line_segments_empty() {
894        let segments = HelpTopic::parse_line_segments("");
895        assert_eq!(segments.len(), 1);
896        assert_eq!(segments[0], TextSegment::Normal(String::new()));
897    }
898}