Skip to content

Commit 9d17aa6

Browse files
committed
feat: add release context export tool
1 parent 40b6940 commit 9d17aa6

File tree

3 files changed

+421
-0
lines changed

3 files changed

+421
-0
lines changed

tools/export-content/Cargo.toml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[package]
2+
name = "export-content"
3+
version = "0.1.0"
4+
edition = "2024"
5+
6+
[dependencies]
7+
miette = { version = "7.6.0", features = ["fancy"] }
8+
ratatui = "0.29.0"
9+
regex = "1.11.1"
10+
serde = { version = "1.0", features = ["derive"] }
11+
serde_yml = "0.0.12"
12+
thiserror = "2.0.12"
13+
14+
[lints]
15+
workspace = true

tools/export-content/src/app.rs

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
use std::{env, fs, io::Write, path};
2+
3+
use miette::{diagnostic, Context, Diagnostic, IntoDiagnostic, 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 != "" {
82+
if let Some(index) = mode_state.selected() {
83+
mode_entries.insert(
84+
index,
85+
Entry::Section {
86+
title: text.clone(),
87+
},
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+
if let Some(index) = mode_state.selected() {
109+
if index < mode_entries.len() - 1 {
110+
mode_entries.swap(index, index + 1);
111+
}
112+
}
113+
}
114+
mode_state.select_next();
115+
}
116+
KeyCode::Up => {
117+
if key.modifiers.contains(KeyModifiers::SHIFT) {
118+
if let Some(index) = mode_state.selected() {
119+
if index > 0 {
120+
mode_entries.swap(index, index - 1);
121+
}
122+
}
123+
}
124+
mode_state.select_previous();
125+
}
126+
KeyCode::Char('+') => {
127+
self.text_entry = Some(String::new());
128+
}
129+
KeyCode::Char('d') => {
130+
if let Some(index) = mode_state.selected() {
131+
if let Entry::Section { .. } = mode_entries[index] {
132+
mode_entries.remove(index);
133+
}
134+
}
135+
}
136+
_ => {}
137+
}
138+
}
139+
}
140+
141+
self.write_output()
142+
}
143+
144+
pub fn render(&mut self, frame: &mut Frame) {
145+
use Constraint::*;
146+
147+
let page_area = frame.area().inner(Margin::new(1, 1));
148+
let [header_area, instructions_area, _, block_area, _, typing_area] = Layout::vertical([
149+
Length(2), // header
150+
Length(2), // instructions
151+
Length(1), // gap
152+
Fill(1), // blocks
153+
Length(1), // gap
154+
Length(2), // text input
155+
])
156+
.areas(page_area);
157+
158+
frame.render_widget(self.header(), header_area);
159+
frame.render_widget(self.instructions(), instructions_area);
160+
161+
let (title, mode_state, mode_entries) = match self.mode {
162+
Mode::ReleaseNotes => (
163+
"Release Notes",
164+
&mut self.release_notes_state,
165+
&self.release_notes,
166+
),
167+
Mode::MigrationGuides => (
168+
"Migration Guides",
169+
&mut self.migration_guide_state,
170+
&self.migration_guides,
171+
),
172+
};
173+
let items = mode_entries.iter().map(|e| e.as_list_entry());
174+
let list = List::new(items)
175+
.block(Block::new().title(title).padding(Padding::uniform(1)))
176+
.highlight_symbol(">>")
177+
.highlight_style(Color::Green);
178+
179+
frame.render_stateful_widget(list, block_area, mode_state);
180+
181+
if let Some(text) = &self.text_entry {
182+
let text_entry = Paragraph::new(format!("Section Title: {}", text)).fg(Color::Blue);
183+
frame.render_widget(text_entry, typing_area);
184+
}
185+
}
186+
187+
fn header(&self) -> impl Widget {
188+
let text = "Content Exporter Tool";
189+
text.bold().underlined().into_centered_line()
190+
}
191+
192+
fn instructions(&self) -> impl Widget {
193+
let text =
194+
"▲ ▼ : navagate shift + ▲ ▼ : re-order + : insert section d : delete section tab : change focus esc : save and quit";
195+
Paragraph::new(text)
196+
.fg(Color::Magenta)
197+
.centered()
198+
.wrap(Wrap { trim: false })
199+
}
200+
201+
fn write_output(self) -> Result<()> {
202+
// Write release notes
203+
let mut file =
204+
fs::File::create(self.content_dir.join("merged_release_notes.md")).into_diagnostic()?;
205+
206+
for entry in self.release_notes {
207+
match entry {
208+
Entry::Section { title } => write!(file, "# {title}\n\n").into_diagnostic()?,
209+
Entry::File { metadata, content } => {
210+
let title = metadata.title;
211+
212+
let authors = metadata
213+
.authors
214+
.iter()
215+
.flatten()
216+
.map(|a| format!("\"{a}\""))
217+
.collect::<Vec<_>>()
218+
.join(", ");
219+
220+
let pull_requests = metadata
221+
.pull_requests
222+
.iter()
223+
.map(|n| format!("{}", n))
224+
.collect::<Vec<_>>()
225+
.join(", ");
226+
227+
write!(
228+
file,
229+
"## {title}\n{{% heading_metadata(authors=[{authors}] prs=[{pull_requests}]) %}}\n{content}\n\n"
230+
)
231+
.into_diagnostic()?;
232+
}
233+
}
234+
}
235+
236+
// Write migration guide
237+
let mut file = fs::File::create(self.content_dir.join("merged_migration_guides.md"))
238+
.into_diagnostic()?;
239+
240+
for entry in self.migration_guides {
241+
match entry {
242+
Entry::Section { title } => write!(file, "## {title}\n\n").into_diagnostic()?,
243+
Entry::File { metadata, content } => {
244+
let title = metadata.title;
245+
246+
let pull_requests = metadata
247+
.pull_requests
248+
.iter()
249+
.map(|n| format!("{}", n))
250+
.collect::<Vec<_>>()
251+
.join(", ");
252+
253+
write!(
254+
file,
255+
"### {title}\n{{% heading_metadata(prs=[{pull_requests}]) %}}\n{content}\n\n"
256+
)
257+
.into_diagnostic()?;
258+
}
259+
}
260+
}
261+
262+
Ok(())
263+
}
264+
}
265+
266+
#[derive(Deserialize, Debug)]
267+
struct Metadata {
268+
title: String,
269+
authors: Option<Vec<String>>,
270+
pull_requests: Vec<u32>,
271+
}
272+
273+
#[derive(Debug)]
274+
pub enum Entry {
275+
Section { title: String },
276+
File { metadata: Metadata, content: String },
277+
}
278+
279+
impl Entry {
280+
fn as_list_entry(&'_ self) -> ListItem<'_> {
281+
match self {
282+
Entry::Section { title } => ListItem::new(title.as_str()).underlined().fg(Color::Blue),
283+
Entry::File { metadata, .. } => ListItem::new(metadata.title.as_str()),
284+
}
285+
}
286+
}
287+
288+
/// Loads release content from files in the specified directory
289+
pub fn load_content(dir: path::PathBuf, kind: &'static str) -> Result<Vec<Entry>> {
290+
let re = Regex::new(r"(?s)^---\s*\n(.*?)\n---\n(.*)").unwrap();
291+
292+
let mut entries = vec![];
293+
294+
for dir_entry in fs::read_dir(dir)
295+
.into_diagnostic()
296+
.wrap_err("unable to read directory")?
297+
{
298+
let dir_entry = dir_entry
299+
.into_diagnostic()
300+
.wrap_err(format!("unable to access {} file", kind))?;
301+
302+
// Skip directories
303+
if !dir_entry.path().is_file() {
304+
continue;
305+
}
306+
// Skip files with invalid names
307+
let Ok(file_name) = dir_entry.file_name().into_string() else {
308+
continue;
309+
};
310+
// Skip hidden files (like .gitkeep or .DS_Store)
311+
if file_name.starts_with(".") {
312+
continue;
313+
}
314+
315+
let file_content = fs::read_to_string(&dir_entry.path())
316+
.into_diagnostic()
317+
.wrap_err(format!("unable to read {} file", kind))?;
318+
319+
let caps = re.captures(&file_content).ok_or(diagnostic!(
320+
"failed to find frontmatter in {} file {}",
321+
kind,
322+
file_name
323+
))?;
324+
325+
let frontmatter = caps.get(1).unwrap().as_str();
326+
let metadata = serde_yml::from_str::<Metadata>(frontmatter).map_err(|e| ParseError {
327+
src: frontmatter.to_owned(),
328+
kind,
329+
file_name,
330+
err_span: e.location().map(|l| l.index()),
331+
error: e,
332+
})?;
333+
let content = caps.get(2).unwrap().as_str().to_owned();
334+
335+
entries.push(Entry::File { metadata, content })
336+
}
337+
338+
Ok(entries)
339+
}
340+
341+
#[derive(Diagnostic, Debug, Error)]
342+
#[error("failed to parse metadata in {kind} file {file_name}")]
343+
pub struct ParseError {
344+
#[source_code]
345+
src: String,
346+
kind: &'static str,
347+
file_name: String,
348+
#[label("{error}")]
349+
err_span: Option<usize>,
350+
error: serde_yml::Error,
351+
}

0 commit comments

Comments
 (0)