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