Skip to main content

vtcode_config/
output_styles.rs

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