ri/fs/mod.rs
1//! Copyright © 2025-2026 Wenze Wei. All Rights Reserved.
2//!
3//! This file is part of Ri.
4//! The Ri project belongs to the Dunimd Team.
5//!
6//! Licensed under the Apache License, Version 2.0 (the "License");
7//! You may not use this file except in compliance with the License.
8//! You may obtain a copy of the License at
9//!
10//! http://www.apache.org/licenses/LICENSE-2.0
11//!
12//! Unless required by applicable law or agreed to in writing, software
13//! distributed under the License is distributed on an "AS IS" BASIS,
14//! WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15//! See the License for the specific language governing permissions and
16//! limitations under the License.
17
18#![allow(non_snake_case)]
19
20//! # File System Module
21//!
22//! This module provides a comprehensive file system abstraction for Ri, offering safe and reliable
23//! file operations with support for atomic writes, directory management, and structured data formats.
24//!
25//! ## Key Components
26//!
27//! - **RiFileSystem**: Public-facing file system class
28//! - **FileSystemImpl**: Internal file system implementation
29//!
30//! ## Design Principles
31//!
32//! 1. **Safe Operations**: All file operations are designed to be safe and reliable
33//! 2. **Atomic Writes**: Uses atomic write operations to prevent data corruption
34//! 3. **Directory Management**: Automatically creates necessary directories
35//! 4. **Structured Data Support**: Built-in support for JSON serialization and deserialization
36//! 5. **Category-Based Organization**: Organizes files into categories (logs, cache, reports, etc.)
37//! 6. **Error Handling**: Provides comprehensive error handling for all file operations
38//! 7. **Cloneable**: Designed to be easily cloned for use across different components
39//!
40//! ## Usage
41//!
42//! ```rust
43//! use ri::prelude::*;
44//! use std::path::PathBuf;
45//!
46//! fn example() -> RiResult<()> {
47//! // Create a file system with a project root
48//! let project_root = PathBuf::from(".");
49//! let fs = RiFileSystem::new_with_root(project_root);
50//!
51//! // Write text to a file
52//! fs.atomic_write_text("example.txt", "Hello, Ri!")?;
53//!
54//! // Read text from a file
55//! let content = fs.read_text("example.txt")?;
56//! println!("File content: {}", content);
57//!
58//! // Write JSON to a file
59//! let data = json!({"key": "value"});
60//! fs.write_json("example.json", &data)?;
61//!
62//! // Read JSON from a file
63//! let read_data: serde_json::Value = fs.read_json("example.json")?;
64//! println!("JSON data: {:?}", read_data);
65//!
66//! // Get category directories
67//! let logs_dir = fs.logs_dir();
68//! println!("Logs directory: {:?}", logs_dir);
69//!
70//! Ok(())
71//! }
72//! ```
73
74use std::fs;
75use std::io::{Read, Write};
76use std::path::{Path, PathBuf};
77use std::fs::OpenOptions;
78use std::time::SystemTime;
79
80use crate::core::RiResult;
81use serde::de::DeserializeOwned;
82use serde::Serialize;
83
84/// Internal filesystem implementation.
85///
86/// This struct provides the internal implementation of the file system functionality, including
87/// directory management, file operations, and category-based organization.
88#[derive(Clone)]
89struct FileSystemImpl {
90 /// Project root directory
91 project_root: PathBuf,
92 /// Application data root directory
93 app_data_root: PathBuf,
94}
95
96impl FileSystemImpl {
97 /// Creates a new internal file system implementation with specified roots.
98 ///
99 /// # Parameters
100 ///
101 /// - `project_root`: The project root directory
102 /// - `app_data_root`: The application data root directory
103 ///
104 /// # Returns
105 ///
106 /// A new `FileSystemImpl` instance
107 fn new_with_roots(project_root: PathBuf, app_data_root: PathBuf) -> Self {
108 FileSystemImpl { project_root, app_data_root }
109 }
110
111 /// Creates a new internal file system implementation with a project root and default app data root.
112 ///
113 /// The default app data root is created under the project root at `.dms`.
114 ///
115 /// # Parameters
116 ///
117 /// - `project_root`: The project root directory
118 ///
119 /// # Returns
120 ///
121 /// A new `FileSystemImpl` instance
122 fn new_with_root(project_root: PathBuf) -> Self {
123 // Default app data root under project root; can be overridden by core/config.
124 let app_data_root = project_root.join(".dms");
125 FileSystemImpl::new_with_roots(project_root, app_data_root)
126 }
127
128 /// Validates that a path is safe and within the allowed directories.
129 ///
130 /// This method prevents path traversal attacks by ensuring that the resolved
131 /// path is within the project root or app data root.
132 ///
133 /// # Parameters
134 ///
135 /// - `path`: The path to validate
136 ///
137 /// # Returns
138 ///
139 /// A `RiResult<PathBuf>` containing the canonicalized path if valid
140 fn validate_path(&self, path: &Path) -> RiResult<PathBuf> {
141 // Security: Check for symlink attacks
142 // 1. First canonicalize the allowed directories
143 let canonical_project = self.project_root.canonicalize()
144 .map_err(|e| crate::core::RiError::Other(format!("Project root canonicalization failed: {e}")))?;
145
146 let canonical_app_data = self.app_data_root.canonicalize()
147 .map_err(|e| crate::core::RiError::Other(format!("App data root canonicalization failed: {e}")))?;
148
149 // 2. For the target path, handle non-existent paths safely
150 let canonical_path = if path.exists() {
151 // Path exists - canonicalize it (this resolves symlinks)
152 path.canonicalize()
153 .map_err(|e| crate::core::RiError::Other(format!("Path canonicalization failed: {e}")))?
154 } else {
155 // Path doesn't exist - check parent directory for symlinks
156 let parent = path.parent().ok_or_else(|| {
157 crate::core::RiError::Other("Invalid path: no parent directory".to_string())
158 })?;
159
160 if parent.exists() {
161 // Canonicalize parent to resolve any symlinks
162 let canonical_parent = parent.canonicalize()
163 .map_err(|e| crate::core::RiError::Other(format!("Parent canonicalization failed: {e}")))?;
164
165 // Reconstruct the path with canonicalized parent
166 let file_name = path.file_name().ok_or_else(|| {
167 crate::core::RiError::Other("Invalid path: no file name".to_string())
168 })?;
169
170 canonical_parent.join(file_name)
171 } else {
172 // Parent doesn't exist - this is an error for security
173 return Err(crate::core::RiError::Other(
174 "Path validation failed: parent directory does not exist".to_string()
175 ));
176 }
177 };
178
179 // 3. Check if the canonical path is within allowed directories
180 if !canonical_path.starts_with(&canonical_project) &&
181 !canonical_path.starts_with(&canonical_app_data) {
182 return Err(crate::core::RiError::Other(
183 "Path traversal detected: path is outside allowed directories".to_string()
184 ));
185 }
186
187 // 4. Additional symlink check: ensure no symlink components in the path
188 // This prevents TOCTOU attacks where a symlink is created after validation
189 let mut current = canonical_path.clone();
190 while let Some(parent) = current.parent() {
191 if parent.join(current.file_name().unwrap_or_default()).exists() {
192 let metadata = std::fs::symlink_metadata(¤t)
193 .map_err(|e| crate::core::RiError::Other(format!("Symlink metadata check failed: {e}")))?;
194
195 if metadata.file_type().is_symlink() {
196 // Symlink found - verify it points to allowed directory
197 let symlink_target = std::fs::read_link(¤t)
198 .map_err(|e| crate::core::RiError::Other(format!("Failed to read symlink: {e}")))?;
199
200 let canonical_target = symlink_target.canonicalize()
201 .map_err(|e| crate::core::RiError::Other(format!("Symlink target canonicalization failed: {e}")))?;
202
203 if !canonical_target.starts_with(&canonical_project) &&
204 !canonical_target.starts_with(&canonical_app_data) {
205 return Err(crate::core::RiError::Other(
206 "Symlink points outside allowed directories".to_string()
207 ));
208 }
209 }
210 }
211 current = parent.to_path_buf();
212 }
213
214 Ok(canonical_path)
215 }
216
217 /// Safely resolves and validates a path relative to the project root.
218 ///
219 /// # Parameters
220 ///
221 /// - `path`: The relative or absolute path to resolve
222 ///
223 /// # Returns
224 ///
225 /// A `RiResult<PathBuf>` containing the resolved and validated path
226 fn resolve_and_validate_path(&self, path: &Path) -> RiResult<PathBuf> {
227 let resolved = if path.is_absolute() {
228 path.to_path_buf()
229 } else {
230 self.project_root.join(path)
231 };
232
233 self.validate_path(&resolved)
234 }
235
236 /// Returns the project root directory.
237 ///
238 /// # Returns
239 ///
240 /// A reference to the project root path
241 fn project_root(&self) -> &Path {
242 &self.project_root
243 }
244
245 /// Safely creates a directory and all its parent directories if they don't exist.
246 ///
247 /// # Parameters
248 ///
249 /// - `path`: The path to the directory to create
250 ///
251 /// # Returns
252 ///
253 /// A `RiResult<PathBuf>` containing the created directory path
254 fn safe_mkdir(&self, path: &Path) -> RiResult<PathBuf> {
255 fs::create_dir_all(path).map_err(|e| crate::core::RiError::Other(format!("safe_mkdir failed: {e}")))?;
256 Ok(path.to_path_buf())
257 }
258
259 /// Ensures that the parent directory of a given path exists.
260 ///
261 /// # Parameters
262 ///
263 /// - `path`: The path whose parent directory should be ensured
264 ///
265 /// # Returns
266 ///
267 /// A `RiResult<PathBuf>` containing the parent directory path
268 fn ensure_parent_dir(&self, path: &Path) -> RiResult<PathBuf> {
269 if let Some(parent) = path.parent() {
270 self.safe_mkdir(parent)
271 } else {
272 Ok(self.project_root.clone())
273 }
274 }
275
276 /// Atomically writes text to a file.
277 ///
278 /// This method writes to a temporary file first, then renames it to the target path,
279 /// ensuring that the write operation is atomic.
280 ///
281 /// # Parameters
282 ///
283 /// - `path`: The path to the file to write
284 /// - `text`: The text to write to the file
285 ///
286 /// # Returns
287 ///
288 /// A `RiResult<()>` indicating success or failure
289 fn atomic_write_text(&self, path: &Path, text: &str) -> RiResult<()> {
290 let validated_path = self.resolve_and_validate_path(path)?;
291 self.ensure_parent_dir(&validated_path)?;
292 let dir = validated_path.parent().unwrap_or_else(|| Path::new("."));
293 let ts = SystemTime::now()
294 .duration_since(SystemTime::UNIX_EPOCH)
295 .map_err(|e| crate::core::RiError::Other(format!("timestamp error: {e}")))?;
296 let tmp_name = format!(".tmp_{}_{}", ts.as_millis(), validated_path.file_name().and_then(|s| s.to_str()).unwrap_or("tmp"));
297 let tmp_path = dir.join(tmp_name);
298
299 {
300 let mut file = OpenOptions::new()
301 .write(true)
302 .create(true)
303 .truncate(true)
304 .open(&tmp_path)
305 .map_err(|e| crate::core::RiError::Other(format!("atomic_write_text open tmp failed: {e}")))?;
306 file.write_all(text.as_bytes())
307 .map_err(|e| crate::core::RiError::Other(format!("atomic_write_text write failed: {e}")))?;
308 file.sync_all()
309 .map_err(|e| crate::core::RiError::Other(format!("atomic_write_text sync failed: {e}")))?;
310 }
311
312 fs::rename(&tmp_path, &validated_path)
313 .map_err(|e| crate::core::RiError::Other(format!("atomic_write_text rename failed: {e}")))?;
314
315 Ok(())
316 }
317
318 /// Atomically writes bytes to a file.
319 ///
320 /// This method writes to a temporary file first, then renames it to the target path,
321 /// ensuring that the write operation is atomic.
322 ///
323 /// # Parameters
324 ///
325 /// - `path`: The path to the file to write
326 /// - `data`: The bytes to write to the file
327 ///
328 /// # Returns
329 ///
330 /// A `RiResult<()>` indicating success or failure
331 fn atomic_write_bytes(&self, path: &Path, data: &[u8]) -> RiResult<()> {
332 let validated_path = self.resolve_and_validate_path(path)?;
333 self.ensure_parent_dir(&validated_path)?;
334 let dir = validated_path.parent().unwrap_or_else(|| Path::new("."));
335 let ts = SystemTime::now()
336 .duration_since(SystemTime::UNIX_EPOCH)
337 .map_err(|e| crate::core::RiError::Other(format!("timestamp error: {e}")))?;
338 let tmp_name = format!(".tmp_{}_{}", ts.as_millis(), validated_path.file_name().and_then(|s| s.to_str()).unwrap_or("tmp"));
339 let tmp_path = dir.join(tmp_name);
340
341 {
342 let mut file = OpenOptions::new()
343 .write(true)
344 .create(true)
345 .truncate(true)
346 .open(&tmp_path)
347 .map_err(|e| crate::core::RiError::Other(format!("atomic_write_bytes open tmp failed: {e}")))?;
348 file.write_all(data)
349 .map_err(|e| crate::core::RiError::Other(format!("atomic_write_bytes write failed: {e}")))?;
350 file.sync_all()
351 .map_err(|e| crate::core::RiError::Other(format!("atomic_write_bytes sync failed: {e}")))?;
352 }
353
354 fs::rename(&tmp_path, &validated_path)
355 .map_err(|e| crate::core::RiError::Other(format!("atomic_write_bytes rename failed: {e}")))?;
356
357 Ok(())
358 }
359
360 /// Reads text from a file.
361 ///
362 /// # Parameters
363 ///
364 /// - `path`: The path to the file to read
365 ///
366 /// # Returns
367 ///
368 /// A `RiResult<String>` containing the file content
369 fn read_text(&self, path: &Path) -> RiResult<String> {
370 let validated_path = self.resolve_and_validate_path(path)?;
371 let mut file = OpenOptions::new()
372 .read(true)
373 .open(&validated_path)
374 .map_err(|e| crate::core::RiError::Other(format!("read_text open failed: {e}")))?;
375 let mut buf = String::new();
376 file.read_to_string(&mut buf)
377 .map_err(|e| crate::core::RiError::Other(format!("read_text read failed: {e}")))?;
378 Ok(buf)
379 }
380
381 /// Returns the application data directory.
382 ///
383 /// Ensures the directory exists before returning it.
384 ///
385 /// # Returns
386 ///
387 /// The application data directory path
388 fn app_dir(&self) -> PathBuf {
389 let _ = fs::create_dir_all(&self.app_data_root);
390 self.app_data_root.clone()
391 }
392
393 /// Returns a category-specific directory.
394 ///
395 /// Ensures the directory exists before returning it.
396 ///
397 /// # Parameters
398 ///
399 /// - `name`: The name of the category
400 ///
401 /// # Returns
402 ///
403 /// The category directory path
404 fn category_dir(&self, name: &str) -> PathBuf {
405 let dir = self.app_dir().join(name);
406 let _ = fs::create_dir_all(&dir);
407 dir
408 }
409}
410
411/// Public-facing filesystem class.
412///
413/// This struct provides a comprehensive file system abstraction for Ri, offering safe and reliable
414/// file operations with support for atomic writes, directory management, and structured data formats.
415#[cfg_attr(feature = "pyo3", pyo3::prelude::pyclass)]
416#[derive(Clone)]
417pub struct RiFileSystem {
418 /// Internal file system implementation
419 inner: FileSystemImpl,
420}
421
422impl RiFileSystem {
423 /// Creates a new file system with a project root and default app data root.
424 ///
425 /// # Parameters
426 ///
427 /// - `project_root`: The project root directory
428 ///
429 /// # Returns
430 ///
431 /// A new `RiFileSystem` instance
432 pub fn new_with_root(project_root: PathBuf) -> Self {
433 let inner = FileSystemImpl::new_with_root(project_root);
434 RiFileSystem { inner }
435 }
436
437 /// Creates a new file system with specified roots.
438 ///
439 /// # Parameters
440 ///
441 /// - `project_root`: The project root directory
442 /// - `app_data_root`: The application data root directory
443 ///
444 /// # Returns
445 ///
446 /// A new `RiFileSystem` instance
447 pub fn new_with_roots(project_root: PathBuf, app_data_root: PathBuf) -> Self {
448 let inner = FileSystemImpl::new_with_roots(project_root, app_data_root);
449 RiFileSystem { inner }
450 }
451
452 /// Creates a new file system with the current working directory as the project root.
453 ///
454 /// # Returns
455 ///
456 /// A `RiResult<Self>` containing the new `RiFileSystem` instance
457 pub fn new_auto_root() -> RiResult<Self> {
458 let cwd = std::env::current_dir()
459 .map_err(|e| crate::core::RiError::Other(format!("detect project root failed: {e}")))?;
460 Ok(Self::new_with_root(cwd))
461 }
462
463 /// Returns the project root directory.
464 ///
465 /// # Returns
466 ///
467 /// A reference to the project root path
468 pub fn project_root(&self) -> &Path {
469 self.inner.project_root()
470 }
471
472 /// Safely creates a directory and all its parent directories if they don't exist.
473 ///
474 /// # Parameters
475 ///
476 /// - `path`: The path to the directory to create
477 ///
478 /// # Returns
479 ///
480 /// A `RiResult<PathBuf>` containing the created directory path
481 pub fn safe_mkdir<P: AsRef<Path>>(&self, path: P) -> RiResult<PathBuf> {
482 self.inner.safe_mkdir(path.as_ref())
483 }
484
485 /// Ensures that the parent directory of a given path exists.
486 ///
487 /// # Parameters
488 ///
489 /// - `path`: The path whose parent directory should be ensured
490 ///
491 /// # Returns
492 ///
493 /// A `RiResult<PathBuf>` containing the parent directory path
494 pub fn ensure_parent_dir<P: AsRef<Path>>(&self, path: P) -> RiResult<PathBuf> {
495 self.inner.ensure_parent_dir(path.as_ref())
496 }
497
498 /// Atomically writes text to a file.
499 ///
500 /// This method writes to a temporary file first, then renames it to the target path,
501 /// ensuring that the write operation is atomic.
502 ///
503 /// # Parameters
504 ///
505 /// - `path`: The path to the file to write
506 /// - `text`: The text to write to the file
507 ///
508 /// # Returns
509 ///
510 /// A `RiResult<()>` indicating success or failure
511 pub fn atomic_write_text<P: AsRef<Path>>(&self, path: P, text: &str) -> RiResult<()> {
512 self.inner.atomic_write_text(path.as_ref(), text)
513 }
514
515 /// Atomically writes bytes to a file.
516 ///
517 /// This method writes to a temporary file first, then renames it to the target path,
518 /// ensuring that the write operation is atomic.
519 ///
520 /// # Parameters
521 ///
522 /// - `path`: The path to the file to write
523 /// - `data`: The bytes to write to the file
524 ///
525 /// # Returns
526 ///
527 /// A `RiResult<()>` indicating success or failure
528 pub fn atomic_write_bytes<P: AsRef<Path>>(&self, path: P, data: &[u8]) -> RiResult<()> {
529 self.inner.atomic_write_bytes(path.as_ref(), data)
530 }
531
532 /// Reads text from a file.
533 ///
534 /// # Parameters
535 ///
536 /// - `path`: The path to the file to read
537 ///
538 /// # Returns
539 ///
540 /// A `RiResult<String>` containing the file content
541 pub fn read_text<P: AsRef<Path>>(&self, path: P) -> RiResult<String> {
542 self.inner.read_text(path.as_ref())
543 }
544
545 /// Reads JSON from a file and deserializes it into a type.
546 ///
547 /// # Parameters
548 ///
549 /// - `path`: The path to the JSON file to read
550 ///
551 /// # Type Parameters
552 ///
553 /// - `T`: The type to deserialize the JSON into
554 ///
555 /// # Returns
556 ///
557 /// A `RiResult<T>` containing the deserialized data
558 pub fn read_json<P: AsRef<Path>, T: DeserializeOwned>(&self, path: P) -> RiResult<T> {
559 let text = self.read_text(path)?;
560 serde_json::from_str(&text)
561 .map_err(|e| crate::core::RiError::Other(format!("json read failed: {e}")))
562 }
563
564 /// Checks if a file or directory exists.
565 ///
566 /// # Parameters
567 ///
568 /// - `path`: The path to check
569 ///
570 /// # Returns
571 ///
572 /// `true` if the path exists, `false` otherwise
573 pub fn exists<P: AsRef<Path>>(&self, path: P) -> bool {
574 path.as_ref().exists()
575 }
576
577 /// Removes a file.
578 ///
579 /// # Security
580 ///
581 /// This method validates the path to prevent path traversal attacks.
582 ///
583 /// # Parameters
584 ///
585 /// - `path`: The path to the file to remove
586 ///
587 /// # Returns
588 ///
589 /// A `RiResult<()>` indicating success or failure
590 pub fn remove_file<P: AsRef<Path>>(&self, path: P) -> RiResult<()> {
591 let validated_path = self.inner.resolve_and_validate_path(path.as_ref())?;
592 match fs::remove_file(&validated_path) {
593 Ok(()) => Ok(()),
594 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
595 Err(e) => Err(crate::core::RiError::Other(format!("remove_file failed: {e}"))),
596 }
597 }
598
599 /// Removes a directory and all its contents.
600 ///
601 /// # Security
602 ///
603 /// This method validates the path to prevent path traversal attacks.
604 ///
605 /// # Parameters
606 ///
607 /// - `path`: The path to the directory to remove
608 ///
609 /// # Returns
610 ///
611 /// A `RiResult<()>` indicating success or failure
612 pub fn remove_dir_all<P: AsRef<Path>>(&self, path: P) -> RiResult<()> {
613 let validated_path = self.inner.resolve_and_validate_path(path.as_ref())?;
614 match fs::remove_dir_all(&validated_path) {
615 Ok(()) => Ok(()),
616 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
617 Err(e) => Err(crate::core::RiError::Other(format!("remove_dir_all failed: {e}"))),
618 }
619 }
620
621 /// Copies a file from one path to another.
622 ///
623 /// # Security
624 ///
625 /// This method validates both source and destination paths to prevent
626 /// path traversal attacks.
627 ///
628 /// # Parameters
629 ///
630 /// - `from`: The source file path
631 /// - `to`: The destination file path
632 ///
633 /// # Returns
634 ///
635 /// A `RiResult<()>` indicating success or failure
636 pub fn copy_file<P: AsRef<Path>, Q: AsRef<Path>>(&self, from: P, to: Q) -> RiResult<()> {
637 let src = self.inner.resolve_and_validate_path(from.as_ref())?;
638 let dst = self.inner.resolve_and_validate_path(to.as_ref())?;
639
640 if let Some(parent) = dst.parent() {
641 self.safe_mkdir(parent)?;
642 }
643 fs::copy(&src, &dst)
644 .map_err(|e| crate::core::RiError::Other(format!("copy_file failed: {e}")))?;
645 Ok(())
646 }
647
648 /// Appends text to a file.
649 ///
650 /// # Security
651 ///
652 /// This method validates the path to prevent path traversal attacks.
653 ///
654 /// # Parameters
655 ///
656 /// - `path`: The path to the file to append to
657 /// - `text`: The text to append to the file
658 ///
659 /// # Returns
660 ///
661 /// A `RiResult<()>` indicating success or failure
662 pub fn append_text<P: AsRef<Path>>(&self, path: P, text: &str) -> RiResult<()> {
663 use std::io::Write as _;
664
665 let validated_path = self.inner.resolve_and_validate_path(path.as_ref())?;
666 self.ensure_parent_dir(&validated_path)?;
667 let mut file = OpenOptions::new()
668 .create(true)
669 .append(true)
670 .open(&validated_path)
671 .map_err(|e| crate::core::RiError::Other(format!("append_text open failed: {e}")))?;
672 file.write_all(text.as_bytes())
673 .map_err(|e| crate::core::RiError::Other(format!("append_text write failed: {e}")))?;
674 file.flush()
675 .map_err(|e| crate::core::RiError::Other(format!("append_text flush failed: {e}")))?;
676 Ok(())
677 }
678
679 /// Writes a JSON value to a file.
680 ///
681 /// # Parameters
682 ///
683 /// - `path`: The path to the file to write
684 /// - `value`: The value to serialize and write
685 ///
686 /// # Type Parameters
687 ///
688 /// - `T`: The type of the value to serialize
689 ///
690 /// # Returns
691 ///
692 /// A `RiResult<()>` indicating success or failure
693 pub fn write_json<P: AsRef<Path>, T: Serialize>(&self, path: P, value: &T) -> RiResult<()> {
694 let text = serde_json::to_string_pretty(value)
695 .map_err(|e| crate::core::RiError::Other(format!("json serialize failed: {e}")))?;
696 self.atomic_write_text(path, &text)
697 }
698
699 /// Returns the application data directory.
700 ///
701 /// # Returns
702 ///
703 /// The application data directory path
704 pub fn app_dir(&self) -> PathBuf {
705 self.inner.app_dir()
706 }
707
708 /// Returns the logs directory.
709 ///
710 /// # Returns
711 ///
712 /// The logs directory path
713 pub fn logs_dir(&self) -> PathBuf {
714 self.inner.category_dir("logs")
715 }
716
717 /// Returns the cache directory.
718 ///
719 /// # Returns
720 ///
721 /// The cache directory path
722 pub fn cache_dir(&self) -> PathBuf {
723 self.inner.category_dir("cache")
724 }
725
726 /// Returns the reports directory.
727 ///
728 /// # Returns
729 ///
730 /// The reports directory path
731 pub fn reports_dir(&self) -> PathBuf {
732 self.inner.category_dir("reports")
733 }
734
735 /// Returns the observability directory.
736 ///
737 /// # Returns
738 ///
739 /// The observability directory path
740 pub fn observability_dir(&self) -> PathBuf {
741 self.inner.category_dir("observability")
742 }
743
744 /// Returns the temporary directory.
745 ///
746 /// # Returns
747 ///
748 /// The temporary directory path
749 pub fn temp_dir(&self) -> PathBuf {
750 self.inner.category_dir("tmp")
751 }
752
753 /// Ensures a path exists under a specific category directory.
754 ///
755 /// # Parameters
756 ///
757 /// - `category`: The category name ("logs", "cache", "reports", "observability", "tmp", or other)
758 /// - `path_or_name`: The path or name to ensure under the category directory
759 ///
760 /// # Returns
761 ///
762 /// The full path to the ensured file or directory
763 pub fn ensure_category_path<S: AsRef<str>, P: AsRef<Path>>(&self, category: S, path_or_name: P) -> PathBuf {
764 let base = match category.as_ref() {
765 "logs" => self.logs_dir(),
766 "cache" => self.cache_dir(),
767 "reports" => self.reports_dir(),
768 "observability" => self.observability_dir(),
769 "tmp" => self.temp_dir(),
770 _ => self.app_dir(),
771 };
772
773 let target = base.join(path_or_name.as_ref());
774 let _ = fs::create_dir_all(target.parent().unwrap_or(&base));
775 target
776 }
777
778 /// Normalizes a path under a specific category directory, using only the file name.
779 ///
780 /// # Parameters
781 ///
782 /// - `category`: The category name ("logs", "cache", "reports", "observability", "tmp", or other)
783 /// - `path_or_name`: The path or name to normalize
784 ///
785 /// # Returns
786 ///
787 /// The full path to the normalized file or directory
788 pub fn normalize_under_category<S: AsRef<str>, P: AsRef<Path>>(&self, category: S, path_or_name: P) -> PathBuf {
789 let name = path_or_name.as_ref().file_name().unwrap_or_else(|| std::ffi::OsStr::new(""));
790 self.ensure_category_path(category, PathBuf::from(name))
791 }
792}
793
794#[cfg(feature = "pyo3")]
795/// Python bindings for RiFileSystem
796#[pyo3::prelude::pymethods]
797impl RiFileSystem {
798 /// Create a new file system with a project root from Python
799 #[new]
800 fn py_new(project_root: String) -> Result<Self, pyo3::prelude::PyErr> {
801 let path = PathBuf::from(project_root);
802 Ok(Self::new_with_root(path))
803 }
804
805 /// Create a new file system with current working directory as root from Python
806 #[staticmethod]
807 fn new_auto_root_py() -> Result<Self, pyo3::PyErr> {
808 Self::new_auto_root()
809 .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to create filesystem: {e}")))
810 }
811
812 #[pyo3(name = "atomic_write_text")]
813 fn atomic_write_text_impl(&self, path: String, text: String) -> Result<(), pyo3::PyErr> {
814 self.atomic_write_text(&path, &text)
815 .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to write text: {e}")))
816 }
817
818 #[pyo3(name = "atomic_write_bytes")]
819 fn atomic_write_bytes_impl(&self, path: String, data: Vec<u8>) -> Result<(), pyo3::PyErr> {
820 self.atomic_write_bytes(&path, &data)
821 .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to write bytes: {e}")))
822 }
823
824 #[pyo3(name = "read_text")]
825 fn read_text_impl(&self, path: String) -> Result<String, pyo3::PyErr> {
826 self.read_text(&path)
827 .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to read text: {e}")))
828 }
829
830 #[pyo3(name = "write_json")]
831 fn write_json_impl(&self, path: String, value: String) -> Result<(), pyo3::PyErr> {
832 let json_value: serde_json::Value = serde_json::from_str(&value)
833 .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("Invalid JSON: {e}")))?;
834 self.write_json(&path, &json_value)
835 .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to write JSON: {e}")))
836 }
837
838 #[pyo3(name = "read_json")]
839 fn read_json_impl(&self, path: String) -> Result<String, pyo3::PyErr> {
840 let json_value: serde_json::Value = self.read_json(&path)
841 .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to read JSON: {e}")))?;
842 serde_json::to_string_pretty(&json_value)
843 .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to serialize JSON: {e}")))
844 }
845
846 #[pyo3(name = "exists")]
847 fn exists_impl(&self, path: String) -> bool {
848 self.exists(&path)
849 }
850
851 #[pyo3(name = "remove_file")]
852 fn remove_file_impl(&self, path: String) -> Result<(), pyo3::PyErr> {
853 self.remove_file(&path)
854 .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to remove file: {e}")))
855 }
856
857 #[pyo3(name = "remove_dir_all")]
858 fn remove_dir_all_impl(&self, path: String) -> Result<(), pyo3::PyErr> {
859 self.remove_dir_all(&path)
860 .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to remove directory: {e}")))
861 }
862
863 #[pyo3(name = "copy_file")]
864 fn copy_file_impl(&self, from: String, to: String) -> Result<(), pyo3::PyErr> {
865 self.copy_file(&from, &to)
866 .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to copy file: {e}")))
867 }
868
869 #[pyo3(name = "append_text")]
870 fn append_text_impl(&self, path: String, text: String) -> Result<(), pyo3::PyErr> {
871 self.append_text(&path, &text)
872 .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to append text: {e}")))
873 }
874
875 #[pyo3(name = "safe_mkdir")]
876 fn safe_mkdir_impl(&self, path: String) -> Result<String, pyo3::PyErr> {
877 let result = self.safe_mkdir(&path)
878 .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to create directory: {e}")))?;
879 Ok(result.to_string_lossy().to_string())
880 }
881
882 #[pyo3(name = "ensure_parent_dir")]
883 fn ensure_parent_dir_impl(&self, path: String) -> Result<String, pyo3::PyErr> {
884 let result = self.ensure_parent_dir(&path)
885 .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to ensure parent directory: {e}")))?;
886 Ok(result.to_string_lossy().to_string())
887 }
888
889 #[pyo3(name = "get_project_root")]
890 fn get_project_root_impl(&self) -> String {
891 self.project_root().to_string_lossy().to_string()
892 }
893
894 #[pyo3(name = "get_app_dir")]
895 fn get_app_dir_impl(&self) -> String {
896 self.app_dir().to_string_lossy().to_string()
897 }
898
899 #[pyo3(name = "get_logs_dir")]
900 fn get_logs_dir_impl(&self) -> String {
901 self.logs_dir().to_string_lossy().to_string()
902 }
903
904 #[pyo3(name = "get_cache_dir")]
905 fn get_cache_dir_impl(&self) -> String {
906 self.cache_dir().to_string_lossy().to_string()
907 }
908
909 #[pyo3(name = "get_reports_dir")]
910 fn get_reports_dir_impl(&self) -> String {
911 self.reports_dir().to_string_lossy().to_string()
912 }
913
914 #[pyo3(name = "get_observability_dir")]
915 fn get_observability_dir_impl(&self) -> String {
916 self.observability_dir().to_string_lossy().to_string()
917 }
918
919 #[pyo3(name = "get_temp_dir")]
920 fn get_temp_dir_impl(&self) -> String {
921 self.temp_dir().to_string_lossy().to_string()
922 }
923
924 #[pyo3(name = "ensure_category_path")]
925 fn ensure_category_path_impl(&self, category: String, path_or_name: String) -> String {
926 self.ensure_category_path(&category, &path_or_name)
927 .to_string_lossy()
928 .to_string()
929 }
930
931 #[pyo3(name = "normalize_under_category")]
932 fn normalize_under_category_impl(&self, category: String, path_or_name: String) -> String {
933 self.normalize_under_category(&category, &path_or_name)
934 .to_string_lossy()
935 .to_string()
936 }
937}