Skip to main content

vtcode_config/
output_styles.rs

1use anyhow::Result;
2use hashbrown::HashMap;
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::path::Path;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
9pub struct OutputStyleConfig {
10    #[serde(default = "default_output_style")]
11    pub active_style: String,
12}
13
14fn default_output_style() -> String {
15    "default".to_string()
16}
17
18impl Default for OutputStyleConfig {
19    fn default() -> Self {
20        Self { active_style: default_output_style() }
21    }
22}
23
24#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25#[serde(rename_all = "kebab-case")]
26pub struct OutputStyleFileConfig {
27    name: String,
28    description: Option<String>,
29    #[serde(default)]
30    pub keep_coding_instructions: bool,
31}
32
33#[derive(Debug, Clone)]
34pub struct OutputStyle {
35    pub config: OutputStyleFileConfig,
36    pub content: String,
37}
38
39#[derive(Debug)]
40pub struct OutputStyleManager {
41    styles: HashMap<String, OutputStyle>,
42}
43
44impl Default for OutputStyleManager {
45    fn default() -> Self {
46        Self::new()
47    }
48}
49
50impl OutputStyleManager {
51    pub fn new() -> Self {
52        Self { styles: HashMap::new() }
53    }
54
55    pub fn load_from_directory<P: AsRef<Path>>(dir: P) -> Result<Self> {
56        let mut manager = Self::new();
57        let dir = dir.as_ref();
58
59        if !dir.exists() {
60            return Ok(manager);
61        }
62
63        for entry in fs::read_dir(dir)? {
64            let entry = entry?;
65            let path = entry.path();
66
67            if path.extension().and_then(|s| s.to_str()) == Some("md")
68                && let Ok(output_style) = Self::load_from_file(&path)
69            {
70                manager.styles.insert(output_style.config.name.clone(), output_style);
71            }
72        }
73
74        Ok(manager)
75    }
76
77    fn load_from_file<P: AsRef<Path>>(path: P) -> Result<OutputStyle> {
78        let content = fs::read_to_string(path)?;
79        Self::parse_output_style(&content)
80    }
81
82    fn parse_output_style(content: &str) -> Result<OutputStyle> {
83        // Look for frontmatter (between --- and ---)
84        if let Some(frontmatter_body) = content.strip_prefix("---\n")
85            && let Some(frontmatter_end) = frontmatter_body.find("\n---\n")
86        {
87            let frontmatter_content = &frontmatter_body[..frontmatter_end];
88            let config: OutputStyleFileConfig = serde_saphyr::from_str(frontmatter_content)?;
89
90            // Get the content after the frontmatter
91            let content_start = frontmatter_end + 5; // Skip past body + "\n---\n"
92            let actual_content = if content_start < frontmatter_body.len() {
93                &frontmatter_body[content_start..]
94            } else {
95                ""
96            };
97
98            Ok(OutputStyle { config, content: actual_content.to_string() })
99        } else {
100            // No frontmatter, create default config
101            Ok(OutputStyle {
102                config: OutputStyleFileConfig {
103                    name: "default".to_string(),
104                    description: Some("Default output style".to_string()),
105                    keep_coding_instructions: true,
106                },
107                content: content.to_string(),
108            })
109        }
110    }
111
112    pub fn get_style(&self, name: &str) -> Option<&OutputStyle> {
113        self.styles.get(name)
114    }
115
116    pub fn list_styles(&self) -> Vec<(&str, &str)> {
117        self.styles
118            .iter()
119            .map(|(name, style)| (name.as_str(), style.config.description.as_deref().unwrap_or("No description")))
120            .collect()
121    }
122
123    fn apply_style(&self, name: &str, base_prompt: &str) -> String {
124        if let Some(style) = self.get_style(name) {
125            if style.config.keep_coding_instructions {
126                // Combine base prompt with style content
127                format!("{}\n\n{}", base_prompt, style.content)
128            } else {
129                // Replace base prompt with style content
130                style.content.clone()
131            }
132        } else {
133            base_prompt.to_string()
134        }
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use std::fs;
142    use tempfile::TempDir;
143
144    #[test]
145    fn test_parse_output_style_with_frontmatter() {
146        let content = r#"---
147name: Test Style
148description: A test output style
149keep-coding-instructions: false
150---
151
152# Test Output Style
153
154This is a test output style."#;
155
156        let style = OutputStyleManager::parse_output_style(content).unwrap();
157        assert_eq!(style.config.name, "Test Style");
158        assert_eq!(style.config.description, Some("A test output style".to_string()));
159        assert!(!style.config.keep_coding_instructions);
160        assert!(style.content.contains("This is a test output style"));
161    }
162
163    #[test]
164    fn test_parse_output_style_with_bare_frontmatter_fence() {
165        let style = OutputStyleManager::parse_output_style("---").unwrap();
166
167        assert_eq!(style.config.name, "default");
168        assert_eq!(style.content, "---");
169    }
170
171    #[test]
172    fn test_parse_output_style_without_frontmatter() {
173        let content = r#"This is a plain output style without frontmatter."#;
174
175        let style = OutputStyleManager::parse_output_style(content).unwrap();
176        assert_eq!(style.config.name, "default");
177        assert!(style.content.contains("This is a plain output style"));
178    }
179
180    #[test]
181    fn test_load_from_directory() {
182        let temp_dir = TempDir::new().unwrap();
183        let style_file = temp_dir.path().join("test_style.md");
184
185        fs::write(
186            &style_file,
187            r#"---
188name: Test Style
189description: A test output style
190keep-coding-instructions: true
191---
192
193# Test Output Style
194
195This is a test output style."#,
196        )
197        .unwrap();
198
199        let manager = OutputStyleManager::load_from_directory(temp_dir.path()).unwrap();
200        assert!(manager.get_style("Test Style").is_some());
201    }
202
203    #[test]
204    fn test_apply_style_with_keep_instructions() {
205        let content = r#"---
206name: Test Style
207description: A test output style
208keep-coding-instructions: true
209---
210
211## Custom Instructions
212
213Custom instructions here."#;
214
215        let style = OutputStyleManager::parse_output_style(content).unwrap();
216        let mut manager = OutputStyleManager::new();
217        manager.styles.insert("Test Style".to_string(), style);
218
219        let base_prompt = "Base system prompt";
220        let result = manager.apply_style("Test Style", base_prompt);
221
222        assert!(result.contains("Base system prompt"));
223        assert!(result.contains("Custom instructions here"));
224    }
225
226    #[test]
227    fn test_apply_style_without_keep_instructions() {
228        let content = r#"---
229name: Test Style
230description: A test output style
231keep-coding-instructions: false
232---
233
234## Custom Instructions
235
236Custom instructions here."#;
237
238        let style = OutputStyleManager::parse_output_style(content).unwrap();
239        let mut manager = OutputStyleManager::new();
240        manager.styles.insert("Test Style".to_string(), style);
241
242        let base_prompt = "Base system prompt";
243        let result = manager.apply_style("Test Style", base_prompt);
244
245        assert!(!result.contains("Base system prompt"));
246        assert!(result.contains("Custom instructions here"));
247    }
248}