runtara_agents/agents/
file.rs

1// Copyright (C) 2025 SyncMyOrders Sp. z o.o.
2// SPDX-License-Identifier: AGPL-3.0-or-later
3//! File agent for workspace file operations
4//!
5//! This module provides file operations for ephemeral workspace storage:
6//! - Writing files (from FileData, base64, or text)
7//! - Reading files (as FileData or text)
8//! - Listing files with optional glob patterns
9//! - Deleting files and directories
10//! - Checking file existence
11//! - Copying and moving files
12//! - Creating directories
13//! - Getting file metadata
14//! - Appending to files
15//!
16//! All operations are scoped to the workspace directory provided via
17//! the `RUNTARA_WORKSPACE_DIR` environment variable.
18
19use crate::types::FileData;
20use base64::{Engine as _, engine::general_purpose};
21use glob::Pattern;
22use runtara_agent_macro::{CapabilityInput, CapabilityOutput, capability};
23use serde::{Deserialize, Serialize};
24use std::fs::{self, File, OpenOptions};
25use std::io::{Read, Write};
26use std::path::{Path, PathBuf};
27use std::time::SystemTime;
28
29// ============================================================================
30// Constants and Configuration
31// ============================================================================
32
33/// Default maximum file size for write operations (100MB)
34const DEFAULT_MAX_FILE_SIZE: u64 = 100 * 1024 * 1024;
35
36/// Default maximum file size for read operations (50MB)
37const DEFAULT_MAX_READ_SIZE: u64 = 50 * 1024 * 1024;
38
39// ============================================================================
40// Helper Functions
41// ============================================================================
42
43/// Get the workspace directory from environment
44fn get_workspace_dir() -> Result<PathBuf, String> {
45    std::env::var("RUNTARA_WORKSPACE_DIR")
46        .map(PathBuf::from)
47        .map_err(|_| {
48            "RUNTARA_WORKSPACE_DIR not set - file agent requires workspace context".to_string()
49        })
50}
51
52/// Get maximum file size for write operations
53fn get_max_file_size() -> u64 {
54    std::env::var("RUNTARA_MAX_FILE_SIZE")
55        .ok()
56        .and_then(|s| s.parse().ok())
57        .unwrap_or(DEFAULT_MAX_FILE_SIZE)
58}
59
60/// Get maximum file size for read operations
61fn get_max_read_size() -> u64 {
62    std::env::var("RUNTARA_MAX_READ_SIZE")
63        .ok()
64        .and_then(|s| s.parse().ok())
65        .unwrap_or(DEFAULT_MAX_READ_SIZE)
66}
67
68/// Resolve a relative path within the workspace, preventing path traversal
69fn resolve_path(relative: &str) -> Result<PathBuf, String> {
70    let workspace = get_workspace_dir()?;
71
72    // Normalize the relative path by removing leading slashes
73    let normalized = relative.trim_start_matches('/');
74
75    // Join with workspace
76    let path = workspace.join(normalized);
77
78    // For existing paths, use canonicalize
79    // For non-existing paths, we need to check the parent and ensure no traversal
80    let canonical = if path.exists() {
81        path.canonicalize()
82            .map_err(|e| format!("Failed to resolve path '{}': {}", relative, e))?
83    } else {
84        // For non-existing files, resolve the parent directory
85        let parent = path.parent().ok_or_else(|| "Invalid path".to_string())?;
86
87        // If parent doesn't exist, walk up until we find an existing ancestor
88        let mut existing_ancestor = parent.to_path_buf();
89        let mut remaining_components = Vec::new();
90
91        while !existing_ancestor.exists() {
92            if let Some(file_name) = existing_ancestor.file_name() {
93                remaining_components.push(file_name.to_os_string());
94            }
95            existing_ancestor = existing_ancestor
96                .parent()
97                .ok_or_else(|| "Invalid path structure".to_string())?
98                .to_path_buf();
99        }
100
101        // Canonicalize the existing ancestor
102        let canonical_ancestor = existing_ancestor
103            .canonicalize()
104            .map_err(|e| format!("Failed to resolve path: {}", e))?;
105
106        // Rebuild the path
107        let mut result = canonical_ancestor;
108        for component in remaining_components.into_iter().rev() {
109            result.push(component);
110        }
111        if let Some(file_name) = path.file_name() {
112            result.push(file_name);
113        }
114        result
115    };
116
117    // Security check: ensure path is within workspace
118    let canonical_workspace = workspace
119        .canonicalize()
120        .map_err(|e| format!("Workspace not accessible: {}", e))?;
121
122    if !canonical.starts_with(&canonical_workspace) {
123        return Err("Path traversal not allowed - path must be within workspace".to_string());
124    }
125
126    Ok(canonical)
127}
128
129/// Infer MIME type from file extension
130fn infer_mime_type(path: &Path) -> Option<String> {
131    path.extension().and_then(|ext| ext.to_str()).map(|ext| {
132        match ext.to_lowercase().as_str() {
133            "json" => "application/json",
134            "xml" => "application/xml",
135            "csv" => "text/csv",
136            "txt" => "text/plain",
137            "html" | "htm" => "text/html",
138            "pdf" => "application/pdf",
139            "png" => "image/png",
140            "jpg" | "jpeg" => "image/jpeg",
141            "gif" => "image/gif",
142            "svg" => "image/svg+xml",
143            "zip" => "application/zip",
144            "tar" => "application/x-tar",
145            "gz" | "gzip" => "application/gzip",
146            "md" => "text/markdown",
147            "yaml" | "yml" => "application/x-yaml",
148            "toml" => "application/toml",
149            _ => "application/octet-stream",
150        }
151        .to_string()
152    })
153}
154
155/// Decode content from various input formats
156fn decode_content(data: &serde_json::Value) -> Result<Vec<u8>, String> {
157    match data {
158        // Plain text string - treat as UTF-8 text
159        serde_json::Value::String(s) => {
160            // Try to decode as base64 first, fall back to treating as plain text
161            if let Ok(decoded) = general_purpose::STANDARD.decode(s) {
162                Ok(decoded)
163            } else {
164                Ok(s.as_bytes().to_vec())
165            }
166        }
167        // Object - expect FileData structure
168        serde_json::Value::Object(obj) => {
169            if let Some(content) = obj.get("content") {
170                if let Some(content_str) = content.as_str() {
171                    general_purpose::STANDARD
172                        .decode(content_str)
173                        .map_err(|e| format!("Failed to decode base64 content: {}", e))
174                } else {
175                    Err("FileData content must be a string".to_string())
176                }
177            } else if let Some(text) = obj.get("text") {
178                if let Some(text_str) = text.as_str() {
179                    Ok(text_str.as_bytes().to_vec())
180                } else {
181                    Err("text field must be a string".to_string())
182                }
183            } else {
184                Err("Object must have 'content' (base64) or 'text' field".to_string())
185            }
186        }
187        // Array - treat as byte array
188        serde_json::Value::Array(arr) => {
189            let mut bytes = Vec::with_capacity(arr.len());
190            for v in arr {
191                let num = v
192                    .as_u64()
193                    .ok_or_else(|| "Byte array must contain only numbers".to_string())?;
194                if num > 255 {
195                    return Err("Byte values must be in range 0-255".to_string());
196                }
197                bytes.push(num as u8);
198            }
199            Ok(bytes)
200        }
201        _ => Err("Data must be a string, FileData object, or byte array".to_string()),
202    }
203}
204
205/// Get Unix timestamp from SystemTime
206fn system_time_to_unix(time: SystemTime) -> Option<i64> {
207    time.duration_since(SystemTime::UNIX_EPOCH)
208        .ok()
209        .map(|d| d.as_secs() as i64)
210}
211
212// ============================================================================
213// Output Types
214// ============================================================================
215
216/// Response for write file operation
217#[derive(Debug, Serialize, CapabilityOutput)]
218#[capability_output(
219    display_name = "Write File Response",
220    description = "Response from writing a file to the workspace"
221)]
222pub struct WriteFileResponse {
223    #[field(
224        display_name = "Path",
225        description = "Full path of the written file",
226        example = "output/report.csv"
227    )]
228    pub path: String,
229
230    #[field(
231        display_name = "Bytes Written",
232        description = "Number of bytes written",
233        example = "1024"
234    )]
235    pub bytes_written: usize,
236
237    #[field(
238        display_name = "Created Directories",
239        description = "Whether parent directories were created",
240        example = "true"
241    )]
242    pub created_dirs: bool,
243}
244
245/// File information for list operations
246#[derive(Debug, Clone, Serialize, Deserialize, CapabilityOutput)]
247#[capability_output(
248    display_name = "Workspace File Info",
249    description = "Information about a file or directory in the workspace"
250)]
251pub struct WorkspaceFileInfo {
252    #[field(
253        display_name = "Name",
254        description = "File or directory name",
255        example = "report.csv"
256    )]
257    pub name: String,
258
259    #[field(
260        display_name = "Path",
261        description = "Relative path within workspace",
262        example = "output/report.csv"
263    )]
264    pub path: String,
265
266    #[field(
267        display_name = "Size",
268        description = "File size in bytes",
269        example = "1024"
270    )]
271    pub size: u64,
272
273    #[field(
274        display_name = "Is Directory",
275        description = "Whether this is a directory",
276        example = "false"
277    )]
278    pub is_directory: bool,
279
280    #[field(
281        display_name = "Modified Time",
282        description = "Last modified timestamp (Unix epoch seconds)"
283    )]
284    pub modified_time: Option<i64>,
285}
286
287/// Response for delete operation
288#[derive(Debug, Serialize, CapabilityOutput)]
289#[capability_output(
290    display_name = "Delete Response",
291    description = "Response from deleting a file or directory"
292)]
293pub struct DeleteResponse {
294    #[field(
295        display_name = "Success",
296        description = "Whether deletion was successful",
297        example = "true"
298    )]
299    pub success: bool,
300
301    #[field(
302        display_name = "Path",
303        description = "Path of deleted item",
304        example = "temp/old-file.txt"
305    )]
306    pub path: String,
307
308    #[field(
309        display_name = "Items Deleted",
310        description = "Number of items deleted (1 for file, N for recursive dir)",
311        example = "1"
312    )]
313    pub items_deleted: usize,
314}
315
316/// Response for file exists check
317#[derive(Debug, Serialize, CapabilityOutput)]
318#[capability_output(
319    display_name = "Exists Response",
320    description = "Response from checking if a file exists"
321)]
322pub struct ExistsResponse {
323    #[field(
324        display_name = "Exists",
325        description = "Whether the path exists",
326        example = "true"
327    )]
328    pub exists: bool,
329
330    #[field(
331        display_name = "Is Directory",
332        description = "Whether the path is a directory",
333        example = "false"
334    )]
335    pub is_directory: bool,
336
337    #[field(display_name = "Size", description = "File size in bytes if it exists")]
338    pub size: Option<u64>,
339}
340
341/// Response for copy operation
342#[derive(Debug, Serialize, CapabilityOutput)]
343#[capability_output(
344    display_name = "Copy Response",
345    description = "Response from copying a file"
346)]
347pub struct CopyResponse {
348    #[field(
349        display_name = "Source",
350        description = "Source path",
351        example = "input/data.csv"
352    )]
353    pub source: String,
354
355    #[field(
356        display_name = "Destination",
357        description = "Destination path",
358        example = "output/data.csv"
359    )]
360    pub destination: String,
361
362    #[field(
363        display_name = "Bytes Copied",
364        description = "Number of bytes copied",
365        example = "1024"
366    )]
367    pub bytes_copied: u64,
368}
369
370/// Response for move operation
371#[derive(Debug, Serialize, CapabilityOutput)]
372#[capability_output(
373    display_name = "Move Response",
374    description = "Response from moving/renaming a file"
375)]
376pub struct MoveResponse {
377    #[field(
378        display_name = "Source",
379        description = "Original path",
380        example = "temp/file.txt"
381    )]
382    pub source: String,
383
384    #[field(
385        display_name = "Destination",
386        description = "New path",
387        example = "output/file.txt"
388    )]
389    pub destination: String,
390}
391
392/// Response for create directory operation
393#[derive(Debug, Serialize, CapabilityOutput)]
394#[capability_output(
395    display_name = "Create Directory Response",
396    description = "Response from creating a directory"
397)]
398pub struct CreateDirResponse {
399    #[field(
400        display_name = "Path",
401        description = "Directory path",
402        example = "output/reports"
403    )]
404    pub path: String,
405
406    #[field(
407        display_name = "Created",
408        description = "Whether the directory was created (false if existed)",
409        example = "true"
410    )]
411    pub created: bool,
412}
413
414/// Detailed file metadata
415#[derive(Debug, Serialize, CapabilityOutput)]
416#[capability_output(
417    display_name = "File Metadata",
418    description = "Detailed metadata about a file"
419)]
420pub struct FileMetadata {
421    #[field(
422        display_name = "Path",
423        description = "Relative path within workspace",
424        example = "output/report.csv"
425    )]
426    pub path: String,
427
428    #[field(
429        display_name = "Name",
430        description = "File name",
431        example = "report.csv"
432    )]
433    pub name: String,
434
435    #[field(display_name = "Extension", description = "File extension")]
436    pub extension: Option<String>,
437
438    #[field(
439        display_name = "Size",
440        description = "File size in bytes",
441        example = "1024"
442    )]
443    pub size: u64,
444
445    #[field(
446        display_name = "Is Directory",
447        description = "Whether this is a directory",
448        example = "false"
449    )]
450    pub is_directory: bool,
451
452    #[field(
453        display_name = "Created Time",
454        description = "Creation timestamp (Unix epoch)"
455    )]
456    pub created_time: Option<i64>,
457
458    #[field(
459        display_name = "Modified Time",
460        description = "Last modified timestamp (Unix epoch)"
461    )]
462    pub modified_time: Option<i64>,
463
464    #[field(display_name = "MIME Type", description = "Inferred MIME type")]
465    pub mime_type: Option<String>,
466}
467
468/// Response for append operation
469#[derive(Debug, Serialize, CapabilityOutput)]
470#[capability_output(
471    display_name = "Append File Response",
472    description = "Response from appending to a file"
473)]
474pub struct AppendFileResponse {
475    #[field(
476        display_name = "Path",
477        description = "File path",
478        example = "logs/output.log"
479    )]
480    pub path: String,
481
482    #[field(
483        display_name = "Bytes Appended",
484        description = "Number of bytes appended",
485        example = "256"
486    )]
487    pub bytes_appended: usize,
488
489    #[field(
490        display_name = "Total Size",
491        description = "Total file size after append",
492        example = "1024"
493    )]
494    pub total_size: u64,
495
496    #[field(
497        display_name = "Created",
498        description = "Whether the file was created",
499        example = "false"
500    )]
501    pub created: bool,
502}
503
504// ============================================================================
505// Input Types
506// ============================================================================
507
508/// Input for write file operation
509#[derive(Debug, Deserialize, CapabilityInput)]
510#[capability_input(display_name = "Write File Input")]
511pub struct WriteFileInput {
512    /// Relative path within workspace
513    #[field(
514        display_name = "Path",
515        description = "Relative path within workspace (e.g., 'output/report.csv')",
516        example = "output/report.csv"
517    )]
518    pub path: String,
519
520    /// File data to write (FileData object, base64 string, plain text, or byte array)
521    #[field(
522        display_name = "Data",
523        description = "File content: FileData object, base64 string, plain text, or byte array",
524        example = "SGVsbG8gV29ybGQh"
525    )]
526    pub data: serde_json::Value,
527
528    /// Whether to create parent directories
529    #[field(
530        display_name = "Create Directories",
531        description = "Auto-create parent directories if they don't exist",
532        default = "true"
533    )]
534    #[serde(default = "default_true")]
535    pub create_dirs: bool,
536
537    /// Whether to overwrite existing file
538    #[field(
539        display_name = "Overwrite",
540        description = "Overwrite if file already exists",
541        default = "true"
542    )]
543    #[serde(default = "default_true")]
544    pub overwrite: bool,
545}
546
547fn default_true() -> bool {
548    true
549}
550
551fn default_false() -> bool {
552    false
553}
554
555fn default_response_format() -> String {
556    "file".to_string()
557}
558
559/// Input for read file operation
560#[derive(Debug, Deserialize, CapabilityInput)]
561#[capability_input(display_name = "Read File Input")]
562pub struct ReadFileInput {
563    /// Relative path within workspace
564    #[field(
565        display_name = "Path",
566        description = "Relative path to the file to read",
567        example = "input/data.csv"
568    )]
569    pub path: String,
570
571    /// Response format
572    #[field(
573        display_name = "Response Format",
574        description = "Output format: 'file' for FileData object, 'text' for plain text",
575        default = "file"
576    )]
577    #[serde(default = "default_response_format")]
578    pub response_format: String,
579}
580
581/// Input for list files operation
582#[derive(Debug, Deserialize, CapabilityInput)]
583#[capability_input(display_name = "List Files Input")]
584pub struct ListFilesInput {
585    /// Directory path (relative to workspace root)
586    #[field(
587        display_name = "Path",
588        description = "Directory path to list (empty or '/' for root)",
589        default = ""
590    )]
591    #[serde(default)]
592    pub path: String,
593
594    /// Whether to list recursively
595    #[field(
596        display_name = "Recursive",
597        description = "Include files from subdirectories",
598        default = "false"
599    )]
600    #[serde(default = "default_false")]
601    pub recursive: bool,
602
603    /// Glob pattern filter
604    #[field(
605        display_name = "Pattern",
606        description = "Glob pattern to filter files (e.g., '*.csv', '*.json')"
607    )]
608    pub pattern: Option<String>,
609}
610
611/// Input for delete file operation
612#[derive(Debug, Deserialize, CapabilityInput)]
613#[capability_input(display_name = "Delete File Input")]
614pub struct DeleteFileInput {
615    /// Path to delete
616    #[field(
617        display_name = "Path",
618        description = "Relative path to delete",
619        example = "temp/old-file.txt"
620    )]
621    pub path: String,
622
623    /// Whether to delete directory contents recursively
624    #[field(
625        display_name = "Recursive",
626        description = "For directories, delete all contents",
627        default = "false"
628    )]
629    #[serde(default = "default_false")]
630    pub recursive: bool,
631}
632
633/// Input for file exists operation
634#[derive(Debug, Deserialize, CapabilityInput)]
635#[capability_input(display_name = "File Exists Input")]
636pub struct FileExistsInput {
637    /// Path to check
638    #[field(
639        display_name = "Path",
640        description = "Relative path to check",
641        example = "output/result.json"
642    )]
643    pub path: String,
644}
645
646/// Input for copy file operation
647#[derive(Debug, Deserialize, CapabilityInput)]
648#[capability_input(display_name = "Copy File Input")]
649pub struct CopyFileInput {
650    /// Source path
651    #[field(
652        display_name = "Source",
653        description = "Source file path",
654        example = "input/template.txt"
655    )]
656    pub source: String,
657
658    /// Destination path
659    #[field(
660        display_name = "Destination",
661        description = "Destination file path",
662        example = "output/copy.txt"
663    )]
664    pub destination: String,
665
666    /// Whether to overwrite existing file
667    #[field(
668        display_name = "Overwrite",
669        description = "Overwrite if destination exists",
670        default = "false"
671    )]
672    #[serde(default = "default_false")]
673    pub overwrite: bool,
674}
675
676/// Input for move file operation
677#[derive(Debug, Deserialize, CapabilityInput)]
678#[capability_input(display_name = "Move File Input")]
679pub struct MoveFileInput {
680    /// Source path
681    #[field(
682        display_name = "Source",
683        description = "Source file path",
684        example = "temp/file.txt"
685    )]
686    pub source: String,
687
688    /// Destination path
689    #[field(
690        display_name = "Destination",
691        description = "Destination file path",
692        example = "output/file.txt"
693    )]
694    pub destination: String,
695
696    /// Whether to overwrite existing file
697    #[field(
698        display_name = "Overwrite",
699        description = "Overwrite if destination exists",
700        default = "false"
701    )]
702    #[serde(default = "default_false")]
703    pub overwrite: bool,
704}
705
706/// Input for create directory operation
707#[derive(Debug, Deserialize, CapabilityInput)]
708#[capability_input(display_name = "Create Directory Input")]
709pub struct CreateDirectoryInput {
710    /// Directory path to create
711    #[field(
712        display_name = "Path",
713        description = "Directory path to create",
714        example = "output/reports"
715    )]
716    pub path: String,
717
718    /// Whether to create parent directories
719    #[field(
720        display_name = "Recursive",
721        description = "Create parent directories if needed",
722        default = "true"
723    )]
724    #[serde(default = "default_true")]
725    pub recursive: bool,
726}
727
728/// Input for get file info operation
729#[derive(Debug, Deserialize, CapabilityInput)]
730#[capability_input(display_name = "Get File Info Input")]
731pub struct GetFileInfoInput {
732    /// Path to get info for
733    #[field(
734        display_name = "Path",
735        description = "File path to get metadata for",
736        example = "output/report.csv"
737    )]
738    pub path: String,
739}
740
741/// Input for append file operation
742#[derive(Debug, Deserialize, CapabilityInput)]
743#[capability_input(display_name = "Append File Input")]
744pub struct AppendFileInput {
745    /// File path to append to
746    #[field(
747        display_name = "Path",
748        description = "File path to append to",
749        example = "logs/output.log"
750    )]
751    pub path: String,
752
753    /// Data to append
754    #[field(
755        display_name = "Data",
756        description = "Content to append: FileData object, base64 string, plain text, or byte array"
757    )]
758    pub data: serde_json::Value,
759
760    /// Whether to create file if missing
761    #[field(
762        display_name = "Create If Missing",
763        description = "Create the file if it doesn't exist",
764        default = "true"
765    )]
766    #[serde(default = "default_true")]
767    pub create_if_missing: bool,
768
769    /// Whether to add newline before appending
770    #[field(
771        display_name = "Newline",
772        description = "Add a newline before the appended content",
773        default = "false"
774    )]
775    #[serde(default = "default_false")]
776    pub newline: bool,
777}
778
779// ============================================================================
780// Capabilities
781// ============================================================================
782
783/// Write a file to the workspace
784#[capability(
785    module = "file",
786    display_name = "Write File",
787    description = "Write data to a file in the workspace",
788    side_effects = true
789)]
790pub fn file_write_file(input: WriteFileInput) -> Result<WriteFileResponse, String> {
791    let resolved_path = resolve_path(&input.path)?;
792
793    // Decode the content
794    let content = decode_content(&input.data)?;
795
796    // Check size limit
797    let max_size = get_max_file_size();
798    if content.len() as u64 > max_size {
799        return Err(format!(
800            "File size {} bytes exceeds maximum allowed size {} bytes",
801            content.len(),
802            max_size
803        ));
804    }
805
806    // Check if file exists and overwrite flag
807    if resolved_path.exists() && !input.overwrite {
808        return Err(format!(
809            "File '{}' already exists and overwrite is false",
810            input.path
811        ));
812    }
813
814    // Create parent directories if needed
815    let mut created_dirs = false;
816    if let Some(parent) = resolved_path.parent() {
817        if !parent.exists() {
818            if input.create_dirs {
819                fs::create_dir_all(parent)
820                    .map_err(|e| format!("Failed to create directories: {}", e))?;
821                created_dirs = true;
822            } else {
823                return Err(format!(
824                    "Parent directory does not exist and create_dirs is false"
825                ));
826            }
827        }
828    }
829
830    // Write the file
831    let mut file = File::create(&resolved_path)
832        .map_err(|e| format!("Failed to create file '{}': {}", input.path, e))?;
833
834    file.write_all(&content)
835        .map_err(|e| format!("Failed to write file '{}': {}", input.path, e))?;
836
837    Ok(WriteFileResponse {
838        path: input.path,
839        bytes_written: content.len(),
840        created_dirs,
841    })
842}
843
844/// Read a file from the workspace
845#[capability(
846    module = "file",
847    display_name = "Read File",
848    description = "Read a file from the workspace and return as FileData or text"
849)]
850pub fn file_read_file(input: ReadFileInput) -> Result<serde_json::Value, String> {
851    let resolved_path = resolve_path(&input.path)?;
852
853    // Check if file exists
854    if !resolved_path.exists() {
855        return Err(format!("File '{}' not found", input.path));
856    }
857
858    if resolved_path.is_dir() {
859        return Err(format!("'{}' is a directory, not a file", input.path));
860    }
861
862    // Check file size
863    let metadata =
864        fs::metadata(&resolved_path).map_err(|e| format!("Failed to get file metadata: {}", e))?;
865
866    let max_size = get_max_read_size();
867    if metadata.len() > max_size {
868        return Err(format!(
869            "File size {} bytes exceeds maximum read size {} bytes",
870            metadata.len(),
871            max_size
872        ));
873    }
874
875    // Read file content
876    let mut file = File::open(&resolved_path)
877        .map_err(|e| format!("Failed to open file '{}': {}", input.path, e))?;
878
879    let mut content = Vec::new();
880    file.read_to_end(&mut content)
881        .map_err(|e| format!("Failed to read file '{}': {}", input.path, e))?;
882
883    // Return based on format
884    match input.response_format.as_str() {
885        "text" => {
886            let text = String::from_utf8_lossy(&content).to_string();
887            Ok(serde_json::Value::String(text))
888        }
889        _ => {
890            // Return as FileData
891            let file_data = FileData {
892                content: general_purpose::STANDARD.encode(&content),
893                filename: resolved_path
894                    .file_name()
895                    .map(|n| n.to_string_lossy().to_string()),
896                mime_type: infer_mime_type(&resolved_path),
897            };
898            serde_json::to_value(file_data)
899                .map_err(|e| format!("Failed to serialize FileData: {}", e))
900        }
901    }
902}
903
904/// List files in the workspace
905#[capability(
906    module = "file",
907    display_name = "List Files",
908    description = "List files and directories in the workspace with optional glob filtering"
909)]
910pub fn file_list_files(input: ListFilesInput) -> Result<Vec<WorkspaceFileInfo>, String> {
911    let workspace = get_workspace_dir()?;
912    let base_path = if input.path.is_empty() || input.path == "/" {
913        workspace.clone()
914    } else {
915        resolve_path(&input.path)?
916    };
917
918    if !base_path.exists() {
919        return Err(format!("Path '{}' not found", input.path));
920    }
921
922    if !base_path.is_dir() {
923        return Err(format!("'{}' is not a directory", input.path));
924    }
925
926    // Compile glob pattern if provided
927    let pattern = input
928        .pattern
929        .as_ref()
930        .map(|p| Pattern::new(p))
931        .transpose()
932        .map_err(|e| format!("Invalid glob pattern: {}", e))?;
933
934    let mut files = Vec::new();
935    collect_files(
936        &workspace,
937        &base_path,
938        input.recursive,
939        &pattern,
940        &mut files,
941    )?;
942
943    Ok(files)
944}
945
946/// Recursively collect file information
947fn collect_files(
948    workspace: &Path,
949    dir: &Path,
950    recursive: bool,
951    pattern: &Option<Pattern>,
952    files: &mut Vec<WorkspaceFileInfo>,
953) -> Result<(), String> {
954    let entries = fs::read_dir(dir).map_err(|e| format!("Failed to read directory: {}", e))?;
955
956    for entry in entries {
957        let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
958        let path = entry.path();
959        let metadata = entry
960            .metadata()
961            .map_err(|e| format!("Failed to get metadata: {}", e))?;
962
963        let name = entry.file_name().to_string_lossy().to_string();
964
965        // Apply pattern filter
966        if let Some(pat) = pattern {
967            if !pat.matches(&name) {
968                // If recursive and is directory, still descend
969                if recursive && metadata.is_dir() {
970                    collect_files(workspace, &path, recursive, pattern, files)?;
971                }
972                continue;
973            }
974        }
975
976        // Calculate relative path from workspace
977        let relative_path = path
978            .strip_prefix(workspace)
979            .map(|p| p.to_string_lossy().to_string())
980            .unwrap_or_else(|_| name.clone());
981
982        files.push(WorkspaceFileInfo {
983            name,
984            path: relative_path,
985            size: metadata.len(),
986            is_directory: metadata.is_dir(),
987            modified_time: metadata.modified().ok().and_then(system_time_to_unix),
988        });
989
990        // Recurse into subdirectories
991        if recursive && metadata.is_dir() {
992            collect_files(workspace, &path, recursive, pattern, files)?;
993        }
994    }
995
996    Ok(())
997}
998
999/// Delete a file or directory from the workspace
1000#[capability(
1001    module = "file",
1002    display_name = "Delete File",
1003    description = "Delete a file or directory from the workspace",
1004    side_effects = true
1005)]
1006pub fn file_delete_file(input: DeleteFileInput) -> Result<DeleteResponse, String> {
1007    let resolved_path = resolve_path(&input.path)?;
1008
1009    if !resolved_path.exists() {
1010        return Err(format!("Path '{}' not found", input.path));
1011    }
1012
1013    let items_deleted;
1014
1015    if resolved_path.is_dir() {
1016        if input.recursive {
1017            // Count items before deleting
1018            items_deleted = count_items(&resolved_path)?;
1019            fs::remove_dir_all(&resolved_path)
1020                .map_err(|e| format!("Failed to delete directory: {}", e))?;
1021        } else {
1022            // Try to remove empty directory
1023            fs::remove_dir(&resolved_path)
1024                .map_err(|e| format!("Failed to delete directory (is it empty?): {}", e))?;
1025            items_deleted = 1;
1026        }
1027    } else {
1028        fs::remove_file(&resolved_path).map_err(|e| format!("Failed to delete file: {}", e))?;
1029        items_deleted = 1;
1030    }
1031
1032    Ok(DeleteResponse {
1033        success: true,
1034        path: input.path,
1035        items_deleted,
1036    })
1037}
1038
1039/// Count items in a directory recursively
1040fn count_items(dir: &Path) -> Result<usize, String> {
1041    let mut count = 1; // Count the directory itself
1042
1043    for entry in fs::read_dir(dir).map_err(|e| format!("Failed to read directory: {}", e))? {
1044        let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
1045        let path = entry.path();
1046
1047        if path.is_dir() {
1048            count += count_items(&path)?;
1049        } else {
1050            count += 1;
1051        }
1052    }
1053
1054    Ok(count)
1055}
1056
1057/// Check if a file or directory exists
1058#[capability(
1059    module = "file",
1060    display_name = "File Exists",
1061    description = "Check if a file or directory exists in the workspace"
1062)]
1063pub fn file_file_exists(input: FileExistsInput) -> Result<ExistsResponse, String> {
1064    let workspace = get_workspace_dir()?;
1065    let normalized = input.path.trim_start_matches('/');
1066    let path = workspace.join(normalized);
1067
1068    // For existence check, we don't require the path to exist for resolution
1069    let exists = path.exists();
1070
1071    if exists {
1072        // Verify it's within workspace
1073        let canonical = path
1074            .canonicalize()
1075            .map_err(|e| format!("Failed to resolve path: {}", e))?;
1076
1077        let canonical_workspace = workspace
1078            .canonicalize()
1079            .map_err(|e| format!("Workspace not accessible: {}", e))?;
1080
1081        if !canonical.starts_with(&canonical_workspace) {
1082            return Err("Path traversal not allowed".to_string());
1083        }
1084
1085        let metadata =
1086            fs::metadata(&canonical).map_err(|e| format!("Failed to get metadata: {}", e))?;
1087
1088        Ok(ExistsResponse {
1089            exists: true,
1090            is_directory: metadata.is_dir(),
1091            size: Some(metadata.len()),
1092        })
1093    } else {
1094        Ok(ExistsResponse {
1095            exists: false,
1096            is_directory: false,
1097            size: None,
1098        })
1099    }
1100}
1101
1102/// Copy a file within the workspace
1103#[capability(
1104    module = "file",
1105    display_name = "Copy File",
1106    description = "Copy a file within the workspace",
1107    side_effects = true
1108)]
1109pub fn file_copy_file(input: CopyFileInput) -> Result<CopyResponse, String> {
1110    let source_path = resolve_path(&input.source)?;
1111    let dest_path = resolve_path(&input.destination)?;
1112
1113    if !source_path.exists() {
1114        return Err(format!("Source file '{}' not found", input.source));
1115    }
1116
1117    if source_path.is_dir() {
1118        return Err("Cannot copy directories - use recursive operations instead".to_string());
1119    }
1120
1121    // Check source file size
1122    let metadata =
1123        fs::metadata(&source_path).map_err(|e| format!("Failed to get source metadata: {}", e))?;
1124
1125    let max_size = get_max_file_size();
1126    if metadata.len() > max_size {
1127        return Err(format!(
1128            "Source file size {} bytes exceeds maximum allowed size {} bytes",
1129            metadata.len(),
1130            max_size
1131        ));
1132    }
1133
1134    // Check if destination exists
1135    if dest_path.exists() && !input.overwrite {
1136        return Err(format!(
1137            "Destination '{}' already exists and overwrite is false",
1138            input.destination
1139        ));
1140    }
1141
1142    // Create parent directories if needed
1143    if let Some(parent) = dest_path.parent() {
1144        if !parent.exists() {
1145            fs::create_dir_all(parent)
1146                .map_err(|e| format!("Failed to create destination directories: {}", e))?;
1147        }
1148    }
1149
1150    // Copy the file
1151    let bytes_copied =
1152        fs::copy(&source_path, &dest_path).map_err(|e| format!("Failed to copy file: {}", e))?;
1153
1154    Ok(CopyResponse {
1155        source: input.source,
1156        destination: input.destination,
1157        bytes_copied,
1158    })
1159}
1160
1161/// Move or rename a file within the workspace
1162#[capability(
1163    module = "file",
1164    display_name = "Move File",
1165    description = "Move or rename a file within the workspace",
1166    side_effects = true
1167)]
1168pub fn file_move_file(input: MoveFileInput) -> Result<MoveResponse, String> {
1169    let source_path = resolve_path(&input.source)?;
1170    let dest_path = resolve_path(&input.destination)?;
1171
1172    if !source_path.exists() {
1173        return Err(format!("Source '{}' not found", input.source));
1174    }
1175
1176    // Check if destination exists
1177    if dest_path.exists() && !input.overwrite {
1178        return Err(format!(
1179            "Destination '{}' already exists and overwrite is false",
1180            input.destination
1181        ));
1182    }
1183
1184    // Create parent directories if needed
1185    if let Some(parent) = dest_path.parent() {
1186        if !parent.exists() {
1187            fs::create_dir_all(parent)
1188                .map_err(|e| format!("Failed to create destination directories: {}", e))?;
1189        }
1190    }
1191
1192    // Move the file
1193    fs::rename(&source_path, &dest_path).map_err(|e| format!("Failed to move file: {}", e))?;
1194
1195    Ok(MoveResponse {
1196        source: input.source,
1197        destination: input.destination,
1198    })
1199}
1200
1201/// Create a directory in the workspace
1202#[capability(
1203    module = "file",
1204    display_name = "Create Directory",
1205    description = "Create a directory in the workspace",
1206    side_effects = true
1207)]
1208pub fn file_create_directory(input: CreateDirectoryInput) -> Result<CreateDirResponse, String> {
1209    let resolved_path = resolve_path(&input.path)?;
1210
1211    // Check if already exists
1212    if resolved_path.exists() {
1213        if resolved_path.is_dir() {
1214            return Ok(CreateDirResponse {
1215                path: input.path,
1216                created: false,
1217            });
1218        } else {
1219            return Err(format!("'{}' exists but is not a directory", input.path));
1220        }
1221    }
1222
1223    // Create directory
1224    if input.recursive {
1225        fs::create_dir_all(&resolved_path)
1226            .map_err(|e| format!("Failed to create directories: {}", e))?;
1227    } else {
1228        fs::create_dir(&resolved_path).map_err(|e| format!("Failed to create directory: {}", e))?;
1229    }
1230
1231    Ok(CreateDirResponse {
1232        path: input.path,
1233        created: true,
1234    })
1235}
1236
1237/// Get detailed file metadata
1238#[capability(
1239    module = "file",
1240    display_name = "Get File Info",
1241    description = "Get detailed metadata about a file in the workspace"
1242)]
1243pub fn file_get_file_info(input: GetFileInfoInput) -> Result<FileMetadata, String> {
1244    let resolved_path = resolve_path(&input.path)?;
1245
1246    if !resolved_path.exists() {
1247        return Err(format!("Path '{}' not found", input.path));
1248    }
1249
1250    let metadata =
1251        fs::metadata(&resolved_path).map_err(|e| format!("Failed to get metadata: {}", e))?;
1252
1253    let name = resolved_path
1254        .file_name()
1255        .map(|n| n.to_string_lossy().to_string())
1256        .unwrap_or_default();
1257
1258    let extension = resolved_path
1259        .extension()
1260        .map(|e| e.to_string_lossy().to_string());
1261
1262    let mime_type = if metadata.is_file() {
1263        infer_mime_type(&resolved_path)
1264    } else {
1265        None
1266    };
1267
1268    Ok(FileMetadata {
1269        path: input.path,
1270        name,
1271        extension,
1272        size: metadata.len(),
1273        is_directory: metadata.is_dir(),
1274        created_time: metadata.created().ok().and_then(system_time_to_unix),
1275        modified_time: metadata.modified().ok().and_then(system_time_to_unix),
1276        mime_type,
1277    })
1278}
1279
1280/// Append data to a file
1281#[capability(
1282    module = "file",
1283    display_name = "Append File",
1284    description = "Append data to an existing file or create a new one",
1285    side_effects = true
1286)]
1287pub fn file_append_file(input: AppendFileInput) -> Result<AppendFileResponse, String> {
1288    let resolved_path = resolve_path(&input.path)?;
1289
1290    // Decode the content
1291    let mut content = decode_content(&input.data)?;
1292
1293    // Add newline prefix if requested
1294    if input.newline {
1295        let mut with_newline = vec![b'\n'];
1296        with_newline.append(&mut content);
1297        content = with_newline;
1298    }
1299
1300    // Check if file exists
1301    let file_existed = resolved_path.exists();
1302
1303    if !file_existed && !input.create_if_missing {
1304        return Err(format!(
1305            "File '{}' does not exist and create_if_missing is false",
1306            input.path
1307        ));
1308    }
1309
1310    // Check resulting size won't exceed limit
1311    let current_size = if file_existed {
1312        fs::metadata(&resolved_path).map(|m| m.len()).unwrap_or(0)
1313    } else {
1314        0
1315    };
1316
1317    let max_size = get_max_file_size();
1318    let resulting_size = current_size + content.len() as u64;
1319
1320    if resulting_size > max_size {
1321        return Err(format!(
1322            "Resulting file size {} bytes would exceed maximum {} bytes",
1323            resulting_size, max_size
1324        ));
1325    }
1326
1327    // Create parent directories if needed for new file
1328    if !file_existed {
1329        if let Some(parent) = resolved_path.parent() {
1330            if !parent.exists() {
1331                fs::create_dir_all(parent)
1332                    .map_err(|e| format!("Failed to create directories: {}", e))?;
1333            }
1334        }
1335    }
1336
1337    // Open file for appending (or create)
1338    let mut file = OpenOptions::new()
1339        .create(true)
1340        .append(true)
1341        .open(&resolved_path)
1342        .map_err(|e| format!("Failed to open file for appending: {}", e))?;
1343
1344    file.write_all(&content)
1345        .map_err(|e| format!("Failed to append to file: {}", e))?;
1346
1347    // Get final size
1348    let total_size = fs::metadata(&resolved_path)
1349        .map(|m| m.len())
1350        .unwrap_or(resulting_size);
1351
1352    Ok(AppendFileResponse {
1353        path: input.path,
1354        bytes_appended: content.len(),
1355        total_size,
1356        created: !file_existed,
1357    })
1358}
1359
1360// ============================================================================
1361// Tests
1362// ============================================================================
1363
1364#[cfg(test)]
1365mod tests {
1366    use super::*;
1367    use serial_test::serial;
1368    use std::env;
1369    use tempfile::TempDir;
1370
1371    fn setup_test_workspace() -> TempDir {
1372        let temp_dir = TempDir::new().unwrap();
1373        // SAFETY: Tests are marked #[serial] to run sequentially, avoiding env var races
1374        unsafe {
1375            env::set_var("RUNTARA_WORKSPACE_DIR", temp_dir.path());
1376        }
1377        temp_dir
1378    }
1379
1380    #[test]
1381    #[serial]
1382    fn test_resolve_path_prevents_traversal() {
1383        let _temp = setup_test_workspace();
1384
1385        // These should fail
1386        assert!(resolve_path("../outside").is_err());
1387        assert!(resolve_path("/absolute/path").is_ok()); // Leading slash is stripped
1388
1389        // This should work
1390        assert!(resolve_path("valid/path").is_ok());
1391    }
1392
1393    #[test]
1394    fn test_infer_mime_type() {
1395        assert_eq!(
1396            infer_mime_type(Path::new("file.json")),
1397            Some("application/json".to_string())
1398        );
1399        assert_eq!(
1400            infer_mime_type(Path::new("file.csv")),
1401            Some("text/csv".to_string())
1402        );
1403        assert_eq!(
1404            infer_mime_type(Path::new("file.unknown")),
1405            Some("application/octet-stream".to_string())
1406        );
1407    }
1408
1409    #[test]
1410    fn test_decode_content_string() {
1411        // Plain text
1412        let result = decode_content(&serde_json::json!("Hello World"));
1413        assert!(result.is_ok());
1414        // Should decode as text since it's not valid base64
1415        assert_eq!(result.unwrap(), b"Hello World");
1416    }
1417
1418    #[test]
1419    fn test_decode_content_base64() {
1420        // Base64 encoded "Hello"
1421        let result = decode_content(&serde_json::json!("SGVsbG8="));
1422        assert!(result.is_ok());
1423        assert_eq!(result.unwrap(), b"Hello");
1424    }
1425
1426    #[test]
1427    fn test_decode_content_object() {
1428        let result = decode_content(&serde_json::json!({
1429            "content": "SGVsbG8="
1430        }));
1431        assert!(result.is_ok());
1432        assert_eq!(result.unwrap(), b"Hello");
1433    }
1434
1435    #[test]
1436    fn test_decode_content_text_object() {
1437        let result = decode_content(&serde_json::json!({
1438            "text": "Plain text content"
1439        }));
1440        assert!(result.is_ok());
1441        assert_eq!(result.unwrap(), b"Plain text content");
1442    }
1443
1444    #[test]
1445    fn test_decode_content_byte_array() {
1446        let result = decode_content(&serde_json::json!([72, 101, 108, 108, 111]));
1447        assert!(result.is_ok());
1448        assert_eq!(result.unwrap(), b"Hello");
1449    }
1450
1451    #[test]
1452    #[serial]
1453    fn test_write_and_read_file() {
1454        let temp = setup_test_workspace();
1455
1456        // Write a file
1457        let write_result = file_write_file(WriteFileInput {
1458            path: "test.txt".to_string(),
1459            data: serde_json::json!("Hello, World!"),
1460            create_dirs: true,
1461            overwrite: true,
1462        });
1463        assert!(write_result.is_ok());
1464        let write_response = write_result.unwrap();
1465        assert_eq!(write_response.bytes_written, 13);
1466
1467        // Read it back as text
1468        let read_result = file_read_file(ReadFileInput {
1469            path: "test.txt".to_string(),
1470            response_format: "text".to_string(),
1471        });
1472        assert!(read_result.is_ok());
1473        assert_eq!(read_result.unwrap(), serde_json::json!("Hello, World!"));
1474
1475        drop(temp);
1476    }
1477
1478    #[test]
1479    #[serial]
1480    fn test_list_files() {
1481        let temp = setup_test_workspace();
1482
1483        // Create some files
1484        let path = temp.path().join("test1.txt");
1485        fs::write(&path, "content1").unwrap();
1486
1487        let path = temp.path().join("test2.csv");
1488        fs::write(&path, "content2").unwrap();
1489
1490        // List all files
1491        let result = file_list_files(ListFilesInput {
1492            path: String::new(),
1493            recursive: false,
1494            pattern: None,
1495        });
1496        assert!(result.is_ok());
1497        let files = result.unwrap();
1498        assert_eq!(files.len(), 2);
1499
1500        // List with pattern
1501        let result = file_list_files(ListFilesInput {
1502            path: String::new(),
1503            recursive: false,
1504            pattern: Some("*.csv".to_string()),
1505        });
1506        assert!(result.is_ok());
1507        let files = result.unwrap();
1508        assert_eq!(files.len(), 1);
1509        assert_eq!(files[0].name, "test2.csv");
1510
1511        drop(temp);
1512    }
1513
1514    #[test]
1515    #[serial]
1516    fn test_file_exists() {
1517        let temp = setup_test_workspace();
1518
1519        // Create a file
1520        let path = temp.path().join("exists.txt");
1521        fs::write(&path, "content").unwrap();
1522
1523        // Check existing file
1524        let result = file_file_exists(FileExistsInput {
1525            path: "exists.txt".to_string(),
1526        });
1527        assert!(result.is_ok());
1528        let response = result.unwrap();
1529        assert!(response.exists);
1530        assert!(!response.is_directory);
1531
1532        // Check non-existing file
1533        let result = file_file_exists(FileExistsInput {
1534            path: "nonexistent.txt".to_string(),
1535        });
1536        assert!(result.is_ok());
1537        let response = result.unwrap();
1538        assert!(!response.exists);
1539
1540        drop(temp);
1541    }
1542
1543    #[test]
1544    #[serial]
1545    fn test_append_file() {
1546        let temp = setup_test_workspace();
1547
1548        // Create initial file
1549        file_write_file(WriteFileInput {
1550            path: "append.txt".to_string(),
1551            data: serde_json::json!("Line 1"),
1552            create_dirs: true,
1553            overwrite: true,
1554        })
1555        .unwrap();
1556
1557        // Append to it
1558        let result = file_append_file(AppendFileInput {
1559            path: "append.txt".to_string(),
1560            data: serde_json::json!("Line 2"),
1561            create_if_missing: true,
1562            newline: true,
1563        });
1564        assert!(result.is_ok());
1565        let response = result.unwrap();
1566        assert!(!response.created);
1567        assert_eq!(response.bytes_appended, 7); // "\nLine 2"
1568
1569        // Read and verify
1570        let content = fs::read_to_string(temp.path().join("append.txt")).unwrap();
1571        assert_eq!(content, "Line 1\nLine 2");
1572
1573        drop(temp);
1574    }
1575
1576    #[test]
1577    #[serial]
1578    fn test_copy_and_move_file() {
1579        let temp = setup_test_workspace();
1580
1581        // Create source file
1582        file_write_file(WriteFileInput {
1583            path: "source.txt".to_string(),
1584            data: serde_json::json!("Source content"),
1585            create_dirs: true,
1586            overwrite: true,
1587        })
1588        .unwrap();
1589
1590        // Copy it
1591        let copy_result = file_copy_file(CopyFileInput {
1592            source: "source.txt".to_string(),
1593            destination: "copy.txt".to_string(),
1594            overwrite: false,
1595        });
1596        assert!(copy_result.is_ok());
1597
1598        // Move it
1599        let move_result = file_move_file(MoveFileInput {
1600            source: "copy.txt".to_string(),
1601            destination: "moved.txt".to_string(),
1602            overwrite: false,
1603        });
1604        assert!(move_result.is_ok());
1605
1606        // Verify original exists
1607        assert!(temp.path().join("source.txt").exists());
1608        // Copy should be moved
1609        assert!(!temp.path().join("copy.txt").exists());
1610        assert!(temp.path().join("moved.txt").exists());
1611
1612        drop(temp);
1613    }
1614
1615    #[test]
1616    #[serial]
1617    fn test_create_directory() {
1618        let temp = setup_test_workspace();
1619
1620        let result = file_create_directory(CreateDirectoryInput {
1621            path: "deep/nested/dir".to_string(),
1622            recursive: true,
1623        });
1624        assert!(result.is_ok());
1625        assert!(result.unwrap().created);
1626
1627        // Directory should exist
1628        assert!(temp.path().join("deep/nested/dir").is_dir());
1629
1630        // Creating again should return created: false
1631        let result = file_create_directory(CreateDirectoryInput {
1632            path: "deep/nested/dir".to_string(),
1633            recursive: true,
1634        });
1635        assert!(result.is_ok());
1636        assert!(!result.unwrap().created);
1637
1638        drop(temp);
1639    }
1640
1641    #[test]
1642    #[serial]
1643    fn test_delete_file() {
1644        let temp = setup_test_workspace();
1645
1646        // Create a file
1647        file_write_file(WriteFileInput {
1648            path: "to_delete.txt".to_string(),
1649            data: serde_json::json!("Delete me"),
1650            create_dirs: true,
1651            overwrite: true,
1652        })
1653        .unwrap();
1654
1655        assert!(temp.path().join("to_delete.txt").exists());
1656
1657        // Delete it
1658        let result = file_delete_file(DeleteFileInput {
1659            path: "to_delete.txt".to_string(),
1660            recursive: false,
1661        });
1662        assert!(result.is_ok());
1663        assert!(result.unwrap().success);
1664
1665        assert!(!temp.path().join("to_delete.txt").exists());
1666
1667        drop(temp);
1668    }
1669
1670    #[test]
1671    #[serial]
1672    fn test_get_file_info() {
1673        let temp = setup_test_workspace();
1674
1675        // Create a file
1676        file_write_file(WriteFileInput {
1677            path: "info.csv".to_string(),
1678            data: serde_json::json!("a,b,c"),
1679            create_dirs: true,
1680            overwrite: true,
1681        })
1682        .unwrap();
1683
1684        let result = file_get_file_info(GetFileInfoInput {
1685            path: "info.csv".to_string(),
1686        });
1687        assert!(result.is_ok());
1688        let info = result.unwrap();
1689        assert_eq!(info.name, "info.csv");
1690        assert_eq!(info.extension, Some("csv".to_string()));
1691        assert_eq!(info.size, 5);
1692        assert!(!info.is_directory);
1693        assert_eq!(info.mime_type, Some("text/csv".to_string()));
1694
1695        drop(temp);
1696    }
1697}