#[cfg(feature = "local")]
use std::path::PathBuf;
#[cfg(feature = "local")]
use serde::Deserialize;
#[cfg(feature = "local")]
use theway_core::{ExecutionEnv, FileErrorCode, FileKind, SkillDiagnosticCode};
use theway_core::{PromptTemplate, SkillDiagnostic};
#[cfg(feature = "local")]
use tokio_util::sync::CancellationToken;
#[cfg(feature = "local")]
use crate::env::native::NativeEnv;
pub struct LoadedTemplates {
pub templates: Vec<PromptTemplate>,
pub diagnostics: Vec<SkillDiagnostic>,
}
#[cfg(feature = "local")]
pub async fn load_all(paths: &crate::DaemonPaths) -> LoadedTemplates {
let project: PathBuf = paths.work_dir.join(".theway").join("templates");
let user: PathBuf = paths.base.join("templates");
let env = NativeEnv::new(paths.work_dir.to_string_lossy().to_string());
let cancel = CancellationToken::new();
let mut combined: Vec<PromptTemplate> = Vec::new();
let mut diagnostics = Vec::new();
for dir in [user, project] {
let s = dir.to_string_lossy().to_string();
let LoadTemplatesOutput {
templates,
diagnostics: diags,
} = load_templates(&env, &[s.as_str()], cancel.clone()).await;
diagnostics.extend(diags);
for t in templates {
if let Some(i) = combined.iter().position(|x| x.name == t.name) {
combined[i] = t;
} else {
combined.push(t);
}
}
}
LoadedTemplates {
templates: combined,
diagnostics,
}
}
#[cfg(not(feature = "local"))]
pub async fn load_all(_paths: &crate::DaemonPaths) -> LoadedTemplates {
tracing::warn!(
"template discovery unavailable in sandbox build — loading no templates (the sandbox \
feature has no local filesystem access)"
);
LoadedTemplates {
templates: Vec::new(),
diagnostics: Vec::new(),
}
}
#[cfg(feature = "local")]
#[derive(Debug, Default, Deserialize)]
struct TemplateFrontmatter {
name: Option<String>,
description: Option<String>,
}
#[derive(Default, Clone, Debug)]
pub struct LoadTemplatesOutput {
pub templates: Vec<PromptTemplate>,
pub diagnostics: Vec<SkillDiagnostic>,
}
#[cfg(feature = "local")]
async fn load_templates(
env: &dyn ExecutionEnv,
dirs: &[&str],
cancel: CancellationToken,
) -> LoadTemplatesOutput {
let mut out = LoadTemplatesOutput::default();
for dir in dirs {
let info = match env.file_info(dir, cancel.clone()).await {
Ok(i) => i,
Err(e) => {
if e.code != FileErrorCode::NotFound {
out.diagnostics.push(SkillDiagnostic {
code: SkillDiagnosticCode::FileInfoFailed,
message: e.message.clone(),
path: dir.to_string(),
});
}
continue;
}
};
if !matches!(info.kind, FileKind::Directory) {
continue;
}
let entries = match env.list_dir(dir, cancel.clone()).await {
Ok(e) => e,
Err(e) => {
out.diagnostics.push(SkillDiagnostic {
code: SkillDiagnosticCode::ListFailed,
message: e.message,
path: dir.to_string(),
});
continue;
}
};
for entry in entries {
if !entry.name.ends_with(".md") {
continue;
}
if !matches!(entry.kind, FileKind::File) {
continue;
}
let raw = match env.read_text_file(&entry.path, cancel.clone()).await {
Ok(t) => t,
Err(e) => {
out.diagnostics.push(SkillDiagnostic {
code: SkillDiagnosticCode::ReadFailed,
message: e.message,
path: entry.path.clone(),
});
continue;
}
};
let (frontmatter, body) = match parse_frontmatter(&raw) {
Ok(parts) => parts,
Err(msg) => {
out.diagnostics.push(SkillDiagnostic {
code: SkillDiagnosticCode::ParseFailed,
message: msg,
path: entry.path.clone(),
});
continue;
}
};
let stem = entry
.name
.strip_suffix(".md")
.unwrap_or(&entry.name)
.to_string();
let name = frontmatter.name.unwrap_or(stem);
out.templates.push(PromptTemplate {
name,
description: frontmatter.description,
content: body,
file_path: entry.path,
});
}
}
out
}
#[cfg(feature = "local")]
fn parse_frontmatter(content: &str) -> Result<(TemplateFrontmatter, String), String> {
let normalized = content.replace("\r\n", "\n").replace('\r', "\n");
if !normalized.starts_with("---") {
return Ok((TemplateFrontmatter::default(), normalized));
}
let Some(end) = normalized[3..].find("\n---") else {
return Ok((TemplateFrontmatter::default(), normalized));
};
let end = end + 3;
let yaml = &normalized[4..end];
let body = normalized[end + 4..].trim().to_string();
let fm: TemplateFrontmatter = serde_yaml::from_str(yaml).map_err(|e| format!("yaml: {e}"))?;
Ok((fm, body))
}
#[cfg(all(test, feature = "local"))]
mod tests {
use super::parse_frontmatter;
#[test]
fn parses_frontmatter_name_and_description() {
let raw = "---\nname: review\ndescription: code review checklist\n---\nBody {{var}}";
let (fm, body) = parse_frontmatter(raw).unwrap();
assert_eq!(fm.name.as_deref(), Some("review"));
assert_eq!(fm.description.as_deref(), Some("code review checklist"));
assert_eq!(body, "Body {{var}}");
}
}
#[cfg(all(test, feature = "local"))]
mod templates_tests {
tests_bridge_macro::tests_bridge!("templates");
}
#[cfg(all(test, feature = "local"))]
mod templates_extra_tests {
tests_bridge_macro::tests_bridge!("templates/extra");
}