use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsString;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::config::{Config, HarnessConfig, InjectionConfig, ModelFamily};
use crate::launch::expand_path;
const FILE_PLACEHOLDER: &str = "{file}";
const TEXT_PLACEHOLDER: &str = "{text}";
#[derive(Debug, Default)]
pub struct PromptInjection {
pub prompt: Option<String>,
pub arguments: Vec<OsString>,
pub environment: BTreeMap<OsString, OsString>,
pub cache_path: Option<PathBuf>,
}
#[derive(Debug, thiserror::Error)]
pub enum PromptError {
#[error("prompter path: {0}")]
Path(String),
#[error("prompter unavailable: {0}")]
Unavailable(String),
#[error("unknown prompter profile(s): {}", profiles.join(", "))]
UnknownProfiles {
profiles: Vec<String>,
},
#[error("prompt rendering failed: {0}")]
Render(String),
#[error("rendered prompt is not valid UTF-8: {0}")]
NonUtf8(#[from] std::string::FromUtf8Error),
#[error("failed to create prompt cache {path}: {source}")]
CacheDirectory {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to write prompt file {path}: {source}")]
CacheFile {
path: PathBuf,
source: std::io::Error,
},
}
pub fn compose_and_inject(
profiles: &[String],
family: &ModelFamily,
harness: &HarnessConfig,
config: &Config,
home: &Path,
persist_file: bool,
) -> Result<PromptInjection, PromptError> {
let bundle = expand_path(&config.defaults.prompter_bundle, home)
.map_err(|error| PromptError::Path(error.to_string()))?;
let bundle_config = bundle.join("config.toml");
let available = prompter::available_profiles(Some(&bundle_config))
.map_err(|error| PromptError::Unavailable(error.to_string()))?
.into_iter()
.collect::<BTreeSet<_>>();
let unknown: Vec<String> = profiles
.iter()
.filter(|profile| !available.contains(*profile))
.cloned()
.collect();
if !unknown.is_empty() {
return Err(PromptError::UnknownProfiles { profiles: unknown });
}
let mut injection = PromptInjection::default();
if profiles.is_empty() {
return Ok(injection);
}
let prompt = prompter::render_to_vec(
profiles,
Some(family.as_prompter_family()),
Some(&bundle_config),
)
.map_err(|error| PromptError::Render(error.to_string()))?;
let prompt = String::from_utf8(prompt)?;
match &harness.injection {
InjectionConfig::ArgText { args } => {
injection.arguments = args
.iter()
.map(|argument| OsString::from(argument.replace(TEXT_PLACEHOLDER, &prompt)))
.collect();
}
InjectionConfig::ArgFile { args } => {
let path = prepare_prompt_file(config, home, &prompt, persist_file)?;
let display = path.display().to_string();
injection.arguments = args
.iter()
.map(|argument| OsString::from(argument.replace(FILE_PLACEHOLDER, &display)))
.collect();
injection.cache_path = Some(path);
}
InjectionConfig::EnvFile { environment } => {
let path = prepare_prompt_file(config, home, &prompt, persist_file)?;
injection.environment.insert(
OsString::from(environment),
OsString::from(path.as_os_str()),
);
injection.cache_path = Some(path);
}
}
injection.prompt = Some(prompt);
Ok(injection)
}
fn prepare_prompt_file(
config: &Config,
home: &Path,
prompt: &str,
persist: bool,
) -> Result<PathBuf, PromptError> {
let cache = expand_path(&config.defaults.prompt_cache, home)
.map_err(|error| PromptError::Path(error.to_string()))?;
if !persist {
return Ok(cache.join("dry-run-prompt.md"));
}
fs::create_dir_all(&cache).map_err(|source| PromptError::CacheDirectory {
path: cache.clone(),
source,
})?;
let now = SystemTime::now();
gc_prompt_cache(
&cache,
Duration::from_secs(config.defaults.prompt_cache_ttl_seconds),
now,
);
let timestamp = now
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let path = cache.join(format!("{}-{timestamp}.md", std::process::id()));
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&path)
.map_err(|source| PromptError::CacheFile {
path: path.clone(),
source,
})?;
file.write_all(prompt.as_bytes())
.map_err(|source| PromptError::CacheFile {
path: path.clone(),
source,
})?;
Ok(path)
}
fn gc_prompt_cache(cache: &Path, ttl: Duration, now: SystemTime) {
let Ok(entries) = fs::read_dir(cache) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(metadata) = entry.metadata() else {
continue;
};
let Ok(modified) = metadata.modified() else {
continue;
};
let Ok(age) = now.duration_since(modified) else {
continue;
};
if age > ttl {
let _ignored = fs::remove_file(path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn prompt_cache_gc_removes_only_expired_files() {
let temp = TempDir::new().unwrap();
let expired = temp.path().join("expired.md");
let current = temp.path().join("current.md");
fs::write(&expired, "old").unwrap();
std::thread::sleep(Duration::from_millis(5));
fs::write(¤t, "new").unwrap();
let now = SystemTime::now();
let expired_modified = fs::metadata(&expired).unwrap().modified().unwrap();
let current_modified = fs::metadata(¤t).unwrap().modified().unwrap();
let threshold = now
.duration_since(expired_modified)
.unwrap()
.checked_sub(Duration::from_millis(1))
.unwrap();
assert!(now.duration_since(current_modified).unwrap() < threshold);
gc_prompt_cache(temp.path(), threshold, now);
assert!(!expired.exists());
assert!(current.exists());
}
}