1const DOC: &str = include_str!("../magic-lux.md");
15
16pub struct Spell {
19 pub id: String,
20 pub title: String,
21 pub question: String,
22 pub example: String,
23 pub trail: Vec<String>,
24}
25
26const WIDTH: usize = 76;
27
28pub fn spells() -> Vec<Spell> {
32 let marker = "<!-- spell:";
33 let mut out = Vec::new();
34 let mut rest = DOC;
35 while let Some(pos) = rest.find(marker) {
36 let after = &rest[pos + marker.len()..];
37 let id_end = after.find("-->").unwrap_or(0);
38 let id = after[..id_end].trim().to_string();
39 let body_start = after.find('\n').map(|i| i + 1).unwrap_or(after.len());
40 let body = &after[body_start..];
41 let next = body.find(marker).unwrap_or(body.len());
42 out.push(parse_spell(id, &body[..next]));
43 rest = &body[next..];
44 }
45 out
46}
47
48fn parse_spell(id: String, body: &str) -> Spell {
49 let mut lines = body.lines().peekable();
50
51 let mut title = String::new();
52 for line in lines.by_ref() {
53 if let Some(rest) = line.trim_start().strip_prefix("## ") {
54 title = rest.trim().to_string();
55 break;
56 }
57 }
58
59 let mut question = Vec::new();
61 while let Some(line) = lines.peek() {
62 if line.trim_start().starts_with("```") {
63 break;
64 }
65 let l = line.trim();
66 if !l.is_empty() {
67 question.push(l.to_string());
68 }
69 lines.next();
70 }
71
72 lines.next(); let mut example = Vec::new();
75 for line in lines.by_ref() {
76 if line.trim_start().starts_with("```") {
77 break;
78 }
79 example.push(line);
80 }
81
82 let mut trail = Vec::new();
84 for line in lines.by_ref() {
85 let l = line.trim();
86 if let Some(rest) = l.strip_prefix("> ") {
87 if let Some(ids) = rest.trim().strip_prefix("trail:") {
88 for piece in ids.split('·') {
89 let p = piece.trim();
90 if !p.is_empty() {
91 trail.push(p.to_string());
92 }
93 }
94 }
95 break;
96 }
97 }
98
99 Spell {
100 id,
101 title,
102 question: question.join(" "),
103 example: example.join("\n"),
104 trail,
105 }
106}
107
108fn summary(title: &str) -> String {
110 match title.split_once('—') {
111 Some((_, rest)) => rest.trim().to_string(),
112 None => title.to_string(),
113 }
114}
115
116fn intro() -> String {
120 let after_comment = match DOC.split_once("-->") {
121 Some((_, rest)) => rest,
122 None => DOC,
123 };
124 after_comment
125 .split("<!-- spell:")
126 .next()
127 .unwrap_or_default()
128 .trim()
129 .to_string()
130}
131
132fn render(s: &Spell) -> String {
136 let mut out = String::new();
137 out.push_str(&plain(&s.title));
138 out.push_str("\n\n");
139 out.push_str(&wrap(&plain(&s.question), WIDTH));
140 out.push_str("\n\n");
141 for line in s.example.lines() {
142 if line.is_empty() {
143 out.push('\n');
144 } else {
145 out.push_str(" ");
146 out.push_str(line);
147 out.push('\n');
148 }
149 }
150 if !s.trail.is_empty() {
151 let follow: Vec<String> = s.trail.iter().map(|t| format!("lux learn {}", t)).collect();
152 out.push('\n');
153 out.push_str(&wrap(
154 &format!("how it works: {}", follow.join(", ")),
155 WIDTH,
156 ));
157 out.push('\n');
158 }
159 out
160}
161
162pub fn menu() -> String {
164 let spells = spells();
165 let mut out = String::new();
166 out.push_str("lux magic — spells for things you want to do now\n\n");
167 let tagline = tagline();
168 if !tagline.is_empty() {
169 out.push_str(&wrap(&tagline, WIDTH));
170 out.push_str("\n\n");
171 }
172 out.push_str("cast a spell:\n");
173 let width = spells.iter().map(|s| s.id.len()).max().unwrap_or(0);
174 for s in &spells {
175 out.push_str(&format!(
176 " lux magic {:<width$} {}\n",
177 s.id,
178 summary(&s.title),
179 width = width
180 ));
181 }
182 out.push_str("\neach spell ends with how it works — a trail into `lux learn`,\n");
183 out.push_str("where the ideas it uses are explained.\n");
184 out
185}
186
187fn tagline() -> String {
189 let para = intro().split("\n\n").next().unwrap_or_default().to_string();
190 let unwrapped = para.split_whitespace().collect::<Vec<_>>().join(" ");
191 let plain = plain(&unwrapped);
192 match plain.find(". ") {
193 Some(i) => plain[..=i].trim().to_string(),
194 None => plain,
195 }
196}
197
198pub fn lookup(name: &str) -> Option<String> {
200 spells().iter().find(|s| s.id == name).map(render)
201}
202
203fn plain(s: &str) -> String {
206 s.chars().filter(|c| *c != '`' && *c != '*').collect()
207}
208
209fn wrap(text: &str, width: usize) -> String {
211 let mut out = String::new();
212 let mut line = String::new();
213 let mut has_word = false;
214 for word in text.split_whitespace() {
215 if has_word && line.len() + 1 + word.len() > width {
216 out.push_str(&line);
217 out.push('\n');
218 line = String::new();
219 has_word = false;
220 }
221 if has_word {
222 line.push(' ');
223 }
224 line.push_str(word);
225 has_word = true;
226 }
227 out.push_str(&line);
228 out
229}