1use anyhow::Result;
2use async_trait::async_trait;
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::collections::hash_map::DefaultHasher;
7use std::hash::{Hash, Hasher};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct FileContext {
12 pub thread_id: String,
14 pub task_id: Option<String>,
16 pub tool_call_id: Option<String>,
18 pub content_type: Option<String>,
20 pub original_filename: Option<String>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
26pub struct FileMetadata {
27 pub file_id: String,
29 pub relative_path: String,
31 pub size: u64,
33 pub content_type: Option<String>,
35 pub original_filename: Option<String>,
37 #[schemars(with = "String")]
39 pub created_at: chrono::DateTime<chrono::Utc>,
40 #[schemars(with = "String")]
42 pub updated_at: chrono::DateTime<chrono::Utc>,
43 pub checksum: Option<String>,
45 pub stats: Option<FileStats>,
47 pub preview: Option<String>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
53pub struct Artifact {
54 pub file_metadata: FileMetadata,
56 pub thread_id: String,
58 pub task_id: Option<String>,
60 pub tool_call_id: Option<String>,
62}
63
64impl FileMetadata {
65 pub fn display_name(&self) -> String {
67 self.original_filename
68 .clone()
69 .unwrap_or_else(|| format!("file_{}", &self.file_id[..8]))
70 }
71
72 pub fn size_display(&self) -> String {
74 let size = self.size as f64;
75 if size < 1024.0 {
76 format!("{}B", self.size)
77 } else if size < 1024.0 * 1024.0 {
78 format!("{:.1}KB", size / 1024.0)
79 } else if size < 1024.0 * 1024.0 * 1024.0 {
80 format!("{:.1}MB", size / (1024.0 * 1024.0))
81 } else {
82 format!("{:.1}GB", size / (1024.0 * 1024.0 * 1024.0))
83 }
84 }
85
86 pub fn is_text_file(&self) -> bool {
88 self.content_type
89 .as_ref()
90 .map(|ct| ct.starts_with("text/") || ct.contains("json") || ct.contains("xml"))
91 .unwrap_or(false)
92 }
93
94 pub fn summary(&self) -> String {
96 format!(
97 "{} ({}{})",
98 self.display_name(),
99 self.size_display(),
100 if let Some(ct) = &self.content_type {
101 format!(", {}", ct)
102 } else {
103 String::new()
104 }
105 )
106 }
107}
108
109impl Artifact {
110 pub fn new(
112 file_metadata: FileMetadata,
113 thread_id: String,
114 task_id: Option<String>,
115 tool_call_id: Option<String>,
116 ) -> Self {
117 Self {
118 file_metadata,
119 thread_id,
120 task_id,
121 tool_call_id,
122 }
123 }
124
125 pub fn artifact_path(&self) -> String {
127 if let Some(task_id) = &self.task_id {
128 format!(
129 "{}/artifact/{}/{}",
130 self.thread_id, task_id, self.file_metadata.file_id
131 )
132 } else {
133 format!("{}/artifact/{}", self.thread_id, self.file_metadata.file_id)
134 }
135 }
136
137 pub fn display_name(&self) -> String {
139 self.file_metadata.display_name()
140 }
141
142 pub fn size_display(&self) -> String {
143 self.file_metadata.size_display()
144 }
145
146 pub fn summary(&self) -> String {
147 self.file_metadata.summary()
148 }
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
154pub struct ArtifactNamespace {
155 pub thread_id: String,
157 pub task_id: Option<String>,
159}
160
161impl ArtifactNamespace {
162 pub fn new(thread_id: String, task_id: Option<String>) -> Self {
164 Self { thread_id, task_id }
165 }
166
167 fn short_hex(id: &str) -> String {
169 let mut hasher = DefaultHasher::new();
170 id.hash(&mut hasher);
171 format!("{:08x}", hasher.finish())
172 }
173
174 pub fn thread_path(&self) -> String {
176 let short_thread = Self::short_hex(&self.thread_id);
177 format!("threads/{}", short_thread)
178 }
179
180 pub fn task_path(&self) -> Option<String> {
183 self.task_id.as_ref().map(|task_id| {
184 let short_thread = Self::short_hex(&self.thread_id);
185 let short_task = Self::short_hex(task_id);
186 format!("threads/{}/tasks/{}", short_thread, short_task)
187 })
188 }
189
190 pub fn primary_path(&self) -> String {
193 self.task_path().unwrap_or_else(|| self.thread_path())
194 }
195
196 pub fn all_paths(&self) -> Vec<String> {
200 let mut paths = vec![self.thread_path()];
201 if let Some(task_path) = self.task_path() {
202 paths.push(task_path);
203 }
204 paths
205 }
206
207 pub fn from_path(_path: &str) -> Option<Self> {
212 None
215 }
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
220#[serde(tag = "type", rename_all = "snake_case")]
221pub enum FileStats {
222 Json(JsonStats),
223 Markdown(MarkdownStats),
224 Text(TextStats),
225}
226
227impl FileStats {
228 pub fn stats_type(&self) -> &'static str {
230 match self {
231 FileStats::Json(_) => "json",
232 FileStats::Markdown(_) => "markdown",
233 FileStats::Text(_) => "text",
234 }
235 }
236
237 pub fn summary(&self) -> String {
239 match self {
240 FileStats::Json(stats) => stats.summary(),
241 FileStats::Markdown(stats) => stats.summary(),
242 FileStats::Text(stats) => stats.summary(),
243 }
244 }
245
246 pub fn context_info(&self) -> String {
248 match self {
249 FileStats::Json(stats) => stats.context_info(),
250 FileStats::Markdown(stats) => stats.context_info(),
251 FileStats::Text(stats) => stats.context_info(),
252 }
253 }
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
258pub struct JsonStats {
259 pub is_array: bool,
261 pub array_length: Option<usize>,
263 pub top_level_keys: Vec<String>,
265 pub nested_depth: usize,
267 pub unique_values_sample: HashMap<String, Vec<String>>,
269 pub cardinality_estimates: HashMap<String, usize>,
271 pub preview: String,
273}
274
275impl JsonStats {
276 pub fn summary(&self) -> String {
277 if self.is_array {
278 format!(
279 "JSON array with {} elements, {} unique keys, depth {}",
280 self.array_length.unwrap_or(0),
281 self.top_level_keys.len(),
282 self.nested_depth
283 )
284 } else {
285 format!(
286 "JSON object with {} keys, depth {}",
287 self.top_level_keys.len(),
288 self.nested_depth
289 )
290 }
291 }
292
293 pub fn context_info(&self) -> String {
294 let mut info = self.summary();
295
296 if !self.top_level_keys.is_empty() {
297 info.push_str(&format!("\nKeys: {}", self.top_level_keys.join(", ")));
298 }
299
300 let high_card_fields: Vec<_> = self
302 .cardinality_estimates
303 .iter()
304 .filter(|&(_, &count)| count > 50)
305 .map(|(field, count)| format!("{} (~{})", field, count))
306 .collect();
307
308 if !high_card_fields.is_empty() {
309 info.push_str(&format!(
310 "\nHigh-cardinality fields: {}",
311 high_card_fields.join(", ")
312 ));
313 }
314
315 for (field, values) in &self.unique_values_sample {
317 if values.len() <= 10 {
318 info.push_str(&format!("\n{}: {}", field, values.join(", ")));
320 }
321 }
322
323 info
324 }
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
329pub struct MarkdownStats {
330 pub word_count: usize,
332 pub headings: Vec<HeadingInfo>,
334 pub code_blocks: usize,
336 pub links: usize,
338 pub images: usize,
340 pub tables: usize,
342 pub lists: usize,
344 pub front_matter: Option<String>,
346 pub preview: String,
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
351pub struct HeadingInfo {
352 pub text: String,
353 pub level: usize,
354}
355
356impl MarkdownStats {
357 pub fn summary(&self) -> String {
358 format!(
359 "Markdown: {} words, {} headings, {} code blocks, {} tables",
360 self.word_count,
361 self.headings.len(),
362 self.code_blocks,
363 self.tables
364 )
365 }
366
367 pub fn context_info(&self) -> String {
368 let mut info = self.summary();
369
370 if !self.headings.is_empty() {
371 info.push_str("\nStructure:");
372 for heading in &self.headings[..5.min(self.headings.len())] {
373 let indent = " ".repeat(heading.level.saturating_sub(1));
374 info.push_str(&format!("\n{}{}", indent, heading.text));
375 }
376 }
377
378 if let Some(fm_type) = &self.front_matter {
379 info.push_str(&format!("\nFrontmatter: {}", fm_type));
380 }
381
382 info
383 }
384}
385
386#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
388pub struct TextStats {
389 pub lines: usize,
391 pub words: usize,
393 pub characters: usize,
395 pub encoding: String,
397 pub language: Option<String>,
399 pub structure_hints: TextStructure,
401 pub preview: String,
403}
404
405#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
407#[serde(tag = "type", rename_all = "snake_case")]
408pub enum TextStructure {
409 LogFile {
410 log_level_counts: HashMap<String, usize>,
411 },
412 ConfigFile {
413 format: String,
414 },
415 CodeFile {
416 language: String,
417 function_count: usize,
418 },
419 PlainText,
420}
421
422impl TextStats {
423 pub fn summary(&self) -> String {
424 format!(
425 "Text: {} lines, {} words ({} chars)",
426 self.lines, self.words, self.characters
427 )
428 }
429
430 pub fn context_info(&self) -> String {
431 let mut info = self.summary();
432
433 if let Some(lang) = &self.language {
434 info.push_str(&format!("\nLanguage: {}", lang));
435 }
436
437 match &self.structure_hints {
438 TextStructure::LogFile { log_level_counts } => {
439 info.push_str("\nStructure: Log file");
440 let levels: Vec<_> = log_level_counts
441 .iter()
442 .map(|(level, count)| format!("{}: {}", level, count))
443 .collect();
444 if !levels.is_empty() {
445 info.push_str(&format!("\nLevels: {}", levels.join(", ")));
446 }
447 }
448 TextStructure::ConfigFile { format } => {
449 info.push_str(&format!("\nStructure: Config file ({})", format));
450 }
451 TextStructure::CodeFile {
452 language,
453 function_count,
454 } => {
455 info.push_str(&format!(
456 "\nStructure: Code file ({}, {} functions)",
457 language, function_count
458 ));
459 }
460 TextStructure::PlainText => {
461 info.push_str("\nStructure: Plain text");
462 }
463 }
464
465 info
466 }
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize, Default)]
471pub struct ReadParams {
472 pub start_line: Option<u64>,
473 pub end_line: Option<u64>,
474}
475
476#[derive(Debug, Clone, Serialize, Deserialize)]
478pub struct FileReadResult {
479 pub content: String,
480 pub start_line: u64,
481 pub end_line: u64,
482 pub total_lines: u64,
483}
484
485#[derive(Debug, Clone, Serialize, Deserialize)]
487pub struct DirectoryListing {
488 pub path: String,
489 pub entries: Vec<DirectoryEntry>,
490}
491
492#[derive(Debug, Clone, Serialize, Deserialize)]
494pub struct DirectoryEntry {
495 pub name: String,
496 pub is_file: bool,
497 pub is_dir: bool,
498 pub size: Option<u64>,
499}
500
501#[derive(Debug, Clone, Serialize, Deserialize)]
503pub struct SearchResult {
504 pub path: String,
505 pub matches: Vec<SearchMatch>,
506}
507
508#[derive(Debug, Clone, Serialize, Deserialize)]
510pub struct SearchMatch {
511 pub file_path: String,
512 pub line_number: Option<u64>,
513 pub line_content: String,
514 pub match_text: String,
515}
516
517#[async_trait]
519pub trait FileSystemOps: Send + Sync + std::fmt::Debug {
520 async fn read(&self, path: &str, params: ReadParams) -> Result<FileReadResult>;
522
523 async fn read_raw(&self, path: &str) -> Result<String> {
525 let result = self.read(path, ReadParams::default()).await?;
527 if result.content.contains("→") {
529 Ok(result
530 .content
531 .lines()
532 .map(|line| {
533 if let Some(pos) = line.find("→") {
534 &line[pos + 1..]
535 } else {
536 line
537 }
538 })
539 .collect::<Vec<_>>()
540 .join("\n"))
541 } else {
542 Ok(result.content)
543 }
544 }
545
546 async fn read_with_line_numbers(
548 &self,
549 path: &str,
550 params: ReadParams,
551 ) -> Result<FileReadResult> {
552 self.read(path, params).await
553 }
554
555 async fn write(&self, path: &str, content: &str) -> Result<()>;
557
558 async fn list(&self, path: &str) -> Result<DirectoryListing>;
560
561 async fn delete(&self, path: &str, recursive: bool) -> Result<()>;
563
564 async fn search(
566 &self,
567 path: &str,
568 content_pattern: Option<&str>,
569 file_pattern: Option<&str>,
570 ) -> Result<SearchResult>;
571
572 async fn copy(&self, from: &str, to: &str) -> Result<()>;
574
575 async fn move_file(&self, from: &str, to: &str) -> Result<()>;
577
578 async fn mkdir(&self, path: &str) -> Result<()>;
580
581 async fn info(&self, path: &str) -> Result<FileMetadata>;
583
584 async fn tree(&self, path: &str) -> Result<DirectoryListing>;
586}