vtcode_commons/
file_input.rs1use anyhow::{Context, Result};
4use base64::Engine as _;
5use std::path::Path;
6
7pub const MAX_INPUT_FILE_BYTES: u64 = 50 * 1024 * 1024;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct FileInputData {
12 pub base64_data: String,
13 pub filename: String,
14 file_path: String,
15 size: u64,
16}
17
18pub async fn read_input_file_any_path<P: AsRef<Path>>(file_path: P) -> Result<FileInputData> {
29 let path = file_path.as_ref().to_path_buf();
30 tokio::task::spawn_blocking(move || read_input_file_blocking(&path))
31 .await
32 .context("input file read task failed")?
33}
34
35fn read_input_file_blocking(path: &Path) -> Result<FileInputData> {
36 let metadata = std::fs::metadata(path).with_context(|| format!("Failed to stat input file: {}", path.display()))?;
37
38 if !metadata.is_file() {
39 return Err(anyhow::anyhow!("Input path is not a file: {}", path.display()));
40 }
41
42 if metadata.len() > MAX_INPUT_FILE_BYTES {
43 return Err(anyhow::anyhow!(
44 "Input file too large: {} bytes (max {} bytes)",
45 metadata.len(),
46 MAX_INPUT_FILE_BYTES
47 ));
48 }
49
50 let file_contents =
51 std::fs::read(path).with_context(|| format!("Failed to read input file: {}", path.display()))?;
52
53 let filename = path
54 .file_name()
55 .and_then(|name| name.to_str())
56 .filter(|name| !name.is_empty())
57 .map(ToOwned::to_owned)
58 .unwrap_or_else(|| path.display().to_string());
59
60 Ok(FileInputData {
61 base64_data: base64::engine::general_purpose::STANDARD.encode(&file_contents),
62 filename,
63 file_path: path.display().to_string(),
64 size: file_contents.len() as u64,
65 })
66}
67
68pub fn decoded_base64_size(file_data: &str) -> Result<u64> {
69 let payload = inline_base64_payload(file_data);
70 let decoded = base64::engine::general_purpose::STANDARD
71 .decode(payload)
72 .context("Invalid base64 file_data payload")?;
73 Ok(decoded.len() as u64)
74}
75
76fn inline_base64_payload(file_data: &str) -> &str {
77 let trimmed = file_data.trim();
78 if let Some((prefix, payload)) = trimmed.split_once(',')
79 && prefix.contains(";base64")
80 {
81 payload.trim()
82 } else {
83 trimmed
84 }
85}
86
87#[cfg(test)]
88mod tests {
89 use super::{MAX_INPUT_FILE_BYTES, decoded_base64_size};
90 use base64::Engine as _;
91
92 #[test]
93 fn decoded_base64_size_supports_raw_base64() {
94 assert_eq!(decoded_base64_size("aGVsbG8=").unwrap(), 5);
95 }
96
97 #[test]
98 fn decoded_base64_size_supports_data_url_prefix() {
99 assert_eq!(decoded_base64_size("data:application/pdf;base64,aGVsbG8=").unwrap(), 5);
100 }
101
102 #[test]
103 fn max_input_file_bytes_matches_openai_limit() {
104 assert_eq!(MAX_INPUT_FILE_BYTES, 50 * 1024 * 1024);
105 }
106
107 #[tokio::test]
108 async fn read_input_file_any_path_round_trips_bytes() {
109 let dir = tempfile::tempdir().expect("tempdir");
110 let path = dir.path().join("payload.bin");
111 std::fs::write(&path, b"hello world").expect("write payload");
112
113 let data = super::read_input_file_any_path(&path).await.expect("read payload");
114
115 assert_eq!(data.filename, "payload.bin");
116 assert_eq!(data.size, 11);
117 let decoded = base64::engine::general_purpose::STANDARD
118 .decode(&data.base64_data)
119 .expect("valid base64");
120 assert_eq!(decoded, b"hello world");
121 }
122
123 #[tokio::test]
124 async fn read_input_file_any_path_rejects_directory() {
125 let dir = tempfile::tempdir().expect("tempdir");
126
127 let err = super::read_input_file_any_path(dir.path())
128 .await
129 .expect_err("directory must be rejected");
130 assert!(err.to_string().contains("not a file"), "unexpected error: {err}");
131 }
132}