Skip to main content

vtcode_core/utils/
file_input.rs

1//! File input helpers for provider-specific inline file attachments.
2
3use anyhow::{Context, Result};
4use base64::Engine as _;
5use std::path::Path;
6
7pub const MAX_INPUT_FILE_BYTES: u64 = 50 * 1024 * 1024;
8
9/// File data prepared for inline model input.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct FileInputData {
12    pub base64_data: String,
13    pub filename: String,
14    pub file_path: String,
15    pub size: u64,
16}
17
18/// Read a validated local file path for inline model input.
19///
20/// Callers must validate path scope and user intent before using this helper.
21pub async fn read_input_file_any_path<P: AsRef<Path>>(file_path: P) -> Result<FileInputData> {
22    let path = file_path.as_ref();
23    let metadata = tokio::fs::metadata(path)
24        .await
25        .with_context(|| format!("Failed to stat input file: {}", path.display()))?;
26
27    if !metadata.is_file() {
28        return Err(anyhow::anyhow!(
29            "Input path is not a file: {}",
30            path.display()
31        ));
32    }
33
34    if metadata.len() > MAX_INPUT_FILE_BYTES {
35        return Err(anyhow::anyhow!(
36            "Input file too large: {} bytes (max {} bytes)",
37            metadata.len(),
38            MAX_INPUT_FILE_BYTES
39        ));
40    }
41
42    let file_contents = tokio::fs::read(path)
43        .await
44        .with_context(|| format!("Failed to read input file: {}", path.display()))?;
45
46    let filename = path
47        .file_name()
48        .and_then(|name| name.to_str())
49        .filter(|name| !name.is_empty())
50        .map(ToOwned::to_owned)
51        .unwrap_or_else(|| path.display().to_string());
52
53    Ok(FileInputData {
54        base64_data: base64::engine::general_purpose::STANDARD.encode(&file_contents),
55        filename,
56        file_path: path.display().to_string(),
57        size: file_contents.len() as u64,
58    })
59}
60
61pub fn decoded_base64_size(file_data: &str) -> Result<u64> {
62    let payload = inline_base64_payload(file_data);
63    let decoded = base64::engine::general_purpose::STANDARD
64        .decode(payload)
65        .context("Invalid base64 file_data payload")?;
66    Ok(decoded.len() as u64)
67}
68
69fn inline_base64_payload(file_data: &str) -> &str {
70    let trimmed = file_data.trim();
71    if let Some((prefix, payload)) = trimmed.split_once(',')
72        && prefix.contains(";base64")
73    {
74        payload.trim()
75    } else {
76        trimmed
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::{MAX_INPUT_FILE_BYTES, decoded_base64_size};
83
84    #[test]
85    fn decoded_base64_size_supports_raw_base64() {
86        assert_eq!(decoded_base64_size("aGVsbG8=").unwrap(), 5);
87    }
88
89    #[test]
90    fn decoded_base64_size_supports_data_url_prefix() {
91        assert_eq!(
92            decoded_base64_size("data:application/pdf;base64,aGVsbG8=").unwrap(),
93            5
94        );
95    }
96
97    #[test]
98    fn max_input_file_bytes_matches_openai_limit() {
99        assert_eq!(MAX_INPUT_FILE_BYTES, 50 * 1024 * 1024);
100    }
101}