use anyhow::{Context, Result};
use std::collections::HashMap;
use std::fs;
use std::io::{self, Write};
const INPUT_PLACEHOLDER_PREFIX: &str = "{{INPUT:";
const INPUT_PLACEHOLDER_SUFFIX: &str = "}}";
const ENV_PLACEHOLDER_PREFIX: &str = "{{ENV:";
const ENV_PLACEHOLDER_SUFFIX: &str = "}}";
fn extract_input_keys(raw: &str) -> Vec<String> {
let mut keys = Vec::new();
let mut search = raw;
while let Some(start) = search.find(INPUT_PLACEHOLDER_PREFIX) {
let after_prefix = &search[start + INPUT_PLACEHOLDER_PREFIX.len()..];
if let Some(end) = after_prefix.find(INPUT_PLACEHOLDER_SUFFIX) {
let key = after_prefix[..end].to_string();
if !key.is_empty() && !keys.contains(&key) {
keys.push(key);
}
search = &after_prefix[end + INPUT_PLACEHOLDER_SUFFIX.len()..];
} else {
break;
}
}
keys
}
fn inputs_file_path() -> Result<std::path::PathBuf> {
Ok(crate::directories::get_octomind_data_dir()?.join("inputs.toml"))
}
fn load_inputs() -> Result<HashMap<String, String>> {
let path = inputs_file_path()?;
if !path.exists() {
return Ok(HashMap::new());
}
let content = fs::read_to_string(&path)
.context(format!("Failed to read inputs file: {}", path.display()))?;
let table: toml::Table = toml::from_str(&content).context("Failed to parse inputs.toml")?;
Ok(table
.into_iter()
.filter_map(|(k, v)| {
if let toml::Value::String(s) = v {
Some((k, s))
} else {
None
}
})
.collect())
}
fn save_input(key: &str, value: &str) -> Result<()> {
let path = inputs_file_path()?;
let mut inputs = load_inputs()?;
inputs.insert(key.to_string(), value.to_string());
let mut table = toml::Table::new();
for (k, v) in &inputs {
table.insert(k.clone(), toml::Value::String(v.clone()));
}
let content =
toml::to_string_pretty(&toml::Value::Table(table)).context("Failed to serialize inputs")?;
fs::write(&path, content)
.context(format!("Failed to write inputs file: {}", path.display()))?;
Ok(())
}
fn prompt_user(key: &str) -> Result<String> {
let stderr = io::stderr();
let mut err = stderr.lock();
write!(err, "Enter value for {key}: ").ok();
err.flush().ok();
let mut value = String::new();
io::stdin()
.read_line(&mut value)
.context(format!("Failed to read input for {key}"))?;
Ok(value.trim().to_string())
}
pub async fn resolve_inputs(raw: &str) -> Result<String> {
let mut result = protect_escaped_braces(raw);
let keys = extract_input_keys(&result);
if keys.is_empty() {
return Ok(restore_escaped_braces(&result));
}
let mut stored = load_inputs()?;
for key in &keys {
let value = if let Some(v) = stored.get(key) {
v.clone()
} else {
let v = prompt_user(key)?;
save_input(key, &v)?;
stored.insert(key.clone(), v.clone());
v
};
let placeholder = format!("{INPUT_PLACEHOLDER_PREFIX}{key}{INPUT_PLACEHOLDER_SUFFIX}");
result = result.replace(&placeholder, &value);
}
Ok(restore_escaped_braces(&result))
}
pub fn protect_escaped_braces(s: &str) -> String {
s.replace("{{{{", "\x00LBRACE\x00")
.replace("}}}}", "\x00RBRACE\x00")
}
pub fn restore_escaped_braces(s: &str) -> String {
s.replace("\x00LBRACE\x00", "{{")
.replace("\x00RBRACE\x00", "}}")
}
fn extract_env_keys(raw: &str) -> Vec<String> {
let mut keys = Vec::new();
let mut search = raw;
while let Some(start) = search.find(ENV_PLACEHOLDER_PREFIX) {
let after_prefix = &search[start + ENV_PLACEHOLDER_PREFIX.len()..];
if let Some(end) = after_prefix.find(ENV_PLACEHOLDER_SUFFIX) {
let key = after_prefix[..end].to_string();
if !key.is_empty() && !keys.contains(&key) {
keys.push(key);
}
search = &after_prefix[end + ENV_PLACEHOLDER_SUFFIX.len()..];
} else {
break;
}
}
keys
}
fn save_env_to_dotenv(key: &str, value: &str) -> Result<()> {
let dotenv_path = std::path::Path::new(".env");
let line = format!("{}={}\n", key, value);
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(dotenv_path)
.context(format!(
"Failed to open .env for writing: {}",
dotenv_path.display()
))?;
file.write_all(line.as_bytes())
.context(format!("Failed to write {key} to .env"))?;
Ok(())
}
pub async fn resolve_env_vars(raw: &str) -> Result<String> {
let mut result = protect_escaped_braces(raw);
let keys = extract_env_keys(&result);
if keys.is_empty() {
return Ok(restore_escaped_braces(&result));
}
for key in &keys {
let value = match std::env::var(key) {
Ok(v) if !v.trim().is_empty() => v,
_ => {
let v = prompt_user(key)?;
save_env_to_dotenv(key, &v)?;
std::env::set_var(key, &v);
v
}
};
let placeholder = format!("{ENV_PLACEHOLDER_PREFIX}{key}{ENV_PLACEHOLDER_SUFFIX}");
result = result.replace(&placeholder, &value);
}
Ok(restore_escaped_braces(&result))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_protect_restore_roundtrip() {
let input = "Use {{{{INPUT:KEY}}}} or {{{{ENV:URL}}}} as examples";
assert_eq!(
restore_escaped_braces(&protect_escaped_braces(input)),
"Use {{INPUT:KEY}} or {{ENV:URL}} as examples"
);
}
#[test]
fn test_restore_basic() {
let protected = protect_escaped_braces("{{{{INPUT:KEY}}}}");
assert_eq!(restore_escaped_braces(&protected), "{{INPUT:KEY}}");
let protected = protect_escaped_braces("{{{{ENV:KEY}}}}");
assert_eq!(restore_escaped_braces(&protected), "{{ENV:KEY}}");
let protected = protect_escaped_braces("{{{{CWD}}}}");
assert_eq!(restore_escaped_braces(&protected), "{{CWD}}");
}
#[test]
fn test_protect_hides_from_substitution() {
let protected = protect_escaped_braces("{{{{CWD}}}}");
assert!(
!protected.contains("{{"),
"sentinel must not contain {{: {protected}"
);
}
#[test]
fn test_no_escaped_braces_unchanged() {
let plain = "no placeholders here";
assert_eq!(protect_escaped_braces(plain), plain);
assert_eq!(restore_escaped_braces(plain), plain);
}
#[test]
fn test_multiple_escaped_occurrences() {
let input = "Use {{{{INPUT:TOKEN}}}} or {{{{ENV:URL}}}} as examples";
let result = restore_escaped_braces(&protect_escaped_braces(input));
assert_eq!(result, "Use {{INPUT:TOKEN}} or {{ENV:URL}} as examples");
}
#[tokio::test]
async fn test_escaped_placeholder_survives_substitution() {
let prompt = "Example: {{{{CWD}}}}";
let dir = std::path::Path::new("/tmp");
let result = crate::session::helper_functions::process_placeholders_async_with_role(
prompt, dir, None,
)
.await;
assert_eq!(result, "Example: {{CWD}}");
}
#[tokio::test]
async fn test_real_and_escaped_placeholder_together() {
let prompt = "Real: {{CWD}}, Escaped: {{{{CWD}}}}";
let dir = std::path::Path::new("/tmp");
let result = crate::session::helper_functions::process_placeholders_async_with_role(
prompt, dir, None,
)
.await;
assert_eq!(result, "Real: /tmp, Escaped: {{CWD}}");
}
#[test]
fn test_extract_input_keys_ignores_escaped() {
let raw = "Use {{{{INPUT:KEY}}}} as an example";
let protected = protect_escaped_braces(raw);
let keys = extract_input_keys(&protected);
assert!(
keys.is_empty(),
"Escaped {{{{INPUT:KEY}}}} must not produce any keys, got: {:?}",
keys
);
}
#[test]
fn test_extract_env_keys_ignores_escaped() {
let raw = "Use {{{{ENV:BASE_URL}}}} as an example";
let protected = protect_escaped_braces(raw);
let keys = extract_env_keys(&protected);
assert!(
keys.is_empty(),
"Escaped {{{{ENV:BASE_URL}}}} must not produce any keys, got: {:?}",
keys
);
}
#[tokio::test]
async fn test_resolve_inputs_no_prompt_for_escaped() {
let raw = "Example: {{{{INPUT:SECRET}}}}";
let result = resolve_inputs(raw).await.unwrap();
assert_eq!(result, "Example: {{INPUT:SECRET}}");
}
#[tokio::test]
async fn test_resolve_env_vars_no_prompt_for_escaped() {
let raw = "Example: {{{{ENV:URL}}}}";
let result = resolve_env_vars(raw).await.unwrap();
assert_eq!(result, "Example: {{ENV:URL}}");
}
}