1use std::fs;
18use std::io;
19use std::path::{Path, PathBuf};
20
21#[derive(Debug, Clone, PartialEq)]
23pub struct HelpEntry {
24 pub title: String,
26 pub content: String,
28}
29
30#[derive(Debug, Clone, PartialEq)]
32pub struct HelpResponse {
33 pub entries: Vec<HelpEntry>,
35}
36
37pub fn query_help(topic: Option<&str>) -> io::Result<HelpResponse> {
48 let files = discover_files(&["comms/docs", "comms/specs"])?;
49 let matches = match topic {
50 Some(keyword) => filter_by_topic(&files, keyword),
51 None => default_entries(&files),
52 };
53 let mut entries = Vec::new();
54 for path in matches {
55 if let Ok(content) = fs::read_to_string(&path) {
56 entries.push(HelpEntry {
57 title: display_title(&path),
58 content,
59 });
60 }
61 }
62 Ok(HelpResponse { entries })
63}
64
65fn discover_files(roots: &[&str]) -> io::Result<Vec<PathBuf>> {
66 let base = Path::new(env!("CARGO_MANIFEST_DIR"))
67 .parent()
68 .and_then(|p| p.parent())
69 .unwrap_or_else(|| Path::new("."));
70 let mut files = Vec::new();
71 for root in roots {
72 let path = base.join(root);
73 if path.exists() {
74 collect_files(&path, &mut files)?;
75 }
76 }
77 Ok(files)
78}
79
80fn collect_files(dir: &Path, files: &mut Vec<PathBuf>) -> io::Result<()> {
81 for entry in fs::read_dir(dir)? {
82 let entry = entry?;
83 let path = entry.path();
84 if path.is_dir() {
85 collect_files(&path, files)?;
86 } else if path.extension().and_then(|ext| ext.to_str()) == Some("lex") {
87 files.push(path);
88 }
89 }
90 Ok(())
91}
92
93fn filter_by_topic(files: &[PathBuf], topic: &str) -> Vec<PathBuf> {
94 let needle = topic.to_ascii_lowercase();
95 files
96 .iter()
97 .filter(|path| {
98 path.to_string_lossy()
99 .to_ascii_lowercase()
100 .contains(&needle)
101 })
102 .cloned()
103 .collect()
104}
105
106fn default_entries(files: &[PathBuf]) -> Vec<PathBuf> {
107 let mut entries = Vec::new();
108 if let Some(entry) = find_file(files, "on-all-of-lex") {
109 entries.push(entry);
110 }
111 if let Some(entry) = find_file(files, "general.lex") {
112 entries.push(entry);
113 }
114 if entries.is_empty() {
115 entries.extend_from_slice(&files[..files.len().min(3)]);
116 }
117 entries
118}
119
120fn find_file(files: &[PathBuf], needle: &str) -> Option<PathBuf> {
121 let lower = needle.to_ascii_lowercase();
122 files
123 .iter()
124 .find(|path| path.to_string_lossy().to_ascii_lowercase().contains(&lower))
125 .cloned()
126}
127
128fn display_title(path: &Path) -> String {
129 path.strip_prefix("docs")
130 .or_else(|_| path.strip_prefix("specs"))
131 .unwrap_or(path)
132 .to_string_lossy()
133 .trim_start_matches('/')
134 .trim_start_matches('\\')
135 .replace("\\", "/")
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141
142 #[test]
143 fn returns_general_help_without_topic() {
144 let response = query_help(None).expect("help");
145 assert!(!response.entries.is_empty());
146 assert!(
147 response.entries[0].title.contains("on-all-of-lex")
148 || response.entries[0].title.contains("general")
149 );
150 }
151
152 #[test]
153 fn filters_entries_by_topic() {
154 let response = query_help(Some("grammar")).expect("help");
155 assert!(response
156 .entries
157 .iter()
158 .any(|entry| entry.title.to_ascii_lowercase().contains("grammar")));
159 }
160}