use std::io::Write; use std::path::PathBuf; use std::time::UNIX_EPOCH; use serde::Serialize; const MAX_READ_BYTES: u64 = 10 * 1024 * 1024; // 10 MB const BINARY_SNIFF_BYTES: usize = 8 * 1024; #[derive(Serialize)] #[serde(tag = "kind", rename_all = "lowercase")] pub enum ReadResult { Text { content: String, size: u64, }, Binary { size: u64, }, /// File exceeds MAX_READ_BYTES. UI decides whether to offer "open anyway". TooLarge { size: u64, limit: u64, }, } #[derive(Serialize)] #[serde(rename_all = "lowercase")] pub enum StatKind { File, Dir, Symlink, } #[derive(Serialize)] pub struct FileStat { pub size: u64, pub mtime: u64, pub kind: StatKind, } #[tauri::command] pub fn fs_read_file(path: String) -> Result { let p = PathBuf::from(&path); let meta = std::fs::metadata(&p).map_err(|e| { log::debug!("fs_read_file stat({}) failed: {e}", p.display()); e.to_string() })?; let size = meta.len(); if size > MAX_READ_BYTES { return Ok(ReadResult::TooLarge { size, limit: MAX_READ_BYTES, }); } let bytes = std::fs::read(&p).map_err(|e| { log::debug!("fs_read_file read({}) failed: {e}", p.display()); e.to_string() })?; // Null-byte sniff on the first chunk. Not perfect (misses UTF-16 BOM // cases) but catches the common "this is a PNG" mistake cheaply. let sniff_len = bytes.len().min(BINARY_SNIFF_BYTES); if bytes[..sniff_len].contains(&0) { return Ok(ReadResult::Binary { size }); } match String::from_utf8(bytes) { Ok(content) => Ok(ReadResult::Text { content, size }), Err(_) => Ok(ReadResult::Binary { size }), } } /// Atomic write: stage into a sibling temp file, then rename over the target. /// Prevents partial writes from leaving a half-saved file on crash/power loss. #[tauri::command] pub fn fs_write_file(path: String, content: String) -> Result<(), String> { let target = PathBuf::from(&path); let parent = target .parent() .ok_or_else(|| "path has no parent".to_string())?; let file_name = target .file_name() .and_then(|s| s.to_str()) .ok_or_else(|| "path has no file name".to_string())?; let tmp = parent.join(format!(".{file_name}.terax.tmp")); { let mut f = std::fs::File::create(&tmp).map_err(|e| { log::debug!("fs_write_file create({}) failed: {e}", tmp.display()); e.to_string() })?; f.write_all(content.as_bytes()).map_err(|e| { log::debug!("fs_write_file write({}) failed: {e}", tmp.display()); e.to_string() })?; f.sync_all().map_err(|e| e.to_string())?; } std::fs::rename(&tmp, &target).map_err(|e| { log::warn!( "fs_write_file rename({} -> {}) failed: {e}", tmp.display(), target.display() ); // Best-effort cleanup of the staged temp. let _ = std::fs::remove_file(&tmp); e.to_string() })?; Ok(()) } #[tauri::command] pub fn fs_stat(path: String) -> Result { let p = PathBuf::from(&path); let meta = std::fs::metadata(&p).map_err(|e| e.to_string())?; let kind = if meta.is_dir() { StatKind::Dir } else if meta.file_type().is_symlink() { StatKind::Symlink } else { StatKind::File }; let mtime = meta .modified() .ok() .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) .map(|d| d.as_millis() as u64) .unwrap_or(0); Ok(FileStat { size: meta.len(), mtime, kind, }) }