Rework prompt frontmatter (#12262)

Nate Butler created

Moved some things around so prompts now always have front-matter to
return, either by creating a prompt with default front-matter, or
bailing earlier on importing the prompt to the library.

In the future we'll improve visibility of malformed prompts in the
`prompts` folder in the prompt manager UI.

Fixes:

- Prompts inserted with the `/prompt` command now only include their
body, not the entire file including metadata.
- Prompts with an invalid title will now show "Untitled prompt" instead
of an empty line.

Release Notes:

- N/A

Change summary

crates/assistant/src/prompts/prompt.rs               | 140 ++++---------
crates/assistant/src/prompts/prompt_library.rs       |  24 ++
crates/assistant/src/prompts/prompt_manager.rs       |   2 
crates/assistant/src/slash_command/prompt_command.rs |  12 
4 files changed, 66 insertions(+), 112 deletions(-)

Detailed changes

crates/assistant/src/prompts/prompt.rs 🔗

@@ -19,7 +19,7 @@ pub struct StaticPromptFrontmatter {
 impl Default for StaticPromptFrontmatter {
     fn default() -> Self {
         Self {
-            title: "New Prompt".to_string(),
+            title: "Untitled Prompt".to_string(),
             version: "1.0".to_string(),
             author: "No Author".to_string(),
             languages: vec!["*".to_string()],
@@ -28,37 +28,7 @@ impl Default for StaticPromptFrontmatter {
     }
 }
 
-impl StaticPromptFrontmatter {
-    pub fn title(&self) -> SharedString {
-        self.title.clone().into()
-    }
-
-    // pub fn version(&self) -> SharedString {
-    //     self.version.clone().into()
-    // }
-
-    // pub fn author(&self) -> SharedString {
-    //     self.author.clone().into()
-    // }
-
-    // pub fn languages(&self) -> Vec<SharedString> {
-    //     self.languages
-    //         .clone()
-    //         .into_iter()
-    //         .map(|s| s.into())
-    //         .collect()
-    // }
-
-    // pub fn dependencies(&self) -> Vec<SharedString> {
-    //     self.dependencies
-    //         .clone()
-    //         .into_iter()
-    //         .map(|s| s.into())
-    //         .collect()
-    // }
-}
-
-/// A statuc prompt that can be loaded into the prompt library
+/// A static prompt that can be loaded into the prompt library
 /// from Markdown with a frontmatter header
 ///
 /// Examples:
@@ -92,95 +62,69 @@ impl StaticPromptFrontmatter {
 /// ```
 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
 pub struct StaticPrompt {
+    #[serde(skip)]
+    metadata: StaticPromptFrontmatter,
     content: String,
     file_name: Option<String>,
 }
 
 impl StaticPrompt {
-    pub fn new(content: String) -> Self {
+    pub fn new(content: String, file_name: Option<String>) -> Self {
+        let matter = Matter::<YAML>::new();
+        let result = matter.parse(&content);
+
+        let metadata = result
+            .data
+            .map_or_else(
+                || Err(anyhow::anyhow!("Failed to parse frontmatter")),
+                |data| {
+                    let front_matter: StaticPromptFrontmatter = data.deserialize()?;
+                    Ok(front_matter)
+                },
+            )
+            .unwrap_or_else(|e| {
+                if let Some(file_name) = &file_name {
+                    log::error!("Failed to parse frontmatter for {}: {}", file_name, e);
+                } else {
+                    log::error!("Failed to parse frontmatter: {}", e);
+                }
+                StaticPromptFrontmatter::default()
+            });
+
         StaticPrompt {
             content,
-            file_name: None,
+            file_name,
+            metadata,
         }
     }
-
-    pub fn title(&self) -> Option<SharedString> {
-        self.metadata().map(|m| m.title())
-    }
-
-    // pub fn version(&self) -> Option<SharedString> {
-    //     self.metadata().map(|m| m.version())
-    // }
-
-    // pub fn author(&self) -> Option<SharedString> {
-    //     self.metadata().map(|m| m.author())
-    // }
-
-    // pub fn languages(&self) -> Vec<SharedString> {
-    //     self.metadata().map(|m| m.languages()).unwrap_or_default()
-    // }
-
-    // pub fn dependencies(&self) -> Vec<SharedString> {
-    //     self.metadata()
-    //         .map(|m| m.dependencies())
-    //         .unwrap_or_default()
-    // }
-
-    // pub fn load(fs: Arc<Fs>, file_name: String) -> anyhow::Result<Self> {
-    //     todo!()
-    // }
-
-    // pub fn save(&self, fs: Arc<Fs>) -> anyhow::Result<()> {
-    //     todo!()
-    // }
-
-    // pub fn rename(&self, new_file_name: String, fs: Arc<Fs>) -> anyhow::Result<()> {
-    //     todo!()
-    // }
 }
 
 impl StaticPrompt {
-    // pub fn update(&mut self, contents: String) -> &mut Self {
-    //     self.content = contents;
-    //     self
-    // }
-
     /// Sets the file name of the prompt
-    pub fn file_name(&mut self, file_name: String) -> &mut Self {
+    pub fn _file_name(&mut self, file_name: String) -> &mut Self {
         self.file_name = Some(file_name);
         self
     }
 
-    /// Sets the file name of the prompt based on the title
-    // pub fn file_name_from_title(&mut self) -> &mut Self {
-    //     if let Some(title) = self.title() {
-    //         let file_name = title.to_lowercase().replace(" ", "_");
-    //         if !file_name.is_empty() {
-    //             self.file_name = Some(file_name);
-    //         }
-    //     }
-    //     self
-    // }
-
     /// Returns the prompt's content
     pub fn content(&self) -> &String {
         &self.content
     }
-    fn parse(&self) -> anyhow::Result<(StaticPromptFrontmatter, String)> {
-        let matter = Matter::<YAML>::new();
-        let result = matter.parse(self.content.as_str());
-        match result.data {
-            Some(data) => {
-                let front_matter: StaticPromptFrontmatter = data.deserialize()?;
-                let body = result.content;
-                Ok((front_matter, body))
-            }
-            None => Err(anyhow::anyhow!("Failed to parse frontmatter")),
-        }
+
+    /// Returns the prompt's metadata
+    pub fn _metadata(&self) -> &StaticPromptFrontmatter {
+        &self.metadata
     }
 
-    pub fn metadata(&self) -> Option<StaticPromptFrontmatter> {
-        self.parse().ok().map(|(front_matter, _)| front_matter)
+    /// Returns the prompt's title
+    pub fn title(&self) -> SharedString {
+        self.metadata.title.clone().into()
+    }
+
+    pub fn body(&self) -> String {
+        let matter = Matter::<YAML>::new();
+        let result = matter.parse(self.content.as_str());
+        result.content.clone()
     }
 }
 

crates/assistant/src/prompts/prompt_library.rs 🔗

@@ -2,6 +2,7 @@ use anyhow::Context;
 use collections::HashMap;
 use fs::Fs;
 
+use gray_matter::{engine::YAML, Matter};
 use parking_lot::RwLock;
 use serde::{Deserialize, Serialize};
 use smol::stream::StreamExt;
@@ -119,6 +120,17 @@ impl PromptLibrary {
 
         while let Some(prompt_path) = prompt_paths.next().await {
             let prompt_path = prompt_path.with_context(|| "Failed to read prompt path")?;
+            let file_name_lossy = if prompt_path.file_name().is_some() {
+                Some(
+                    prompt_path
+                        .file_name()
+                        .unwrap()
+                        .to_string_lossy()
+                        .to_string(),
+                )
+            } else {
+                None
+            };
 
             if !fs.is_file(&prompt_path).await
                 || prompt_path.extension().and_then(|ext| ext.to_str()) != Some("md")
@@ -130,13 +142,17 @@ impl PromptLibrary {
                 .load(&prompt_path)
                 .await
                 .with_context(|| format!("Failed to load prompt {:?}", prompt_path))?;
-            let mut static_prompt = StaticPrompt::new(json);
 
-            if let Some(file_name) = prompt_path.file_name() {
-                let file_name = file_name.to_string_lossy().into_owned();
-                static_prompt.file_name(file_name);
+            // Check that the prompt is valid
+            let matter = Matter::<YAML>::new();
+            let result = matter.parse(&json);
+            if result.data.is_none() {
+                log::warn!("Invalid prompt: {:?}", prompt_path);
+                continue;
             }
 
+            let static_prompt = StaticPrompt::new(json, file_name_lossy.clone());
+
             let state = self.state.get_mut();
 
             let id = Uuid::new_v4();

crates/assistant/src/prompts/prompt_manager.rs 🔗

@@ -321,7 +321,7 @@ impl PickerDelegate for PromptManagerDelegate {
                 .inset(true)
                 .spacing(ListItemSpacing::Sparse)
                 .selected(selected)
-                .child(Label::new(prompt.title().unwrap_or_default().clone())),
+                .child(Label::new(prompt.title())),
         )
     }
 }

crates/assistant/src/slash_command/prompt_command.rs 🔗

@@ -43,12 +43,7 @@ impl SlashCommand for PromptSlashCommand {
                 .prompts()
                 .into_iter()
                 .enumerate()
-                .filter_map(|(ix, prompt)| {
-                    prompt
-                        .1
-                        .title()
-                        .map(|title| StringMatchCandidate::new(ix, title.into()))
-                })
+                .map(|(ix, prompt)| StringMatchCandidate::new(ix, prompt.1.title().to_string()))
                 .collect::<Vec<_>>();
             let matches = fuzzy::match_strings(
                 &candidates,
@@ -86,11 +81,10 @@ impl SlashCommand for PromptSlashCommand {
             let prompt = library
                 .prompts()
                 .into_iter()
-                .filter_map(|prompt| prompt.1.title().map(|title| (title, prompt)))
-                .find(|(t, _)| t == &title)
+                .find(|prompt| &prompt.1.title().to_string() == &title)
                 .with_context(|| format!("no prompt found with title {:?}", title))?
                 .1;
-            Ok(prompt.1.content().to_owned())
+            Ok(prompt.body())
         });
         SlashCommandInvocation {
             output,