example_spec.rs

  1use serde::{Deserialize, Serialize};
  2use std::{borrow::Cow, mem, path::Path, sync::Arc};
  3
  4#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
  5pub struct ExampleSpec {
  6    #[serde(default)]
  7    pub name: String,
  8    pub repository_url: String,
  9    pub revision: String,
 10    #[serde(default)]
 11    pub uncommitted_diff: String,
 12    pub cursor_path: Arc<Path>,
 13    pub cursor_position: String,
 14    pub edit_history: String,
 15    pub expected_patch: String,
 16}
 17
 18const UNCOMMITTED_DIFF_HEADING: &str = "Uncommitted Diff";
 19const EDIT_HISTORY_HEADING: &str = "Edit History";
 20const CURSOR_POSITION_HEADING: &str = "Cursor Position";
 21const EXPECTED_PATCH_HEADING: &str = "Expected Patch";
 22const EXPECTED_CONTEXT_HEADING: &str = "Expected Context";
 23
 24#[derive(Serialize, Deserialize)]
 25struct FrontMatter<'a> {
 26    repository_url: Cow<'a, str>,
 27    revision: Cow<'a, str>,
 28}
 29
 30impl ExampleSpec {
 31    /// Format this example spec as markdown.
 32    pub fn to_markdown(&self) -> String {
 33        use std::fmt::Write as _;
 34
 35        let front_matter = FrontMatter {
 36            repository_url: Cow::Borrowed(&self.repository_url),
 37            revision: Cow::Borrowed(&self.revision),
 38        };
 39        let front_matter_toml =
 40            toml::to_string_pretty(&front_matter).unwrap_or_else(|_| String::new());
 41
 42        let mut markdown = String::new();
 43
 44        _ = writeln!(markdown, "+++");
 45        markdown.push_str(&front_matter_toml);
 46        if !markdown.ends_with('\n') {
 47            markdown.push('\n');
 48        }
 49        _ = writeln!(markdown, "+++");
 50        markdown.push('\n');
 51
 52        _ = writeln!(markdown, "# {}", self.name);
 53        markdown.push('\n');
 54
 55        if !self.uncommitted_diff.is_empty() {
 56            _ = writeln!(markdown, "## {}", UNCOMMITTED_DIFF_HEADING);
 57            _ = writeln!(markdown);
 58            _ = writeln!(markdown, "```diff");
 59            markdown.push_str(&self.uncommitted_diff);
 60            if !markdown.ends_with('\n') {
 61                markdown.push('\n');
 62            }
 63            _ = writeln!(markdown, "```");
 64            markdown.push('\n');
 65        }
 66
 67        _ = writeln!(markdown, "## {}", EDIT_HISTORY_HEADING);
 68        _ = writeln!(markdown);
 69
 70        if self.edit_history.is_empty() {
 71            _ = writeln!(markdown, "(No edit history)");
 72            _ = writeln!(markdown);
 73        } else {
 74            _ = writeln!(markdown, "```diff");
 75            markdown.push_str(&self.edit_history);
 76            if !markdown.ends_with('\n') {
 77                markdown.push('\n');
 78            }
 79            _ = writeln!(markdown, "```");
 80            markdown.push('\n');
 81        }
 82
 83        _ = writeln!(markdown, "## {}", CURSOR_POSITION_HEADING);
 84        _ = writeln!(markdown);
 85        _ = writeln!(markdown, "```{}", self.cursor_path.to_string_lossy());
 86        markdown.push_str(&self.cursor_position);
 87        if !markdown.ends_with('\n') {
 88            markdown.push('\n');
 89        }
 90        _ = writeln!(markdown, "```");
 91        markdown.push('\n');
 92
 93        _ = writeln!(markdown, "## {}", EXPECTED_PATCH_HEADING);
 94        markdown.push('\n');
 95        _ = writeln!(markdown, "```diff");
 96        markdown.push_str(&self.expected_patch);
 97        if !markdown.ends_with('\n') {
 98            markdown.push('\n');
 99        }
100        _ = writeln!(markdown, "```");
101        markdown.push('\n');
102
103        markdown
104    }
105
106    /// Parse an example spec from markdown.
107    pub fn from_markdown(name: String, mut input: &str) -> anyhow::Result<Self> {
108        use pulldown_cmark::{CodeBlockKind, CowStr, Event, HeadingLevel, Parser, Tag, TagEnd};
109
110        let mut spec = ExampleSpec {
111            name,
112            repository_url: String::new(),
113            revision: String::new(),
114            uncommitted_diff: String::new(),
115            cursor_path: Path::new("").into(),
116            cursor_position: String::new(),
117            edit_history: String::new(),
118            expected_patch: String::new(),
119        };
120
121        if let Some(rest) = input.strip_prefix("+++\n")
122            && let Some((front_matter, rest)) = rest.split_once("+++\n")
123        {
124            if let Ok(data) = toml::from_str::<FrontMatter<'_>>(front_matter) {
125                spec.repository_url = data.repository_url.into_owned();
126                spec.revision = data.revision.into_owned();
127            }
128            input = rest.trim_start();
129        }
130
131        let parser = Parser::new(input);
132        let mut text = String::new();
133        let mut block_info: CowStr = "".into();
134
135        #[derive(PartialEq)]
136        enum Section {
137            Start,
138            UncommittedDiff,
139            EditHistory,
140            CursorPosition,
141            ExpectedExcerpts,
142            ExpectedPatch,
143            Other,
144        }
145
146        let mut current_section = Section::Start;
147
148        for event in parser {
149            match event {
150                Event::Text(line) => {
151                    text.push_str(&line);
152                }
153                Event::End(TagEnd::Heading(HeadingLevel::H2)) => {
154                    let title = mem::take(&mut text);
155                    current_section = if title.eq_ignore_ascii_case(UNCOMMITTED_DIFF_HEADING) {
156                        Section::UncommittedDiff
157                    } else if title.eq_ignore_ascii_case(EDIT_HISTORY_HEADING) {
158                        Section::EditHistory
159                    } else if title.eq_ignore_ascii_case(CURSOR_POSITION_HEADING) {
160                        Section::CursorPosition
161                    } else if title.eq_ignore_ascii_case(EXPECTED_PATCH_HEADING) {
162                        Section::ExpectedPatch
163                    } else if title.eq_ignore_ascii_case(EXPECTED_CONTEXT_HEADING) {
164                        Section::ExpectedExcerpts
165                    } else {
166                        Section::Other
167                    };
168                }
169                Event::End(TagEnd::Heading(HeadingLevel::H3)) => {
170                    mem::take(&mut text);
171                }
172                Event::End(TagEnd::Heading(HeadingLevel::H4)) => {
173                    mem::take(&mut text);
174                }
175                Event::End(TagEnd::Heading(level)) => {
176                    anyhow::bail!("Unexpected heading level: {level}");
177                }
178                Event::Start(Tag::CodeBlock(kind)) => {
179                    match kind {
180                        CodeBlockKind::Fenced(info) => {
181                            block_info = info;
182                        }
183                        CodeBlockKind::Indented => {
184                            anyhow::bail!("Unexpected indented codeblock");
185                        }
186                    };
187                }
188                Event::Start(_) => {
189                    text.clear();
190                    block_info = "".into();
191                }
192                Event::End(TagEnd::CodeBlock) => {
193                    let block_info = block_info.trim();
194                    match current_section {
195                        Section::UncommittedDiff => {
196                            spec.uncommitted_diff = mem::take(&mut text);
197                        }
198                        Section::EditHistory => {
199                            spec.edit_history.push_str(&mem::take(&mut text));
200                        }
201                        Section::CursorPosition => {
202                            spec.cursor_path = Path::new(block_info).into();
203                            spec.cursor_position = mem::take(&mut text);
204                        }
205                        Section::ExpectedExcerpts => {
206                            mem::take(&mut text);
207                        }
208                        Section::ExpectedPatch => {
209                            spec.expected_patch = mem::take(&mut text);
210                        }
211                        Section::Start | Section::Other => {}
212                    }
213                }
214                _ => {}
215            }
216        }
217
218        if spec.cursor_path.as_ref() == Path::new("") || spec.cursor_position.is_empty() {
219            anyhow::bail!("Missing cursor position codeblock");
220        }
221
222        Ok(spec)
223    }
224}