Skip to main content

prompter/
render.rs

1//! Profile rendering and output formatting.
2use 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/// Render one or more profiles to the given writer.
18///
19/// # Errors
20/// Returns an error if profile resolution fails (missing files, cycles, unknown
21/// profiles), if reading a fragment fails, or if writing to the sink fails.
22#[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    // Resolve all profiles with shared deduplication
41    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    // Text output mode. Bare framing suppresses every auto-injected piece —
58    // the default pre-prompt, the date/system prefix, and the default (and
59    // config) post-prompt — leaving only the fragment bodies. An explicit
60    // -p/-P still wins: the user asked for that exact text.
61    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    // Date/system stamp is auto-injected, so it appears only in full framing;
72    // it is the cache-buster bare exists to drop.
73    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        // Newline before each file. In bare framing the leading newline is
83        // dropped so the output begins directly with the first fragment.
84        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        // Write separator after each file if provided
95        if !sep.is_empty() {
96            w.write_all(sep.as_bytes()).map_err(PrompterError::Write)?;
97        }
98    }
99
100    // Post-prompt: explicit -P wins. Full falls back to the config post-prompt
101    // then the default; bare emits nothing without an explicit -P. The full
102    // framing keeps its two-newline lead-in; bare writes the explicit text
103    // verbatim so nothing is auto-injected.
104    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
123/// Render the deduplicated fragments as the shared JSON response envelope.
124///
125/// Bare framing drops the auto-injected default pre-prompt and the date/system
126/// stamp, leaving the fragments as the payload; an explicit `pre_prompt` still
127/// wins.
128fn 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/// Render one or more profiles to stdout.
182///
183/// Convenience function that reads configuration and renders the specified
184/// profiles to standard output with optional separator, pre-prompt, and post-prompt.
185/// When multiple profiles are provided, files are deduplicated across all profiles.
186///
187/// # Arguments
188/// * `profiles` - Profile names to render (deduplicated in order)
189/// * `family` - Optional family used to substitute matching fragment variants
190/// * `separator` - Optional separator between files
191/// * `pre_prompt` - Optional custom pre-prompt text
192/// * `post_prompt` - Optional custom post-prompt text
193/// * `framing` - Whether to wrap fragments in framing context or emit bare bodies
194/// * `config_override` - Optional configuration file override
195/// * `json` - Whether to output in JSON format
196///
197/// # Errors
198/// Returns an error if:
199/// - Configuration file cannot be read or parsed
200/// - Profile resolution fails
201/// - Writing to stdout fails
202#[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
232/// Render composed profiles to a byte vector.
233///
234/// Convenience wrapper around [`render_to_writer`] that handles config
235/// resolution and returns the rendered output as bytes. Intended for
236/// use by other crates that need prompt composition as a library.
237///
238/// # Arguments
239/// * `profiles` - Profile names to compose
240/// * `family` - Optional family used to substitute matching fragment variants
241/// * `config_override` - Optional path to custom config file
242///
243/// # Errors
244/// Returns an error if config resolution, profile resolution, or rendering fails.
245pub 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        // The dep resolves (the path exists) but reading it as a file fails
299        // because it is a directory, surfacing a typed I/O error.
300        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}