use std::path::{Path, PathBuf};
use mermaid_domain::{ActiveStyle, OutputStyleSummary};
const STYLES_DIR: &str = "output-styles";
fn user_styles_dir() -> Option<PathBuf> {
crate::app::get_config_dir()
.ok()
.map(|dir| dir.join(STYLES_DIR))
}
fn project_styles_dir(cwd: &Path) -> Option<PathBuf> {
crate::app::memory::find_git_root(cwd).map(|root| root.join(".mermaid").join(STYLES_DIR))
}
struct DiscoveredFile {
name: String,
path: PathBuf,
source: &'static str,
}
fn discover_dir(root: Option<PathBuf>, source: &'static str) -> Vec<DiscoveredFile> {
let Some(root) = root else {
return Vec::new();
};
let Ok(read) = std::fs::read_dir(&root) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in read.flatten() {
let path = entry.path();
if !entry
.file_type()
.map(|kind| kind.is_file())
.unwrap_or(false)
{
continue;
}
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
continue;
}
let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
continue;
};
if !mermaid_domain::prompts::is_valid_style_name(stem) {
tracing::warn!(path = %path.display(), "output style filename is not a valid style name; skipping");
continue;
}
out.push(DiscoveredFile {
name: stem.to_string(),
path,
source,
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
fn read_style_file(path: &Path) -> Option<(String, String, bool, String)> {
let cap = mermaid_model::constants::MAX_OUTPUT_STYLE_BYTES;
let (bytes, truncated) = match mermaid_model::utils::read_file_capped(path, cap) {
Ok(body) => body,
Err(error) => {
tracing::warn!(path = %path.display(), %error, "output style: skipping unreadable file");
return None;
},
};
if truncated {
tracing::warn!(path = %path.display(), "output style exceeds the {cap}-byte cap; skipping");
return None;
}
let raw = String::from_utf8_lossy(&bytes).into_owned();
let parsed = mermaid_domain::prompts::parse_style_file(&raw);
if parsed.body.trim().is_empty() {
tracing::warn!(path = %path.display(), "output style has no body; skipping");
return None;
}
let name = parsed.name.unwrap_or_default();
let description = parsed.description.unwrap_or_else(|| {
parsed
.body
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or("Custom output style")
.to_string()
});
Some((
name,
description,
parsed.keep_coding_instructions,
parsed.body,
))
}
#[must_use]
pub fn list_styles(cwd: &Path) -> Vec<OutputStyleSummary> {
let mut entries = vec![OutputStyleSummary {
name: mermaid_domain::prompts::DEFAULT_OUTPUT_STYLE.to_string(),
description: "The stock system prompt".to_string(),
custom: false,
source: "builtin".to_string(),
}];
for builtin in mermaid_domain::prompts::BUILTIN_OUTPUT_STYLES {
entries.push(OutputStyleSummary {
name: builtin.name.to_string(),
description: builtin.description.to_string(),
custom: false,
source: "builtin".to_string(),
});
}
let mut seen = std::collections::HashSet::new();
for file in discover_dir(project_styles_dir(cwd), "project")
.into_iter()
.chain(discover_dir(user_styles_dir(), "user"))
{
if !seen.insert(file.name.clone()) {
continue;
}
let (_name, description, _keep, _body) = match read_style_file(&file.path) {
Some(parsed) => parsed,
None => continue,
};
entries.push(OutputStyleSummary {
name: file.name,
description,
custom: true,
source: file.source.to_string(),
});
}
entries
}
#[must_use]
pub fn load_style_body(cwd: &Path, name: &str) -> Option<(String, bool, bool, &'static str)> {
if let Some(builtin) = mermaid_domain::prompts::builtin_output_style(name) {
return Some((builtin.body.to_string(), true, false, "builtin"));
}
for file in discover_dir(project_styles_dir(cwd), "project")
.into_iter()
.chain(discover_dir(user_styles_dir(), "user"))
{
if file.name != name {
continue;
}
let (frontmatter_name, _description, keep, body) = read_style_file(&file.path)?;
if !frontmatter_name.is_empty() && frontmatter_name != name {
tracing::warn!(path = %file.path.display(), "output style frontmatter name disagrees with the filename; using the filename");
}
return Some((body, keep, true, file.source));
}
None
}
pub fn resolve_and_apply(
cwd: &Path,
flags: &mermaid_domain::SessionFlags,
config: &mut mermaid_domain::Config,
) -> Vec<String> {
let mut warnings = Vec::new();
let name = config.output.style.trim().to_string();
if name.is_empty() || name == mermaid_domain::prompts::DEFAULT_OUTPUT_STYLE {
config.output.style = mermaid_domain::prompts::DEFAULT_OUTPUT_STYLE.to_string();
config.active_style = ActiveStyle::none();
return warnings;
}
if !mermaid_domain::prompts::is_valid_style_name(&name) {
warnings.push(format!(
"unknown output style '{name}' — using default (bare `/output-style` lists styles)"
));
config.output.style = mermaid_domain::prompts::DEFAULT_OUTPUT_STYLE.to_string();
config.active_style = ActiveStyle::none();
return warnings;
}
let source = style_source(cwd, flags);
if let Some((body, keep, custom, _)) = load_style_body(cwd, &name) {
config.active_style = ActiveStyle {
body,
keep_coding_instructions: keep,
custom,
source: source.to_string(),
};
} else {
warnings.push(format!(
"unknown output style '{name}' — using default (bare `/output-style` lists styles)"
));
config.output.style = mermaid_domain::prompts::DEFAULT_OUTPUT_STYLE.to_string();
config.active_style = ActiveStyle::none();
}
warnings
}
fn style_source(cwd: &Path, flags: &mermaid_domain::SessionFlags) -> &'static str {
if flags
.output_style
.as_deref()
.is_some_and(|name| !name.trim().is_empty())
|| flags
.overrides
.iter()
.any(|raw| raw.split('=').next().map(str::trim) == Some("output.style"))
{
return "session";
}
if let Some(root) = crate::app::memory::find_git_root(cwd)
&& let Ok(table) =
crate::app::config::read_config_table(&root.join(".mermaid").join("config.toml"))
&& table
.get("output")
.and_then(|output| output.get("style"))
.and_then(|style| style.as_str())
.is_some_and(|style| !style.trim().is_empty())
{
return "project";
}
if let Ok(dir) = crate::app::get_config_dir()
&& let Ok(table) = crate::app::config::read_config_table(&dir.join("config.toml"))
&& table
.get("output")
.and_then(|output| output.get("style"))
.and_then(|style| style.as_str())
.is_some_and(|style| !style.trim().is_empty())
{
return "user";
}
"default"
}
#[must_use]
pub fn output_style_notice(config: &mermaid_domain::Config) -> Option<String> {
let name = config.output.style.trim();
if name.is_empty() || name == mermaid_domain::prompts::DEFAULT_OUTPUT_STYLE {
return None;
}
let kind = if config.active_style.custom {
"custom"
} else {
"built-in"
};
Some(format!(
"Output style: {name} ({kind}, from {}).",
config.active_style.source
))
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"mermaid-output-styles-{tag}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn write_style(dir: &Path, name: &str, content: &str) -> PathBuf {
let styles = dir.join(STYLES_DIR);
std::fs::create_dir_all(&styles).unwrap();
let path = styles.join(format!("{name}.md"));
std::fs::write(&path, content).unwrap();
path
}
#[test]
fn list_styles_has_default_and_builtins_without_any_files() {
let dir = temp_dir("bare");
let entries = list_styles(&dir);
let names: Vec<&str> = entries.iter().map(|entry| entry.name.as_str()).collect();
assert!(names.contains(&"default"));
for builtin in ["proactive", "concise", "explanatory", "learning"] {
assert!(names.contains(&builtin), "missing {builtin}: {names:?}");
}
assert!(entries.iter().all(|entry| !entry.custom));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn list_styles_discovers_user_files() {
let dir = temp_dir("user-file");
temp_env::with_var(
crate::app::config::CONFIG_DIR_ENV,
Some(dir.to_str().unwrap()),
|| {
write_style(
&dir,
"terse",
"---\ndescription: Very short\n---\n\nBe brief.\n",
);
let entries = list_styles(std::path::Path::new("/nonexistent-cwd"));
let terse = entries
.iter()
.find(|entry| entry.name == "terse")
.expect("listed");
assert!(terse.custom);
assert_eq!(terse.source, "user");
assert_eq!(terse.description, "Very short");
},
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_style_body_prefers_builtin_without_touching_files() {
let dir = temp_dir("builtin");
let (body, keep, custom, source) = load_style_body(&dir, "concise").expect("builtin");
assert!(body.contains("concise"));
assert!(keep);
assert!(!custom);
assert_eq!(source, "builtin");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_style_body_reads_custom_files_project_first() {
let dir = temp_dir("custom");
temp_env::with_var(
crate::app::config::CONFIG_DIR_ENV,
Some(dir.to_str().unwrap()),
|| {
write_style(&dir, "terse", "User body.\n");
let (body, keep, custom, source) = load_style_body(&dir, "terse").expect("custom");
assert_eq!(body, "User body.");
assert!(keep, "keeping the base prompt is the default");
assert!(custom);
assert_eq!(source, "user");
assert!(load_style_body(&dir, "missing").is_none());
},
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn project_styles_shadow_user_files() {
let dir = temp_dir("shadow");
let repo = dir.join("repo");
std::fs::create_dir_all(repo.join(".git")).unwrap();
let project_styles = repo.join(".mermaid").join(STYLES_DIR);
std::fs::create_dir_all(&project_styles).unwrap();
std::fs::write(project_styles.join("terse.md"), "Project body.\n").unwrap();
temp_env::with_var(
crate::app::config::CONFIG_DIR_ENV,
Some(dir.to_str().unwrap()),
|| {
write_style(&dir, "terse", "User body.\n");
let sub = repo.join("src");
std::fs::create_dir_all(&sub).unwrap();
let (body, _, custom, source) = load_style_body(&sub, "terse").expect("shadowed");
assert_eq!(body, "Project body.");
assert!(custom);
assert_eq!(source, "project");
},
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn resolve_and_apply_falls_back_on_unknown_names() {
let dir = temp_dir("fallback");
let mut config = mermaid_domain::Config::default();
config.output.style = "Bogus Name!".to_string();
let warnings =
resolve_and_apply(&dir, &mermaid_domain::SessionFlags::default(), &mut config);
assert_eq!(config.output.style, "default");
assert!(config.active_style.body.is_empty());
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("Bogus Name!"));
let _ = std::fs::remove_dir_all(&dir);
}
}