1use crate::config::Config;
3use crate::config::load_bundle;
4use crate::profile::{FamilyName, resolve_profile_for_family};
5use crate::{
6 FragmentOutput, Framing, PrompterError, RenderOutput, default_post_prompt, default_pre_prompt,
7 format_system_prefix,
8};
9use chrono::Local;
10use std::collections::HashSet;
11use std::env;
12use std::fs;
13use std::io::{self, Write};
14use std::path::{Path, PathBuf};
15use tftio_lib::{JsonOutput, render_response};
16
17#[allow(
23 clippy::too_many_arguments,
24 reason = "stable 4.x public rendering API; the parameters are the rendering knobs and cannot be repacked without a breaking change"
25)]
26pub fn render_to_writer(
27 cfg: &Config,
28 mut w: impl Write,
29 profiles: &[String],
30 family: Option<&FamilyName>,
31 separator: Option<&str>,
32 pre_prompt: Option<&str>,
33 post_prompt: Option<&str>,
34 framing: Framing,
35 output: JsonOutput,
36) -> Result<(), PrompterError> {
37 let mut seen_files = HashSet::new();
38 let mut files: Vec<(PathBuf, PathBuf)> = Vec::new();
39
40 for profile in profiles {
42 let mut stack = Vec::new();
43 resolve_profile_for_family(
44 profile,
45 cfg,
46 family,
47 &mut seen_files,
48 &mut stack,
49 &mut files,
50 )?;
51 }
52
53 if output.is_json() {
54 return write_json_render(&mut w, &files, profiles, pre_prompt, framing);
55 }
56
57 let default_pre = default_pre_prompt();
62 let pre_prompt_text = match (pre_prompt, framing) {
63 (Some(explicit), _) => Some(explicit),
64 (None, Framing::Full) => Some(default_pre.as_str()),
65 (None, Framing::Bare) => None,
66 };
67 if let Some(text) = pre_prompt_text {
68 w.write_all(text.as_bytes()).map_err(PrompterError::Write)?;
69 }
70
71 if framing.is_full() {
74 w.write_all(b"\n").map_err(PrompterError::Write)?;
75 let prefix = format_system_prefix();
76 w.write_all(prefix.as_bytes())
77 .map_err(PrompterError::Write)?;
78 }
79
80 let sep = separator.unwrap_or("");
81 for (index, (path, _library_root)) in files.iter().enumerate() {
82 if framing.is_full() || index > 0 {
85 w.write_all(b"\n").map_err(PrompterError::Write)?;
86 }
87
88 let bytes = fs::read(path).map_err(|source| PrompterError::Io {
89 path: path.clone(),
90 source,
91 })?;
92 w.write_all(&bytes).map_err(PrompterError::Write)?;
93
94 if !sep.is_empty() {
96 w.write_all(sep.as_bytes()).map_err(PrompterError::Write)?;
97 }
98 }
99
100 let default_post = default_post_prompt();
105 let post_prompt_text = match framing {
106 Framing::Full => Some(
107 post_prompt
108 .or(cfg.post_prompt.as_deref())
109 .unwrap_or(&default_post),
110 ),
111 Framing::Bare => post_prompt,
112 };
113 if let Some(text) = post_prompt_text {
114 if framing.is_full() {
115 w.write_all(b"\n\n").map_err(PrompterError::Write)?;
116 }
117 w.write_all(text.as_bytes()).map_err(PrompterError::Write)?;
118 }
119
120 Ok(())
121}
122
123fn write_json_render(
129 mut w: impl Write,
130 files: &[(PathBuf, PathBuf)],
131 profiles: &[String],
132 pre_prompt: Option<&str>,
133 framing: Framing,
134) -> Result<(), PrompterError> {
135 let pre_prompt_text = match pre_prompt {
136 Some(explicit) => explicit.to_string(),
137 None if framing.is_full() => default_pre_prompt(),
138 None => String::new(),
139 };
140
141 let system_info = if framing.is_full() {
142 let date = Local::now().format("%Y-%m-%d").to_string();
143 let os = env::consts::OS;
144 let arch = env::consts::ARCH;
145 format!("Today is {date}, and you are running on a {arch}/{os} system.")
146 } else {
147 String::new()
148 };
149
150 let mut fragments = Vec::new();
151 for (path, library_root) in files {
152 let content = fs::read_to_string(path).map_err(|source| PrompterError::Io {
153 path: path.clone(),
154 source,
155 })?;
156 let rel_path = path
157 .strip_prefix(library_root)
158 .unwrap_or(path)
159 .display()
160 .to_string();
161 fragments.push(FragmentOutput {
162 path: rel_path,
163 content,
164 });
165 }
166
167 let payload = serde_json::to_value(RenderOutput {
168 profile: profiles.join(", "),
169 pre_prompt: pre_prompt_text,
170 system_info,
171 fragments,
172 })?;
173 writeln!(
174 &mut w,
175 "{}",
176 render_response("run", JsonOutput::Json, payload, String::new())
177 )
178 .map_err(PrompterError::Write)
179}
180
181#[allow(
203 clippy::too_many_arguments,
204 reason = "stable 4.x public rendering API; the parameters are the rendering knobs and cannot be repacked without a breaking change"
205)]
206pub fn run_render_stdout(
207 profiles: &[String],
208 family: Option<&FamilyName>,
209 separator: Option<&str>,
210 pre_prompt: Option<&str>,
211 post_prompt: Option<&str>,
212 framing: Framing,
213 config_override: Option<&Path>,
214 output: JsonOutput,
215) -> Result<(), PrompterError> {
216 let (_cfg_path, cfg) = load_bundle(config_override)?;
217 let stdout = io::stdout();
218 let handle = stdout.lock();
219 render_to_writer(
220 &cfg,
221 handle,
222 profiles,
223 family,
224 separator,
225 pre_prompt,
226 post_prompt,
227 framing,
228 output,
229 )
230}
231
232pub fn render_to_vec(
246 profiles: &[String],
247 family: Option<&FamilyName>,
248 config_override: Option<&Path>,
249) -> Result<Vec<u8>, PrompterError> {
250 let (_cfg_path, cfg) = load_bundle(config_override)?;
251 let mut buf = Vec::new();
252 render_to_writer(
253 &cfg,
254 &mut buf,
255 profiles,
256 family,
257 None,
258 None,
259 None,
260 Framing::Full,
261 JsonOutput::Text,
262 )?;
263 Ok(buf)
264}
265
266#[cfg(test)]
267#[allow(clippy::wildcard_imports)]
268mod tests {
269 use super::*;
270 use crate::config::ProfileDef;
271 use std::collections::HashMap;
272 use std::sync::atomic::{AtomicU32, Ordering};
273
274 static COUNTER: AtomicU32 = AtomicU32::new(0);
275
276 fn mk_tmp(prefix: &str) -> PathBuf {
277 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
278 std::env::temp_dir().join(format!("{prefix}_{}_{n}", std::process::id()))
279 }
280
281 fn cfg_one(name: &str, deps: Vec<&str>, lib: &Path) -> Config {
282 let mut profiles = HashMap::new();
283 profiles.insert(
284 name.to_string(),
285 ProfileDef {
286 deps: deps.into_iter().map(String::from).collect(),
287 library_root: lib.to_path_buf(),
288 },
289 );
290 Config {
291 profiles,
292 post_prompt: None,
293 }
294 }
295
296 #[test]
297 fn text_render_errors_when_fragment_path_is_a_directory() {
298 let lib = mk_tmp("prompter_render_dir_frag");
301 fs::create_dir_all(lib.join("a/x.md")).unwrap();
302 let cfg = cfg_one("p", vec!["a/x.md"], &lib);
303 let mut out = Vec::new();
304 let err = render_to_writer(
305 &cfg,
306 &mut out,
307 &["p".to_string()],
308 None,
309 None,
310 None,
311 None,
312 Framing::Full,
313 JsonOutput::Text,
314 )
315 .unwrap_err();
316 assert!(matches!(err, PrompterError::Io { .. }), "err={err}");
317 fs::remove_dir_all(&lib).ok();
318 }
319
320 #[test]
321 fn json_render_full_carries_explicit_pre_prompt_and_system_info() {
322 let lib = mk_tmp("prompter_json_pre");
323 fs::create_dir_all(lib.join("a")).unwrap();
324 fs::write(lib.join("a/x.md"), b"XC\n").unwrap();
325 let cfg = cfg_one("p", vec!["a/x.md"], &lib);
326 let mut out = Vec::new();
327 render_to_writer(
328 &cfg,
329 &mut out,
330 &["p".to_string()],
331 None,
332 None,
333 Some("MY-PRE"),
334 None,
335 Framing::Full,
336 JsonOutput::Json,
337 )
338 .unwrap();
339 let envelope: serde_json::Value =
340 serde_json::from_str(std::str::from_utf8(&out).unwrap().trim()).unwrap();
341 assert_eq!(envelope["data"]["pre_prompt"].as_str(), Some("MY-PRE"));
342 assert!(
343 envelope["data"]["system_info"]
344 .as_str()
345 .unwrap()
346 .contains("Today is "),
347 "envelope={envelope}"
348 );
349 assert_eq!(
350 envelope["data"]["fragments"][0]["path"].as_str(),
351 Some("a/x.md")
352 );
353 fs::remove_dir_all(&lib).ok();
354 }
355
356 #[test]
357 fn json_render_bare_drops_pre_prompt_and_system_info() {
358 let lib = mk_tmp("prompter_json_bare");
359 fs::create_dir_all(lib.join("a")).unwrap();
360 fs::write(lib.join("a/x.md"), b"XC\n").unwrap();
361 let cfg = cfg_one("p", vec!["a/x.md"], &lib);
362 let mut out = Vec::new();
363 render_to_writer(
364 &cfg,
365 &mut out,
366 &["p".to_string()],
367 None,
368 None,
369 None,
370 None,
371 Framing::Bare,
372 JsonOutput::Json,
373 )
374 .unwrap();
375 let envelope: serde_json::Value =
376 serde_json::from_str(std::str::from_utf8(&out).unwrap().trim()).unwrap();
377 assert_eq!(envelope["data"]["pre_prompt"].as_str(), Some(""));
378 assert_eq!(envelope["data"]["system_info"].as_str(), Some(""));
379 fs::remove_dir_all(&lib).ok();
380 }
381
382 #[test]
383 fn json_render_errors_when_fragment_path_is_a_directory() {
384 let lib = mk_tmp("prompter_json_dir_frag");
385 fs::create_dir_all(lib.join("a/x.md")).unwrap();
386 let cfg = cfg_one("p", vec!["a/x.md"], &lib);
387 let mut out = Vec::new();
388 let err = render_to_writer(
389 &cfg,
390 &mut out,
391 &["p".to_string()],
392 None,
393 None,
394 None,
395 None,
396 Framing::Full,
397 JsonOutput::Json,
398 )
399 .unwrap_err();
400 assert!(matches!(err, PrompterError::Io { .. }), "err={err}");
401 fs::remove_dir_all(&lib).ok();
402 }
403
404 #[test]
405 fn render_to_vec_propagates_resolution_error() {
406 let dir = mk_tmp("prompter_render_to_vec_err");
407 fs::create_dir_all(dir.join("library")).unwrap();
408 fs::write(
409 dir.join("config.toml"),
410 "[root]\ndepends_on = [\"missing.md\"]\n",
411 )
412 .unwrap();
413 let err =
414 render_to_vec(&["root".to_string()], None, Some(&dir.join("config.toml"))).unwrap_err();
415 assert!(matches!(err, PrompterError::Resolve(_)), "err={err}");
416 fs::remove_dir_all(&dir).ok();
417 }
418}