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(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: String::new(),
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::H1)) => {
154                    spec.name = mem::take(&mut text);
155                }
156                Event::End(TagEnd::Heading(HeadingLevel::H2)) => {
157                    let title = mem::take(&mut text);
158                    current_section = if title.eq_ignore_ascii_case(UNCOMMITTED_DIFF_HEADING) {
159                        Section::UncommittedDiff
160                    } else if title.eq_ignore_ascii_case(EDIT_HISTORY_HEADING) {
161                        Section::EditHistory
162                    } else if title.eq_ignore_ascii_case(CURSOR_POSITION_HEADING) {
163                        Section::CursorPosition
164                    } else if title.eq_ignore_ascii_case(EXPECTED_PATCH_HEADING) {
165                        Section::ExpectedPatch
166                    } else if title.eq_ignore_ascii_case(EXPECTED_CONTEXT_HEADING) {
167                        Section::ExpectedExcerpts
168                    } else {
169                        Section::Other
170                    };
171                }
172                Event::End(TagEnd::Heading(HeadingLevel::H3)) => {
173                    mem::take(&mut text);
174                }
175                Event::End(TagEnd::Heading(HeadingLevel::H4)) => {
176                    mem::take(&mut text);
177                }
178                Event::End(TagEnd::Heading(level)) => {
179                    anyhow::bail!("Unexpected heading level: {level}");
180                }
181                Event::Start(Tag::CodeBlock(kind)) => {
182                    match kind {
183                        CodeBlockKind::Fenced(info) => {
184                            block_info = info;
185                        }
186                        CodeBlockKind::Indented => {
187                            anyhow::bail!("Unexpected indented codeblock");
188                        }
189                    };
190                }
191                Event::Start(_) => {
192                    text.clear();
193                    block_info = "".into();
194                }
195                Event::End(TagEnd::CodeBlock) => {
196                    let block_info = block_info.trim();
197                    match current_section {
198                        Section::UncommittedDiff => {
199                            spec.uncommitted_diff = mem::take(&mut text);
200                        }
201                        Section::EditHistory => {
202                            spec.edit_history.push_str(&mem::take(&mut text));
203                        }
204                        Section::CursorPosition => {
205                            spec.cursor_path = Path::new(block_info).into();
206                            spec.cursor_position = mem::take(&mut text);
207                        }
208                        Section::ExpectedExcerpts => {
209                            mem::take(&mut text);
210                        }
211                        Section::ExpectedPatch => {
212                            spec.expected_patch = mem::take(&mut text);
213                        }
214                        Section::Start | Section::Other => {}
215                    }
216                }
217                _ => {}
218            }
219        }
220
221        if spec.cursor_path.as_ref() == Path::new("") || spec.cursor_position.is_empty() {
222            anyhow::bail!("Missing cursor position codeblock");
223        }
224
225        Ok(spec)
226    }
227}