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