Skip to main content

praxis_core/
local_fs.rs

1//! Default local filesystem implementation of [`FsPort`].
2//!
3//! [`LocalFs`] wraps `std::fs` with workspace boundary enforcement
4//! via [`FsPolicy`]. It auto-creates parent directories on writes.
5
6use crate::error::PraxisResult;
7use crate::fs_port::{FsDirEntry, FsMetadata, FsPort};
8use crate::workspace::FsPolicy;
9use std::path::{Path, PathBuf};
10
11/// Local filesystem backed by `std::fs` with [`FsPolicy`] enforcement.
12#[derive(Debug, Clone)]
13pub struct LocalFs {
14    policy: FsPolicy,
15}
16
17impl LocalFs {
18    /// Create a new local filesystem bound to a workspace root.
19    pub fn new(policy: FsPolicy) -> Self {
20        Self { policy }
21    }
22
23    /// Access the underlying policy.
24    pub fn policy(&self) -> &FsPolicy {
25        &self.policy
26    }
27}
28
29impl FsPort for LocalFs {
30    fn workspace_root(&self) -> &Path {
31        self.policy.workspace_root()
32    }
33
34    fn resolve(&self, path: &Path) -> PraxisResult<PathBuf> {
35        self.policy.resolve_existing(path)
36    }
37
38    fn resolve_for_write(&self, path: &Path) -> PraxisResult<PathBuf> {
39        self.policy.resolve_for_write(path)
40    }
41
42    fn read_to_string(&self, path: &Path) -> PraxisResult<String> {
43        let resolved = self.policy.resolve_existing(path)?;
44        Ok(std::fs::read_to_string(resolved)?)
45    }
46
47    fn read_bytes(&self, path: &Path) -> PraxisResult<Vec<u8>> {
48        let resolved = self.policy.resolve_existing(path)?;
49        Ok(std::fs::read(resolved)?)
50    }
51
52    fn write(&self, path: &Path, content: &[u8]) -> PraxisResult<()> {
53        // Compute the target path within the workspace.
54        let joined = if path.is_absolute() {
55            path.to_path_buf()
56        } else {
57            self.policy.workspace_root().join(path)
58        };
59        // Create parent directories first (resolve_for_write needs the parent to exist).
60        if let Some(parent) = joined.parent() {
61            std::fs::create_dir_all(parent)?;
62        }
63        let resolved = self.policy.resolve_for_write(path)?;
64        Ok(std::fs::write(resolved, content)?)
65    }
66
67    fn exists(&self, path: &Path) -> bool {
68        self.policy.resolve_existing(path).is_ok()
69    }
70
71    fn metadata(&self, path: &Path) -> PraxisResult<FsMetadata> {
72        let resolved = self.policy.resolve_existing(path)?;
73        let meta = std::fs::metadata(resolved)?;
74        Ok(FsMetadata {
75            is_file: meta.is_file(),
76            is_dir: meta.is_dir(),
77            size_bytes: meta.len(),
78        })
79    }
80
81    fn read_dir(&self, path: &Path) -> PraxisResult<Vec<FsDirEntry>> {
82        let resolved = self.policy.resolve_existing(path)?;
83        let entries = std::fs::read_dir(resolved)?
84            .filter_map(|entry| {
85                entry.ok().map(|e| {
86                    let is_dir = e.path().is_dir();
87                    FsDirEntry {
88                        name: e.file_name().to_string_lossy().to_string(),
89                        is_file: !is_dir,
90                        is_dir,
91                    }
92                })
93            })
94            .collect();
95        Ok(entries)
96    }
97
98    fn create_dir_all(&self, path: &Path) -> PraxisResult<()> {
99        let resolved = self.policy.resolve_for_write(path)?;
100        Ok(std::fs::create_dir_all(resolved)?)
101    }
102
103    fn relative(&self, absolute_path: &Path) -> Option<PathBuf> {
104        self.policy.relative(absolute_path)
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    fn make_fs() -> (tempfile::TempDir, LocalFs) {
113        let tmp = tempfile::tempdir().unwrap();
114        let policy = FsPolicy::new(tmp.path());
115        (tmp, LocalFs::new(policy))
116    }
117
118    #[test]
119    fn write_and_read_roundtrip() {
120        let (tmp, fs) = make_fs();
121        let path = tmp.path().join("hello.txt");
122        fs.write(&path, b"world").unwrap();
123        let content = fs.read_to_string(&path).unwrap();
124        assert_eq!(content, "world");
125    }
126
127    #[test]
128    fn write_creates_parent_directories() {
129        let (tmp, fs) = make_fs();
130        let path = tmp.path().join("sub/dir/file.txt");
131        fs.write(&path, b"nested").unwrap();
132        assert!(path.exists());
133        assert_eq!(std::fs::read_to_string(&path).unwrap(), "nested");
134    }
135
136    #[test]
137    fn read_bytes_roundtrip() {
138        let (tmp, fs) = make_fs();
139        let path = tmp.path().join("data.bin");
140        let data = vec![0u8, 1, 2, 255];
141        fs.write(&path, &data).unwrap();
142        let read = fs.read_bytes(&path).unwrap();
143        assert_eq!(read, data);
144    }
145
146    #[test]
147    fn exists_returns_true_for_existing_file() {
148        let (tmp, fs) = make_fs();
149        let path = tmp.path().join("exists.txt");
150        std::fs::write(&path, "x").unwrap();
151        assert!(fs.exists(&path));
152    }
153
154    #[test]
155    fn exists_returns_false_for_missing_file() {
156        let (tmp, fs) = make_fs();
157        let path = tmp.path().join("nope.txt");
158        assert!(!fs.exists(&path));
159    }
160
161    #[test]
162    fn metadata_reports_correct_size() {
163        let (tmp, fs) = make_fs();
164        let path = tmp.path().join("sized.txt");
165        std::fs::write(&path, "12345").unwrap();
166        let meta = fs.metadata(&path).unwrap();
167        assert!(meta.is_file);
168        assert!(!meta.is_dir);
169        assert_eq!(meta.size_bytes, 5);
170    }
171
172    #[test]
173    fn read_dir_lists_entries() {
174        let (tmp, fs) = make_fs();
175        std::fs::write(tmp.path().join("a.txt"), "").unwrap();
176        std::fs::create_dir(tmp.path().join("sub")).unwrap();
177
178        let entries = fs.read_dir(tmp.path()).unwrap();
179        let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
180        assert!(names.contains(&"a.txt"));
181        assert!(names.contains(&"sub"));
182    }
183
184    #[test]
185    fn resolve_outside_workspace_fails() {
186        let (_tmp, fs) = make_fs();
187        let result = fs.resolve(Path::new("/etc/passwd"));
188        assert!(result.is_err());
189    }
190
191    #[test]
192    fn relative_path_computation() {
193        let (tmp, fs) = make_fs();
194        let file = tmp.path().join("sub/test.txt");
195        std::fs::create_dir_all(tmp.path().join("sub")).unwrap();
196        std::fs::write(&file, "x").unwrap();
197
198        let rel = fs.relative(&file).unwrap();
199        assert_eq!(rel, PathBuf::from("sub/test.txt"));
200    }
201}