use serde::{Deserialize, Serialize}; use std::{fmt::Write as _, mem, path::Path, sync::Arc}; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ExampleSpec { #[serde(default)] pub name: String, pub repository_url: String, pub revision: String, #[serde(default)] pub uncommitted_diff: String, pub cursor_path: Arc, pub cursor_position: String, pub edit_history: String, pub expected_patch: String, } const UNCOMMITTED_DIFF_HEADING: &str = "Uncommitted Diff"; const EDIT_HISTORY_HEADING: &str = "Edit History"; const CURSOR_POSITION_HEADING: &str = "Cursor Position"; const EXPECTED_PATCH_HEADING: &str = "Expected Patch"; const EXPECTED_CONTEXT_HEADING: &str = "Expected Context"; const REPOSITORY_URL_FIELD: &str = "repository_url"; const REVISION_FIELD: &str = "revision"; impl ExampleSpec { /// Format this example spec as markdown. pub fn to_markdown(&self) -> String { let mut markdown = String::new(); _ = writeln!(markdown, "# {}", self.name); markdown.push('\n'); _ = writeln!(markdown, "repository_url = {}", self.repository_url); _ = writeln!(markdown, "revision = {}", self.revision); markdown.push('\n'); if !self.uncommitted_diff.is_empty() { _ = writeln!(markdown, "## {}", UNCOMMITTED_DIFF_HEADING); _ = writeln!(markdown); _ = writeln!(markdown, "```diff"); markdown.push_str(&self.uncommitted_diff); if !markdown.ends_with('\n') { markdown.push('\n'); } _ = writeln!(markdown, "```"); markdown.push('\n'); } _ = writeln!(markdown, "## {}", EDIT_HISTORY_HEADING); _ = writeln!(markdown); if self.edit_history.is_empty() { _ = writeln!(markdown, "(No edit history)"); _ = writeln!(markdown); } else { _ = writeln!(markdown, "```diff"); markdown.push_str(&self.edit_history); if !markdown.ends_with('\n') { markdown.push('\n'); } _ = writeln!(markdown, "```"); markdown.push('\n'); } _ = writeln!(markdown, "## {}", CURSOR_POSITION_HEADING); _ = writeln!(markdown); _ = writeln!(markdown, "```{}", self.cursor_path.to_string_lossy()); markdown.push_str(&self.cursor_position); if !markdown.ends_with('\n') { markdown.push('\n'); } _ = writeln!(markdown, "```"); markdown.push('\n'); _ = writeln!(markdown, "## {}", EXPECTED_PATCH_HEADING); markdown.push('\n'); _ = writeln!(markdown, "```diff"); markdown.push_str(&self.expected_patch); if !markdown.ends_with('\n') { markdown.push('\n'); } _ = writeln!(markdown, "```"); markdown.push('\n'); markdown } /// Parse an example spec from markdown. pub fn from_markdown(name: String, input: &str) -> anyhow::Result { use pulldown_cmark::{CodeBlockKind, CowStr, Event, HeadingLevel, Parser, Tag, TagEnd}; let parser = Parser::new(input); let mut spec = ExampleSpec { name, repository_url: String::new(), revision: String::new(), uncommitted_diff: String::new(), cursor_path: Path::new("").into(), cursor_position: String::new(), edit_history: String::new(), expected_patch: String::new(), }; let mut text = String::new(); let mut block_info: CowStr = "".into(); #[derive(PartialEq)] enum Section { Start, UncommittedDiff, EditHistory, CursorPosition, ExpectedExcerpts, ExpectedPatch, Other, } let mut current_section = Section::Start; for event in parser { match event { Event::Text(line) => { text.push_str(&line); if let Section::Start = current_section && let Some((field, value)) = line.split_once('=') { match field.trim() { REPOSITORY_URL_FIELD => { spec.repository_url = value.trim().to_string(); } REVISION_FIELD => { spec.revision = value.trim().to_string(); } _ => {} } } } Event::End(TagEnd::Heading(HeadingLevel::H2)) => { let title = mem::take(&mut text); current_section = if title.eq_ignore_ascii_case(UNCOMMITTED_DIFF_HEADING) { Section::UncommittedDiff } else if title.eq_ignore_ascii_case(EDIT_HISTORY_HEADING) { Section::EditHistory } else if title.eq_ignore_ascii_case(CURSOR_POSITION_HEADING) { Section::CursorPosition } else if title.eq_ignore_ascii_case(EXPECTED_PATCH_HEADING) { Section::ExpectedPatch } else if title.eq_ignore_ascii_case(EXPECTED_CONTEXT_HEADING) { Section::ExpectedExcerpts } else { Section::Other }; } Event::End(TagEnd::Heading(HeadingLevel::H3)) => { mem::take(&mut text); } Event::End(TagEnd::Heading(HeadingLevel::H4)) => { mem::take(&mut text); } Event::End(TagEnd::Heading(level)) => { anyhow::bail!("Unexpected heading level: {level}"); } Event::Start(Tag::CodeBlock(kind)) => { match kind { CodeBlockKind::Fenced(info) => { block_info = info; } CodeBlockKind::Indented => { anyhow::bail!("Unexpected indented codeblock"); } }; } Event::Start(_) => { text.clear(); block_info = "".into(); } Event::End(TagEnd::CodeBlock) => { let block_info = block_info.trim(); match current_section { Section::UncommittedDiff => { spec.uncommitted_diff = mem::take(&mut text); } Section::EditHistory => { spec.edit_history.push_str(&mem::take(&mut text)); } Section::CursorPosition => { spec.cursor_path = Path::new(block_info).into(); spec.cursor_position = mem::take(&mut text); } Section::ExpectedExcerpts => { mem::take(&mut text); } Section::ExpectedPatch => { spec.expected_patch = mem::take(&mut text); } Section::Start | Section::Other => {} } } _ => {} } } if spec.cursor_path.as_ref() == Path::new("") || spec.cursor_position.is_empty() { anyhow::bail!("Missing cursor position codeblock"); } Ok(spec) } }