tftio-prompter 4.0.0

A CLI tool for composing reusable prompt snippets from a library using TOML profiles
Documentation
//! Profile rendering and output formatting.
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_cli_common::{JsonOutput, render_response};

/// Render one or more profiles to the given writer.
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();

    // Resolve all profiles with shared deduplication
    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() {
        // JSON output mode. Bare framing drops the auto-injected default
        // pre-prompt and the date/system stamp, leaving the fragments as the
        // payload; an explicit -p still wins.
        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 {
        // Text output mode. Bare framing suppresses every auto-injected piece —
        // the default pre-prompt, the date/system prefix, and the default (and
        // config) post-prompt — leaving only the fragment bodies. An explicit
        // -p/-P still wins: the user asked for that exact text.
        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)?;
        }

        // Date/system stamp is auto-injected, so it appears only in full framing;
        // it is the cache-buster bare exists to drop.
        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() {
            // Newline before each file. In bare framing the leading newline is
            // dropped so the output begins directly with the first fragment.
            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)?;

            // Write separator after each file if provided
            if !sep.is_empty() {
                w.write_all(sep.as_bytes()).map_err(PrompterError::Write)?;
            }
        }

        // Post-prompt: explicit -P wins. Full falls back to the config post-prompt
        // then the default; bare emits nothing without an explicit -P. The full
        // framing keeps its two-newline lead-in; bare writes the explicit text
        // verbatim so nothing is auto-injected.
        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(())
}

/// Render one or more profiles to stdout.
///
/// Convenience function that reads configuration and renders the specified
/// profiles to standard output with optional separator, pre-prompt, and post-prompt.
/// When multiple profiles are provided, files are deduplicated across all profiles.
///
/// # Arguments
/// * `profiles` - Profile names to render (deduplicated in order)
/// * `family` - Optional family used to substitute matching fragment variants
/// * `separator` - Optional separator between files
/// * `pre_prompt` - Optional custom pre-prompt text
/// * `post_prompt` - Optional custom post-prompt text
/// * `framing` - Whether to wrap fragments in framing context or emit bare bodies
/// * `config_override` - Optional configuration file override
/// * `json` - Whether to output in JSON format
///
/// # Errors
/// Returns an error if:
/// - Configuration file cannot be read or parsed
/// - Profile resolution fails
/// - Writing to stdout fails
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,
    )
}

/// Render composed profiles to a byte vector.
///
/// Convenience wrapper around [`render_to_writer`] that handles config
/// resolution and returns the rendered output as bytes. Intended for
/// use by other crates that need prompt composition as a library.
///
/// # Arguments
/// * `profiles` - Profile names to compose
/// * `family` - Optional family used to substitute matching fragment variants
/// * `config_override` - Optional path to custom config file
///
/// # Errors
/// Returns an error if config resolution, profile resolution, or rendering fails.
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)
}