1use 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
29const DEFAULT_MAX_FILE_SIZE: u64 = 100 * 1024 * 1024;
35
36const DEFAULT_MAX_READ_SIZE: u64 = 50 * 1024 * 1024;
38
39fn 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
52fn 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
60fn 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
68fn resolve_path(relative: &str) -> Result<PathBuf, String> {
70 let workspace = get_workspace_dir()?;
71
72 let normalized = relative.trim_start_matches('/');
74
75 let path = workspace.join(normalized);
77
78 let canonical = if path.exists() {
81 path.canonicalize()
82 .map_err(|e| format!("Failed to resolve path '{}': {}", relative, e))?
83 } else {
84 let parent = path.parent().ok_or_else(|| "Invalid path".to_string())?;
86
87 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 let canonical_ancestor = existing_ancestor
103 .canonicalize()
104 .map_err(|e| format!("Failed to resolve path: {}", e))?;
105
106 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 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
129fn 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
155fn decode_content(data: &serde_json::Value) -> Result<Vec<u8>, String> {
157 match data {
158 serde_json::Value::String(s) => {
160 if let Ok(decoded) = general_purpose::STANDARD.decode(s) {
162 Ok(decoded)
163 } else {
164 Ok(s.as_bytes().to_vec())
165 }
166 }
167 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 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
205fn 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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[derive(Debug, Deserialize, CapabilityInput)]
510#[capability_input(display_name = "Write File Input")]
511pub struct WriteFileInput {
512 #[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 #[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 #[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 #[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#[derive(Debug, Deserialize, CapabilityInput)]
561#[capability_input(display_name = "Read File Input")]
562pub struct ReadFileInput {
563 #[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 #[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#[derive(Debug, Deserialize, CapabilityInput)]
583#[capability_input(display_name = "List Files Input")]
584pub struct ListFilesInput {
585 #[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 #[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 #[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#[derive(Debug, Deserialize, CapabilityInput)]
613#[capability_input(display_name = "Delete File Input")]
614pub struct DeleteFileInput {
615 #[field(
617 display_name = "Path",
618 description = "Relative path to delete",
619 example = "temp/old-file.txt"
620 )]
621 pub path: String,
622
623 #[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#[derive(Debug, Deserialize, CapabilityInput)]
635#[capability_input(display_name = "File Exists Input")]
636pub struct FileExistsInput {
637 #[field(
639 display_name = "Path",
640 description = "Relative path to check",
641 example = "output/result.json"
642 )]
643 pub path: String,
644}
645
646#[derive(Debug, Deserialize, CapabilityInput)]
648#[capability_input(display_name = "Copy File Input")]
649pub struct CopyFileInput {
650 #[field(
652 display_name = "Source",
653 description = "Source file path",
654 example = "input/template.txt"
655 )]
656 pub source: String,
657
658 #[field(
660 display_name = "Destination",
661 description = "Destination file path",
662 example = "output/copy.txt"
663 )]
664 pub destination: String,
665
666 #[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#[derive(Debug, Deserialize, CapabilityInput)]
678#[capability_input(display_name = "Move File Input")]
679pub struct MoveFileInput {
680 #[field(
682 display_name = "Source",
683 description = "Source file path",
684 example = "temp/file.txt"
685 )]
686 pub source: String,
687
688 #[field(
690 display_name = "Destination",
691 description = "Destination file path",
692 example = "output/file.txt"
693 )]
694 pub destination: String,
695
696 #[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#[derive(Debug, Deserialize, CapabilityInput)]
708#[capability_input(display_name = "Create Directory Input")]
709pub struct CreateDirectoryInput {
710 #[field(
712 display_name = "Path",
713 description = "Directory path to create",
714 example = "output/reports"
715 )]
716 pub path: String,
717
718 #[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#[derive(Debug, Deserialize, CapabilityInput)]
730#[capability_input(display_name = "Get File Info Input")]
731pub struct GetFileInfoInput {
732 #[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#[derive(Debug, Deserialize, CapabilityInput)]
743#[capability_input(display_name = "Append File Input")]
744pub struct AppendFileInput {
745 #[field(
747 display_name = "Path",
748 description = "File path to append to",
749 example = "logs/output.log"
750 )]
751 pub path: String,
752
753 #[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 #[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 #[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#[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 let content = decode_content(&input.data)?;
795
796 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 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 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 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#[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 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 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 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 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 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#[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 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
946fn 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 if let Some(pat) = pattern {
967 if !pat.matches(&name) {
968 if recursive && metadata.is_dir() {
970 collect_files(workspace, &path, recursive, pattern, files)?;
971 }
972 continue;
973 }
974 }
975
976 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 if recursive && metadata.is_dir() {
992 collect_files(workspace, &path, recursive, pattern, files)?;
993 }
994 }
995
996 Ok(())
997}
998
999#[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 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 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
1039fn count_items(dir: &Path) -> Result<usize, String> {
1041 let mut count = 1; 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#[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 let exists = path.exists();
1070
1071 if exists {
1072 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#[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 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 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 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 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#[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 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 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 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#[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 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 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#[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#[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 let mut content = decode_content(&input.data)?;
1292
1293 if input.newline {
1295 let mut with_newline = vec![b'\n'];
1296 with_newline.append(&mut content);
1297 content = with_newline;
1298 }
1299
1300 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 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 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 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 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#[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 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 assert!(resolve_path("../outside").is_err());
1387 assert!(resolve_path("/absolute/path").is_ok()); 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 let result = decode_content(&serde_json::json!("Hello World"));
1413 assert!(result.is_ok());
1414 assert_eq!(result.unwrap(), b"Hello World");
1416 }
1417
1418 #[test]
1419 fn test_decode_content_base64() {
1420 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 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 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 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 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 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 let path = temp.path().join("exists.txt");
1521 fs::write(&path, "content").unwrap();
1522
1523 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 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 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 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); 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 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 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 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 assert!(temp.path().join("source.txt").exists());
1608 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 assert!(temp.path().join("deep/nested/dir").is_dir());
1629
1630 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 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 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 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}