use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, thiserror::Error)]
pub enum FragmentDiscoveryError {
#[error("invalid frontmatter in {path}: {reason}")]
InvalidFrontmatter { path: PathBuf, reason: String },
#[error("missing required field '{field}' in fragment at {path}")]
MissingField { field: String, path: PathBuf },
#[error("duplicate fragment name '{name}' in discovery layer at {path}")]
DuplicateName { name: String, path: PathBuf },
#[error("invalid fragment name in {path}: {reason}")]
InvalidName { path: PathBuf, reason: String },
#[error("invalid description in fragment at {path}: {reason}")]
InvalidDescription { path: PathBuf, reason: String },
#[error("invalid argument name in fragment at {path}: {reason}")]
InvalidArgument { path: PathBuf, reason: String },
#[error("missing required argument '{argument}' for fragment '{fragment}'")]
MissingArgument { fragment: String, argument: String },
#[error("I/O error discovering fragments: {0}")]
Io(#[from] std::io::Error),
}
const MAX_NAME_LEN: usize = 64;
const MAX_DESCRIPTION_LEN: usize = 1024;
#[derive(Debug, Clone, PartialEq)]
pub struct FragmentArgument {
pub name: String,
pub required: bool,
pub default: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct FragmentManifest {
pub name: String,
pub description: String,
pub arguments: Vec<FragmentArgument>,
}
impl FragmentManifest {
pub fn from_fragment_md(content: &str, path: &Path) -> Result<Self, FragmentDiscoveryError> {
let fm = extract_frontmatter(content, path)?;
let name = parse_field(fm, "name")
.map(strip_yaml_quotes)
.filter(|n| !n.is_empty())
.ok_or_else(|| FragmentDiscoveryError::MissingField {
field: "name".into(),
path: path.to_path_buf(),
})?;
validate_name(name, path)?;
let description = parse_field(fm, "description")
.map(strip_yaml_quotes)
.filter(|d| !d.is_empty())
.ok_or_else(|| FragmentDiscoveryError::MissingField {
field: "description".into(),
path: path.to_path_buf(),
})?;
validate_description(description, path)?;
let arguments = match parse_field(fm, "arguments") {
Some(args_str) => parse_arguments(args_str, path)?,
None => Vec::new(),
};
Ok(Self {
name: name.to_string(),
description: description.to_string(),
arguments,
})
}
}
fn extract_frontmatter<'a>(
content: &'a str,
path: &Path,
) -> Result<&'a str, FragmentDiscoveryError> {
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return Err(FragmentDiscoveryError::InvalidFrontmatter {
path: path.to_path_buf(),
reason: "FRAGMENT.md must start with '---' frontmatter delimiter".into(),
});
}
let after_open = trimmed.get(3..).unwrap_or("");
let after_open = after_open.trim_start_matches(['\r', '\n']);
let close_pos = after_open
.find("\n---")
.or_else(|| after_open.find("\r\n---"));
let frontmatter = match close_pos {
Some(pos) => &after_open[..pos],
None => {
return Err(FragmentDiscoveryError::InvalidFrontmatter {
path: path.to_path_buf(),
reason: "FRAGMENT.md frontmatter is missing closing '---' delimiter".into(),
});
}
};
Ok(frontmatter)
}
fn parse_field<'a>(frontmatter: &'a str, key: &str) -> Option<&'a str> {
let prefix = format!("{key}:");
for line in frontmatter.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix(&prefix) {
return Some(rest.trim());
}
}
None
}
fn strip_yaml_quotes(value: &str) -> &str {
if (value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\''))
{
&value[1..value.len().saturating_sub(1)]
} else {
value
}
}
fn parse_arguments(
args_str: &str,
path: &Path,
) -> Result<Vec<FragmentArgument>, FragmentDiscoveryError> {
let mut args = Vec::new();
for part in args_str.split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}
if let Some(eq_pos) = part.find('=') {
let name = part[..eq_pos].trim();
let default = part[eq_pos + 1..].trim();
validate_argument_name(name, path)?;
args.push(FragmentArgument {
name: name.to_string(),
required: false,
default: Some(default.to_string()),
});
} else {
validate_argument_name(part, path)?;
args.push(FragmentArgument {
name: part.to_string(),
required: true,
default: None,
});
}
}
Ok(args)
}
fn validate_name(name: &str, path: &Path) -> Result<(), FragmentDiscoveryError> {
if name.len() > MAX_NAME_LEN {
return Err(FragmentDiscoveryError::InvalidName {
path: path.to_path_buf(),
reason: format!(
"name exceeds maximum length of {MAX_NAME_LEN} characters ({} found)",
name.len()
),
});
}
for ch in name.chars() {
let valid = ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-';
if !valid {
return Err(FragmentDiscoveryError::InvalidName {
path: path.to_path_buf(),
reason: format!(
"name contains invalid character '{ch}': \
only lowercase a-z, 0-9, and hyphens are allowed"
),
});
}
}
Ok(())
}
fn validate_description(desc: &str, path: &Path) -> Result<(), FragmentDiscoveryError> {
if desc.len() > MAX_DESCRIPTION_LEN {
return Err(FragmentDiscoveryError::InvalidDescription {
path: path.to_path_buf(),
reason: format!(
"description exceeds maximum length of {MAX_DESCRIPTION_LEN} characters \
({} found)",
desc.len()
),
});
}
Ok(())
}
fn validate_argument_name(name: &str, path: &Path) -> Result<(), FragmentDiscoveryError> {
if name.is_empty() {
return Err(FragmentDiscoveryError::InvalidArgument {
path: path.to_path_buf(),
reason: "argument name is empty".into(),
});
}
for ch in name.chars() {
let valid = ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_';
if !valid {
return Err(FragmentDiscoveryError::InvalidArgument {
path: path.to_path_buf(),
reason: format!(
"argument name '{name}' contains invalid character '{ch}': \
only lowercase a-z, 0-9, hyphens, and underscores are allowed"
),
});
}
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct FragmentResource {
pub manifest: FragmentManifest,
pub path: PathBuf,
pub fragment_md_path: PathBuf,
pub layer_precedence: u32,
}
impl FragmentResource {
pub fn load_body(&self) -> Result<String, FragmentDiscoveryError> {
let content = std::fs::read_to_string(&self.fragment_md_path)?;
Ok(extract_body(&content))
}
}
fn extract_body(content: &str) -> String {
let trimmed = content.trim_start();
let after_open = trimmed.get(3..).unwrap_or("");
let after_open = after_open.trim_start_matches(['\r', '\n']);
let close_pos = after_open
.find("\n---")
.or_else(|| after_open.find("\r\n---"));
match close_pos {
Some(pos) => {
let after_close = &after_open[pos..];
let delimiter_end = after_close.find("---").map(|i| i + 3).unwrap_or(pos + 4);
let body_start = after_close.get(delimiter_end..).unwrap_or("");
body_start.trim_start_matches(['\r', '\n']).to_string()
}
None => String::new(),
}
}
pub fn discover_fragments(
layers: &[crate::resource::DiscoveryLayer],
) -> Result<Vec<FragmentResource>, FragmentDiscoveryError> {
let mut seen: HashMap<String, FragmentResource> = HashMap::new();
for layer in layers {
let scan_dir = layer.scan_dir();
if !scan_dir.is_dir() {
continue;
}
if scan_dir.join("FRAGMENT.md").exists() {
discover_fragment_dir(&scan_dir, layer, &mut seen)?;
continue;
}
let entries = match std::fs::read_dir(&scan_dir) {
Ok(entries) => entries,
Err(e) => return Err(FragmentDiscoveryError::Io(e)),
};
for entry in entries {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let fragment_md = path.join("FRAGMENT.md");
if !fragment_md.exists() {
continue;
}
discover_fragment_dir(&path, layer, &mut seen)?;
}
}
let mut resources: Vec<FragmentResource> = seen.into_values().collect();
resources.sort_by(|a, b| a.manifest.name.cmp(&b.manifest.name));
Ok(resources)
}
fn discover_fragment_dir(
path: &Path,
layer: &crate::resource::DiscoveryLayer,
seen: &mut HashMap<String, FragmentResource>,
) -> Result<(), FragmentDiscoveryError> {
let fragment_md = path.join("FRAGMENT.md");
let content = std::fs::read_to_string(&fragment_md)?;
let manifest = FragmentManifest::from_fragment_md(&content, &fragment_md)?;
let canonical = path.canonicalize()?;
match seen.get(&manifest.name) {
Some(existing) if layer.precedence == existing.layer_precedence => {
return Err(FragmentDiscoveryError::DuplicateName {
name: manifest.name,
path: canonical,
});
}
Some(existing) if layer.precedence < existing.layer_precedence => return Ok(()),
Some(_) | None => {
seen.insert(
manifest.name.clone(),
FragmentResource {
manifest,
path: canonical,
fragment_md_path: fragment_md,
layer_precedence: layer.precedence,
},
);
}
}
Ok(())
}
pub fn expand_fragment_body(
body: &str,
arguments: &[FragmentArgument],
values: &HashMap<String, String>,
) -> Result<String, FragmentDiscoveryError> {
let mut resolved: HashMap<&str, &str> = HashMap::new();
for arg in arguments {
match values.get(&arg.name) {
Some(val) => {
resolved.insert(&arg.name, val);
}
None => {
if arg.required {
return Err(FragmentDiscoveryError::MissingArgument {
fragment: String::new(),
argument: arg.name.clone(),
});
}
if let Some(ref default) = arg.default {
resolved.insert(&arg.name, default);
}
}
}
}
let mut result = body.to_string();
for arg in arguments {
if let Some(val) = resolved.get(arg.name.as_str()) {
let placeholder = format!("{{{{{}}}}}", arg.name);
result = result.replace(&placeholder, val);
}
}
Ok(result)
}
pub struct FragmentRegistry {
resources: Vec<FragmentResource>,
}
impl FragmentRegistry {
pub fn from_resources(resources: Vec<FragmentResource>) -> Self {
Self { resources }
}
pub fn names(&self) -> Vec<&str> {
self.resources
.iter()
.map(|r| r.manifest.name.as_str())
.collect()
}
pub fn get(&self, name: &str) -> Option<&FragmentResource> {
self.resources.iter().find(|r| r.manifest.name == name)
}
pub fn load_body(&self, name: &str) -> Option<Result<String, FragmentDiscoveryError>> {
self.get(name).map(|r| r.load_body())
}
pub fn expand(
&self,
name: &str,
values: &HashMap<String, String>,
) -> Option<Result<String, FragmentDiscoveryError>> {
let resource = self.get(name)?;
let body = match resource.load_body() {
Ok(b) => b,
Err(e) => return Some(Err(e)),
};
Some(expand_fragment_body(
&body,
&resource.manifest.arguments,
values,
))
}
pub fn format_for_prompt(&self) -> String {
if self.resources.is_empty() {
return String::new();
}
let mut parts = Vec::new();
for r in &self.resources {
let args_summary = if r.manifest.arguments.is_empty() {
String::new()
} else {
let arg_names: Vec<&str> = r
.manifest
.arguments
.iter()
.map(|a| a.name.as_str())
.collect();
format!(" [{}]", arg_names.join(", "))
};
parts.push(format!(
"- {}: {}{}",
r.manifest.name, r.manifest.description, args_summary
));
}
parts.join("\n")
}
pub fn format_for_rpc_metadata(&self) -> String {
if self.resources.is_empty() {
return String::new();
}
let mut parts = Vec::new();
for r in &self.resources {
let mut frag_entry = format!("{}: {}", r.manifest.name, r.manifest.description);
if !r.manifest.arguments.is_empty() {
frag_entry.push_str(" | arguments:");
for arg in &r.manifest.arguments {
if arg.required {
frag_entry.push_str(&format!(" {} (required)", arg.name));
} else {
let default = arg.default.as_deref().unwrap_or("");
frag_entry.push_str(&format!(" {} (default: {})", arg.name, default));
}
}
}
parts.push(frag_entry);
}
parts.join("\n")
}
}