Skip to main content

hojicha_core/async_helpers/
file_io.rs

1//! File I/O helper commands
2
3use crate::commands;
4use crate::core::{Cmd, Message};
5use std::path::{Path, PathBuf};
6
7/// File operation errors
8#[derive(Debug, Clone)]
9pub enum FileError {
10    /// File not found
11    NotFound(PathBuf),
12    /// Permission denied
13    PermissionDenied(PathBuf),
14    /// I/O error
15    IoError(String),
16    /// UTF-8 decoding error
17    Utf8Error(String),
18}
19
20impl std::fmt::Display for FileError {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        match self {
23            Self::NotFound(path) => write!(f, "File not found: {}", path.display()),
24            Self::PermissionDenied(path) => write!(f, "Permission denied: {}", path.display()),
25            Self::IoError(e) => write!(f, "I/O error: {e}"),
26            Self::Utf8Error(e) => write!(f, "UTF-8 error: {e}"),
27        }
28    }
29}
30
31impl std::error::Error for FileError {}
32
33/// File change event for file watching
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum FileEvent {
36    /// File was created
37    Created(PathBuf),
38    /// File was modified
39    Modified(PathBuf),
40    /// File was deleted
41    Deleted(PathBuf),
42    /// File was renamed
43    Renamed {
44        /// Original file path
45        from: PathBuf,
46        /// New file path
47        to: PathBuf,
48    },
49}
50
51/// Read a file asynchronously
52///
53/// # Example
54/// ```no_run
55/// # use hojicha_core::async_helpers::read_file;
56/// # #[derive(Clone)]
57/// # enum Msg {
58/// #     FileLoaded(String),
59/// #     Error(String),
60/// # }
61///
62/// read_file("config.json", |result| {
63///     match result {
64///         Ok(content) => Msg::FileLoaded(content),
65///         Err(e) => Msg::Error(e.to_string()),
66///     }
67/// })
68/// # ;
69/// ```
70pub fn read_file<M, F, P>(path: P, handler: F) -> Cmd<M>
71where
72    M: Message,
73    F: FnOnce(Result<String, FileError>) -> M + Send + 'static,
74    P: AsRef<Path> + Send + 'static,
75{
76    let path = path.as_ref().to_path_buf();
77
78    commands::spawn(async move {
79        let result = tokio::fs::read_to_string(&path).await.map_err(|e| {
80            if e.kind() == std::io::ErrorKind::NotFound {
81                FileError::NotFound(path.clone())
82            } else if e.kind() == std::io::ErrorKind::PermissionDenied {
83                FileError::PermissionDenied(path.clone())
84            } else {
85                FileError::IoError(e.to_string())
86            }
87        });
88
89        Some(handler(result))
90    })
91}
92
93/// Read a file as bytes
94pub fn read_file_bytes<M, F, P>(path: P, handler: F) -> Cmd<M>
95where
96    M: Message,
97    F: FnOnce(Result<Vec<u8>, FileError>) -> M + Send + 'static,
98    P: AsRef<Path> + Send + 'static,
99{
100    let path = path.as_ref().to_path_buf();
101
102    commands::spawn(async move {
103        let result = tokio::fs::read(&path).await.map_err(|e| {
104            if e.kind() == std::io::ErrorKind::NotFound {
105                FileError::NotFound(path.clone())
106            } else if e.kind() == std::io::ErrorKind::PermissionDenied {
107                FileError::PermissionDenied(path.clone())
108            } else {
109                FileError::IoError(e.to_string())
110            }
111        });
112
113        Some(handler(result))
114    })
115}
116
117/// Write to a file asynchronously
118///
119/// # Example
120/// ```no_run
121/// # use hojicha_core::async_helpers::write_file;
122/// # #[derive(Clone)]
123/// # enum Msg {
124/// #     FileSaved,
125/// #     Error(String),
126/// # }
127///
128/// write_file("output.txt", "Hello, World!", |result| {
129///     match result {
130///         Ok(()) => Msg::FileSaved,
131///         Err(e) => Msg::Error(e.to_string()),
132///     }
133/// })
134/// # ;
135/// ```
136pub fn write_file<M, F, P, C>(path: P, content: C, handler: F) -> Cmd<M>
137where
138    M: Message,
139    F: FnOnce(Result<(), FileError>) -> M + Send + 'static,
140    P: AsRef<Path> + Send + 'static,
141    C: AsRef<[u8]> + Send + 'static,
142{
143    let path = path.as_ref().to_path_buf();
144    let content = content.as_ref().to_vec();
145
146    commands::spawn(async move {
147        let result = tokio::fs::write(&path, content).await.map_err(|e| {
148            if e.kind() == std::io::ErrorKind::PermissionDenied {
149                FileError::PermissionDenied(path.clone())
150            } else {
151                FileError::IoError(e.to_string())
152            }
153        });
154
155        Some(handler(result))
156    })
157}
158
159/// Watch a file for changes
160///
161/// This creates a file watcher that will send events when the file changes.
162/// Note: This is a simplified implementation. A real implementation would use
163/// notify or similar crate for actual file system watching.
164///
165/// # Example
166/// ```no_run
167/// # use hojicha_core::async_helpers::{watch_file, FileEvent};
168/// # #[derive(Clone)]
169/// # enum Msg {
170/// #     FileChanged(String),
171/// # }
172///
173/// watch_file("config.json", |event| {
174///     match event {
175///         FileEvent::Modified(path) => {
176///             Some(Msg::FileChanged(path.display().to_string()))
177///         }
178///         _ => None,
179///     }
180/// })
181/// # ;
182/// ```
183pub fn watch_file<M, F, P>(path: P, mut handler: F) -> Cmd<M>
184where
185    M: Message,
186    F: FnMut(FileEvent) -> Option<M> + Send + 'static,
187    P: AsRef<Path> + Send + 'static,
188{
189    let path = path.as_ref().to_path_buf();
190
191    commands::spawn(async move {
192        // In a real implementation, this would use notify or similar
193        // For now, we'll simulate a file change after a delay
194        tokio::time::sleep(std::time::Duration::from_secs(2)).await;
195        handler(FileEvent::Modified(path))
196    })
197}
198
199/// List files in a directory
200pub fn list_dir<M, F, P>(path: P, handler: F) -> Cmd<M>
201where
202    M: Message,
203    F: FnOnce(Result<Vec<PathBuf>, FileError>) -> M + Send + 'static,
204    P: AsRef<Path> + Send + 'static,
205{
206    let path = path.as_ref().to_path_buf();
207
208    commands::spawn(async move {
209        let result = async {
210            let mut entries = Vec::new();
211            let mut dir = tokio::fs::read_dir(&path).await.map_err(|e| {
212                if e.kind() == std::io::ErrorKind::NotFound {
213                    FileError::NotFound(path.clone())
214                } else if e.kind() == std::io::ErrorKind::PermissionDenied {
215                    FileError::PermissionDenied(path.clone())
216                } else {
217                    FileError::IoError(e.to_string())
218                }
219            })?;
220
221            while let Some(entry) = dir
222                .next_entry()
223                .await
224                .map_err(|e| FileError::IoError(e.to_string()))?
225            {
226                entries.push(entry.path());
227            }
228
229            Ok(entries)
230        }
231        .await;
232
233        Some(handler(result))
234    })
235}
236
237/// Create a directory
238pub fn create_dir<M, F, P>(path: P, handler: F) -> Cmd<M>
239where
240    M: Message,
241    F: FnOnce(Result<(), FileError>) -> M + Send + 'static,
242    P: AsRef<Path> + Send + 'static,
243{
244    let path = path.as_ref().to_path_buf();
245
246    commands::spawn(async move {
247        let result = tokio::fs::create_dir_all(&path).await.map_err(|e| {
248            if e.kind() == std::io::ErrorKind::PermissionDenied {
249                FileError::PermissionDenied(path.clone())
250            } else {
251                FileError::IoError(e.to_string())
252            }
253        });
254
255        Some(handler(result))
256    })
257}
258
259/// Delete a file or directory
260pub fn delete<M, F, P>(path: P, handler: F) -> Cmd<M>
261where
262    M: Message,
263    F: FnOnce(Result<(), FileError>) -> M + Send + 'static,
264    P: AsRef<Path> + Send + 'static,
265{
266    let path = path.as_ref().to_path_buf();
267
268    commands::spawn(async move {
269        let result = if path.is_dir() {
270            tokio::fs::remove_dir_all(&path).await
271        } else {
272            tokio::fs::remove_file(&path).await
273        }
274        .map_err(|e| {
275            if e.kind() == std::io::ErrorKind::NotFound {
276                FileError::NotFound(path.clone())
277            } else if e.kind() == std::io::ErrorKind::PermissionDenied {
278                FileError::PermissionDenied(path.clone())
279            } else {
280                FileError::IoError(e.to_string())
281            }
282        });
283
284        Some(handler(result))
285    })
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use proptest::prelude::*;
292
293    proptest! {
294        #[test]
295        fn prop_file_error_consistency(error_msg in ".*") {
296            let file_error = FileError::IoError(error_msg.clone());
297            let error_string = file_error.to_string();
298            prop_assert!(error_string.contains(&error_msg));
299        }
300    }
301}