1use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13
14use thiserror::Error;
15
16use crate::style::{Frontmatter, OutputStyle, OutputStyleSource};
17
18#[derive(Debug, Error)]
20pub enum OutputStyleError {
21 #[error("io: {0}")]
23 Io(#[from] std::io::Error),
24
25 #[error("frontmatter: {0}")]
27 Frontmatter(String),
28
29 #[error("yaml: {0}")]
31 Yaml(#[from] serde_yaml::Error),
32
33 #[error("style name '{name}' does not match file stem '{stem}'")]
35 NameStemMismatch {
36 name: String,
38 stem: String,
40 },
41
42 #[error("description must be non-empty")]
44 EmptyDescription,
45
46 #[error("invalid name '{0}': must match [a-z0-9_-]+ and be lowercase")]
48 InvalidName(String),
49}
50
51const BUILTIN_DEFAULT: &str = include_str!("builtins/default.md");
53const BUILTIN_PROACTIVE: &str = include_str!("builtins/proactive.md");
54const BUILTIN_EXPLANATORY: &str = include_str!("builtins/explanatory.md");
55const BUILTIN_LEARNING: &str = include_str!("builtins/learning.md");
56
57const BUILTINS: &[(&str, &str)] = &[
58 ("default", BUILTIN_DEFAULT),
59 ("proactive", BUILTIN_PROACTIVE),
60 ("explanatory", BUILTIN_EXPLANATORY),
61 ("learning", BUILTIN_LEARNING),
62];
63
64#[must_use]
66pub fn default_roots(workspace_root: &Path) -> DiscoveryRoots {
67 let project = workspace_root.join(".caliban").join("output-styles");
68 let user = dirs::config_dir().map(|d| d.join("caliban").join("output-styles"));
69 let plugins_root = dirs::data_local_dir().map(|d| d.join("caliban").join("plugins"));
70 DiscoveryRoots {
71 project,
72 user,
73 plugins_root,
74 }
75}
76
77#[derive(Debug, Clone)]
79pub struct DiscoveryRoots {
80 pub project: PathBuf,
82 pub user: Option<PathBuf>,
84 pub plugins_root: Option<PathBuf>,
89}
90
91#[must_use]
100pub fn load_styles(roots: &DiscoveryRoots) -> Vec<OutputStyle> {
101 let mut by_name: HashMap<String, OutputStyle> = HashMap::new();
102
103 scan_flat_dir(&roots.project, &OutputStyleKind::Project, &mut by_name);
105
106 if let Some(user) = roots.user.as_ref() {
108 scan_flat_dir(user, &OutputStyleKind::User, &mut by_name);
109 }
110
111 if let Some(plugins_root) = roots.plugins_root.as_ref()
113 && plugins_root.exists()
114 && let Ok(rd) = std::fs::read_dir(plugins_root)
115 {
116 for entry in rd.flatten() {
117 let plugin_dir = entry.path();
118 if !plugin_dir.is_dir() {
119 continue;
120 }
121 let Some(plugin_name) = plugin_dir
122 .file_name()
123 .and_then(|s| s.to_str())
124 .map(str::to_string)
125 else {
126 continue;
127 };
128 let styles_dir = plugin_dir.join("output-styles");
129 scan_flat_dir(
130 &styles_dir,
131 &OutputStyleKind::Plugin {
132 plugin_name: plugin_name.clone(),
133 },
134 &mut by_name,
135 );
136 }
137 }
138
139 for (name, raw) in BUILTINS {
141 if by_name.contains_key(*name) {
142 tracing::debug!(
143 target: caliban_common::tracing_targets::TARGET_OUTPUT_STYLES,
144 name = name,
145 "skipping shadowed built-in (overridden by higher-priority source)",
146 );
147 continue;
148 }
149 match parse_raw(raw, name, OutputStyleSource::BuiltIn) {
150 Ok(style) => {
151 by_name.insert(style.name.clone(), style);
152 }
153 Err(e) => {
154 tracing::error!(
155 target: caliban_common::tracing_targets::TARGET_OUTPUT_STYLES,
156 name = name,
157 error = %e,
158 "embedded built-in failed to parse — this is a bug",
159 );
160 }
161 }
162 }
163
164 let mut out: Vec<OutputStyle> = by_name.into_values().collect();
165 out.sort_by(|a, b| a.name.cmp(&b.name));
166 out
167}
168
169enum OutputStyleKind {
172 Project,
173 User,
174 Plugin { plugin_name: String },
175}
176
177fn scan_flat_dir(dir: &Path, kind: &OutputStyleKind, by_name: &mut HashMap<String, OutputStyle>) {
178 if !dir.exists() {
179 return;
180 }
181 let rd = match std::fs::read_dir(dir) {
182 Ok(rd) => rd,
183 Err(e) => {
184 tracing::warn!(
185 target: caliban_common::tracing_targets::TARGET_OUTPUT_STYLES,
186 dir = %dir.display(),
187 error = %e,
188 "could not read output-styles directory",
189 );
190 return;
191 }
192 };
193 for entry in rd.flatten() {
194 let path = entry.path();
195 if !path.is_file() {
196 continue;
197 }
198 if path.extension().and_then(|s| s.to_str()) != Some("md") {
199 continue;
200 }
201 let source = match kind {
202 OutputStyleKind::Project => OutputStyleSource::Project { path: path.clone() },
203 OutputStyleKind::User => OutputStyleSource::User { path: path.clone() },
204 OutputStyleKind::Plugin { plugin_name } => OutputStyleSource::Plugin {
205 plugin_name: plugin_name.clone(),
206 path: path.clone(),
207 },
208 };
209 match load_one(&path, source) {
210 Ok(mut style) => {
211 if let OutputStyleSource::Plugin { plugin_name, .. } = &style.source {
214 style.name = format!("{plugin_name}:{}", style.name);
215 }
216 if by_name.contains_key(&style.name) {
217 tracing::debug!(
218 target: caliban_common::tracing_targets::TARGET_OUTPUT_STYLES,
219 name = %style.name,
220 path = %path.display(),
221 "skipping shadowed style (already loaded from higher-priority root)",
222 );
223 } else {
224 by_name.insert(style.name.clone(), style);
225 }
226 }
227 Err(e) => {
228 tracing::warn!(
229 target: caliban_common::tracing_targets::TARGET_OUTPUT_STYLES,
230 path = %path.display(),
231 error = %e,
232 "skipping malformed output style",
233 );
234 }
235 }
236 }
237}
238
239pub fn load_one(path: &Path, source: OutputStyleSource) -> Result<OutputStyle, OutputStyleError> {
248 let raw = std::fs::read_to_string(path)?;
249 let stem = path
250 .file_stem()
251 .and_then(|s| s.to_str())
252 .unwrap_or_default();
253 parse_raw(&raw, stem, source)
254}
255
256fn parse_raw(
261 raw: &str,
262 expected_stem: &str,
263 source: OutputStyleSource,
264) -> Result<OutputStyle, OutputStyleError> {
265 let raw_trim = raw.trim_start_matches('\u{feff}');
266 let delim = "---\n";
267 if !raw_trim.starts_with(delim) {
268 return Err(OutputStyleError::Frontmatter(
269 "missing leading `---` frontmatter delimiter".into(),
270 ));
271 }
272 let after_start = &raw_trim[delim.len()..];
273 let Some(end_idx) = find_closing(after_start) else {
275 return Err(OutputStyleError::Frontmatter(
276 "missing closing `---` frontmatter delimiter".into(),
277 ));
278 };
279 let yaml_chunk = &after_start[..end_idx];
280 let after_close = &after_start[end_idx..];
282 let mut body_start = "\n---".len();
285 if after_close.as_bytes().get(body_start).copied() == Some(b'\n') {
286 body_start += 1;
287 }
288 let body = if body_start >= after_close.len() {
289 String::new()
290 } else {
291 after_close[body_start..].to_string()
292 };
293
294 let fm: Frontmatter = serde_yaml::from_str(yaml_chunk)?;
295
296 if fm.description.trim().is_empty() {
297 return Err(OutputStyleError::EmptyDescription);
298 }
299 if !is_valid_name(&fm.name) {
300 return Err(OutputStyleError::InvalidName(fm.name));
301 }
302 if fm.name != expected_stem {
303 return Err(OutputStyleError::NameStemMismatch {
304 name: fm.name,
305 stem: expected_stem.to_string(),
306 });
307 }
308
309 Ok(OutputStyle {
310 name: fm.name,
311 description: fm.description,
312 body,
313 keep_coding_instructions: fm.keep_coding_instructions,
314 force_for_plugin: fm.force_for_plugin,
315 source,
316 })
317}
318
319fn find_closing(s: &str) -> Option<usize> {
323 if let Some(i) = s.find("\n---\n") {
325 return Some(i);
326 }
327 if let Some(i) = s.rfind("\n---")
328 && s[i + "\n---".len()..].chars().all(char::is_whitespace)
329 {
330 return Some(i);
331 }
332 None
333}
334
335fn is_valid_name(name: &str) -> bool {
336 !name.is_empty()
337 && name
338 .chars()
339 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
340}
341
342#[must_use]
355pub fn select_active(
356 all: &[OutputStyle],
357 requested: &str,
358 enabled_plugins: &[String],
359) -> Option<OutputStyle> {
360 for s in all {
363 if !s.force_for_plugin {
364 continue;
365 }
366 if let OutputStyleSource::Plugin { plugin_name, .. } = &s.source
367 && enabled_plugins.iter().any(|n| n == plugin_name)
368 {
369 tracing::debug!(
370 target: caliban_common::tracing_targets::TARGET_OUTPUT_STYLES,
371 style = %s.name,
372 plugin = %plugin_name,
373 "plugin-forced output style active (overrides operator selection)",
374 );
375 return Some(s.clone());
376 } else if !matches!(&s.source, OutputStyleSource::Plugin { .. }) {
377 tracing::debug!(
379 target: caliban_common::tracing_targets::TARGET_OUTPUT_STYLES,
380 style = %s.name,
381 "ignoring force_for_plugin on non-plugin style",
382 );
383 }
384 }
385
386 if let Some(s) = all.iter().find(|s| s.name == requested) {
387 return Some(s.clone());
388 }
389
390 tracing::warn!(
391 target: caliban_common::tracing_targets::TARGET_OUTPUT_STYLES,
392 requested = requested,
393 "unknown output style; falling back to built-in default",
394 );
395 all.iter().find(|s| s.name == "default").cloned()
396}