//! Prefix-addressed prompt asset loader used by [`crate::SystemPromptTemplate`]. //! //! Three prefixes address three physical libraries: //! //! | prefix | location | //! |--------------|---------------------------------------------------------| //! | `$yoi` | builtin, baked into the binary via `include_dir!` | //! | `$user` | `/prompts/` (resolved by `manifest::paths`) | //! | `$workspace` | `/.yoi/prompts/` | //! //! A reference is `$/` where `` is a `/`-separated //! name without the `.md` extension (e.g. `$yoi/common/header`). //! Unqualified names (no `$prefix/` at the front) are resolved relative //! to an optional current reference — typically the file that issued //! the `{% include %}` — so a prompt library can be authored as a //! self-contained directory. //! //! Missing files produce a [`LoaderError::NotFound`]; there is no //! fallthrough between layers. use std::path::{Path, PathBuf}; use include_dir::{Dir, include_dir}; use thiserror::Error; static BUILTIN_PROMPTS: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/../../resources/prompts"); const PREFIX_YOI: &str = "$yoi"; const PREFIX_USER: &str = "$user"; const PREFIX_WORKSPACE: &str = "$workspace"; /// Prefix-resolved reference to a prompt asset. Produced by /// [`PromptLoader::parse_ref`] from a user-supplied string such as /// `"$yoi/default"`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PromptRef { prefix: Prefix, /// Relative path under the prefix root, without the `.md` extension. /// `/`-separated, never empty, never starts with `/`. path: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Prefix { Yoi, User, Workspace, } impl Prefix { fn as_str(self) -> &'static str { match self { Self::Yoi => PREFIX_YOI, Self::User => PREFIX_USER, Self::Workspace => PREFIX_WORKSPACE, } } } impl PromptRef { /// Produce a canonical `$prefix/path` string. pub fn to_qualified_string(&self) -> String { format!("{}/{}", self.prefix.as_str(), self.path) } /// Directory portion (leading prefix segments minus the file name), /// joined with `/`. Returns an empty string when the ref points at /// a file directly under the prefix root. fn dir(&self) -> &str { match self.path.rsplit_once('/') { Some((dir, _)) => dir, None => "", } } } /// Errors produced when resolving a [`PromptRef`]. #[derive(Debug, Error)] pub enum LoaderError { #[error("invalid prompt reference '{raw}': {reason}")] InvalidRef { raw: String, reason: String }, #[error("unknown prompt prefix '{prefix}' in reference '{raw}'")] UnknownPrefix { raw: String, prefix: String }, #[error( "unqualified prompt reference '{raw}' requires a current prefix \ (include it from inside another template, or use an explicit \ $prefix/path form)" )] UnqualifiedWithoutCurrent { raw: String }, #[error("prompt prefix '{prefix}' is not configured for this loader")] PrefixNotConfigured { prefix: &'static str }, #[error("prompt asset not found: '{}'", .reference.to_qualified_string())] NotFound { reference: PromptRef }, #[error("failed to read prompt asset '{}': {source}", .reference.to_qualified_string())] Io { reference: PromptRef, #[source] source: std::io::Error, }, } /// Loader that resolves [`PromptRef`]s against the configured prompt /// libraries. Cheap to clone. /// /// Also carries the auto-discovered `prompts.toml` pack file paths so /// [`crate::prompt::catalog::PromptCatalog`] can read the same user/workspace /// layers without a separate plumbing channel. These fields do not /// affect `$prefix` asset resolution — they are purely metadata /// consulted by the catalog loader. #[derive(Debug, Clone)] pub struct PromptLoader { user_dir: Option, workspace_dir: Option, user_pack_file: Option, workspace_pack_file: Option, } impl PromptLoader { /// Loader with only the builtin `$yoi` library available. /// `$user` / `$workspace` references fail with /// [`LoaderError::PrefixNotConfigured`]. pub fn builtins_only() -> Self { Self { user_dir: None, workspace_dir: None, user_pack_file: None, workspace_pack_file: None, } } /// Loader with optional user and workspace prompt directories. pub fn new(user_dir: Option, workspace_dir: Option) -> Self { Self { user_dir, workspace_dir, user_pack_file: None, workspace_pack_file: None, } } /// Override pack file paths supplied by the caller's profile/manifest /// resolution context. pub fn with_pack_files( mut self, user_pack_file: Option, workspace_pack_file: Option, ) -> Self { self.user_pack_file = user_pack_file; self.workspace_pack_file = workspace_pack_file; self } /// Root of the `$user` prompt library, if configured. pub fn user_dir(&self) -> Option<&Path> { self.user_dir.as_deref() } /// Root of the `$workspace` prompt library, if configured. pub fn workspace_dir(&self) -> Option<&Path> { self.workspace_dir.as_deref() } /// Auto-discovered path to the user-layer `prompts.toml` pack, if any. pub fn user_pack_file(&self) -> Option<&Path> { self.user_pack_file.as_deref() } /// Auto-discovered path to the workspace-layer `prompts.toml` pack, if any. pub fn workspace_pack_file(&self) -> Option<&Path> { self.workspace_pack_file.as_deref() } /// Parse a string reference into a [`PromptRef`]. Unqualified /// references (no leading `$prefix/`) are resolved against /// `current`: the prefix is inherited, and the path is joined to /// the current ref's directory. pub fn parse_ref( &self, raw: &str, current: Option<&PromptRef>, ) -> Result { let trimmed = raw.trim(); if trimmed.is_empty() { return Err(LoaderError::InvalidRef { raw: raw.to_string(), reason: "reference must not be empty".into(), }); } if let Some(prefix) = trimmed.strip_prefix('$') { let (prefix_name, rest) = prefix .split_once('/') .ok_or_else(|| LoaderError::InvalidRef { raw: raw.to_string(), reason: "prefix must be followed by '/'".into(), })?; let prefix = parse_prefix(raw, prefix_name)?; let path = normalize_path(raw, rest)?; Ok(PromptRef { prefix, path }) } else { let Some(current) = current else { return Err(LoaderError::UnqualifiedWithoutCurrent { raw: raw.to_string(), }); }; let dir = current.dir(); let joined = if dir.is_empty() { trimmed.to_string() } else { format!("{dir}/{trimmed}") }; let path = normalize_path(raw, &joined)?; Ok(PromptRef { prefix: current.prefix, path, }) } } /// Resolve a [`PromptRef`] to its raw template source. Hard-errors /// when the prefix is not configured or the file does not exist. pub fn load(&self, reference: &PromptRef) -> Result { match reference.prefix { Prefix::Yoi => load_from_include_dir(&BUILTIN_PROMPTS, reference), Prefix::User => match self.user_dir.as_deref() { Some(dir) => load_from_dir(dir, reference), None => Err(LoaderError::PrefixNotConfigured { prefix: PREFIX_USER, }), }, Prefix::Workspace => match self.workspace_dir.as_deref() { Some(dir) => load_from_dir(dir, reference), None => Err(LoaderError::PrefixNotConfigured { prefix: PREFIX_WORKSPACE, }), }, } } /// Parse `raw` against `current`, then load the resulting ref. /// Convenience wrapper for the minijinja loader hook. pub fn resolve( &self, raw: &str, current: Option<&PromptRef>, ) -> Result<(PromptRef, String), LoaderError> { let reference = self.parse_ref(raw, current)?; let source = self.load(&reference)?; Ok((reference, source)) } } fn parse_prefix(raw: &str, prefix_name: &str) -> Result { match prefix_name { "yoi" => Ok(Prefix::Yoi), "user" => Ok(Prefix::User), "workspace" => Ok(Prefix::Workspace), _ => Err(LoaderError::UnknownPrefix { raw: raw.to_string(), prefix: format!("${prefix_name}"), }), } } fn normalize_path(raw: &str, rest: &str) -> Result { let cleaned = rest.trim_matches('/').trim(); if cleaned.is_empty() { return Err(LoaderError::InvalidRef { raw: raw.to_string(), reason: "path component must not be empty".into(), }); } if cleaned.split('/').any(|seg| seg == "." || seg == "..") { return Err(LoaderError::InvalidRef { raw: raw.to_string(), reason: "path must not contain '.' or '..' segments".into(), }); } Ok(cleaned.to_string()) } fn load_from_dir(dir: &Path, reference: &PromptRef) -> Result { let path = dir.join(format!("{}.md", reference.path)); match std::fs::read_to_string(&path) { Ok(s) => Ok(s), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(LoaderError::NotFound { reference: reference.clone(), }), Err(source) => Err(LoaderError::Io { reference: reference.clone(), source, }), } } fn load_from_include_dir(dir: &Dir<'static>, reference: &PromptRef) -> Result { let path = format!("{}.md", reference.path); dir.get_file(&path) .and_then(|f| f.contents_utf8()) .map(|s| s.to_string()) .ok_or_else(|| LoaderError::NotFound { reference: reference.clone(), }) } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; #[test] fn builtin_default_resolves() { let loader = PromptLoader::builtins_only(); let (r, source) = loader.resolve("$yoi/default", None).unwrap(); assert_eq!(r.to_qualified_string(), "$yoi/default"); assert!(!source.is_empty()); } #[test] fn builtin_subdirectory_lookup() { let loader = PromptLoader::builtins_only(); let (_, source) = loader.resolve("$yoi/common/tool-usage", None).unwrap(); assert!(source.contains("tool")); } #[test] fn user_prefix_resolves() { let tmp = TempDir::new().unwrap(); let user_dir = tmp.path().to_path_buf(); std::fs::write(user_dir.join("my.md"), "user-body").unwrap(); let loader = PromptLoader::new(Some(user_dir), None); let (_, source) = loader.resolve("$user/my", None).unwrap(); assert_eq!(source, "user-body"); } #[test] fn workspace_prefix_resolves() { let tmp = TempDir::new().unwrap(); let ws_dir = tmp.path().to_path_buf(); std::fs::write(ws_dir.join("custom.md"), "ws-body").unwrap(); let loader = PromptLoader::new(None, Some(ws_dir)); let (_, source) = loader.resolve("$workspace/custom", None).unwrap(); assert_eq!(source, "ws-body"); } #[test] fn missing_file_is_hard_error() { let loader = PromptLoader::builtins_only(); let err = loader.resolve("$yoi/definitely-missing", None).unwrap_err(); assert!(matches!(err, LoaderError::NotFound { .. })); } #[test] fn user_prefix_not_configured_errors() { let loader = PromptLoader::builtins_only(); let err = loader.resolve("$user/my", None).unwrap_err(); assert!(matches!( err, LoaderError::PrefixNotConfigured { prefix: "$user" } )); } #[test] fn unknown_prefix_errors() { let loader = PromptLoader::builtins_only(); let err = loader.resolve("$bogus/x", None).unwrap_err(); assert!(matches!(err, LoaderError::UnknownPrefix { .. })); } #[test] fn unqualified_ref_without_current_errors() { let loader = PromptLoader::builtins_only(); let err = loader.resolve("default", None).unwrap_err(); assert!(matches!(err, LoaderError::UnqualifiedWithoutCurrent { .. })); } #[test] fn unqualified_ref_resolves_relative_to_current() { let loader = PromptLoader::builtins_only(); let current = loader.parse_ref("$yoi/common/tool-usage", None).unwrap(); // Sibling lookup under the same prefix and directory. let sibling = loader.parse_ref("workspace", Some(¤t)).unwrap(); assert_eq!(sibling.to_qualified_string(), "$yoi/common/workspace"); } #[test] fn unqualified_ref_from_root_file_has_empty_dir() { let loader = PromptLoader::builtins_only(); let current = loader.parse_ref("$yoi/default", None).unwrap(); let sibling = loader.parse_ref("other", Some(¤t)).unwrap(); assert_eq!(sibling.to_qualified_string(), "$yoi/other"); } #[test] fn explicit_prefix_overrides_current() { let tmp = TempDir::new().unwrap(); let user_dir = tmp.path().to_path_buf(); std::fs::write(user_dir.join("custom.md"), "user-body").unwrap(); let loader = PromptLoader::new(Some(user_dir), None); let current = loader.parse_ref("$yoi/default", None).unwrap(); // Even with an $yoi-rooted current, an explicit $user // prefix must win. let (reference, source) = loader.resolve("$user/custom", Some(¤t)).unwrap(); assert_eq!(reference.to_qualified_string(), "$user/custom"); assert_eq!(source, "user-body"); } #[test] fn traversal_segments_rejected() { let loader = PromptLoader::builtins_only(); let err = loader.resolve("$yoi/../etc/passwd", None).unwrap_err(); assert!(matches!(err, LoaderError::InvalidRef { .. })); } }