Skip to main content

caliban_output_styles/
loader.rs

1//! Filesystem walker + frontmatter parser for output-style `.md` files.
2//!
3//! Discovery roots (priority order, first-wins):
4//!
5//! 1. `<workspace_root>/.caliban/output-styles/<name>.md` (project)
6//! 2. `$XDG_CONFIG_HOME/caliban/output-styles/<name>.md` (user)
7//! 3. `$XDG_DATA_HOME/caliban/plugins/<plugin>/output-styles/<name>.md`
8//!    (plugin — namespaced as `<plugin>:<name>`)
9//! 4. The four embedded built-ins (always present).
10
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13
14use thiserror::Error;
15
16use crate::style::{Frontmatter, OutputStyle, OutputStyleSource};
17
18/// Errors surfaced by [`load_one`].
19#[derive(Debug, Error)]
20pub enum OutputStyleError {
21    /// The file could not be read from disk.
22    #[error("io: {0}")]
23    Io(#[from] std::io::Error),
24
25    /// The frontmatter delimiters were missing or malformed.
26    #[error("frontmatter: {0}")]
27    Frontmatter(String),
28
29    /// The YAML inside the frontmatter could not be parsed.
30    #[error("yaml: {0}")]
31    Yaml(#[from] serde_yaml::Error),
32
33    /// The `name:` field does not match the file stem.
34    #[error("style name '{name}' does not match file stem '{stem}'")]
35    NameStemMismatch {
36        /// The frontmatter-declared name.
37        name: String,
38        /// The on-disk filename stem.
39        stem: String,
40    },
41
42    /// The `description:` field was empty after trimming.
43    #[error("description must be non-empty")]
44    EmptyDescription,
45
46    /// The name contains characters outside `[a-z0-9_-]+`.
47    #[error("invalid name '{0}': must match [a-z0-9_-]+ and be lowercase")]
48    InvalidName(String),
49}
50
51/// Built-in style files embedded at compile time.
52const 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/// Per-source discovery roots used by [`load_styles`].
65#[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/// The set of filesystem roots scanned for output-style files.
78#[derive(Debug, Clone)]
79pub struct DiscoveryRoots {
80    /// `<workspace>/.caliban/output-styles/`.
81    pub project: PathBuf,
82    /// `$XDG_CONFIG_HOME/caliban/output-styles/`.
83    pub user: Option<PathBuf>,
84    /// `$XDG_DATA_HOME/caliban/plugins/` — each subdirectory is a plugin,
85    /// and styles are loaded from `<plugin>/output-styles/`.
86    // v2: plugin styles are loaded but inert (`force_for_plugin` is ignored)
87    // until the plugin system from ADR 0030 lands.
88    pub plugins_root: Option<PathBuf>,
89}
90
91/// Load all available output styles in priority order.
92///
93/// Priority: project > user > plugin > built-in. The first occurrence of a
94/// given `name` wins; subsequent occurrences are shadowed and logged at
95/// `tracing::debug!`.
96///
97/// Plugin-supplied styles are namespaced `<plugin_name>:<style_name>` in
98/// the returned list, so they cannot collide with bare names by accident.
99#[must_use]
100pub fn load_styles(roots: &DiscoveryRoots) -> Vec<OutputStyle> {
101    let mut by_name: HashMap<String, OutputStyle> = HashMap::new();
102
103    // 1. project
104    scan_flat_dir(&roots.project, &OutputStyleKind::Project, &mut by_name);
105
106    // 2. user
107    if let Some(user) = roots.user.as_ref() {
108        scan_flat_dir(user, &OutputStyleKind::User, &mut by_name);
109    }
110
111    // 3. plugin (scan each `<plugins_root>/<plugin>/output-styles/`).
112    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    // 4. built-ins (always present, lowest priority)
140    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
169/// Internal: which kind of source we're scanning. Carries the plugin name
170/// when applicable so we can namespace style names.
171enum 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                // Namespace plugin styles as "<plugin>:<name>" so they don't
212                // collide with bare names from project/user/built-in.
213                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
239/// Parse a single output-style `.md` file from disk, attributing it to the
240/// given `source`.
241///
242/// # Errors
243///
244/// Returns [`OutputStyleError`] when the file cannot be read, the
245/// frontmatter is missing/malformed, the YAML is invalid, the name field
246/// doesn't match the file stem, or the description is empty.
247pub 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
256/// Parse raw markdown-with-frontmatter into an [`OutputStyle`].
257///
258/// `expected_stem` is the file stem (or built-in key) used for the
259/// name-mismatch check.
260fn 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    // Look for the closing `\n---` (with or without trailing newline / content).
274    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    // Body is whatever follows "\n---" (skipping the newline after, if any).
281    let after_close = &after_start[end_idx..];
282    // `after_close` starts with "\n---". Skip those four bytes and one
283    // optional trailing newline.
284    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
319/// Returns the byte offset of the closing `\n---` marker in `s`, if any.
320///
321/// Tolerates both `\n---\n` (closing followed by body) and `\n---` at EOF.
322fn find_closing(s: &str) -> Option<usize> {
323    // Prefer the strict `\n---\n` form; fall back to a trailing `\n---` at EOF.
324    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/// Select the active style from the loaded list given a requested name and
343/// the set of enabled plugins.
344///
345/// Selection precedence:
346///
347/// 1. If any plugin-sourced style has `force_for_plugin = true` *and* its
348///    plugin appears in `enabled_plugins`, that style wins regardless of
349///    `requested`.
350/// 2. Otherwise, the style whose `name` exactly matches `requested` is
351///    returned.
352/// 3. Failing that, the built-in `default` style is returned and a warning
353///    is logged.
354#[must_use]
355pub fn select_active(
356    all: &[OutputStyle],
357    requested: &str,
358    enabled_plugins: &[String],
359) -> Option<OutputStyle> {
360    // v2: plugin force-override path. Today no plugins ship with caliban,
361    // so this branch is exercised only by tests until ADR 0030 lands.
362    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            // Sideload (user/project/built-in) with force_for_plugin = true is ignored.
378            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}