use crate::config::Config;
use crate::config::load_bundle;
use crate::profile::{FamilyName, resolve_profile_for_family};
use crate::{
FragmentOutput, Framing, PrompterError, RenderOutput, default_post_prompt, default_pre_prompt,
format_system_prefix,
};
use chrono::Local;
use std::collections::HashSet;
use std::env;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use tftio_lib::{JsonOutput, render_response};
#[allow(
clippy::too_many_arguments,
reason = "stable 4.x public rendering API; the parameters are the rendering knobs and cannot be repacked without a breaking change"
)]
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() {
return write_json_render(&mut w, &files, profiles, pre_prompt, framing);
}
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(())
}
fn write_json_render(
mut w: impl Write,
files: &[(PathBuf, PathBuf)],
profiles: &[String],
pre_prompt: Option<&str>,
framing: Framing,
) -> Result<(), PrompterError> {
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)
}
#[allow(
clippy::too_many_arguments,
reason = "stable 4.x public rendering API; the parameters are the rendering knobs and cannot be repacked without a breaking change"
)]
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)]
#[allow(clippy::wildcard_imports)]
mod tests {
use super::*;
use crate::config::ProfileDef;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
fn mk_tmp(prefix: &str) -> PathBuf {
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("{prefix}_{}_{n}", std::process::id()))
}
fn cfg_one(name: &str, deps: Vec<&str>, lib: &Path) -> Config {
let mut profiles = HashMap::new();
profiles.insert(
name.to_string(),
ProfileDef {
deps: deps.into_iter().map(String::from).collect(),
library_root: lib.to_path_buf(),
},
);
Config {
profiles,
post_prompt: None,
}
}
#[test]
fn text_render_errors_when_fragment_path_is_a_directory() {
let lib = mk_tmp("prompter_render_dir_frag");
fs::create_dir_all(lib.join("a/x.md")).unwrap();
let cfg = cfg_one("p", vec!["a/x.md"], &lib);
let mut out = Vec::new();
let err = render_to_writer(
&cfg,
&mut out,
&["p".to_string()],
None,
None,
None,
None,
Framing::Full,
JsonOutput::Text,
)
.unwrap_err();
assert!(matches!(err, PrompterError::Io { .. }), "err={err}");
fs::remove_dir_all(&lib).ok();
}
#[test]
fn json_render_full_carries_explicit_pre_prompt_and_system_info() {
let lib = mk_tmp("prompter_json_pre");
fs::create_dir_all(lib.join("a")).unwrap();
fs::write(lib.join("a/x.md"), b"XC\n").unwrap();
let cfg = cfg_one("p", vec!["a/x.md"], &lib);
let mut out = Vec::new();
render_to_writer(
&cfg,
&mut out,
&["p".to_string()],
None,
None,
Some("MY-PRE"),
None,
Framing::Full,
JsonOutput::Json,
)
.unwrap();
let envelope: serde_json::Value =
serde_json::from_str(std::str::from_utf8(&out).unwrap().trim()).unwrap();
assert_eq!(envelope["data"]["pre_prompt"].as_str(), Some("MY-PRE"));
assert!(
envelope["data"]["system_info"]
.as_str()
.unwrap()
.contains("Today is "),
"envelope={envelope}"
);
assert_eq!(
envelope["data"]["fragments"][0]["path"].as_str(),
Some("a/x.md")
);
fs::remove_dir_all(&lib).ok();
}
#[test]
fn json_render_bare_drops_pre_prompt_and_system_info() {
let lib = mk_tmp("prompter_json_bare");
fs::create_dir_all(lib.join("a")).unwrap();
fs::write(lib.join("a/x.md"), b"XC\n").unwrap();
let cfg = cfg_one("p", vec!["a/x.md"], &lib);
let mut out = Vec::new();
render_to_writer(
&cfg,
&mut out,
&["p".to_string()],
None,
None,
None,
None,
Framing::Bare,
JsonOutput::Json,
)
.unwrap();
let envelope: serde_json::Value =
serde_json::from_str(std::str::from_utf8(&out).unwrap().trim()).unwrap();
assert_eq!(envelope["data"]["pre_prompt"].as_str(), Some(""));
assert_eq!(envelope["data"]["system_info"].as_str(), Some(""));
fs::remove_dir_all(&lib).ok();
}
#[test]
fn json_render_errors_when_fragment_path_is_a_directory() {
let lib = mk_tmp("prompter_json_dir_frag");
fs::create_dir_all(lib.join("a/x.md")).unwrap();
let cfg = cfg_one("p", vec!["a/x.md"], &lib);
let mut out = Vec::new();
let err = render_to_writer(
&cfg,
&mut out,
&["p".to_string()],
None,
None,
None,
None,
Framing::Full,
JsonOutput::Json,
)
.unwrap_err();
assert!(matches!(err, PrompterError::Io { .. }), "err={err}");
fs::remove_dir_all(&lib).ok();
}
#[test]
fn render_to_vec_propagates_resolution_error() {
let dir = mk_tmp("prompter_render_to_vec_err");
fs::create_dir_all(dir.join("library")).unwrap();
fs::write(
dir.join("config.toml"),
"[root]\ndepends_on = [\"missing.md\"]\n",
)
.unwrap();
let err =
render_to_vec(&["root".to_string()], None, Some(&dir.join("config.toml"))).unwrap_err();
assert!(matches!(err, PrompterError::Resolve(_)), "err={err}");
fs::remove_dir_all(&dir).ok();
}
}