1use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use uuid::Uuid;
10
11#[cfg(feature = "openapi")]
12use utoipa::ToSchema;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16#[cfg_attr(feature = "openapi", derive(ToSchema))]
17pub struct FileInfo {
18 #[cfg_attr(
20 feature = "openapi",
21 schema(example = "550e8400-e29b-41d4-a716-446655440000")
22 )]
23 pub id: Uuid,
24 #[cfg_attr(
26 feature = "openapi",
27 schema(example = "01933b5a-0000-7000-8000-000000000001")
28 )]
29 pub session_id: Uuid,
30 #[cfg_attr(feature = "openapi", schema(example = "/notes.md"))]
32 pub path: String,
33 #[cfg_attr(feature = "openapi", schema(example = "notes.md"))]
35 pub name: String,
36 #[cfg_attr(feature = "openapi", schema(example = false))]
38 pub is_directory: bool,
39 #[cfg_attr(feature = "openapi", schema(example = false))]
41 pub is_readonly: bool,
42 #[cfg_attr(feature = "openapi", schema(example = 4096))]
44 pub size_bytes: i64,
45 #[cfg_attr(feature = "openapi", schema(example = "2026-05-25T10:14:00Z"))]
47 pub created_at: DateTime<Utc>,
48 #[cfg_attr(feature = "openapi", schema(example = "2026-05-25T10:15:30Z"))]
50 pub updated_at: DateTime<Utc>,
51}
52
53impl FileInfo {
54 pub fn name_from_path(path: &str) -> String {
56 if path == "/" {
57 "/".to_string()
58 } else {
59 path.rsplit('/').next().unwrap_or(path).to_string()
60 }
61 }
62
63 pub fn parent_path(path: &str) -> Option<String> {
65 if path == "/" {
66 None
67 } else {
68 let parent = path.rsplit_once('/').map(|(p, _)| p).unwrap_or("/");
69 Some(if parent.is_empty() { "/" } else { parent }.to_string())
70 }
71 }
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76#[cfg_attr(feature = "openapi", derive(ToSchema))]
77pub struct SessionFile {
78 pub id: Uuid,
80 pub session_id: Uuid,
82 pub path: String,
84 pub name: String,
86 #[serde(skip_serializing_if = "Option::is_none")]
88 pub content: Option<String>,
89 #[serde(default = "default_encoding")]
91 pub encoding: String,
92 pub is_directory: bool,
94 pub is_readonly: bool,
96 pub size_bytes: i64,
98 pub created_at: DateTime<Utc>,
100 pub updated_at: DateTime<Utc>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
106#[cfg_attr(feature = "openapi", derive(ToSchema))]
107pub struct InitialFile {
108 pub path: String,
110 pub content: String,
112 #[serde(default = "default_encoding")]
114 pub encoding: String,
115 #[serde(default)]
117 pub is_readonly: bool,
118}
119
120fn default_encoding() -> String {
121 "text".to_string()
122}
123
124impl SessionFile {
125 pub fn is_text_content(bytes: &[u8]) -> bool {
127 let check_len = bytes.len().min(8192);
129 !bytes[..check_len].contains(&0)
130 }
131
132 pub fn encode_content(bytes: &[u8]) -> (String, String) {
134 if Self::is_text_content(bytes) {
135 match String::from_utf8(bytes.to_vec()) {
136 Ok(text) => (text, "text".to_string()),
137 Err(_) => (BASE64.encode(bytes), "base64".to_string()),
138 }
139 } else {
140 (BASE64.encode(bytes), "base64".to_string())
141 }
142 }
143
144 pub fn decode_content(content: &str, encoding: &str) -> Result<Vec<u8>, base64::DecodeError> {
146 match encoding {
147 "base64" => BASE64.decode(content),
148 _ => Ok(content.as_bytes().to_vec()),
149 }
150 }
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
155#[cfg_attr(feature = "openapi", derive(ToSchema))]
156pub struct FileStat {
157 pub path: String,
159 pub name: String,
161 pub is_directory: bool,
163 pub is_readonly: bool,
165 pub size_bytes: i64,
167 pub created_at: DateTime<Utc>,
169 pub updated_at: DateTime<Utc>,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
175#[cfg_attr(feature = "openapi", derive(ToSchema))]
176pub struct GrepMatch {
177 pub path: String,
178 pub line_number: usize,
179 pub line: String,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
184#[cfg_attr(feature = "openapi", derive(ToSchema))]
185pub struct GrepResult {
186 pub path: String,
187 pub matches: Vec<GrepMatch>,
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 #[test]
195 fn test_name_from_path() {
196 assert_eq!(FileInfo::name_from_path("/"), "/");
197 assert_eq!(FileInfo::name_from_path("/foo"), "foo");
198 assert_eq!(FileInfo::name_from_path("/foo/bar"), "bar");
199 assert_eq!(FileInfo::name_from_path("/foo/bar/baz.txt"), "baz.txt");
200 }
201
202 #[test]
203 fn test_parent_path() {
204 assert_eq!(FileInfo::parent_path("/"), None);
205 assert_eq!(FileInfo::parent_path("/foo"), Some("/".to_string()));
206 assert_eq!(FileInfo::parent_path("/foo/bar"), Some("/foo".to_string()));
207 assert_eq!(
208 FileInfo::parent_path("/foo/bar/baz"),
209 Some("/foo/bar".to_string())
210 );
211 }
212
213 #[test]
214 fn test_is_text_content() {
215 assert!(SessionFile::is_text_content(b"hello world"));
216 assert!(SessionFile::is_text_content(b"line1\nline2\n"));
217 assert!(!SessionFile::is_text_content(b"hello\0world"));
218 }
219
220 #[test]
221 fn test_encode_content_text() {
222 let (content, encoding) = SessionFile::encode_content(b"hello world");
223 assert_eq!(content, "hello world");
224 assert_eq!(encoding, "text");
225 }
226
227 #[test]
228 fn test_encode_content_binary() {
229 let binary = b"\x89PNG\r\n\x1a\n\0";
231 let (content, encoding) = SessionFile::encode_content(binary);
232 assert_eq!(encoding, "base64");
233 assert!(!content.is_empty());
234 }
235
236 #[test]
237 fn test_decode_content_text() {
238 let decoded = SessionFile::decode_content("hello world", "text").unwrap();
239 assert_eq!(decoded, b"hello world");
240 }
241
242 #[test]
243 fn test_decode_content_base64() {
244 let decoded = SessionFile::decode_content("aGVsbG8=", "base64").unwrap();
245 assert_eq!(decoded, b"hello");
246 }
247
248 #[test]
249 fn test_encode_decode_roundtrip() {
250 let original = b"Test content with special chars: \xc3\xa9\xc3\xa0";
251 let (encoded, encoding) = SessionFile::encode_content(original);
252 let decoded = SessionFile::decode_content(&encoded, &encoding).unwrap();
253 assert_eq!(decoded, original);
254 }
255
256 #[test]
257 fn test_file_info_serialization() {
258 let file_info = FileInfo {
259 id: Uuid::nil(),
260 session_id: Uuid::nil(),
261 path: "/test.txt".to_string(),
262 name: "test.txt".to_string(),
263 is_directory: false,
264 is_readonly: false,
265 size_bytes: 100,
266 created_at: DateTime::default(),
267 updated_at: DateTime::default(),
268 };
269
270 let json = serde_json::to_string(&file_info).unwrap();
271 assert!(json.contains("\"path\":\"/test.txt\""));
272 assert!(json.contains("\"is_directory\":false"));
273 }
274
275 #[test]
276 fn test_grep_result_serialization() {
277 let result = GrepResult {
278 path: "/test.txt".to_string(),
279 matches: vec![GrepMatch {
280 path: "/test.txt".to_string(),
281 line_number: 1,
282 line: "hello world".to_string(),
283 }],
284 };
285
286 let json = serde_json::to_string(&result).unwrap();
287 assert!(json.contains("\"line_number\":1"));
288 assert!(json.contains("\"line\":\"hello world\""));
289 }
290}