Skip to main content

agent_plugins/
template.rs

1//! `${PLUGIN_ROOT}` / `${PLUGIN_DATA}` placeholder expansion (§9.2).
2//!
3//! §9.2 demands "a single, non-recursive textual replacement" in which text
4//! introduced by a replacement is never rescanned. Rather than policing that
5//! with careful string code at expansion time, a [`Template`] is parsed once
6//! into segments — literal runs and placeholder markers — and expansion just
7//! concatenates. Non-recursion is a property of the shape, not of discipline.
8
9use std::fmt;
10use std::path::{Path, PathBuf};
11
12/// The two path anchors a client provides to a plugin subprocess (§9.1).
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Anchors {
15    /// Absolute path of the filesystem-resolved plugin root (`PLUGIN_ROOT`).
16    pub plugin_root: PathBuf,
17    /// Absolute path of the client-managed persistent data directory
18    /// dedicated to this installed plugin instance (`PLUGIN_DATA`).
19    pub plugin_data: PathBuf,
20}
21
22impl Anchors {
23    /// Pair a plugin root with its persistent data directory.
24    pub fn new(plugin_root: impl Into<PathBuf>, plugin_data: impl Into<PathBuf>) -> Self {
25        Self { plugin_root: plugin_root.into(), plugin_data: plugin_data.into() }
26    }
27
28    fn path_of(&self, placeholder: Placeholder) -> &Path {
29        match placeholder {
30            Placeholder::PluginRoot => &self.plugin_root,
31            Placeholder::PluginData => &self.plugin_data,
32        }
33    }
34}
35
36/// The two placeholders §9.2 defines — and the only two it permits.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38pub enum Placeholder {
39    /// `${PLUGIN_ROOT}`
40    PluginRoot,
41    /// `${PLUGIN_DATA}`
42    PluginData,
43}
44
45impl Placeholder {
46    /// The literal spelling, braces and all.
47    pub fn spelling(self) -> &'static str {
48        match self {
49            Self::PluginRoot => "${PLUGIN_ROOT}",
50            Self::PluginData => "${PLUGIN_DATA}",
51        }
52    }
53}
54
55impl fmt::Display for Placeholder {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        f.write_str(self.spelling())
58    }
59}
60
61/// One parsed piece of a configured string.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum Segment {
64    /// Literal text, preserved exactly — including any placeholder-*like*
65    /// text that is not one of the two recognized placeholders (§9.2).
66    Literal(String),
67    /// A recognized placeholder to be replaced at expansion time.
68    Anchor(Placeholder),
69}
70
71/// A configured string from `args`, `env`, or `cwd`, parsed for expansion.
72///
73/// Parsing is infallible: anything that is not exactly `${PLUGIN_ROOT}` or
74/// `${PLUGIN_DATA}` — including `$PLUGIN_ROOT`, `${plugin_root}`, `${HOME}` —
75/// remains literal text, as §9.2 requires.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct Template {
78    raw: String,
79    segments: Vec<Segment>,
80}
81
82impl Template {
83    /// Parse a configured string into literal and placeholder segments.
84    pub fn parse(raw: &str) -> Self {
85        let mut segments = Vec::new();
86        let mut literal = String::new();
87        let mut rest = raw;
88        while let Some(dollar) = rest.find("${") {
89            let (before, from_dollar) = rest.split_at(dollar);
90            literal.push_str(before);
91            let placeholder = [Placeholder::PluginRoot, Placeholder::PluginData]
92                .into_iter()
93                .find(|p| from_dollar.starts_with(p.spelling()));
94            match placeholder {
95                Some(p) => {
96                    if !literal.is_empty() {
97                        segments.push(Segment::Literal(std::mem::take(&mut literal)));
98                    }
99                    segments.push(Segment::Anchor(p));
100                    rest = &from_dollar[p.spelling().len()..];
101                }
102                None => {
103                    // Unrecognized placeholder-like text stays literal (§9.2).
104                    literal.push_str("${");
105                    rest = &from_dollar[2..];
106                }
107            }
108        }
109        literal.push_str(rest);
110        if !literal.is_empty() || segments.is_empty() {
111            segments.push(Segment::Literal(literal));
112        }
113        Self { raw: raw.to_owned(), segments }
114    }
115
116    /// The configured string exactly as written.
117    pub fn as_raw(&self) -> &str {
118        &self.raw
119    }
120
121    /// The parsed segments, in order.
122    pub fn segments(&self) -> &[Segment] {
123        &self.segments
124    }
125
126    /// Whether any recognized placeholder appears.
127    pub fn has_anchors(&self) -> bool {
128        self.segments.iter().any(|s| matches!(s, Segment::Anchor(_)))
129    }
130
131    /// Expand against the given anchors: each placeholder becomes its anchor
132    /// path, each literal passes through untouched. Because replacement text
133    /// lives in its own segment, it is never rescanned — the non-recursion
134    /// guarantee of §9.2 holds by construction.
135    pub fn expand(&self, anchors: &Anchors) -> String {
136        let mut out = String::with_capacity(self.raw.len());
137        for segment in &self.segments {
138            match segment {
139                Segment::Literal(text) => out.push_str(text),
140                Segment::Anchor(p) => out.push_str(&anchors.path_of(*p).to_string_lossy()),
141            }
142        }
143        out
144    }
145}
146
147impl fmt::Display for Template {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        f.write_str(&self.raw)
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    fn anchors() -> Anchors {
158        Anchors::new("/plugins/devtools", "/plugins/data/devtools")
159    }
160
161    #[test]
162    fn expands_both_placeholders() {
163        let t = Template::parse("${PLUGIN_ROOT}/config:${PLUGIN_DATA}/cache");
164        assert_eq!(t.expand(&anchors()), "/plugins/devtools/config:/plugins/data/devtools/cache");
165    }
166
167    #[test]
168    fn unrecognized_placeholders_stay_literal() {
169        let t = Template::parse("${HOME}/x ${PLUGIN_ROOT} $PLUGIN_DATA ${plugin_root}");
170        assert_eq!(t.expand(&anchors()), "${HOME}/x /plugins/devtools $PLUGIN_DATA ${plugin_root}");
171    }
172
173    #[test]
174    fn expansion_is_not_recursive() {
175        // An anchor path that itself contains placeholder text must not be
176        // expanded again: replacement text is never rescanned (§9.2).
177        let sneaky = Anchors::new("/evil/${PLUGIN_DATA}", "/data");
178        let t = Template::parse("${PLUGIN_ROOT}/bin");
179        assert_eq!(t.expand(&sneaky), "/evil/${PLUGIN_DATA}/bin");
180    }
181
182    #[test]
183    fn adjacent_and_edge_placeholders() {
184        let t = Template::parse("${PLUGIN_ROOT}${PLUGIN_DATA}");
185        assert_eq!(t.expand(&anchors()), "/plugins/devtools/plugins/data/devtools");
186        let empty = Template::parse("");
187        assert_eq!(empty.expand(&anchors()), "");
188    }
189}