Skip to main content

ito_core/memory/
rendering.rs

1//! Placeholder rendering for the `command` shape of memory operations.
2//!
3//! Rules (from the `agent-memory-abstraction` spec):
4//!
5//! - **Scalar string** (`{context}`, `{query}`, `{scope}`) — replaced with
6//!   a single shell-quoted token. Missing values render as the empty
7//!   string.
8//! - **Scalar integer** (`{limit}`) — replaced with the decimal literal,
9//!   or empty when unset and no operation default applies.
10//! - **List** (`{files}`, `{folders}`) — expanded as repeated flags. The
11//!   flag name is fixed by the placeholder name (`{files}` → `--file`,
12//!   `{folders}` → `--folder`). Empty lists render as empty strings.
13//! - **Unknown placeholder** (e.g. `{foo}`) — preserved literally.
14
15use std::collections::BTreeMap;
16
17use serde_json::{Value, json};
18
19use super::{CaptureInputs, DEFAULT_SEARCH_LIMIT, QueryInputs, SearchInputs};
20
21/// POSIX single-quote shell quoting.
22///
23/// Wraps `value` in single quotes and escapes any embedded single quote with
24/// the canonical `'\''` sequence. The empty string renders as `''`. Pasting
25/// the output into a POSIX shell preserves the original byte sequence.
26#[must_use]
27pub fn shell_quote(value: &str) -> String {
28    let mut out = String::with_capacity(value.len() + 2);
29    out.push('\'');
30    for ch in value.chars() {
31        if ch == '\'' {
32            out.push_str("'\\''");
33        } else {
34            out.push(ch);
35        }
36    }
37    out.push('\'');
38    out
39}
40
41/// Render the `memory-capture` command template with placeholder
42/// substitution.
43pub(super) fn render_capture_command(template: &str, inputs: &CaptureInputs) -> String {
44    let context_quoted = shell_quote(inputs.context.as_deref().unwrap_or(""));
45    let files = render_repeated_flag("--file", &inputs.files);
46    let folders = render_repeated_flag("--folder", &inputs.folders);
47    substitute(template, |name| match name {
48        "context" => Some(context_quoted.clone()),
49        "files" => Some(files.clone()),
50        "folders" => Some(folders.clone()),
51        _ => None,
52    })
53}
54
55/// Render the `memory-search` command template with placeholder
56/// substitution.
57pub(super) fn render_search_command(template: &str, inputs: &SearchInputs) -> String {
58    let query_quoted = shell_quote(&inputs.query);
59    let limit_value = inputs.limit.unwrap_or(DEFAULT_SEARCH_LIMIT).to_string();
60    // Always shell-quote, even when unset, so flag-prefixed templates like
61    // `--scope {scope}` produce a valid shell token (`--scope ''`) rather
62    // than a dangling flag with no argument. Matches `{context}` semantics.
63    let scope_quoted = shell_quote(inputs.scope.as_deref().unwrap_or(""));
64    substitute(template, |name| match name {
65        "query" => Some(query_quoted.clone()),
66        "limit" => Some(limit_value.clone()),
67        "scope" => Some(scope_quoted.clone()),
68        _ => None,
69    })
70}
71
72/// Render the `memory-query` command template with placeholder substitution.
73pub(super) fn render_query_command(template: &str, inputs: &QueryInputs) -> String {
74    let query_quoted = shell_quote(&inputs.query);
75    substitute(template, |name| match name {
76        "query" => Some(query_quoted.clone()),
77        _ => None,
78    })
79}
80
81/// Convert capture inputs to the structured map passed to a delegated skill.
82pub(super) fn capture_inputs_as_structured(inputs: &CaptureInputs) -> BTreeMap<String, Value> {
83    let mut out = BTreeMap::new();
84    out.insert(
85        "context".to_string(),
86        inputs
87            .context
88            .as_ref()
89            .map_or(Value::Null, |s| Value::String(s.clone())),
90    );
91    out.insert("files".to_string(), json!(inputs.files));
92    out.insert("folders".to_string(), json!(inputs.folders));
93    out
94}
95
96/// Convert search inputs to the structured map passed to a delegated skill.
97pub(super) fn search_inputs_as_structured(inputs: &SearchInputs) -> BTreeMap<String, Value> {
98    let mut out = BTreeMap::new();
99    out.insert("query".to_string(), Value::String(inputs.query.clone()));
100    out.insert(
101        "limit".to_string(),
102        Value::Number(inputs.limit.unwrap_or(DEFAULT_SEARCH_LIMIT).into()),
103    );
104    out.insert(
105        "scope".to_string(),
106        inputs
107            .scope
108            .as_ref()
109            .map_or(Value::Null, |s| Value::String(s.clone())),
110    );
111    out
112}
113
114/// Convert query inputs to the structured map passed to a delegated skill.
115pub(super) fn query_inputs_as_structured(inputs: &QueryInputs) -> BTreeMap<String, Value> {
116    let mut out = BTreeMap::new();
117    out.insert("query".to_string(), Value::String(inputs.query.clone()));
118    out
119}
120
121/// Render `--<flag> 'value'` pairs for each value, joined by single spaces.
122/// Empty `values` returns an empty string.
123fn render_repeated_flag(flag: &str, values: &[String]) -> String {
124    let mut out = String::new();
125    for (idx, value) in values.iter().enumerate() {
126        if idx > 0 {
127            out.push(' ');
128        }
129        out.push_str(flag);
130        out.push(' ');
131        out.push_str(&shell_quote(value));
132    }
133    out
134}
135
136/// Walk `template` and replace `{name}` placeholders using `lookup`.
137///
138/// When `lookup(name)` returns `Some(value)`, the entire `{name}` token is
139/// replaced with `value`. When `lookup` returns `None`, the placeholder is
140/// preserved literally (including its braces) — this matches the spec's
141/// "unknown placeholders pass through" rule.
142fn substitute<F>(template: &str, lookup: F) -> String
143where
144    F: Fn(&str) -> Option<String>,
145{
146    let bytes = template.as_bytes();
147    let mut out = String::with_capacity(template.len());
148    let mut i = 0;
149    while i < bytes.len() {
150        if bytes[i] == b'{' {
151            // Find a matching closing brace within the same input. We don't
152            // support nested braces — they're not part of the spec.
153            let rest = &template[i + 1..];
154            if let Some(end) = rest.find('}') {
155                let name = &rest[..end];
156                if is_placeholder_name(name) {
157                    if let Some(replacement) = lookup(name) {
158                        out.push_str(&replacement);
159                    } else {
160                        // Unknown placeholder: preserve literally.
161                        out.push('{');
162                        out.push_str(name);
163                        out.push('}');
164                    }
165                    i += 1 + end + 1; // consume "{name}"
166                    continue;
167                }
168            }
169        }
170        // Default: copy this character literally. Use char_indices to handle
171        // multi-byte UTF-8 correctly.
172        let ch = template[i..]
173            .chars()
174            .next()
175            .expect("non-empty by loop guard");
176        out.push(ch);
177        i += ch.len_utf8();
178    }
179    out
180}
181
182/// A valid placeholder name is non-empty and consists only of ASCII
183/// alphanumerics and underscores. This lets the substituter tell the
184/// difference between `{files}` (a placeholder) and `{}` or `{foo bar}`
185/// (literal text).
186fn is_placeholder_name(name: &str) -> bool {
187    !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
188}