use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[cfg(feature = "openapi")]
use utoipa::ToSchema;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct FileInfo {
#[cfg_attr(
feature = "openapi",
schema(example = "550e8400-e29b-41d4-a716-446655440000")
)]
pub id: Uuid,
#[cfg_attr(
feature = "openapi",
schema(example = "01933b5a-0000-7000-8000-000000000001")
)]
pub session_id: Uuid,
#[cfg_attr(feature = "openapi", schema(example = "/notes.md"))]
pub path: String,
#[cfg_attr(feature = "openapi", schema(example = "notes.md"))]
pub name: String,
#[cfg_attr(feature = "openapi", schema(example = false))]
pub is_directory: bool,
#[cfg_attr(feature = "openapi", schema(example = false))]
pub is_readonly: bool,
#[cfg_attr(feature = "openapi", schema(example = 4096))]
pub size_bytes: i64,
#[cfg_attr(feature = "openapi", schema(example = "2026-05-25T10:14:00Z"))]
pub created_at: DateTime<Utc>,
#[cfg_attr(feature = "openapi", schema(example = "2026-05-25T10:15:30Z"))]
pub updated_at: DateTime<Utc>,
}
impl FileInfo {
pub fn name_from_path(path: &str) -> String {
if path == "/" {
"/".to_string()
} else {
path.rsplit('/').next().unwrap_or(path).to_string()
}
}
pub fn parent_path(path: &str) -> Option<String> {
if path == "/" {
None
} else {
let parent = path.rsplit_once('/').map(|(p, _)| p).unwrap_or("/");
Some(if parent.is_empty() { "/" } else { parent }.to_string())
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct SessionFile {
pub id: Uuid,
pub session_id: Uuid,
pub path: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(default = "default_encoding")]
pub encoding: String,
pub is_directory: bool,
pub is_readonly: bool,
pub size_bytes: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct InitialFile {
pub path: String,
pub content: String,
#[serde(default = "default_encoding")]
pub encoding: String,
#[serde(default)]
pub is_readonly: bool,
}
fn default_encoding() -> String {
"text".to_string()
}
impl SessionFile {
pub fn is_text_content(bytes: &[u8]) -> bool {
let check_len = bytes.len().min(8192);
!bytes[..check_len].contains(&0)
}
pub fn encode_content(bytes: &[u8]) -> (String, String) {
if Self::is_text_content(bytes) {
match String::from_utf8(bytes.to_vec()) {
Ok(text) => (text, "text".to_string()),
Err(_) => (BASE64.encode(bytes), "base64".to_string()),
}
} else {
(BASE64.encode(bytes), "base64".to_string())
}
}
pub fn decode_content(content: &str, encoding: &str) -> Result<Vec<u8>, base64::DecodeError> {
match encoding {
"base64" => BASE64.decode(content),
_ => Ok(content.as_bytes().to_vec()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct FileStat {
pub path: String,
pub name: String,
pub is_directory: bool,
pub is_readonly: bool,
pub size_bytes: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct GrepMatch {
pub path: String,
pub line_number: usize,
pub line: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct GrepResult {
pub path: String,
pub matches: Vec<GrepMatch>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_name_from_path() {
assert_eq!(FileInfo::name_from_path("/"), "/");
assert_eq!(FileInfo::name_from_path("/foo"), "foo");
assert_eq!(FileInfo::name_from_path("/foo/bar"), "bar");
assert_eq!(FileInfo::name_from_path("/foo/bar/baz.txt"), "baz.txt");
}
#[test]
fn test_parent_path() {
assert_eq!(FileInfo::parent_path("/"), None);
assert_eq!(FileInfo::parent_path("/foo"), Some("/".to_string()));
assert_eq!(FileInfo::parent_path("/foo/bar"), Some("/foo".to_string()));
assert_eq!(
FileInfo::parent_path("/foo/bar/baz"),
Some("/foo/bar".to_string())
);
}
#[test]
fn test_is_text_content() {
assert!(SessionFile::is_text_content(b"hello world"));
assert!(SessionFile::is_text_content(b"line1\nline2\n"));
assert!(!SessionFile::is_text_content(b"hello\0world"));
}
#[test]
fn test_encode_content_text() {
let (content, encoding) = SessionFile::encode_content(b"hello world");
assert_eq!(content, "hello world");
assert_eq!(encoding, "text");
}
#[test]
fn test_encode_content_binary() {
let binary = b"\x89PNG\r\n\x1a\n\0";
let (content, encoding) = SessionFile::encode_content(binary);
assert_eq!(encoding, "base64");
assert!(!content.is_empty());
}
#[test]
fn test_decode_content_text() {
let decoded = SessionFile::decode_content("hello world", "text").unwrap();
assert_eq!(decoded, b"hello world");
}
#[test]
fn test_decode_content_base64() {
let decoded = SessionFile::decode_content("aGVsbG8=", "base64").unwrap();
assert_eq!(decoded, b"hello");
}
#[test]
fn test_encode_decode_roundtrip() {
let original = b"Test content with special chars: \xc3\xa9\xc3\xa0";
let (encoded, encoding) = SessionFile::encode_content(original);
let decoded = SessionFile::decode_content(&encoded, &encoding).unwrap();
assert_eq!(decoded, original);
}
#[test]
fn test_file_info_serialization() {
let file_info = FileInfo {
id: Uuid::nil(),
session_id: Uuid::nil(),
path: "/test.txt".to_string(),
name: "test.txt".to_string(),
is_directory: false,
is_readonly: false,
size_bytes: 100,
created_at: DateTime::default(),
updated_at: DateTime::default(),
};
let json = serde_json::to_string(&file_info).unwrap();
assert!(json.contains("\"path\":\"/test.txt\""));
assert!(json.contains("\"is_directory\":false"));
}
#[test]
fn test_grep_result_serialization() {
let result = GrepResult {
path: "/test.txt".to_string(),
matches: vec![GrepMatch {
path: "/test.txt".to_string(),
line_number: 1,
line: "hello world".to_string(),
}],
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"line_number\":1"));
assert!(json.contains("\"line\":\"hello world\""));
}
}