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_cli_common::{JsonOutput, render_response};
16
17/// Render one or more profiles to the given writer.
18pub fn render_to_writer(
19    cfg: &Config,
20    mut w: impl Write,
21    profiles: &[String],
22    family: Option<&FamilyName>,
23    separator: Option<&str>,
24    pre_prompt: Option<&str>,
25    post_prompt: Option<&str>,
26    framing: Framing,
27    output: JsonOutput,
28) -> Result<(), PrompterError> {
29    let mut seen_files = HashSet::new();
30    let mut files: Vec<(PathBuf, PathBuf)> = Vec::new();
31
32    // Resolve all profiles with shared deduplication
33    for profile in profiles {
34        let mut stack = Vec::new();
35        resolve_profile_for_family(
36            profile,
37            cfg,
38            family,
39            &mut seen_files,
40            &mut stack,
41            &mut files,
42        )?;
43    }
44
45    if output.is_json() {
46        // JSON output mode. Bare framing drops the auto-injected default
47        // pre-prompt and the date/system stamp, leaving the fragments as the
48        // payload; an explicit -p still wins.
49        let pre_prompt_text = match pre_prompt {
50            Some(explicit) => explicit.to_string(),
51            None if framing.is_full() => default_pre_prompt(),
52            None => String::new(),
53        };
54
55        let system_info = if framing.is_full() {
56            let date = Local::now().format("%Y-%m-%d").to_string();
57            let os = env::consts::OS;
58            let arch = env::consts::ARCH;
59            format!("Today is {date}, and you are running on a {arch}/{os} system.")
60        } else {
61            String::new()
62        };
63
64        let mut fragments = Vec::new();
65        for (path, library_root) in &files {
66            let content = fs::read_to_string(path).map_err(|source| PrompterError::Io {
67                path: path.clone(),
68                source,
69            })?;
70            let rel_path = path
71                .strip_prefix(library_root)
72                .unwrap_or(path)
73                .display()
74                .to_string();
75            fragments.push(FragmentOutput {
76                path: rel_path,
77                content,
78            });
79        }
80
81        let payload = serde_json::to_value(RenderOutput {
82            profile: profiles.join(", "),
83            pre_prompt: pre_prompt_text,
84            system_info,
85            fragments,
86        })?;
87        writeln!(
88            &mut w,
89            "{}",
90            render_response("run", JsonOutput::Json, payload, String::new())
91        )
92        .map_err(PrompterError::Write)?;
93    } else {
94        // Text output mode. Bare framing suppresses every auto-injected piece —
95        // the default pre-prompt, the date/system prefix, and the default (and
96        // config) post-prompt — leaving only the fragment bodies. An explicit
97        // -p/-P still wins: the user asked for that exact text.
98        let default_pre = default_pre_prompt();
99        let pre_prompt_text = match (pre_prompt, framing) {
100            (Some(explicit), _) => Some(explicit),
101            (None, Framing::Full) => Some(default_pre.as_str()),
102            (None, Framing::Bare) => None,
103        };
104        if let Some(text) = pre_prompt_text {
105            w.write_all(text.as_bytes()).map_err(PrompterError::Write)?;
106        }
107
108        // Date/system stamp is auto-injected, so it appears only in full framing;
109        // it is the cache-buster bare exists to drop.
110        if framing.is_full() {
111            w.write_all(b"\n").map_err(PrompterError::Write)?;
112            let prefix = format_system_prefix();
113            w.write_all(prefix.as_bytes())
114                .map_err(PrompterError::Write)?;
115        }
116
117        let sep = separator.unwrap_or("");
118        for (index, (path, _library_root)) in files.iter().enumerate() {
119            // Newline before each file. In bare framing the leading newline is
120            // dropped so the output begins directly with the first fragment.
121            if framing.is_full() || index > 0 {
122                w.write_all(b"\n").map_err(PrompterError::Write)?;
123            }
124
125            let bytes = fs::read(path).map_err(|source| PrompterError::Io {
126                path: path.clone(),
127                source,
128            })?;
129            w.write_all(&bytes).map_err(PrompterError::Write)?;
130
131            // Write separator after each file if provided
132            if !sep.is_empty() {
133                w.write_all(sep.as_bytes()).map_err(PrompterError::Write)?;
134            }
135        }
136
137        // Post-prompt: explicit -P wins. Full falls back to the config post-prompt
138        // then the default; bare emits nothing without an explicit -P. The full
139        // framing keeps its two-newline lead-in; bare writes the explicit text
140        // verbatim so nothing is auto-injected.
141        let default_post = default_post_prompt();
142        let post_prompt_text = match framing {
143            Framing::Full => Some(
144                post_prompt
145                    .or(cfg.post_prompt.as_deref())
146                    .unwrap_or(&default_post),
147            ),
148            Framing::Bare => post_prompt,
149        };
150        if let Some(text) = post_prompt_text {
151            if framing.is_full() {
152                w.write_all(b"\n\n").map_err(PrompterError::Write)?;
153            }
154            w.write_all(text.as_bytes()).map_err(PrompterError::Write)?;
155        }
156    }
157
158    Ok(())
159}
160
161/// Render one or more profiles to stdout.
162///
163/// Convenience function that reads configuration and renders the specified
164/// profiles to standard output with optional separator, pre-prompt, and post-prompt.
165/// When multiple profiles are provided, files are deduplicated across all profiles.
166///
167/// # Arguments
168/// * `profiles` - Profile names to render (deduplicated in order)
169/// * `family` - Optional family used to substitute matching fragment variants
170/// * `separator` - Optional separator between files
171/// * `pre_prompt` - Optional custom pre-prompt text
172/// * `post_prompt` - Optional custom post-prompt text
173/// * `framing` - Whether to wrap fragments in framing context or emit bare bodies
174/// * `config_override` - Optional configuration file override
175/// * `json` - Whether to output in JSON format
176///
177/// # Errors
178/// Returns an error if:
179/// - Configuration file cannot be read or parsed
180/// - Profile resolution fails
181/// - Writing to stdout fails
182pub fn run_render_stdout(
183    profiles: &[String],
184    family: Option<&FamilyName>,
185    separator: Option<&str>,
186    pre_prompt: Option<&str>,
187    post_prompt: Option<&str>,
188    framing: Framing,
189    config_override: Option<&Path>,
190    output: JsonOutput,
191) -> Result<(), PrompterError> {
192    let (_cfg_path, cfg) = load_bundle(config_override)?;
193    let stdout = io::stdout();
194    let handle = stdout.lock();
195    render_to_writer(
196        &cfg,
197        handle,
198        profiles,
199        family,
200        separator,
201        pre_prompt,
202        post_prompt,
203        framing,
204        output,
205    )
206}
207
208/// Render composed profiles to a byte vector.
209///
210/// Convenience wrapper around [`render_to_writer`] that handles config
211/// resolution and returns the rendered output as bytes. Intended for
212/// use by other crates that need prompt composition as a library.
213///
214/// # Arguments
215/// * `profiles` - Profile names to compose
216/// * `family` - Optional family used to substitute matching fragment variants
217/// * `config_override` - Optional path to custom config file
218///
219/// # Errors
220/// Returns an error if config resolution, profile resolution, or rendering fails.
221pub fn render_to_vec(
222    profiles: &[String],
223    family: Option<&FamilyName>,
224    config_override: Option<&Path>,
225) -> Result<Vec<u8>, PrompterError> {
226    let (_cfg_path, cfg) = load_bundle(config_override)?;
227    let mut buf = Vec::new();
228    render_to_writer(
229        &cfg,
230        &mut buf,
231        profiles,
232        family,
233        None,
234        None,
235        None,
236        Framing::Full,
237        JsonOutput::Text,
238    )?;
239    Ok(buf)
240}