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 = tftio_lib::prompt::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 = tftio_lib::prompt::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 crate::config::Config;
use tempfile::TempDir;
fn write_bundle(home: &Path) {
let prompter = home.join(".local/prompter");
fs::create_dir_all(prompter.join("library/families/gpt")).unwrap();
fs::write(
prompter.join("config.toml"),
r#"
library = "library"
[core.base]
depends_on = ["base.md"]
"#,
)
.unwrap();
fs::write(prompter.join("library/base.md"), "BASE\n").unwrap();
fs::write(prompter.join("library/families/gpt/base.md"), "GPT BASE\n").unwrap();
}
fn sample_config() -> Config {
toml::from_str(
r#"
[defaults]
domain = "eng"
contexts = ["personal"]
context_fallback = "personal"
context_by_hostname = {}
shim_path = "~/unused"
sandbox_wrapper = "~/unused"
prompter_bundle = "~/.local/prompter"
skills_bundle = "~/unused"
prompt_cache = "~/.cache/clanker/prompts"
prompt_cache_ttl_seconds = 3600
[harness.claude]
bin = "claude"
family = "claude"
default_args = []
[harness.claude.injection]
kind = "arg-text"
args = ["--append-system-prompt", "{text}"]
[harness.codex]
bin = "codex"
family = "gpt"
default_args = []
[harness.codex.injection]
kind = "arg-file"
args = ["-c", "model_instructions_file={file}"]
[harness.gemini]
bin = "gemini"
family = "gemini"
default_args = []
[harness.gemini.injection]
kind = "env-file"
environment = "GEMINI_SYSTEM_MD"
[model]
[domain.eng]
profiles = ["core.base"]
skills = []
env = {}
"#,
)
.unwrap()
}
fn harness<'a>(config: &'a Config, name: &str) -> &'a HarnessConfig {
config.harness.get(name).unwrap()
}
#[test]
fn arg_text_injection_substitutes_prompt_into_arguments() {
let home = TempDir::new().unwrap();
write_bundle(home.path());
let config = sample_config();
let claude = harness(&config, "claude");
let injection = compose_and_inject(
&["core.base".to_string()],
&claude.family,
claude,
&config,
home.path(),
false,
)
.unwrap();
assert!(
injection
.prompt
.as_deref()
.is_some_and(|prompt| prompt.contains("BASE\n"))
);
assert!(
injection
.arguments
.iter()
.any(|argument| argument.to_string_lossy() == "--append-system-prompt")
);
assert!(
injection
.arguments
.iter()
.any(|argument| argument.to_string_lossy().contains("BASE\n"))
);
assert!(injection.cache_path.is_none());
}
#[test]
fn arg_file_injection_persists_prompt_and_injects_path() {
let home = TempDir::new().unwrap();
write_bundle(home.path());
let config = sample_config();
let codex = harness(&config, "codex");
let injection = compose_and_inject(
&["core.base".to_string()],
&codex.family,
codex,
&config,
home.path(),
true,
)
.unwrap();
let cache_path = injection
.cache_path
.expect("arg-file injection writes a file");
assert!(cache_path.is_file());
assert!(
fs::read_to_string(&cache_path)
.unwrap()
.contains("GPT BASE\n")
);
let rendered = cache_path.display().to_string();
assert!(
injection
.arguments
.iter()
.any(|argument| argument.to_string_lossy()
== format!("model_instructions_file={rendered}"))
);
}
#[test]
fn env_file_injection_exports_prompt_path() {
let home = TempDir::new().unwrap();
write_bundle(home.path());
let config = sample_config();
let gemini = harness(&config, "gemini");
let injection = compose_and_inject(
&["core.base".to_string()],
&gemini.family,
gemini,
&config,
home.path(),
true,
)
.unwrap();
let cache_path = injection
.cache_path
.expect("env-file injection writes a file");
assert_eq!(
injection
.environment
.get(std::ffi::OsStr::new("GEMINI_SYSTEM_MD")),
Some(&OsString::from(cache_path.as_os_str()))
);
assert!(injection.arguments.is_empty());
}
#[test]
fn unknown_profile_fails_closed() {
let home = TempDir::new().unwrap();
write_bundle(home.path());
let config = sample_config();
let claude = harness(&config, "claude");
let error = compose_and_inject(
&["core.base".to_string(), "missing.profile".to_string()],
&claude.family,
claude,
&config,
home.path(),
false,
)
.unwrap_err();
assert!(matches!(error, PromptError::UnknownProfiles { .. }));
assert!(error.to_string().contains("missing.profile"));
}
#[test]
fn empty_profiles_yield_no_prompt() {
let home = TempDir::new().unwrap();
write_bundle(home.path());
let config = sample_config();
let claude = harness(&config, "claude");
let injection =
compose_and_inject(&[], &claude.family, claude, &config, home.path(), true).unwrap();
assert!(injection.prompt.is_none());
assert!(injection.arguments.is_empty());
assert!(injection.cache_path.is_none());
}
#[test]
fn missing_bundle_reports_unavailable() {
let home = TempDir::new().unwrap();
let config = sample_config();
let claude = harness(&config, "claude");
let error = compose_and_inject(
&["core.base".to_string()],
&claude.family,
claude,
&config,
home.path(),
false,
)
.unwrap_err();
assert!(matches!(error, PromptError::Unavailable(_)));
}
#[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());
}
#[test]
fn gc_ignores_a_missing_cache_directory() {
let temp = TempDir::new().unwrap();
let absent = temp.path().join("no-such-cache");
gc_prompt_cache(&absent, Duration::from_secs(0), SystemTime::now());
assert!(!absent.exists());
}
#[test]
fn gc_keeps_files_modified_after_the_reference_instant() {
let temp = TempDir::new().unwrap();
let kept = temp.path().join("future.md");
fs::write(&kept, "keep").unwrap();
gc_prompt_cache(temp.path(), Duration::from_secs(0), UNIX_EPOCH);
assert!(kept.exists());
}
}