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