1use std::{
2 fmt::{self, Display},
3 fs::File,
4 io::{Read, Write},
5 os::unix::ffi::OsStrExt,
6 path::{Path, PathBuf},
7};
8
9use anyhow::Result;
10use clap::ValueEnum;
11use serde::{Deserialize, Serialize};
12
13const EDIT_HISTORY_HEADING: &str = "Edit History";
14const EXPECTED_HUNKS_HEADING: &str = "Expected Hunks";
15const EXPECTED_PATCH_HEADING: &str = "Expected Patch";
16const EXPECTED_EXCERPTS_HEADING: &str = "Expected Excerpts";
17
18pub struct NamedExample {
19 name: String,
20 example: Example,
21}
22
23#[derive(Serialize, Deserialize)]
24pub struct Example {
25 repository_url: String,
26 commit: String,
27 edit_history: Vec<String>,
28 expected_hunks: Vec<String>,
29 expected_patch: String,
30 expected_excerpts: Vec<ExpectedExcerpt>,
31}
32
33#[derive(Serialize, Deserialize)]
34pub struct ExpectedExcerpt {
35 path: PathBuf,
36 text: String,
37}
38
39#[derive(ValueEnum, Debug, Clone)]
40pub enum ExampleFormat {
41 Json,
42 Toml,
43 Md,
44}
45
46impl NamedExample {
47 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
48 let path = path.as_ref();
49 let mut file = File::open(path)?;
50 let ext = path.extension();
51
52 match ext.map(|s| s.as_bytes()) {
53 Some(b"json") => {
54 let mut content = Vec::new();
55 file.read_to_end(&mut content)?;
56 Ok(Self {
57 name: path.file_name().unwrap_or_default().display().to_string(),
58 example: serde_json::from_slice(&content)?,
59 })
60 }
61 Some(b"toml") => {
62 let mut content = String::new();
63 file.read_to_string(&mut content)?;
64 Ok(Self {
65 name: path.file_name().unwrap_or_default().display().to_string(),
66 example: toml::from_str(&content)?,
67 })
68 }
69 Some(b"md") => {
70 let mut content = String::new();
71 file.read_to_string(&mut content)?;
72 Self::parse_md(&content)
73 }
74 Some(_) => {
75 anyhow::bail!("Unrecognized example extension: {}", ext.unwrap().display());
76 }
77 None => {
78 anyhow::bail!(
79 "Failed to determine example type since the file does not have an extension."
80 );
81 }
82 }
83 }
84
85 pub fn parse_md(input: &str) -> Result<Self> {
86 use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Parser, Tag, TagEnd};
87
88 let parser = Parser::new(input);
89
90 let mut name = String::new();
91 let mut repository_url = String::new();
92 let mut commit = String::new();
93 let mut edit_history = Vec::new();
94 let mut expected_hunks = Vec::new();
95 let mut expected_patch = String::new();
96 let mut expected_excerpts = Vec::new();
97
98 let mut current_heading_level: Option<HeadingLevel> = None;
99 let mut current_heading_text = String::new();
100 let mut current_section = String::new();
101 let mut in_code_block = false;
102 let mut current_code_block = String::new();
103 let mut current_code_info = String::new();
104
105 for event in parser {
106 match event {
107 Event::Start(Tag::Heading { level, .. }) => {
108 current_heading_level = Some(level);
109 current_heading_text.clear();
110 }
111 Event::End(TagEnd::Heading(_)) => {
112 let heading_text = current_heading_text.trim();
113 if let Some(HeadingLevel::H1) = current_heading_level {
114 if !name.is_empty() {
115 anyhow::bail!(
116 "Found multiple H1 headings. There should only be one with the name of the example."
117 );
118 }
119 name = heading_text.to_string();
120 } else if let Some(HeadingLevel::H2) = current_heading_level {
121 current_section = heading_text.to_string();
122 }
123 current_heading_level = None;
124 }
125 Event::Start(Tag::CodeBlock(kind)) => {
126 in_code_block = true;
127 current_code_block.clear();
128 current_code_info = match kind {
129 CodeBlockKind::Fenced(info) => info.to_string(),
130 CodeBlockKind::Indented => String::new(),
131 };
132 }
133 Event::End(TagEnd::CodeBlock) => {
134 in_code_block = false;
135
136 match current_section.as_str() {
137 EDIT_HISTORY_HEADING => {
138 edit_history.push(current_code_block.clone());
139 }
140 EXPECTED_HUNKS_HEADING => {
141 expected_hunks.push(current_code_block.clone());
142 }
143 EXPECTED_PATCH_HEADING => {
144 expected_patch = current_code_block.clone();
145 }
146 EXPECTED_EXCERPTS_HEADING => {
147 if let Some(path_start) = current_code_info.find("path=") {
148 let path_str = ¤t_code_info[path_start + 5..];
149 let path = PathBuf::from(path_str.trim());
150 expected_excerpts.push(ExpectedExcerpt {
151 path,
152 text: current_code_block.clone(),
153 });
154 }
155 }
156 _ => {}
157 }
158 }
159 Event::Text(text) => {
160 if let Some(_) = current_heading_level {
161 current_heading_text.push_str(&text);
162 } else if in_code_block {
163 current_code_block.push_str(&text);
164 } else if current_section.is_empty()
165 && let Some(eq_pos) = text.find('=')
166 {
167 let key = text[..eq_pos].trim();
168 let value = text[eq_pos + 1..].trim();
169 match key {
170 "repository_url" => repository_url = dbg!(value.to_string()),
171 "commit" => commit = value.to_string(),
172 _ => {}
173 }
174 }
175 }
176 _ => {}
177 }
178 }
179
180 if name.is_empty() {
181 anyhow::bail!("Missing required H1 heading for example name");
182 }
183
184 if repository_url.is_empty() {
185 anyhow::bail!("Missing required field: repository_url");
186 }
187
188 if commit.is_empty() {
189 anyhow::bail!("Missing required field: commit");
190 }
191
192 Ok(Self {
193 name,
194 example: Example {
195 repository_url,
196 commit,
197 edit_history,
198 expected_hunks,
199 expected_patch,
200 expected_excerpts,
201 },
202 })
203 }
204
205 pub fn write(&self, format: ExampleFormat, mut out: impl Write) -> Result<()> {
206 match format {
207 ExampleFormat::Json => Ok(serde_json::to_writer(out, &self.example)?),
208 ExampleFormat::Toml => {
209 Ok(out.write_all(toml::to_string_pretty(&self.example)?.as_bytes())?)
210 }
211 ExampleFormat::Md => Ok(write!(out, "{}", self)?),
212 }
213 }
214}
215
216impl Display for NamedExample {
217 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218 write!(f, "# {}\n\n", self.name)?;
219 write!(f, "repository_url = {}\n", self.example.repository_url)?;
220 write!(f, "commit = {}\n\n", self.example.commit)?;
221 write!(f, "## {EDIT_HISTORY_HEADING}\n\n")?;
222
223 if !self.example.edit_history.is_empty() {
224 write!(f, "`````diff\n")?;
225 for item in &self.example.edit_history {
226 write!(f, "{item}")?;
227 }
228 write!(f, "`````\n")?;
229 }
230
231 if !self.example.expected_hunks.is_empty() {
232 write!(f, "\n## {EXPECTED_HUNKS_HEADING}\n\n`````diff\n")?;
233 for hunk in &self.example.expected_hunks {
234 write!(f, "{hunk}")?;
235 }
236 write!(f, "`````\n")?;
237 }
238
239 if !self.example.expected_patch.is_empty() {
240 write!(
241 f,
242 "\n## {EXPECTED_PATCH_HEADING}\n\n`````diff\n{}`````\n",
243 self.example.expected_patch
244 )?;
245 }
246
247 if !self.example.expected_excerpts.is_empty() {
248 write!(f, "\n## {EXPECTED_EXCERPTS_HEADING}\n\n")?;
249
250 for excerpt in &self.example.expected_excerpts {
251 write!(
252 f,
253 "`````{}path={}\n{}`````\n\n",
254 excerpt
255 .path
256 .extension()
257 .map(|ext| format!("{} ", ext.to_string_lossy()))
258 .unwrap_or_default(),
259 excerpt.path.display(),
260 excerpt.text
261 )?;
262 }
263 }
264
265 Ok(())
266 }
267}