Skip to main content

opi_coding_agent/
prompt_fragment.rs

1//! Prompt fragment/template progressive discovery, registry, and expansion.
2//!
3//! Provides the discovery and registry system for prompt fragments (templates)
4//! that are progressively loaded from project, user, explicit, and package
5//! resources. Fragment metadata (name, description, arguments) is available
6//! without loading the full fragment body, which is loaded on demand when
7//! needed for expansion.
8//!
9//! # Fragment Format
10//!
11//! Each fragment is a directory containing a `FRAGMENT.md` file with YAML
12//! frontmatter:
13//!
14//! ```markdown
15//! ---
16//! name: translate
17//! description: Translate text between languages.
18//! arguments: text, from=en, to=fr
19//! ---
20//!
21//! Translate {{text}} from {{from}} to {{to}}.
22//! ```
23//!
24//! # Name Validation
25//!
26//! Fragment names follow the same rules as skill names: lowercase ASCII
27//! letters (`a-z`), digits (`0-9`), and hyphens (`-`), with a maximum
28//! length of 64 characters.
29//!
30//! # Argument Declaration
31//!
32//! Arguments are declared as a comma-separated list in the frontmatter.
33//! Each argument is either:
34//!
35//! - **Required**: just the name (e.g. `text`)
36//! - **Optional with default**: `name=default_value` (e.g. `format=markdown`)
37//!
38//! In the fragment body, arguments are referenced as `{{name}}` placeholders.
39//!
40//! # Progressive Disclosure
41//!
42//! Discovery returns [`FragmentResource`] entries containing only the parsed
43//! frontmatter metadata. The full fragment body can be loaded on demand via
44//! [`FragmentResource::load_body`]. Argument expansion is performed by
45//! [`expand_fragment_body`] or [`FragmentRegistry::expand`].
46//!
47//! # Discovery Precedence
48//!
49//! Fragments are discovered from multiple layers using the same precedence
50//! model as extensions and skills (see [`crate::resource`]). Higher
51//! precedence values override lower ones when fragment names collide.
52//!
53//! # Unstable
54//!
55//! This module is part of the **unstable 0.x extension API**. Breaking changes
56//! may occur between minor versions without a major version bump.
57
58use std::collections::HashMap;
59use std::path::{Path, PathBuf};
60
61// ---------------------------------------------------------------------------
62// Error types
63// ---------------------------------------------------------------------------
64
65/// Errors from fragment discovery, manifest parsing, and expansion.
66#[derive(Debug, thiserror::Error)]
67pub enum FragmentDiscoveryError {
68    /// The FRAGMENT.md file has no valid YAML frontmatter delimiters (`---`).
69    #[error("invalid frontmatter in {path}: {reason}")]
70    InvalidFrontmatter { path: PathBuf, reason: String },
71    /// A required field is missing or empty in the frontmatter.
72    #[error("missing required field '{field}' in fragment at {path}")]
73    MissingField { field: String, path: PathBuf },
74    /// Two fragments in the same precedence layer use the same name.
75    #[error("duplicate fragment name '{name}' in discovery layer at {path}")]
76    DuplicateName { name: String, path: PathBuf },
77    /// The fragment name contains invalid characters or exceeds the length limit.
78    #[error("invalid fragment name in {path}: {reason}")]
79    InvalidName { path: PathBuf, reason: String },
80    /// The description is empty or exceeds the length limit.
81    #[error("invalid description in fragment at {path}: {reason}")]
82    InvalidDescription { path: PathBuf, reason: String },
83    /// An argument name is invalid.
84    #[error("invalid argument name in fragment at {path}: {reason}")]
85    InvalidArgument { path: PathBuf, reason: String },
86    /// A required argument was not provided during expansion.
87    #[error("missing required argument '{argument}' for fragment '{fragment}'")]
88    MissingArgument { fragment: String, argument: String },
89    /// An I/O error occurred during discovery or body loading.
90    #[error("I/O error discovering fragments: {0}")]
91    Io(#[from] std::io::Error),
92}
93
94// ---------------------------------------------------------------------------
95// Constants
96// ---------------------------------------------------------------------------
97
98/// Maximum allowed length for a fragment name.
99const MAX_NAME_LEN: usize = 64;
100
101/// Maximum allowed length for a fragment description.
102const MAX_DESCRIPTION_LEN: usize = 1024;
103
104// ---------------------------------------------------------------------------
105// Argument types
106// ---------------------------------------------------------------------------
107
108/// A declared fragment argument with name, requirement flag, and optional
109/// default value.
110#[derive(Debug, Clone, PartialEq)]
111pub struct FragmentArgument {
112    /// Argument name. Must be a valid identifier (lowercase a-z, 0-9, hyphens).
113    pub name: String,
114    /// Whether this argument must be provided during expansion.
115    pub required: bool,
116    /// Default value used when the argument is not provided. When `None` and
117    /// `required` is `false`, the argument has no default.
118    pub default: Option<String>,
119}
120
121// ---------------------------------------------------------------------------
122// Manifest types
123// ---------------------------------------------------------------------------
124
125/// Parsed fragment manifest from `FRAGMENT.md` frontmatter.
126#[derive(Debug, Clone, PartialEq)]
127pub struct FragmentManifest {
128    /// Fragment name. Required, non-empty. Lowercase ASCII letters, digits,
129    /// and hyphens. Maximum 64 characters.
130    pub name: String,
131    /// Human-readable description. Required, non-empty. Maximum 1024
132    /// characters.
133    pub description: String,
134    /// Declared arguments for template expansion. May be empty.
135    pub arguments: Vec<FragmentArgument>,
136}
137
138impl FragmentManifest {
139    /// Parse a manifest from the full content of a `FRAGMENT.md` file.
140    ///
141    /// The content must contain YAML frontmatter between `---` delimiters.
142    /// Only the frontmatter is parsed; the body is ignored.
143    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
179// ---------------------------------------------------------------------------
180// Frontmatter parsing helpers
181// ---------------------------------------------------------------------------
182
183/// Extract the text between the first two `---` delimiters.
184fn 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
216/// Parse a `key: value` field from frontmatter text.
217fn 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
228/// Strip surrounding single or double quotes from a YAML scalar value.
229fn 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
239/// Parse a comma-separated arguments string into a list of [`FragmentArgument`].
240///
241/// Format: `arg1, arg2=default_value, arg3`
242fn 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
277/// Validate that a fragment name contains only allowed characters and is within
278/// length bounds.
279fn 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
306/// Validate that a description is non-empty and within length bounds.
307fn 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
321/// Validate that an argument name is non-empty and contains only allowed
322/// characters.
323fn 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// ---------------------------------------------------------------------------
348// Discovery types
349// ---------------------------------------------------------------------------
350
351/// A discovered fragment resource with its manifest, filesystem path, and layer
352/// precedence.
353///
354/// The manifest metadata is available immediately. The full fragment body can
355/// be loaded on demand via [`load_body`](FragmentResource::load_body).
356#[derive(Debug, Clone)]
357pub struct FragmentResource {
358    /// The parsed fragment manifest (metadata only).
359    pub manifest: FragmentManifest,
360    /// Absolute path to the fragment directory (containing `FRAGMENT.md`).
361    pub path: PathBuf,
362    /// Path to the `FRAGMENT.md` file itself, for on-demand body loading.
363    pub fragment_md_path: PathBuf,
364    /// Precedence value of the discovery layer that produced this resource.
365    pub layer_precedence: u32,
366}
367
368impl FragmentResource {
369    /// Load the full fragment body (everything after the frontmatter) on demand.
370    ///
371    /// This reads the `FRAGMENT.md` file from disk, strips the frontmatter,
372    /// and returns the remaining content.
373    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
379/// Extract the body (everything after the closing `---`) from a FRAGMENT.md.
380fn 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
400// ---------------------------------------------------------------------------
401// Discovery
402// ---------------------------------------------------------------------------
403
404/// Discover fragments across multiple layers with precedence-based
405/// deduplication.
406///
407/// Each layer's scan directory is enumerated for subdirectories containing
408/// `FRAGMENT.md` files. When multiple layers produce fragments with the same
409/// name, the one with the highest `precedence` value is kept. Duplicate names
410/// within the same precedence layer are reported as an error.
411///
412/// Returns the deduplicated list of discovered fragment resources, sorted by
413/// name. Missing scan directories are silently skipped.
414pub 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
492// ---------------------------------------------------------------------------
493// Expansion
494// ---------------------------------------------------------------------------
495
496/// Expand `{{arg}}` placeholders in a fragment body using declared arguments
497/// and provided values.
498///
499/// - Required arguments that are missing produce a
500///   [`FragmentDiscoveryError::MissingArgument`] error.
501/// - Optional arguments use their default value when not provided.
502/// - Placeholders not matching any declared argument are left as-is.
503/// - Extra values not matching any argument are silently ignored.
504pub fn expand_fragment_body(
505    body: &str,
506    arguments: &[FragmentArgument],
507    values: &HashMap<String, String>,
508) -> Result<String, FragmentDiscoveryError> {
509    // Build a resolved map: argument name -> final value.
510    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    // Replace all {{name}} placeholders.
531    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
542// ---------------------------------------------------------------------------
543// Registry
544// ---------------------------------------------------------------------------
545
546/// A registry of discovered fragments supporting progressive disclosure,
547/// argument expansion, and prompt/RPC metadata formatting.
548pub struct FragmentRegistry {
549    resources: Vec<FragmentResource>,
550}
551
552impl FragmentRegistry {
553    /// Build a registry from discovered fragment resources.
554    pub fn from_resources(resources: Vec<FragmentResource>) -> Self {
555        Self { resources }
556    }
557
558    /// Return sorted list of all fragment names.
559    pub fn names(&self) -> Vec<&str> {
560        self.resources
561            .iter()
562            .map(|r| r.manifest.name.as_str())
563            .collect()
564    }
565
566    /// Look up a fragment by name, returning its resource (metadata only).
567    pub fn get(&self, name: &str) -> Option<&FragmentResource> {
568        self.resources.iter().find(|r| r.manifest.name == name)
569    }
570
571    /// Load the full body of a fragment by name.
572    ///
573    /// Returns `None` if the fragment is not found or `Some(Err(...))` if the
574    /// file cannot be read.
575    pub fn load_body(&self, name: &str) -> Option<Result<String, FragmentDiscoveryError>> {
576        self.get(name).map(|r| r.load_body())
577    }
578
579    /// Expand a fragment by name with the provided argument values.
580    ///
581    /// Loads the body on demand, validates arguments, and performs placeholder
582    /// substitution. Returns `None` if the fragment is not found.
583    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    /// Format all fragment metadata as a string suitable for inclusion in a
602    /// system prompt or command listing.
603    ///
604    /// Each fragment is represented as a brief entry with name, description,
605    /// and argument summary.
606    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    /// Format all fragment metadata as a string suitable for RPC command
633    /// metadata listing.
634    ///
635    /// Includes argument names, required/optional status, and default values.
636    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}