use std::collections::HashMap;
use std::fs;
#[derive(Debug, Clone)]
pub struct HelpTopic {
pub id: String,
pub title: String,
pub content: Vec<String>,
pub links: Vec<String>,
}
impl HelpTopic {
pub fn new(id: String, title: String) -> Self {
Self {
id,
title,
content: Vec::new(),
links: Vec::new(),
}
}
pub fn add_line(&mut self, line: String) {
self.content.push(line);
}
pub fn add_link(&mut self, topic_id: String) {
if !self.links.contains(&topic_id) {
self.links.push(topic_id);
}
}
pub fn get_formatted_content(&self) -> Vec<String> {
let mut lines = vec![
format!("═══ {} ═══", self.title),
String::new(),
];
lines.extend(self.content.clone());
if !self.links.is_empty() {
lines.push(String::new());
lines.push("See also:".to_string());
for link in &self.links {
lines.push(format!(" → {}", link));
}
}
lines
}
}
pub struct HelpFile {
path: String,
topics: HashMap<String, HelpTopic>,
default_topic: Option<String>,
}
impl HelpFile {
pub fn new(path: impl AsRef<std::path::Path>) -> std::io::Result<Self> {
let path_ref = path.as_ref();
let mut help_file = Self {
path: path_ref.to_string_lossy().to_string(),
topics: HashMap::new(),
default_topic: None,
};
help_file.load()?;
Ok(help_file)
}
fn load(&mut self) -> std::io::Result<()> {
let content = fs::read_to_string(&self.path)?;
self.parse_markdown(&content);
Ok(())
}
fn parse_markdown(&mut self, content: &str) {
let mut current_topic: Option<HelpTopic> = None;
for line in content.lines() {
if let Some(topic) = self.parse_topic_header(line) {
if let Some(topic) = current_topic.take() {
if self.default_topic.is_none() {
self.default_topic = Some(topic.id.clone());
}
self.topics.insert(topic.id.clone(), topic);
}
current_topic = Some(topic);
} else if let Some(ref mut topic) = current_topic {
if let Some(link_id) = self.parse_link(line) {
topic.add_link(link_id);
}
if !topic.content.is_empty() || !line.trim().is_empty() {
topic.add_line(line.to_string());
}
}
}
if let Some(topic) = current_topic {
if self.default_topic.is_none() {
self.default_topic = Some(topic.id.clone());
}
self.topics.insert(topic.id.clone(), topic);
}
}
fn parse_topic_header(&self, line: &str) -> Option<HelpTopic> {
let trimmed = line.trim();
if !trimmed.starts_with('#') {
return None;
}
if let Some(start) = trimmed.find("{#") {
if let Some(end) = trimmed[start..].find('}') {
let id = trimmed[start + 2..start + end].to_string();
let title = trimmed[1..start].trim().to_string();
return Some(HelpTopic::new(id, title));
}
}
None
}
fn parse_link(&self, line: &str) -> Option<String> {
if let Some(start) = line.find("](#") {
if let Some(end) = line[start..].find(')') {
let id = line[start + 3..start + end].to_string();
return Some(id);
}
}
None
}
pub fn get_topic(&self, id: &str) -> Option<&HelpTopic> {
self.topics.get(id)
}
pub fn get_default_topic(&self) -> Option<&HelpTopic> {
if let Some(ref id) = self.default_topic {
self.get_topic(id)
} else {
None
}
}
pub fn get_topic_ids(&self) -> Vec<String> {
let mut ids: Vec<String> = self.topics.keys().cloned().collect();
ids.sort();
ids
}
pub fn has_topic(&self, id: &str) -> bool {
self.topics.contains_key(id)
}
pub fn path(&self) -> &str {
&self.path
}
pub fn reload(&mut self) -> std::io::Result<()> {
self.topics.clear();
self.default_topic = None;
self.load()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn create_test_help_file() -> NamedTempFile {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "# Introduction {{#intro}}").unwrap();
writeln!(file, "").unwrap();
writeln!(file, "Welcome to the help system!").unwrap();
writeln!(file, "").unwrap();
writeln!(file, "For more information, see [File Menu](#file-menu).").unwrap();
writeln!(file, "").unwrap();
writeln!(file, "# File Menu {{#file-menu}}").unwrap();
writeln!(file, "").unwrap();
writeln!(file, "The File menu contains:").unwrap();
writeln!(file, "- Open: Open a file").unwrap();
writeln!(file, "- Save: Save the file").unwrap();
writeln!(file, "").unwrap();
writeln!(file, "See also [Edit Menu](#edit-menu).").unwrap();
writeln!(file, "").unwrap();
writeln!(file, "# Edit Menu {{#edit-menu}}").unwrap();
writeln!(file, "").unwrap();
writeln!(file, "The Edit menu contains:").unwrap();
writeln!(file, "- Copy: Copy text").unwrap();
writeln!(file, "- Paste: Paste text").unwrap();
file.flush().unwrap();
file
}
#[test]
fn test_help_file_load() {
let file = create_test_help_file();
let help = HelpFile::new(file.path().to_str().unwrap()).unwrap();
assert_eq!(help.get_topic_ids().len(), 3);
assert!(help.has_topic("intro"));
assert!(help.has_topic("file-menu"));
assert!(help.has_topic("edit-menu"));
}
#[test]
fn test_help_topic_content() {
let file = create_test_help_file();
let help = HelpFile::new(file.path().to_str().unwrap()).unwrap();
let topic = help.get_topic("intro").unwrap();
assert_eq!(topic.title, "Introduction");
assert!(topic.content.len() > 0);
assert_eq!(topic.links.len(), 1);
assert_eq!(topic.links[0], "file-menu");
}
#[test]
fn test_default_topic() {
let file = create_test_help_file();
let help = HelpFile::new(file.path().to_str().unwrap()).unwrap();
let default = help.get_default_topic().unwrap();
assert_eq!(default.id, "intro");
}
#[test]
fn test_formatted_content() {
let file = create_test_help_file();
let help = HelpFile::new(file.path().to_str().unwrap()).unwrap();
let topic = help.get_topic("file-menu").unwrap();
let formatted = topic.get_formatted_content();
assert!(formatted[0].contains("File Menu"));
assert!(formatted.iter().any(|line| line.contains("See also:")));
}
#[test]
fn test_cross_references() {
let file = create_test_help_file();
let help = HelpFile::new(file.path().to_str().unwrap()).unwrap();
let file_menu = help.get_topic("file-menu").unwrap();
assert_eq!(file_menu.links.len(), 1);
assert_eq!(file_menu.links[0], "edit-menu");
}
#[test]
fn test_reload() {
let file = create_test_help_file();
let path = file.path().to_str().unwrap().to_string();
let mut help = HelpFile::new(&path).unwrap();
assert_eq!(help.get_topic_ids().len(), 3);
help.reload().unwrap();
assert_eq!(help.get_topic_ids().len(), 3);
}
}