mod blocks;
mod path;
#[cfg(test)]
mod tests;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use glob::{MatchOptions, Pattern};
use promptforge_core::observe::NullObserver;
use promptforge_core::parser::Prompt;
use promptforge_core::promptforge_version;
use crate::catalog::{Catalog, Entry, OnBroken};
use crate::config::Config;
use crate::error::{CatalogError, Fault, FaultKind};
use crate::tools::reserved_names;
const MAX_TOOL_NAME_LEN: usize = 48;
const MAX_PROMPT_BYTES: u64 = 2 * 1024 * 1024;
pub(crate) fn resolve(config: &Config, on_broken: OnBroken) -> Result<Catalog, CatalogError> {
let root = config.paths.prompts.as_path();
let mut faults: Vec<Fault> = Vec::new();
let mut entries: Vec<Entry> = Vec::new();
for path in globbed_files(config, root, &mut faults) {
let source = match read(&path) {
Ok(source) => source,
Err(detail) => {
entries.push(Entry::broken_as(
stem_name(&path),
path,
FaultKind::Unreadable,
detail,
));
continue;
}
};
if promptforge_version(&source).is_none() {
if malformed_prompt_candidate(&source) {
entries.push(Entry::broken_as(
stem_name(&path),
path,
FaultKind::Unparsable,
"declares a promptforge frontmatter that does not parse",
));
}
continue;
}
entries.push(match parse(&source) {
Ok(prompt) => admit(path, source, prompt, None),
Err(detail) => Entry::broken_as(stem_name(&path), path, FaultKind::Unparsable, detail),
});
}
blocks::apply(config, root, &mut entries, &mut faults);
if on_broken == OnBroken::Reject {
for entry in &entries {
if let Some(problem) = entry.problem() {
faults.push(Fault::new(
entry.problem_kind().unwrap_or(FaultKind::Unparsable),
Some(entry.name().to_string()),
Some(entry.path().to_path_buf()),
problem,
));
}
}
}
faults.extend(duplicate_faults(&entries));
if entries.is_empty() {
faults.push(Fault::new(
FaultKind::Empty,
None,
None,
"no prompts resolved; check [catalog].include and [prompts.*]",
));
}
if faults.is_empty() {
Ok(Catalog::new(entries))
} else {
Err(CatalogError::new(faults))
}
}
fn globbed_files(config: &Config, root: &Path, faults: &mut Vec<Fault>) -> Vec<PathBuf> {
let mut matched: BTreeSet<PathBuf> = BTreeSet::new();
for pattern in &config.catalog.include {
match expand(root, pattern.as_str()) {
Ok(paths) => matched.extend(paths),
Err(detail) => faults.push(Fault::new(
FaultKind::Pattern,
None,
None,
format!("include {:?}: {detail}", pattern.as_str()),
)),
}
}
let mut excludes: Vec<Pattern> = Vec::with_capacity(config.catalog.exclude.len());
for pattern in &config.catalog.exclude {
match Pattern::new(pattern.as_str()) {
Ok(compiled) => excludes.push(compiled),
Err(e) => faults.push(Fault::new(
FaultKind::Pattern,
None,
None,
format!("exclude {:?}: {e}", pattern.as_str()),
)),
}
}
matched
.into_iter()
.filter(|p| p.is_file() && path::confined(root, p) && !is_excluded(p, root, &excludes))
.collect()
}
fn expand(root: &Path, pattern: &str) -> Result<Vec<PathBuf>, String> {
let joined = root.join(pattern);
let text = joined
.to_str()
.ok_or_else(|| "pattern path is not valid UTF-8".to_string())?;
let paths = glob::glob_with(text, match_options()).map_err(|e| e.to_string())?;
paths
.map(|entry| entry.map_err(|e| e.to_string()))
.collect()
}
fn is_excluded(path: &Path, root: &Path, excludes: &[Pattern]) -> bool {
let relative = path.strip_prefix(root).unwrap_or(path);
excludes
.iter()
.any(|pattern| pattern.matches_path_with(relative, match_options()))
}
fn match_options() -> MatchOptions {
MatchOptions {
case_sensitive: true,
require_literal_separator: true,
require_literal_leading_dot: false,
}
}
fn read(path: &Path) -> Result<String, String> {
use std::io::Read as _;
if let Ok(metadata) = std::fs::metadata(path)
&& metadata.len() > MAX_PROMPT_BYTES
{
return Err(format!(
"is {} bytes, over the {MAX_PROMPT_BYTES}-byte prompt limit",
metadata.len()
));
}
let file = std::fs::File::open(path).map_err(|e| format!("unreadable: {e}"))?;
let mut source = String::new();
file.take(MAX_PROMPT_BYTES + 1)
.read_to_string(&mut source)
.map_err(|e| format!("unreadable: {e}"))?;
if source.len() as u64 > MAX_PROMPT_BYTES {
return Err(format!("is over the {MAX_PROMPT_BYTES}-byte prompt limit"));
}
Ok(source)
}
fn malformed_prompt_candidate(source: &str) -> bool {
let source = source.strip_prefix('\u{feff}').unwrap_or(source);
let mut lines = source.lines();
if !matches!(lines.next(), Some(line) if line.trim() == "---") {
return false;
}
for line in lines {
if line.trim() == "---" {
break;
}
if line.trim_start().starts_with("promptforge") {
return true;
}
}
false
}
fn parse(source: &str) -> Result<Prompt, String> {
Prompt::parse(source, "catalog", &NullObserver::default())
.map_err(|e| format!("does not parse: {e}"))
}
fn admit(path: PathBuf, source: String, prompt: Prompt, block_key: Option<&str>) -> Entry {
let name = prompt.frontmatter().name().to_owned();
if !is_valid_tool_name(&name) {
let detail = format!(
"tool name {name:?} is not ^[a-z][a-z0-9_]{{0,{}}}$",
MAX_TOOL_NAME_LEN - 1
);
let fallback = block_key.map_or_else(|| stem_name(&path), ToString::to_string);
return Entry::broken_as(
safe_placeholder(fallback),
path,
FaultKind::InvalidName,
detail,
);
}
if reserved_names().any(|reserved| reserved == name.as_str()) {
let detail = format!(
"prompt name {name:?} is reserved: a built-in already answers to it, so \"run {name}\" is ambiguous"
);
return Entry::broken_as(
safe_placeholder(stem_name(&path)),
path,
FaultKind::InvalidName,
detail,
);
}
if let Some(key) = block_key
&& key != name
{
let detail = format!("frontmatter name {name:?} does not match its [prompts.{key}] block");
return Entry::broken_as(key.to_string(), path, FaultKind::InvalidName, detail);
}
Entry::healthy(path, source, prompt)
}
fn duplicate_faults(entries: &[Entry]) -> Vec<Fault> {
let mut by_name: BTreeMap<&str, Vec<&Path>> = BTreeMap::new();
for entry in entries.iter().filter(|entry| entry.problem().is_none()) {
by_name.entry(entry.name()).or_default().push(entry.path());
}
by_name
.into_iter()
.filter(|(_, paths)| paths.len() > 1)
.map(|(name, paths)| {
let files: Vec<String> = paths.iter().map(|p| p.display().to_string()).collect();
Fault::new(
FaultKind::Duplicate,
Some(name.to_string()),
None,
format!("declared by {} prompts: {}", files.len(), files.join(", ")),
)
})
.collect()
}
fn stem_name(path: &Path) -> String {
path.file_stem().map_or_else(
|| path.display().to_string(),
|stem| stem.to_string_lossy().into_owned(),
)
}
fn safe_placeholder(name: String) -> String {
if reserved_names().any(|reserved| reserved == name.as_str()) {
format!("{name} (broken)")
} else {
name
}
}
fn is_valid_tool_name(name: &str) -> bool {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
name.len() <= MAX_TOOL_NAME_LEN
&& first.is_ascii_lowercase()
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}