@ebowwa/large-output 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -134
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/package.json +21 -4
- package/rust/Cargo.lock +690 -0
- package/rust/Cargo.toml +29 -0
- package/rust/README.md +185 -0
- package/rust/src/lib.rs +596 -0
package/rust/src/lib.rs
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
//! # large-output
|
|
2
|
+
//!
|
|
3
|
+
//! Shared utility for handling large outputs with automatic file fallback
|
|
4
|
+
//! and actionable LLM prompts.
|
|
5
|
+
//!
|
|
6
|
+
//! When output size exceeds the threshold, automatically writes to a temp file
|
|
7
|
+
//! and returns a structured response with file path and preview.
|
|
8
|
+
//!
|
|
9
|
+
//! ## Example
|
|
10
|
+
//!
|
|
11
|
+
//! ```rust
|
|
12
|
+
//! use large_output::{handle_output, handle_mcp_output, OutputResponse};
|
|
13
|
+
//!
|
|
14
|
+
//! // Basic usage
|
|
15
|
+
//! let content = "very large content...".repeat(1000);
|
|
16
|
+
//! let response = handle_output(&content, None);
|
|
17
|
+
//!
|
|
18
|
+
//! match response {
|
|
19
|
+
//! OutputResponse::Inline { content, .. } => println!("{}", content),
|
|
20
|
+
//! OutputResponse::File { path, size, .. } => {
|
|
21
|
+
//! println!("Written {} bytes to {:?}", size, path);
|
|
22
|
+
//! }
|
|
23
|
+
//! }
|
|
24
|
+
//!
|
|
25
|
+
//! // MCP convenience function
|
|
26
|
+
//! let mcp_text = handle_mcp_output(&content, None);
|
|
27
|
+
//! println!("{}", mcp_text);
|
|
28
|
+
//! ```
|
|
29
|
+
|
|
30
|
+
use chrono::Local;
|
|
31
|
+
use serde::{Deserialize, Serialize};
|
|
32
|
+
use std::fs;
|
|
33
|
+
use std::path::PathBuf;
|
|
34
|
+
use uuid::Uuid;
|
|
35
|
+
|
|
36
|
+
/// Configuration options for the output handler
|
|
37
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
38
|
+
pub struct LargeOutputOptions {
|
|
39
|
+
/// Character threshold for file fallback
|
|
40
|
+
/// Default: 15000 (safety margin under Claude's ~20K limit)
|
|
41
|
+
#[serde(default = "default_threshold")]
|
|
42
|
+
pub threshold: usize,
|
|
43
|
+
|
|
44
|
+
/// Number of characters to include in preview when writing to file
|
|
45
|
+
/// Default: 500
|
|
46
|
+
#[serde(default = "default_preview_length")]
|
|
47
|
+
pub preview_length: usize,
|
|
48
|
+
|
|
49
|
+
/// Custom temp directory for output files
|
|
50
|
+
/// Default: OS temp directory
|
|
51
|
+
pub temp_dir: Option<PathBuf>,
|
|
52
|
+
|
|
53
|
+
/// Custom filename prefix
|
|
54
|
+
/// Default: "mcp_output"
|
|
55
|
+
#[serde(default = "default_filename_prefix")]
|
|
56
|
+
pub filename_prefix: String,
|
|
57
|
+
|
|
58
|
+
/// Whether to include a size indicator in the response
|
|
59
|
+
/// Default: true
|
|
60
|
+
#[serde(default = "default_true")]
|
|
61
|
+
pub include_size: bool,
|
|
62
|
+
|
|
63
|
+
/// Whether to include a preview in the file response
|
|
64
|
+
/// Default: true
|
|
65
|
+
#[serde(default = "default_true")]
|
|
66
|
+
pub include_preview: bool,
|
|
67
|
+
|
|
68
|
+
/// Response format for file outputs
|
|
69
|
+
/// - "actionable" (default): Returns actionable text prompting LLM to read the file
|
|
70
|
+
/// - "json": Returns structured JSON object
|
|
71
|
+
#[serde(default = "default_response_format")]
|
|
72
|
+
pub response_format: ResponseFormat,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
fn default_threshold() -> usize { 15000 }
|
|
76
|
+
fn default_preview_length() -> usize { 500 }
|
|
77
|
+
fn default_filename_prefix() -> String { "mcp_output".to_string() }
|
|
78
|
+
fn default_true() -> bool { true }
|
|
79
|
+
fn default_response_format() -> ResponseFormat { ResponseFormat::Actionable }
|
|
80
|
+
|
|
81
|
+
impl Default for LargeOutputOptions {
|
|
82
|
+
fn default() -> Self {
|
|
83
|
+
Self {
|
|
84
|
+
threshold: default_threshold(),
|
|
85
|
+
preview_length: default_preview_length(),
|
|
86
|
+
temp_dir: None,
|
|
87
|
+
filename_prefix: default_filename_prefix(),
|
|
88
|
+
include_size: default_true(),
|
|
89
|
+
include_preview: default_true(),
|
|
90
|
+
response_format: default_response_format(),
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/// Response format for file outputs
|
|
96
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
97
|
+
#[serde(rename_all = "lowercase")]
|
|
98
|
+
pub enum ResponseFormat {
|
|
99
|
+
/// Returns actionable text prompting LLM to read the file
|
|
100
|
+
Actionable,
|
|
101
|
+
/// Returns structured JSON object
|
|
102
|
+
Json,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
impl Default for ResponseFormat {
|
|
106
|
+
fn default() -> Self {
|
|
107
|
+
Self::Actionable
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// Response when output is returned inline (under threshold)
|
|
112
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
113
|
+
pub struct InlineResponse {
|
|
114
|
+
#[serde(rename = "type")]
|
|
115
|
+
pub response_type: String,
|
|
116
|
+
pub content: String,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/// Response when output is written to file (over threshold)
|
|
120
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
121
|
+
pub struct FileResponse {
|
|
122
|
+
#[serde(rename = "type")]
|
|
123
|
+
pub response_type: String,
|
|
124
|
+
pub path: PathBuf,
|
|
125
|
+
pub size: usize,
|
|
126
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
127
|
+
pub size_formatted: Option<String>,
|
|
128
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
129
|
+
pub preview: Option<String>,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/// Union type for all possible responses
|
|
133
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
134
|
+
#[serde(untagged)]
|
|
135
|
+
pub enum OutputResponse {
|
|
136
|
+
Inline {
|
|
137
|
+
#[serde(rename = "type")]
|
|
138
|
+
response_type: String,
|
|
139
|
+
content: String,
|
|
140
|
+
},
|
|
141
|
+
File {
|
|
142
|
+
#[serde(rename = "type")]
|
|
143
|
+
response_type: String,
|
|
144
|
+
path: PathBuf,
|
|
145
|
+
size: usize,
|
|
146
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
147
|
+
size_formatted: Option<String>,
|
|
148
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
149
|
+
preview: Option<String>,
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
impl OutputResponse {
|
|
154
|
+
/// Check if response is inline
|
|
155
|
+
pub fn is_inline(&self) -> bool {
|
|
156
|
+
matches!(self, OutputResponse::Inline { .. })
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/// Check if response is file
|
|
160
|
+
pub fn is_file(&self) -> bool {
|
|
161
|
+
matches!(self, OutputResponse::File { .. })
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/// Get the content if inline
|
|
165
|
+
pub fn content(&self) -> Option<&str> {
|
|
166
|
+
match self {
|
|
167
|
+
OutputResponse::Inline { content, .. } => Some(content),
|
|
168
|
+
_ => None,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/// Get the file path if file
|
|
173
|
+
pub fn path(&self) -> Option<&PathBuf> {
|
|
174
|
+
match self {
|
|
175
|
+
OutputResponse::File { path, .. } => Some(path),
|
|
176
|
+
_ => None,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/// Format bytes to human-readable string
|
|
182
|
+
fn format_size(bytes: usize) -> String {
|
|
183
|
+
if bytes < 1024 {
|
|
184
|
+
format!("{} B", bytes)
|
|
185
|
+
} else if bytes < 1024 * 1024 {
|
|
186
|
+
format!("{:.1} KB", bytes as f64 / 1024.0)
|
|
187
|
+
} else {
|
|
188
|
+
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/// Generate a unique filename with timestamp
|
|
193
|
+
fn generate_filename(prefix: &str) -> String {
|
|
194
|
+
let timestamp = Local::now().format("%Y-%m-%dT%H-%M-%S");
|
|
195
|
+
let random: String = Uuid::new_v4()
|
|
196
|
+
.to_string()
|
|
197
|
+
.split('-')
|
|
198
|
+
.next()
|
|
199
|
+
.unwrap_or("unknown")
|
|
200
|
+
.to_string();
|
|
201
|
+
format!("{}_{}_{}.txt", prefix, timestamp, random)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/// Ensure directory exists, create if not
|
|
205
|
+
fn ensure_dir(dir: &PathBuf) -> std::io::Result<()> {
|
|
206
|
+
if !dir.exists() {
|
|
207
|
+
fs::create_dir_all(dir)?;
|
|
208
|
+
}
|
|
209
|
+
Ok(())
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/// Handle output with automatic file fallback
|
|
213
|
+
///
|
|
214
|
+
/// # Arguments
|
|
215
|
+
///
|
|
216
|
+
/// * `content` - The content to return
|
|
217
|
+
/// * `options` - Configuration options (None for defaults)
|
|
218
|
+
///
|
|
219
|
+
/// # Returns
|
|
220
|
+
///
|
|
221
|
+
/// `OutputResponse` with inline content or file reference
|
|
222
|
+
///
|
|
223
|
+
/// # Example
|
|
224
|
+
///
|
|
225
|
+
/// ```rust
|
|
226
|
+
/// use large_output::{handle_output, OutputResponse};
|
|
227
|
+
///
|
|
228
|
+
/// let content = "very large content...".repeat(1000);
|
|
229
|
+
/// let response = handle_output(&content, None);
|
|
230
|
+
///
|
|
231
|
+
/// match response {
|
|
232
|
+
/// OutputResponse::Inline { content, .. } => println!("{}", content),
|
|
233
|
+
/// OutputResponse::File { path, size, .. } => {
|
|
234
|
+
/// println!("Written {} bytes to {:?}", size, path);
|
|
235
|
+
/// }
|
|
236
|
+
/// }
|
|
237
|
+
/// ```
|
|
238
|
+
pub fn handle_output(content: &str, options: Option<LargeOutputOptions>) -> OutputResponse {
|
|
239
|
+
let opts = options.unwrap_or_default();
|
|
240
|
+
let content_length = content.len();
|
|
241
|
+
|
|
242
|
+
// Return inline if under threshold
|
|
243
|
+
if content_length <= opts.threshold {
|
|
244
|
+
return OutputResponse::Inline {
|
|
245
|
+
response_type: "inline".to_string(),
|
|
246
|
+
content: content.to_string(),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Write to file if over threshold
|
|
251
|
+
let temp_dir = opts.temp_dir.unwrap_or_else(std::env::temp_dir);
|
|
252
|
+
let filename_prefix = if opts.filename_prefix.is_empty() {
|
|
253
|
+
"mcp_output".to_string()
|
|
254
|
+
} else {
|
|
255
|
+
opts.filename_prefix
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// Ensure temp directory exists
|
|
259
|
+
if let Err(e) = ensure_dir(&temp_dir) {
|
|
260
|
+
eprintln!("Warning: Could not create temp directory: {}", e);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
let filename = generate_filename(&filename_prefix);
|
|
264
|
+
let filepath = temp_dir.join(&filename);
|
|
265
|
+
|
|
266
|
+
// Write file
|
|
267
|
+
if let Err(e) = fs::write(&filepath, content) {
|
|
268
|
+
eprintln!("Warning: Could not write to file: {}", e);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
let size_formatted = if opts.include_size {
|
|
272
|
+
Some(format_size(content_length))
|
|
273
|
+
} else {
|
|
274
|
+
None
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
let preview = if opts.include_preview {
|
|
278
|
+
let preview = if content_length > opts.preview_length {
|
|
279
|
+
format!("{}...", &content[..opts.preview_length])
|
|
280
|
+
} else {
|
|
281
|
+
content.to_string()
|
|
282
|
+
};
|
|
283
|
+
Some(preview)
|
|
284
|
+
} else {
|
|
285
|
+
None
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
OutputResponse::File {
|
|
289
|
+
response_type: "file".to_string(),
|
|
290
|
+
path: filepath,
|
|
291
|
+
size: content_length,
|
|
292
|
+
size_formatted,
|
|
293
|
+
preview,
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/// Convert an OutputResponse to a string for MCP tool return
|
|
298
|
+
///
|
|
299
|
+
/// # Arguments
|
|
300
|
+
///
|
|
301
|
+
/// * `response` - The response from handle_output()
|
|
302
|
+
/// * `format` - Response format: "actionable" (default) or "json"
|
|
303
|
+
///
|
|
304
|
+
/// # Returns
|
|
305
|
+
///
|
|
306
|
+
/// Text or JSON string suitable for MCP tool return value
|
|
307
|
+
///
|
|
308
|
+
/// # Example
|
|
309
|
+
///
|
|
310
|
+
/// ```rust
|
|
311
|
+
/// use large_output::{handle_output, to_mcp_response, ResponseFormat};
|
|
312
|
+
///
|
|
313
|
+
/// let response = handle_output("content", None);
|
|
314
|
+
/// let text = to_mcp_response(&response, ResponseFormat::Actionable);
|
|
315
|
+
/// println!("{}", text);
|
|
316
|
+
/// ```
|
|
317
|
+
pub fn to_mcp_response(response: &OutputResponse, format: ResponseFormat) -> String {
|
|
318
|
+
match response {
|
|
319
|
+
OutputResponse::Inline { content, .. } => content.clone(),
|
|
320
|
+
|
|
321
|
+
OutputResponse::File {
|
|
322
|
+
path,
|
|
323
|
+
size,
|
|
324
|
+
size_formatted,
|
|
325
|
+
preview,
|
|
326
|
+
..
|
|
327
|
+
} => {
|
|
328
|
+
// JSON format for programmatic consumers
|
|
329
|
+
if format == ResponseFormat::Json {
|
|
330
|
+
let mut result = serde_json::Map::new();
|
|
331
|
+
result.insert("type".to_string(), serde_json::json!("file"));
|
|
332
|
+
result.insert("path".to_string(), serde_json::json!(path.to_string_lossy()));
|
|
333
|
+
result.insert("size".to_string(), serde_json::json!(size));
|
|
334
|
+
|
|
335
|
+
if let Some(sf) = size_formatted {
|
|
336
|
+
result.insert("sizeFormatted".to_string(), serde_json::json!(sf));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if let Some(p) = preview {
|
|
340
|
+
result.insert("preview".to_string(), serde_json::json!(p));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return serde_json::to_string_pretty(&result).unwrap_or_default();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Actionable format - prompts LLM to read the file (DEFAULT)
|
|
347
|
+
let size_display = size_formatted
|
|
348
|
+
.clone()
|
|
349
|
+
.unwrap_or_else(|| format_size(*size));
|
|
350
|
+
|
|
351
|
+
let mut lines = vec![
|
|
352
|
+
format!("Large output ({}) saved to file.", size_display),
|
|
353
|
+
String::new(),
|
|
354
|
+
"ACTION REQUIRED: Use the Read tool to read this file:".to_string(),
|
|
355
|
+
String::new(),
|
|
356
|
+
format!(" {}", path.to_string_lossy()),
|
|
357
|
+
String::new(),
|
|
358
|
+
];
|
|
359
|
+
|
|
360
|
+
if let Some(p) = preview {
|
|
361
|
+
lines.push("--- PREVIEW (first 500 chars) ---".to_string());
|
|
362
|
+
lines.push(p.clone());
|
|
363
|
+
lines.push("--- END PREVIEW ---".to_string());
|
|
364
|
+
lines.push(String::new());
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
lines.push("File contains the complete data. Read it to proceed.".to_string());
|
|
368
|
+
|
|
369
|
+
lines.join("\n")
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/// Convenience function: handle output and return MCP-formatted string
|
|
375
|
+
///
|
|
376
|
+
/// # Arguments
|
|
377
|
+
///
|
|
378
|
+
/// * `content` - The content to return
|
|
379
|
+
/// * `options` - Configuration options (including response_format)
|
|
380
|
+
///
|
|
381
|
+
/// # Returns
|
|
382
|
+
///
|
|
383
|
+
/// Text string with actionable instructions (default) or JSON
|
|
384
|
+
///
|
|
385
|
+
/// # Example
|
|
386
|
+
///
|
|
387
|
+
/// ```rust
|
|
388
|
+
/// use large_output::handle_mcp_output;
|
|
389
|
+
///
|
|
390
|
+
/// let content = "very large content...".repeat(1000);
|
|
391
|
+
/// let text = handle_mcp_output(&content, None);
|
|
392
|
+
/// println!("{}", text);
|
|
393
|
+
/// ```
|
|
394
|
+
pub fn handle_mcp_output(content: &str, options: Option<LargeOutputOptions>) -> String {
|
|
395
|
+
let opts = options.clone().unwrap_or_default();
|
|
396
|
+
let response = handle_output(content, options);
|
|
397
|
+
to_mcp_response(&response, opts.response_format)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/// Batch handler for multiple outputs
|
|
401
|
+
///
|
|
402
|
+
/// # Arguments
|
|
403
|
+
///
|
|
404
|
+
/// * `contents` - Slice of content strings
|
|
405
|
+
/// * `options` - Configuration options (applied to all)
|
|
406
|
+
///
|
|
407
|
+
/// # Returns
|
|
408
|
+
///
|
|
409
|
+
/// Vector of OutputResponse
|
|
410
|
+
///
|
|
411
|
+
/// # Example
|
|
412
|
+
///
|
|
413
|
+
/// ```rust
|
|
414
|
+
/// use large_output::handle_batch;
|
|
415
|
+
///
|
|
416
|
+
/// let contents = vec!["data1", "data2", "data3"];
|
|
417
|
+
/// let outputs = handle_batch(&contents, None);
|
|
418
|
+
/// for output in outputs {
|
|
419
|
+
/// println!("{:?}", output);
|
|
420
|
+
/// }
|
|
421
|
+
/// ```
|
|
422
|
+
pub fn handle_batch(contents: &[&str], options: Option<LargeOutputOptions>) -> Vec<OutputResponse> {
|
|
423
|
+
contents
|
|
424
|
+
.iter()
|
|
425
|
+
.map(|content| handle_output(content, options.clone()))
|
|
426
|
+
.collect()
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/// Builder for LargeOutputOptions
|
|
430
|
+
///
|
|
431
|
+
/// # Example
|
|
432
|
+
///
|
|
433
|
+
/// ```rust
|
|
434
|
+
/// use large_output::OptionsBuilder;
|
|
435
|
+
///
|
|
436
|
+
/// let options = OptionsBuilder::new()
|
|
437
|
+
/// .threshold(20000)
|
|
438
|
+
/// .preview_length(1000)
|
|
439
|
+
/// .filename_prefix("github_search")
|
|
440
|
+
/// .build();
|
|
441
|
+
/// ```
|
|
442
|
+
#[derive(Debug, Clone, Default)]
|
|
443
|
+
pub struct OptionsBuilder {
|
|
444
|
+
options: LargeOutputOptions,
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
impl OptionsBuilder {
|
|
448
|
+
/// Create a new OptionsBuilder with defaults
|
|
449
|
+
pub fn new() -> Self {
|
|
450
|
+
Self::default()
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/// Set character threshold for file fallback
|
|
454
|
+
pub fn threshold(mut self, threshold: usize) -> Self {
|
|
455
|
+
self.options.threshold = threshold;
|
|
456
|
+
self
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/// Set number of characters to include in preview
|
|
460
|
+
pub fn preview_length(mut self, length: usize) -> Self {
|
|
461
|
+
self.options.preview_length = length;
|
|
462
|
+
self
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/// Set custom temp directory
|
|
466
|
+
pub fn temp_dir(mut self, dir: PathBuf) -> Self {
|
|
467
|
+
self.options.temp_dir = Some(dir);
|
|
468
|
+
self
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/// Set filename prefix
|
|
472
|
+
pub fn filename_prefix(mut self, prefix: impl Into<String>) -> Self {
|
|
473
|
+
self.options.filename_prefix = prefix.into();
|
|
474
|
+
self
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/// Set whether to include size in response
|
|
478
|
+
pub fn include_size(mut self, include: bool) -> Self {
|
|
479
|
+
self.options.include_size = include;
|
|
480
|
+
self
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/// Set whether to include preview in response
|
|
484
|
+
pub fn include_preview(mut self, include: bool) -> Self {
|
|
485
|
+
self.options.include_preview = include;
|
|
486
|
+
self
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/// Set response format
|
|
490
|
+
pub fn response_format(mut self, format: ResponseFormat) -> Self {
|
|
491
|
+
self.options.response_format = format;
|
|
492
|
+
self
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/// Build the options
|
|
496
|
+
pub fn build(self) -> LargeOutputOptions {
|
|
497
|
+
self.options
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
#[cfg(test)]
|
|
502
|
+
mod tests {
|
|
503
|
+
use super::*;
|
|
504
|
+
|
|
505
|
+
#[test]
|
|
506
|
+
fn test_inline_output() {
|
|
507
|
+
let content = "small content";
|
|
508
|
+
let response = handle_output(content, None);
|
|
509
|
+
assert!(response.is_inline());
|
|
510
|
+
assert_eq!(response.content(), Some(content));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
#[test]
|
|
514
|
+
fn test_file_output() {
|
|
515
|
+
let content = "x".repeat(20000);
|
|
516
|
+
let response = handle_output(&content, None);
|
|
517
|
+
assert!(response.is_file());
|
|
518
|
+
|
|
519
|
+
if let OutputResponse::File { path, size, .. } = response {
|
|
520
|
+
assert_eq!(size, 20000);
|
|
521
|
+
assert!(path.exists());
|
|
522
|
+
let file_content = fs::read_to_string(&path).unwrap();
|
|
523
|
+
assert_eq!(file_content, content);
|
|
524
|
+
// Cleanup
|
|
525
|
+
let _ = fs::remove_file(&path);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
#[test]
|
|
530
|
+
fn test_format_size() {
|
|
531
|
+
assert_eq!(format_size(500), "500 B");
|
|
532
|
+
assert_eq!(format_size(1024), "1.0 KB");
|
|
533
|
+
assert_eq!(format_size(1536), "1.5 KB");
|
|
534
|
+
assert_eq!(format_size(1048576), "1.0 MB");
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
#[test]
|
|
538
|
+
fn test_options_builder() {
|
|
539
|
+
let options = OptionsBuilder::new()
|
|
540
|
+
.threshold(20000)
|
|
541
|
+
.preview_length(1000)
|
|
542
|
+
.filename_prefix("test")
|
|
543
|
+
.response_format(ResponseFormat::Json)
|
|
544
|
+
.build();
|
|
545
|
+
|
|
546
|
+
assert_eq!(options.threshold, 20000);
|
|
547
|
+
assert_eq!(options.preview_length, 1000);
|
|
548
|
+
assert_eq!(options.filename_prefix, "test");
|
|
549
|
+
assert_eq!(options.response_format, ResponseFormat::Json);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
#[test]
|
|
553
|
+
fn test_to_mcp_response_actionable() {
|
|
554
|
+
let content = "x".repeat(20000);
|
|
555
|
+
let response = handle_output(&content, None);
|
|
556
|
+
let text = to_mcp_response(&response, ResponseFormat::Actionable);
|
|
557
|
+
|
|
558
|
+
assert!(text.contains("Large output"));
|
|
559
|
+
assert!(text.contains("ACTION REQUIRED"));
|
|
560
|
+
assert!(text.contains("Read tool"));
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
#[test]
|
|
564
|
+
fn test_to_mcp_response_json() {
|
|
565
|
+
let content = "x".repeat(20000);
|
|
566
|
+
let response = handle_output(&content, None);
|
|
567
|
+
let text = to_mcp_response(&response, ResponseFormat::Json);
|
|
568
|
+
|
|
569
|
+
assert!(text.contains("\"type\": \"file\""));
|
|
570
|
+
assert!(text.contains("\"size\": 20000"));
|
|
571
|
+
|
|
572
|
+
// Cleanup
|
|
573
|
+
if let OutputResponse::File { path, .. } = response {
|
|
574
|
+
let _ = fs::remove_file(&path);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
#[test]
|
|
579
|
+
fn test_handle_batch() {
|
|
580
|
+
let large = "x".repeat(20000);
|
|
581
|
+
let contents: Vec<&str> = vec!["small", &large, "tiny"];
|
|
582
|
+
let responses = handle_batch(&contents, None);
|
|
583
|
+
|
|
584
|
+
assert_eq!(responses.len(), 3);
|
|
585
|
+
assert!(responses[0].is_inline());
|
|
586
|
+
assert!(responses[1].is_file());
|
|
587
|
+
assert!(responses[2].is_inline());
|
|
588
|
+
|
|
589
|
+
// Cleanup
|
|
590
|
+
for response in responses {
|
|
591
|
+
if let OutputResponse::File { path, .. } = response {
|
|
592
|
+
let _ = fs::remove_file(&path);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|