use super::config::SddConfig;
use anyhow::{Result, anyhow};
use serde::Deserialize;
use std::fs;
use std::path::{Path, PathBuf};
const MANAGED_SKILL_PREFIX: &str = "llman-sdd-";
#[derive(Debug, Deserialize)]
struct SkillFrontmatter {
#[serde(default)]
metadata: Option<SkillMetadata>,
}
#[derive(Debug, Deserialize)]
struct SkillMetadata {
#[serde(default)]
llman_sdd: Option<LlmanSddMeta>,
}
#[derive(Debug, Deserialize)]
struct LlmanSddMeta {
#[serde(default)]
bdd_mode: Option<String>,
#[serde(default)]
#[allow(dead_code)]
skill_set: Option<String>,
}
pub fn expected_bdd_mode(config: &SddConfig) -> &'static str {
if config.bdd.is_some() { "on" } else { "off" }
}
pub fn check_installed_skills_bdd_mode(root: &Path, config: &SddConfig) -> Result<()> {
let skills_dir = root.join(".agents").join("skills");
if !skills_dir.exists() {
return Ok(());
}
let expected = expected_bdd_mode(config);
let mut bdd_violations: Vec<(PathBuf, String)> = Vec::new();
let mut jinja_violations: Vec<(PathBuf, String)> = Vec::new();
for entry in fs::read_dir(&skills_dir)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let dir_name = entry.file_name().to_string_lossy().to_string();
if !dir_name.starts_with(MANAGED_SKILL_PREFIX) {
continue;
}
let skill_md = entry.path().join("SKILL.md");
if !skill_md.exists() {
bdd_violations.push((skill_md, "missing SKILL.md".to_string()));
continue;
}
let content = match fs::read_to_string(&skill_md) {
Ok(c) => c,
Err(e) => {
bdd_violations.push((skill_md, format!("read failed: {e}")));
continue;
}
};
match read_bdd_mode_from_content(&content) {
Ok(Some(mode)) if mode == expected => {}
Ok(Some(mode)) => {
bdd_violations.push((
skill_md.clone(),
format!("bdd_mode={mode}, expected {expected}"),
));
}
Ok(None) => {
bdd_violations.push((
skill_md.clone(),
format!("missing metadata.llman_sdd.bdd_mode (expected {expected})"),
));
}
Err(msg) => {
bdd_violations.push((skill_md.clone(), msg));
}
}
if let Some(snippet) = first_unrendered_jinja_snippet(&content) {
jinja_violations.push((
skill_md,
format!("unrendered MiniJinja tag near: {snippet}"),
));
}
}
if !bdd_violations.is_empty() {
let mut detail = String::new();
for (path, reason) in &bdd_violations {
detail.push_str(&format!("\n - {}: {reason}", path.display()));
}
return Err(anyhow!(t!(
"sdd.skill_consistency.bdd_mode_mismatch",
expected = expected,
details = detail.as_str(),
fix = "llman sdd init --update"
)));
}
if !jinja_violations.is_empty() {
let mut detail = String::new();
for (path, reason) in &jinja_violations {
detail.push_str(&format!("\n - {}: {reason}", path.display()));
}
return Err(anyhow!(t!(
"sdd.skill_consistency.unrendered_template_syntax",
details = detail.as_str(),
fix = "llman sdd init --update"
)));
}
Ok(())
}
fn first_unrendered_jinja_snippet(content: &str) -> Option<String> {
let idx = content.find("{%")?;
let end = (idx + 48).min(content.len());
let mut snippet = content[idx..end].replace('\n', " ");
if end < content.len() {
snippet.push('…');
}
Some(snippet)
}
fn read_bdd_mode_from_content(content: &str) -> Result<Option<String>, String> {
let Some(yaml) = extract_frontmatter_yaml(content) else {
return Ok(None);
};
let fm: SkillFrontmatter =
serde_yaml::from_str(yaml).map_err(|e| format!("frontmatter parse error: {e}"))?;
let mode = fm
.metadata
.and_then(|m| m.llman_sdd)
.and_then(|l| l.bdd_mode);
match mode {
Some(m) => {
let m = m.trim().to_ascii_lowercase();
if m == "on" || m == "off" {
Ok(Some(m))
} else {
Err(format!("invalid bdd_mode={m} (want on|off)"))
}
}
None => Ok(None),
}
}
fn extract_frontmatter_yaml(content: &str) -> Option<&str> {
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return None;
}
let after = &trimmed[3..];
let after = after.strip_prefix('\n').unwrap_or(after);
let end = after.find("\n---")?;
Some(&after[..end])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sdd::project::config::{BddConfig, SddConfig};
use std::fs;
use tempfile::TempDir;
fn write_skill(root: &Path, name: &str, body: &str) {
let dir = root.join(".agents/skills").join(name);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("SKILL.md"), body).unwrap();
}
fn cfg_bdd_on() -> SddConfig {
SddConfig {
schema: "spec-driven".into(),
locale: "en".into(),
bdd: Some(BddConfig {
framework: "rstest-bdd".into(),
feature_dir: None,
default_language: None,
run_command: Some("cargo test --features bdd".into()),
verify_prompt: None,
bindings: None,
}),
extra_skills: None,
archive: None,
sdd: None,
}
}
fn cfg_bdd_off() -> SddConfig {
SddConfig {
schema: "spec-driven".into(),
locale: "en".into(),
bdd: None,
extra_skills: None,
archive: None,
sdd: None,
}
}
#[test]
fn ok_when_no_skills_dir() {
let tmp = TempDir::new().unwrap();
check_installed_skills_bdd_mode(tmp.path(), &cfg_bdd_on()).unwrap();
}
#[test]
fn ignores_custom_skill_without_prefix() {
let tmp = TempDir::new().unwrap();
write_skill(tmp.path(), "my-custom-skill", "no frontmatter\n");
check_installed_skills_bdd_mode(tmp.path(), &cfg_bdd_on()).unwrap();
}
#[test]
fn errors_when_metadata_missing() {
let tmp = TempDir::new().unwrap();
write_skill(tmp.path(), "llman-sdd-explore", "planted\n");
let err = check_installed_skills_bdd_mode(tmp.path(), &cfg_bdd_on()).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("init --update"), "{msg}");
assert!(
msg.contains("llman-sdd-explore") || msg.contains("missing"),
"{msg}"
);
}
#[test]
fn errors_on_mismatch() {
let tmp = TempDir::new().unwrap();
write_skill(
tmp.path(),
"llman-sdd-explore",
"---\nname: llman-sdd-explore\nmetadata:\n version: \"1.0.0\"\n llman_sdd:\n bdd_mode: off\n skill_set: default\n---\nbody\n",
);
let err = check_installed_skills_bdd_mode(tmp.path(), &cfg_bdd_on()).unwrap_err();
assert!(format!("{err:#}").contains("expected on"));
}
#[test]
fn ok_when_matching() {
let tmp = TempDir::new().unwrap();
write_skill(
tmp.path(),
"llman-sdd-explore",
"---\nname: llman-sdd-explore\nmetadata:\n version: \"1.0.0\"\n llman_sdd:\n bdd_mode: on\n skill_set: default\n---\nbody\n",
);
check_installed_skills_bdd_mode(tmp.path(), &cfg_bdd_on()).unwrap();
write_skill(
tmp.path(),
"llman-sdd-explore",
"---\nname: llman-sdd-explore\nmetadata:\n version: \"1.0.0\"\n llman_sdd:\n bdd_mode: off\n skill_set: default\n---\nbody\n",
);
check_installed_skills_bdd_mode(tmp.path(), &cfg_bdd_off()).unwrap();
}
#[test]
fn errors_on_unrendered_jinja_in_body() {
let tmp = TempDir::new().unwrap();
write_skill(
tmp.path(),
"llman-sdd-explore",
"---\nname: llman-sdd-explore\nmetadata:\n version: \"1.0.0\"\n llman_sdd:\n bdd_mode: on\n skill_set: default\n---\n{% if bdd_enabled %}\n- attach\n{% endif %}\n",
);
let err = check_installed_skills_bdd_mode(tmp.path(), &cfg_bdd_on()).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("unrendered") || msg.contains("MiniJinja"),
"{msg}"
);
assert!(msg.contains("init --update"), "{msg}");
assert!(msg.contains("{%"), "{msg}");
}
}