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}