1use crate::error::PraxisResult;
7use crate::fs_port::{FsDirEntry, FsMetadata, FsPort};
8use crate::workspace::FsPolicy;
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Clone)]
13pub struct LocalFs {
14 policy: FsPolicy,
15}
16
17impl LocalFs {
18 pub fn new(policy: FsPolicy) -> Self {
20 Self { policy }
21 }
22
23 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 let joined = if path.is_absolute() {
55 path.to_path_buf()
56 } else {
57 self.policy.workspace_root().join(path)
58 };
59 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}