1use std::collections::HashMap;
59use std::path::{Path, PathBuf};
60
61#[derive(Debug, thiserror::Error)]
67pub enum FragmentDiscoveryError {
68 #[error("invalid frontmatter in {path}: {reason}")]
70 InvalidFrontmatter { path: PathBuf, reason: String },
71 #[error("missing required field '{field}' in fragment at {path}")]
73 MissingField { field: String, path: PathBuf },
74 #[error("duplicate fragment name '{name}' in discovery layer at {path}")]
76 DuplicateName { name: String, path: PathBuf },
77 #[error("invalid fragment name in {path}: {reason}")]
79 InvalidName { path: PathBuf, reason: String },
80 #[error("invalid description in fragment at {path}: {reason}")]
82 InvalidDescription { path: PathBuf, reason: String },
83 #[error("invalid argument name in fragment at {path}: {reason}")]
85 InvalidArgument { path: PathBuf, reason: String },
86 #[error("missing required argument '{argument}' for fragment '{fragment}'")]
88 MissingArgument { fragment: String, argument: String },
89 #[error("I/O error discovering fragments: {0}")]
91 Io(#[from] std::io::Error),
92}
93
94const MAX_NAME_LEN: usize = 64;
100
101const MAX_DESCRIPTION_LEN: usize = 1024;
103
104#[derive(Debug, Clone, PartialEq)]
111pub struct FragmentArgument {
112 pub name: String,
114 pub required: bool,
116 pub default: Option<String>,
119}
120
121#[derive(Debug, Clone, PartialEq)]
127pub struct FragmentManifest {
128 pub name: String,
131 pub description: String,
134 pub arguments: Vec<FragmentArgument>,
136}
137
138impl FragmentManifest {
139 pub fn from_fragment_md(content: &str, path: &Path) -> Result<Self, FragmentDiscoveryError> {
144 let fm = extract_frontmatter(content, path)?;
145
146 let name = parse_field(fm, "name")
147 .map(strip_yaml_quotes)
148 .filter(|n| !n.is_empty())
149 .ok_or_else(|| FragmentDiscoveryError::MissingField {
150 field: "name".into(),
151 path: path.to_path_buf(),
152 })?;
153
154 validate_name(name, path)?;
155
156 let description = parse_field(fm, "description")
157 .map(strip_yaml_quotes)
158 .filter(|d| !d.is_empty())
159 .ok_or_else(|| FragmentDiscoveryError::MissingField {
160 field: "description".into(),
161 path: path.to_path_buf(),
162 })?;
163
164 validate_description(description, path)?;
165
166 let arguments = match parse_field(fm, "arguments") {
167 Some(args_str) => parse_arguments(args_str, path)?,
168 None => Vec::new(),
169 };
170
171 Ok(Self {
172 name: name.to_string(),
173 description: description.to_string(),
174 arguments,
175 })
176 }
177}
178
179fn extract_frontmatter<'a>(
185 content: &'a str,
186 path: &Path,
187) -> Result<&'a str, FragmentDiscoveryError> {
188 let trimmed = content.trim_start();
189 if !trimmed.starts_with("---") {
190 return Err(FragmentDiscoveryError::InvalidFrontmatter {
191 path: path.to_path_buf(),
192 reason: "FRAGMENT.md must start with '---' frontmatter delimiter".into(),
193 });
194 }
195
196 let after_open = trimmed.get(3..).unwrap_or("");
197 let after_open = after_open.trim_start_matches(['\r', '\n']);
198
199 let close_pos = after_open
200 .find("\n---")
201 .or_else(|| after_open.find("\r\n---"));
202
203 let frontmatter = match close_pos {
204 Some(pos) => &after_open[..pos],
205 None => {
206 return Err(FragmentDiscoveryError::InvalidFrontmatter {
207 path: path.to_path_buf(),
208 reason: "FRAGMENT.md frontmatter is missing closing '---' delimiter".into(),
209 });
210 }
211 };
212
213 Ok(frontmatter)
214}
215
216fn parse_field<'a>(frontmatter: &'a str, key: &str) -> Option<&'a str> {
218 let prefix = format!("{key}:");
219 for line in frontmatter.lines() {
220 let trimmed = line.trim();
221 if let Some(rest) = trimmed.strip_prefix(&prefix) {
222 return Some(rest.trim());
223 }
224 }
225 None
226}
227
228fn strip_yaml_quotes(value: &str) -> &str {
230 if (value.starts_with('"') && value.ends_with('"'))
231 || (value.starts_with('\'') && value.ends_with('\''))
232 {
233 &value[1..value.len().saturating_sub(1)]
234 } else {
235 value
236 }
237}
238
239fn parse_arguments(
243 args_str: &str,
244 path: &Path,
245) -> Result<Vec<FragmentArgument>, FragmentDiscoveryError> {
246 let mut args = Vec::new();
247 for part in args_str.split(',') {
248 let part = part.trim();
249 if part.is_empty() {
250 continue;
251 }
252
253 if let Some(eq_pos) = part.find('=') {
254 let name = part[..eq_pos].trim();
255 let default = part[eq_pos + 1..].trim();
256
257 validate_argument_name(name, path)?;
258
259 args.push(FragmentArgument {
260 name: name.to_string(),
261 required: false,
262 default: Some(default.to_string()),
263 });
264 } else {
265 validate_argument_name(part, path)?;
266
267 args.push(FragmentArgument {
268 name: part.to_string(),
269 required: true,
270 default: None,
271 });
272 }
273 }
274 Ok(args)
275}
276
277fn validate_name(name: &str, path: &Path) -> Result<(), FragmentDiscoveryError> {
280 if name.len() > MAX_NAME_LEN {
281 return Err(FragmentDiscoveryError::InvalidName {
282 path: path.to_path_buf(),
283 reason: format!(
284 "name exceeds maximum length of {MAX_NAME_LEN} characters ({} found)",
285 name.len()
286 ),
287 });
288 }
289
290 for ch in name.chars() {
291 let valid = ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-';
292 if !valid {
293 return Err(FragmentDiscoveryError::InvalidName {
294 path: path.to_path_buf(),
295 reason: format!(
296 "name contains invalid character '{ch}': \
297 only lowercase a-z, 0-9, and hyphens are allowed"
298 ),
299 });
300 }
301 }
302
303 Ok(())
304}
305
306fn validate_description(desc: &str, path: &Path) -> Result<(), FragmentDiscoveryError> {
308 if desc.len() > MAX_DESCRIPTION_LEN {
309 return Err(FragmentDiscoveryError::InvalidDescription {
310 path: path.to_path_buf(),
311 reason: format!(
312 "description exceeds maximum length of {MAX_DESCRIPTION_LEN} characters \
313 ({} found)",
314 desc.len()
315 ),
316 });
317 }
318 Ok(())
319}
320
321fn validate_argument_name(name: &str, path: &Path) -> Result<(), FragmentDiscoveryError> {
324 if name.is_empty() {
325 return Err(FragmentDiscoveryError::InvalidArgument {
326 path: path.to_path_buf(),
327 reason: "argument name is empty".into(),
328 });
329 }
330
331 for ch in name.chars() {
332 let valid = ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_';
333 if !valid {
334 return Err(FragmentDiscoveryError::InvalidArgument {
335 path: path.to_path_buf(),
336 reason: format!(
337 "argument name '{name}' contains invalid character '{ch}': \
338 only lowercase a-z, 0-9, hyphens, and underscores are allowed"
339 ),
340 });
341 }
342 }
343
344 Ok(())
345}
346
347#[derive(Debug, Clone)]
357pub struct FragmentResource {
358 pub manifest: FragmentManifest,
360 pub path: PathBuf,
362 pub fragment_md_path: PathBuf,
364 pub layer_precedence: u32,
366}
367
368impl FragmentResource {
369 pub fn load_body(&self) -> Result<String, FragmentDiscoveryError> {
374 let content = std::fs::read_to_string(&self.fragment_md_path)?;
375 Ok(extract_body(&content))
376 }
377}
378
379fn extract_body(content: &str) -> String {
381 let trimmed = content.trim_start();
382 let after_open = trimmed.get(3..).unwrap_or("");
383 let after_open = after_open.trim_start_matches(['\r', '\n']);
384
385 let close_pos = after_open
386 .find("\n---")
387 .or_else(|| after_open.find("\r\n---"));
388
389 match close_pos {
390 Some(pos) => {
391 let after_close = &after_open[pos..];
392 let delimiter_end = after_close.find("---").map(|i| i + 3).unwrap_or(pos + 4);
393 let body_start = after_close.get(delimiter_end..).unwrap_or("");
394 body_start.trim_start_matches(['\r', '\n']).to_string()
395 }
396 None => String::new(),
397 }
398}
399
400pub fn discover_fragments(
415 layers: &[crate::resource::DiscoveryLayer],
416) -> Result<Vec<FragmentResource>, FragmentDiscoveryError> {
417 let mut seen: HashMap<String, FragmentResource> = HashMap::new();
418
419 for layer in layers {
420 let scan_dir = layer.scan_dir();
421 if !scan_dir.is_dir() {
422 continue;
423 }
424
425 if scan_dir.join("FRAGMENT.md").exists() {
426 discover_fragment_dir(&scan_dir, layer, &mut seen)?;
427 continue;
428 }
429
430 let entries = match std::fs::read_dir(&scan_dir) {
431 Ok(entries) => entries,
432 Err(e) => return Err(FragmentDiscoveryError::Io(e)),
433 };
434
435 for entry in entries {
436 let entry = entry?;
437 let path = entry.path();
438
439 if !path.is_dir() {
440 continue;
441 }
442
443 let fragment_md = path.join("FRAGMENT.md");
444 if !fragment_md.exists() {
445 continue;
446 }
447
448 discover_fragment_dir(&path, layer, &mut seen)?;
449 }
450 }
451
452 let mut resources: Vec<FragmentResource> = seen.into_values().collect();
453 resources.sort_by(|a, b| a.manifest.name.cmp(&b.manifest.name));
454 Ok(resources)
455}
456
457fn discover_fragment_dir(
458 path: &Path,
459 layer: &crate::resource::DiscoveryLayer,
460 seen: &mut HashMap<String, FragmentResource>,
461) -> Result<(), FragmentDiscoveryError> {
462 let fragment_md = path.join("FRAGMENT.md");
463 let content = std::fs::read_to_string(&fragment_md)?;
464 let manifest = FragmentManifest::from_fragment_md(&content, &fragment_md)?;
465
466 let canonical = path.canonicalize()?;
467
468 match seen.get(&manifest.name) {
469 Some(existing) if layer.precedence == existing.layer_precedence => {
470 return Err(FragmentDiscoveryError::DuplicateName {
471 name: manifest.name,
472 path: canonical,
473 });
474 }
475 Some(existing) if layer.precedence < existing.layer_precedence => return Ok(()),
476 Some(_) | None => {
477 seen.insert(
478 manifest.name.clone(),
479 FragmentResource {
480 manifest,
481 path: canonical,
482 fragment_md_path: fragment_md,
483 layer_precedence: layer.precedence,
484 },
485 );
486 }
487 }
488
489 Ok(())
490}
491
492pub fn expand_fragment_body(
505 body: &str,
506 arguments: &[FragmentArgument],
507 values: &HashMap<String, String>,
508) -> Result<String, FragmentDiscoveryError> {
509 let mut resolved: HashMap<&str, &str> = HashMap::new();
511 for arg in arguments {
512 match values.get(&arg.name) {
513 Some(val) => {
514 resolved.insert(&arg.name, val);
515 }
516 None => {
517 if arg.required {
518 return Err(FragmentDiscoveryError::MissingArgument {
519 fragment: String::new(),
520 argument: arg.name.clone(),
521 });
522 }
523 if let Some(ref default) = arg.default {
524 resolved.insert(&arg.name, default);
525 }
526 }
527 }
528 }
529
530 let mut result = body.to_string();
532 for arg in arguments {
533 if let Some(val) = resolved.get(arg.name.as_str()) {
534 let placeholder = format!("{{{{{}}}}}", arg.name);
535 result = result.replace(&placeholder, val);
536 }
537 }
538
539 Ok(result)
540}
541
542pub struct FragmentRegistry {
549 resources: Vec<FragmentResource>,
550}
551
552impl FragmentRegistry {
553 pub fn from_resources(resources: Vec<FragmentResource>) -> Self {
555 Self { resources }
556 }
557
558 pub fn names(&self) -> Vec<&str> {
560 self.resources
561 .iter()
562 .map(|r| r.manifest.name.as_str())
563 .collect()
564 }
565
566 pub fn get(&self, name: &str) -> Option<&FragmentResource> {
568 self.resources.iter().find(|r| r.manifest.name == name)
569 }
570
571 pub fn load_body(&self, name: &str) -> Option<Result<String, FragmentDiscoveryError>> {
576 self.get(name).map(|r| r.load_body())
577 }
578
579 pub fn expand(
584 &self,
585 name: &str,
586 values: &HashMap<String, String>,
587 ) -> Option<Result<String, FragmentDiscoveryError>> {
588 let resource = self.get(name)?;
589 let body = match resource.load_body() {
590 Ok(b) => b,
591 Err(e) => return Some(Err(e)),
592 };
593
594 Some(expand_fragment_body(
595 &body,
596 &resource.manifest.arguments,
597 values,
598 ))
599 }
600
601 pub fn format_for_prompt(&self) -> String {
607 if self.resources.is_empty() {
608 return String::new();
609 }
610
611 let mut parts = Vec::new();
612 for r in &self.resources {
613 let args_summary = if r.manifest.arguments.is_empty() {
614 String::new()
615 } else {
616 let arg_names: Vec<&str> = r
617 .manifest
618 .arguments
619 .iter()
620 .map(|a| a.name.as_str())
621 .collect();
622 format!(" [{}]", arg_names.join(", "))
623 };
624 parts.push(format!(
625 "- {}: {}{}",
626 r.manifest.name, r.manifest.description, args_summary
627 ));
628 }
629 parts.join("\n")
630 }
631
632 pub fn format_for_rpc_metadata(&self) -> String {
637 if self.resources.is_empty() {
638 return String::new();
639 }
640
641 let mut parts = Vec::new();
642 for r in &self.resources {
643 let mut frag_entry = format!("{}: {}", r.manifest.name, r.manifest.description);
644 if !r.manifest.arguments.is_empty() {
645 frag_entry.push_str(" | arguments:");
646 for arg in &r.manifest.arguments {
647 if arg.required {
648 frag_entry.push_str(&format!(" {} (required)", arg.name));
649 } else {
650 let default = arg.default.as_deref().unwrap_or("");
651 frag_entry.push_str(&format!(" {} (default: {})", arg.name, default));
652 }
653 }
654 }
655 parts.push(frag_entry);
656 }
657 parts.join("\n")
658 }
659}