Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
bevyengine
GitHub Repository: bevyengine/bevy
Path: blob/main/tools/export-content/src/app.rs
6849 views
1
use std::{env, fs, io::Write, path::PathBuf};
2
3
use miette::{diagnostic, Context, Diagnostic, IntoDiagnostic, NamedSource, Result};
4
use ratatui::{
5
crossterm::event::{self, Event, KeyCode, KeyModifiers},
6
prelude::*,
7
widgets::*,
8
};
9
use regex::Regex;
10
use serde::Deserialize;
11
use thiserror::Error;
12
13
enum Mode {
14
ReleaseNotes,
15
MigrationGuides,
16
}
17
18
pub struct App {
19
content_dir: PathBuf,
20
release_notes: Vec<Entry>,
21
release_notes_state: ListState,
22
migration_guides: Vec<Entry>,
23
migration_guide_state: ListState,
24
text_entry: Option<String>,
25
mode: Mode,
26
exit: bool,
27
}
28
29
pub struct Content {
30
content_dir: PathBuf,
31
migration_guides: Vec<Entry>,
32
release_notes: Vec<Entry>,
33
}
34
35
impl Content {
36
pub fn load() -> Result<Self> {
37
let exe_dir = env::current_exe()
38
.into_diagnostic()
39
.wrap_err("failed to determine path to binary")?;
40
41
let content_dir = exe_dir
42
.ancestors()
43
.nth(3)
44
.ok_or(diagnostic!("failed to determine path to repo root"))?
45
.join("release-content");
46
47
let release_notes_dir = content_dir.join("release-notes");
48
let release_notes = load_content(release_notes_dir, "release note")?;
49
50
let migration_guides_dir = content_dir.join("migration-guides");
51
let migration_guides = load_content(migration_guides_dir, "migration guide")?;
52
Ok(Content {
53
content_dir,
54
migration_guides,
55
release_notes,
56
})
57
}
58
}
59
60
impl App {
61
pub fn new() -> Result<App> {
62
let Content {
63
content_dir,
64
release_notes,
65
migration_guides,
66
} = Content::load()?;
67
68
Ok(App {
69
content_dir,
70
release_notes,
71
release_notes_state: ListState::default().with_selected(Some(0)),
72
migration_guides,
73
migration_guide_state: ListState::default().with_selected(Some(0)),
74
text_entry: None,
75
mode: Mode::ReleaseNotes,
76
exit: false,
77
})
78
}
79
80
pub fn run<B: Backend>(mut self, terminal: &mut Terminal<B>) -> Result<()> {
81
while !self.exit {
82
terminal
83
.draw(|frame| self.render(frame))
84
.into_diagnostic()?;
85
86
let (mode_state, mode_entries) = match self.mode {
87
Mode::ReleaseNotes => (&mut self.release_notes_state, &mut self.release_notes),
88
Mode::MigrationGuides => {
89
(&mut self.migration_guide_state, &mut self.migration_guides)
90
}
91
};
92
93
if let Event::Key(key) = event::read().into_diagnostic()? {
94
// If text entry is enabled, capture all input events
95
if let Some(text) = &mut self.text_entry {
96
match key.code {
97
KeyCode::Esc => self.text_entry = None,
98
KeyCode::Backspace => {
99
text.pop();
100
}
101
KeyCode::Enter => {
102
if !text.is_empty()
103
&& let Some(index) = mode_state.selected()
104
{
105
mode_entries.insert(
106
index,
107
Entry::Section {
108
title: text.clone(),
109
},
110
);
111
}
112
self.text_entry = None;
113
}
114
KeyCode::Char(c) => text.push(c),
115
_ => {}
116
}
117
118
continue;
119
}
120
121
match key.code {
122
KeyCode::Esc => self.exit = true,
123
KeyCode::Tab => match self.mode {
124
Mode::ReleaseNotes => self.mode = Mode::MigrationGuides,
125
Mode::MigrationGuides => self.mode = Mode::ReleaseNotes,
126
},
127
KeyCode::Down => {
128
if key.modifiers.contains(KeyModifiers::SHIFT)
129
&& let Some(index) = mode_state.selected()
130
&& index < mode_entries.len() - 1
131
{
132
mode_entries.swap(index, index + 1);
133
}
134
mode_state.select_next();
135
}
136
KeyCode::Up => {
137
if key.modifiers.contains(KeyModifiers::SHIFT)
138
&& let Some(index) = mode_state.selected()
139
&& index > 0
140
{
141
mode_entries.swap(index, index - 1);
142
}
143
mode_state.select_previous();
144
}
145
KeyCode::Char('+') => {
146
self.text_entry = Some(String::new());
147
}
148
KeyCode::Char('d') => {
149
if let Some(index) = mode_state.selected()
150
&& let Entry::Section { .. } = mode_entries[index]
151
{
152
mode_entries.remove(index);
153
}
154
}
155
_ => {}
156
}
157
}
158
}
159
160
self.write_output()
161
}
162
163
pub fn render(&mut self, frame: &mut Frame) {
164
use Constraint::*;
165
166
let page_area = frame.area().inner(Margin::new(1, 1));
167
let [header_area, instructions_area, _, block_area, _, typing_area] = Layout::vertical([
168
Length(2), // header
169
Length(2), // instructions
170
Length(1), // gap
171
Fill(1), // blocks
172
Length(1), // gap
173
Length(2), // text input
174
])
175
.areas(page_area);
176
177
frame.render_widget(self.header(), header_area);
178
frame.render_widget(self.instructions(), instructions_area);
179
180
let (title, mode_state, mode_entries) = match self.mode {
181
Mode::ReleaseNotes => (
182
"Release Notes",
183
&mut self.release_notes_state,
184
&self.release_notes,
185
),
186
Mode::MigrationGuides => (
187
"Migration Guides",
188
&mut self.migration_guide_state,
189
&self.migration_guides,
190
),
191
};
192
let items = mode_entries.iter().map(|e| e.as_list_entry());
193
let list = List::new(items)
194
.block(Block::new().title(title).padding(Padding::uniform(1)))
195
.highlight_symbol(">>")
196
.highlight_style(Color::Green);
197
198
frame.render_stateful_widget(list, block_area, mode_state);
199
200
if let Some(text) = &self.text_entry {
201
let text_entry = Paragraph::new(format!("Section Title: {}", text)).fg(Color::Blue);
202
frame.render_widget(text_entry, typing_area);
203
}
204
}
205
206
fn header(&self) -> impl Widget {
207
let text = "Content Exporter Tool";
208
text.bold().underlined().into_centered_line()
209
}
210
211
fn instructions(&self) -> impl Widget {
212
let text =
213
"▲ ▼ : navigate shift + ▲ ▼ : re-order + : insert section d : delete section tab : change focus esc : save and quit";
214
Paragraph::new(text)
215
.fg(Color::Magenta)
216
.centered()
217
.wrap(Wrap { trim: false })
218
}
219
220
fn write_output(self) -> Result<()> {
221
// Write release notes
222
let mut file =
223
fs::File::create(self.content_dir.join("merged_release_notes.md")).into_diagnostic()?;
224
225
for entry in self.release_notes {
226
match entry {
227
Entry::Section { title } => write!(file, "# {title}\n\n").into_diagnostic()?,
228
Entry::File { metadata, content } => {
229
let title = metadata.title;
230
231
let authors = metadata
232
.authors
233
.iter()
234
.flatten()
235
.map(|a| format!("\"{a}\""))
236
.collect::<Vec<_>>()
237
.join(", ");
238
239
let pull_requests = metadata
240
.pull_requests
241
.iter()
242
.map(|n| format!("{}", n))
243
.collect::<Vec<_>>()
244
.join(", ");
245
246
write!(
247
file,
248
"## {title}\n\n{{{{ heading_metadata(authors=[{authors}] prs=[{pull_requests}]) }}}}\n\n{content}\n"
249
)
250
.into_diagnostic()?;
251
}
252
}
253
}
254
255
// Write migration guide
256
let mut file = fs::File::create(self.content_dir.join("merged_migration_guides.md"))
257
.into_diagnostic()?;
258
259
for entry in self.migration_guides {
260
match entry {
261
Entry::Section { title } => write!(file, "## {title}\n\n").into_diagnostic()?,
262
Entry::File { metadata, content } => {
263
let title = metadata.title;
264
265
let pull_requests = metadata
266
.pull_requests
267
.iter()
268
.map(|n| format!("{}", n))
269
.collect::<Vec<_>>()
270
.join(", ");
271
272
write!(
273
file,
274
"### {title}\n\n{{{{ heading_metadata(prs=[{pull_requests}]) }}}}\n\n{content}\n"
275
)
276
.into_diagnostic()?;
277
}
278
}
279
}
280
281
Ok(())
282
}
283
}
284
285
#[derive(Deserialize, Debug)]
286
struct Metadata {
287
title: String,
288
authors: Option<Vec<String>>,
289
pull_requests: Vec<u32>,
290
}
291
292
#[derive(Debug)]
293
enum Entry {
294
Section { title: String },
295
File { metadata: Metadata, content: String },
296
}
297
298
impl Entry {
299
fn as_list_entry(&'_ self) -> ListItem<'_> {
300
match self {
301
Entry::Section { title } => ListItem::new(title.as_str()).underlined().fg(Color::Blue),
302
Entry::File { metadata, .. } => ListItem::new(metadata.title.as_str()),
303
}
304
}
305
}
306
307
/// Loads release content from files in the specified directory
308
fn load_content(dir: PathBuf, kind: &'static str) -> Result<Vec<Entry>> {
309
let re = Regex::new(r"(?s)^---\s*\n(?<frontmatter>.*?)\s*\n---\s*\n(?<content>.*)").unwrap();
310
311
let mut entries = vec![];
312
313
for dir_entry in fs::read_dir(dir)
314
.into_diagnostic()
315
.wrap_err("unable to read directory")?
316
{
317
let dir_entry = dir_entry
318
.into_diagnostic()
319
.wrap_err(format!("unable to access {} file", kind))?;
320
321
// Skip directories
322
if !dir_entry.path().is_file() {
323
continue;
324
}
325
// Skip files with invalid names
326
let Ok(file_name) = dir_entry.file_name().into_string() else {
327
continue;
328
};
329
// Skip hidden files (like .gitkeep or .DS_Store)
330
if file_name.starts_with(".") {
331
continue;
332
}
333
334
let file_content = fs::read_to_string(dir_entry.path())
335
.into_diagnostic()
336
.wrap_err(format!("unable to read {} file", kind))?;
337
338
let caps = re.captures(&file_content).ok_or(diagnostic!(
339
"failed to find frontmatter in {} file {}",
340
kind,
341
file_name
342
))?;
343
344
let frontmatter = caps.name("frontmatter").unwrap().as_str();
345
let metadata = serde_yml::from_str::<Metadata>(frontmatter).map_err(|e| ParseError {
346
src: NamedSource::new(
347
format!("{}", dir_entry.path().display()),
348
frontmatter.to_owned(),
349
),
350
kind,
351
file_name,
352
err_span: e.location().map(|l| l.index()),
353
error: e,
354
})?;
355
let content = caps.name("content").unwrap().as_str().to_owned();
356
357
entries.push(Entry::File { metadata, content });
358
}
359
360
Ok(entries)
361
}
362
363
#[derive(Diagnostic, Debug, Error)]
364
#[error("failed to parse metadata in {kind} file {file_name}")]
365
pub struct ParseError {
366
#[source_code]
367
src: NamedSource<String>,
368
kind: &'static str,
369
file_name: String,
370
#[label("{error}")]
371
err_span: Option<usize>,
372
error: serde_yml::Error,
373
}
374
375