example.rs

  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 = &current_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}