@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.
@@ -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
+ }