use super::config::Config;
use super::config::load_bundle;
use super::profile::{FamilyName, resolve_profile_for_family};
use super::{
FragmentOutput, Framing, PrompterError, RenderOutput, default_post_prompt, default_pre_prompt,
format_system_prefix,
};
use crate::{JsonOutput, render_response};
use chrono::Local;
use std::collections::HashSet;
use std::env;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
#[allow(
clippy::too_many_arguments,
clippy::too_many_lines,
reason = "preserves the published prompter library surface; TODO.md tracks an options-based redesign"
)]
pub fn render_to_writer(
cfg: &Config,
mut w: impl Write,
profiles: &[String],
family: Option<&FamilyName>,
separator: Option<&str>,
pre_prompt: Option<&str>,
post_prompt: Option<&str>,
framing: Framing,
output: JsonOutput,
) -> Result<(), PrompterError> {
let mut seen_files = HashSet::new();
let mut files: Vec<(PathBuf, PathBuf)> = Vec::new();
for profile in profiles {
let mut stack = Vec::new();
resolve_profile_for_family(
profile,
cfg,
family,
&mut seen_files,
&mut stack,
&mut files,
)?;
}
if output.is_json() {
let pre_prompt_text = match pre_prompt {
Some(explicit) => explicit.to_string(),
None if framing.is_full() => default_pre_prompt(),
None => String::new(),
};
let system_info = if framing.is_full() {
let date = Local::now().format("%Y-%m-%d").to_string();
let os = env::consts::OS;
let arch = env::consts::ARCH;
format!("Today is {date}, and you are running on a {arch}/{os} system.")
} else {
String::new()
};
let mut fragments = Vec::new();
for (path, library_root) in &files {
let content = fs::read_to_string(path).map_err(|source| PrompterError::Io {
path: path.clone(),
source,
})?;
let rel_path = path
.strip_prefix(library_root)
.unwrap_or(path)
.display()
.to_string();
fragments.push(FragmentOutput {
path: rel_path,
content,
});
}
let payload = serde_json::to_value(RenderOutput {
profile: profiles.join(", "),
pre_prompt: pre_prompt_text,
system_info,
fragments,
})?;
writeln!(
&mut w,
"{}",
render_response("run", JsonOutput::Json, payload, String::new())
)
.map_err(PrompterError::Write)?;
} else {
let default_pre = default_pre_prompt();
let pre_prompt_text = match (pre_prompt, framing) {
(Some(explicit), _) => Some(explicit),
(None, Framing::Full) => Some(default_pre.as_str()),
(None, Framing::Bare) => None,
};
if let Some(text) = pre_prompt_text {
w.write_all(text.as_bytes()).map_err(PrompterError::Write)?;
}
if framing.is_full() {
w.write_all(b"\n").map_err(PrompterError::Write)?;
let prefix = format_system_prefix();
w.write_all(prefix.as_bytes())
.map_err(PrompterError::Write)?;
}
let sep = separator.unwrap_or("");
for (index, (path, _library_root)) in files.iter().enumerate() {
if framing.is_full() || index > 0 {
w.write_all(b"\n").map_err(PrompterError::Write)?;
}
let bytes = fs::read(path).map_err(|source| PrompterError::Io {
path: path.clone(),
source,
})?;
w.write_all(&bytes).map_err(PrompterError::Write)?;
if !sep.is_empty() {
w.write_all(sep.as_bytes()).map_err(PrompterError::Write)?;
}
}
let default_post = default_post_prompt();
let post_prompt_text = match framing {
Framing::Full => Some(
post_prompt
.or(cfg.post_prompt.as_deref())
.unwrap_or(&default_post),
),
Framing::Bare => post_prompt,
};
if let Some(text) = post_prompt_text {
if framing.is_full() {
w.write_all(b"\n\n").map_err(PrompterError::Write)?;
}
w.write_all(text.as_bytes()).map_err(PrompterError::Write)?;
}
}
Ok(())
}
#[allow(
clippy::too_many_arguments,
reason = "preserves the published prompter library surface; TODO.md tracks an options-based redesign"
)]
pub fn run_render_stdout(
profiles: &[String],
family: Option<&FamilyName>,
separator: Option<&str>,
pre_prompt: Option<&str>,
post_prompt: Option<&str>,
framing: Framing,
config_override: Option<&Path>,
output: JsonOutput,
) -> Result<(), PrompterError> {
let (_cfg_path, cfg) = load_bundle(config_override)?;
let stdout = io::stdout();
let handle = stdout.lock();
render_to_writer(
&cfg,
handle,
profiles,
family,
separator,
pre_prompt,
post_prompt,
framing,
output,
)
}
pub fn render_to_vec(
profiles: &[String],
family: Option<&FamilyName>,
config_override: Option<&Path>,
) -> Result<Vec<u8>, PrompterError> {
let (_cfg_path, cfg) = load_bundle(config_override)?;
let mut buf = Vec::new();
render_to_writer(
&cfg,
&mut buf,
profiles,
family,
None,
None,
None,
Framing::Full,
JsonOutput::Text,
)?;
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::prompt::config::ProfileDef;
use std::sync::atomic::{AtomicU32, Ordering};
fn unique_temp_path(label: &str) -> PathBuf {
static COUNTER: AtomicU32 = AtomicU32::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"tftio-lib-render-{label}-{}-{n}",
std::process::id()
))
}
fn cfg_with_lib<I>(profiles: I, lib: &Path, post_prompt: Option<&str>) -> Config
where
I: IntoIterator<Item = (&'static str, Vec<&'static str>)>,
{
let profiles = profiles
.into_iter()
.map(|(name, deps)| {
(
name.to_string(),
ProfileDef {
deps: deps.into_iter().map(String::from).collect(),
library_root: lib.to_path_buf(),
},
)
})
.collect();
Config {
profiles,
post_prompt: post_prompt.map(String::from),
}
}
struct FailAfterN {
writes_done: usize,
fail_on: usize,
}
impl Write for FailAfterN {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.writes_done += 1;
if self.writes_done == self.fail_on {
Err(io::Error::other("synthetic write failure"))
} else {
Ok(buf.len())
}
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn render_json_full_includes_default_pre_system_info_and_fragments() {
let lib = unique_temp_path("json-full");
fs::create_dir_all(lib.join("a")).unwrap();
fs::write(lib.join("a/x.md"), b"AX-CONTENT\n").unwrap();
let cfg = cfg_with_lib([("root", vec!["a/x.md"])], &lib, None);
let mut out = Vec::new();
render_to_writer(
&cfg,
&mut out,
&["root".to_string()],
None,
None,
None,
None,
Framing::Full,
JsonOutput::Json,
)
.unwrap();
let text = String::from_utf8(out).unwrap();
let value: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
assert_eq!(value["ok"], serde_json::json!(true));
assert_eq!(value["command"], serde_json::json!("run"));
assert_eq!(value["data"]["profile"], serde_json::json!("root"));
assert_eq!(
value["data"]["pre_prompt"],
serde_json::json!(default_pre_prompt())
);
let system_info = value["data"]["system_info"].as_str().unwrap();
assert!(
system_info.contains("Today is ") && system_info.contains(" system."),
"system_info={system_info}"
);
let fragments = value["data"]["fragments"].as_array().unwrap();
assert_eq!(fragments.len(), 1);
assert_eq!(fragments[0]["path"], serde_json::json!("a/x.md"));
assert_eq!(fragments[0]["content"], serde_json::json!("AX-CONTENT\n"));
fs::remove_dir_all(&lib).ok();
}
#[test]
fn render_json_uses_explicit_pre_prompt() {
let lib = unique_temp_path("json-explicit-pre");
fs::create_dir_all(lib.join("a")).unwrap();
fs::write(lib.join("a/x.md"), b"AX\n").unwrap();
let cfg = cfg_with_lib([("root", vec!["a/x.md"])], &lib, None);
let mut out = Vec::new();
render_to_writer(
&cfg,
&mut out,
&["root".to_string()],
None,
None,
Some("EXPLICIT-PRE"),
None,
Framing::Full,
JsonOutput::Json,
)
.unwrap();
let text = String::from_utf8(out).unwrap();
let value: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
assert_eq!(
value["data"]["pre_prompt"],
serde_json::json!("EXPLICIT-PRE")
);
fs::remove_dir_all(&lib).ok();
}
#[test]
fn render_json_bare_drops_default_pre_and_system_info() {
let lib = unique_temp_path("json-bare");
fs::create_dir_all(lib.join("a")).unwrap();
fs::write(lib.join("a/x.md"), b"AX\n").unwrap();
let cfg = cfg_with_lib([("root", vec!["a/x.md"])], &lib, None);
let mut out = Vec::new();
render_to_writer(
&cfg,
&mut out,
&["root".to_string()],
None,
None,
None,
None,
Framing::Bare,
JsonOutput::Json,
)
.unwrap();
let text = String::from_utf8(out).unwrap();
let value: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
assert_eq!(value["data"]["pre_prompt"], serde_json::json!(""));
assert_eq!(value["data"]["system_info"], serde_json::json!(""));
assert_eq!(
value["data"]["fragments"][0]["content"],
serde_json::json!("AX\n")
);
fs::remove_dir_all(&lib).ok();
}
#[test]
fn render_json_surfaces_fragment_read_error() {
let lib = unique_temp_path("json-read-err");
fs::create_dir_all(lib.join("a/x.md")).unwrap();
let cfg = cfg_with_lib([("root", vec!["a/x.md"])], &lib, None);
let mut out = Vec::new();
let err = render_to_writer(
&cfg,
&mut out,
&["root".to_string()],
None,
None,
None,
None,
Framing::Full,
JsonOutput::Json,
)
.unwrap_err();
match err {
PrompterError::Io { path, .. } => assert_eq!(path, lib.join("a/x.md")),
other => panic!("expected Io error, got {other:?}"),
}
fs::remove_dir_all(&lib).ok();
}
#[test]
fn render_json_surfaces_writer_error() {
let lib = unique_temp_path("json-write-err");
fs::create_dir_all(lib.join("a")).unwrap();
fs::write(lib.join("a/x.md"), b"AX\n").unwrap();
let cfg = cfg_with_lib([("root", vec!["a/x.md"])], &lib, None);
let mut w = FailAfterN {
writes_done: 0,
fail_on: 1,
};
let err = render_to_writer(
&cfg,
&mut w,
&["root".to_string()],
None,
None,
None,
None,
Framing::Full,
JsonOutput::Json,
)
.unwrap_err();
assert!(err.to_string().contains("Write error"), "err={err}");
fs::remove_dir_all(&lib).ok();
}
#[test]
fn render_text_surfaces_fragment_read_error() {
let lib = unique_temp_path("text-read-err");
fs::create_dir_all(lib.join("a/x.md")).unwrap();
let cfg = cfg_with_lib([("root", vec!["a/x.md"])], &lib, None);
let mut out = Vec::new();
let err = render_to_writer(
&cfg,
&mut out,
&["root".to_string()],
None,
None,
None,
None,
Framing::Full,
JsonOutput::Text,
)
.unwrap_err();
match err {
PrompterError::Io { path, .. } => assert_eq!(path, lib.join("a/x.md")),
other => panic!("expected Io error, got {other:?}"),
}
fs::remove_dir_all(&lib).ok();
}
#[test]
fn render_to_writer_propagates_resolve_error() {
let lib = unique_temp_path("resolve-err");
fs::create_dir_all(&lib).unwrap();
let cfg = cfg_with_lib([("root", vec!["missing.md"])], &lib, None);
let mut out = Vec::new();
let err = render_to_writer(
&cfg,
&mut out,
&["root".to_string()],
None,
None,
None,
None,
Framing::Full,
JsonOutput::Text,
)
.unwrap_err();
assert!(matches!(err, PrompterError::Resolve(_)), "err={err}");
fs::remove_dir_all(&lib).ok();
}
#[test]
fn run_render_stdout_with_explicit_config_succeeds() {
let root = unique_temp_path("stdout-cfg");
fs::create_dir_all(root.join("library/a")).unwrap();
fs::write(root.join("library/a/x.md"), b"AX\n").unwrap();
let config = root.join("config.toml");
fs::write(&config, "[root]\ndepends_on = [\"a/x.md\"]\n").unwrap();
assert!(
run_render_stdout(
&["root".to_string()],
None,
None,
None,
None,
Framing::Full,
Some(&config),
JsonOutput::Text,
)
.is_ok()
);
fs::remove_dir_all(&root).ok();
}
#[test]
fn render_to_vec_with_explicit_config_returns_bytes() {
let root = unique_temp_path("vec-cfg");
fs::create_dir_all(root.join("library/a")).unwrap();
fs::write(root.join("library/a/x.md"), b"AX-VEC\n").unwrap();
let config = root.join("config.toml");
fs::write(&config, "[root]\ndepends_on = [\"a/x.md\"]\n").unwrap();
let bytes = render_to_vec(&["root".to_string()], None, Some(&config)).unwrap();
let text = String::from_utf8(bytes).unwrap();
assert!(
text.starts_with("You are an LLM coding agent."),
"text={text}"
);
assert!(text.contains("AX-VEC\n"), "text={text}");
assert!(
text.ends_with(
"Now, read the @AGENTS.md and @CLAUDE.md files in this directory, if they exist."
),
"text={text}"
);
fs::remove_dir_all(&root).ok();
}
}