1use std::fs;
8use std::io::Write;
9use std::time::UNIX_EPOCH;
10
11use crate::error::{DogeError, DogeResult};
12use crate::stdlib::{bytes_arg, str_arg};
13use crate::value::Value;
14
15pub fn fetch_read(path: &Value) -> DogeResult {
18 let path = str_arg("fetch", "read", path)?;
19 match fs::read_to_string(path) {
20 Ok(text) => Ok(Value::str(text)),
21 Err(err) => Err(DogeError::io_error(format!("cannot read {path}: {err}"))),
22 }
23}
24
25pub fn fetch_write(path: &Value, text: &Value) -> DogeResult {
28 let path = str_arg("fetch", "write", path)?;
29 let text = str_arg("fetch", "write", text)?;
30 match fs::write(path, text) {
31 Ok(()) => Ok(Value::None),
32 Err(err) => Err(DogeError::io_error(format!("cannot write {path}: {err}"))),
33 }
34}
35
36pub fn fetch_append(path: &Value, text: &Value) -> DogeResult {
39 let path = str_arg("fetch", "append", path)?;
40 let text = str_arg("fetch", "append", text)?;
41 let result = fs::OpenOptions::new()
42 .create(true)
43 .append(true)
44 .open(path)
45 .and_then(|mut file| file.write_all(text.as_bytes()));
46 match result {
47 Ok(()) => Ok(Value::None),
48 Err(err) => Err(DogeError::io_error(format!(
49 "cannot append to {path}: {err}"
50 ))),
51 }
52}
53
54pub fn fetch_read_bytes(path: &Value) -> DogeResult {
57 let path = str_arg("fetch", "read_bytes", path)?;
58 match fs::read(path) {
59 Ok(bytes) => Ok(Value::bytes(bytes)),
60 Err(err) => Err(DogeError::io_error(format!("cannot read {path}: {err}"))),
61 }
62}
63
64pub fn fetch_write_bytes(path: &Value, data: &Value) -> DogeResult {
67 let path = str_arg("fetch", "write_bytes", path)?;
68 let data = bytes_arg("fetch", "write_bytes", data)?;
69 match fs::write(path, data) {
70 Ok(()) => Ok(Value::None),
71 Err(err) => Err(DogeError::io_error(format!("cannot write {path}: {err}"))),
72 }
73}
74
75pub fn fetch_exists(path: &Value) -> DogeResult {
77 let path = str_arg("fetch", "exists", path)?;
78 Ok(Value::Bool(fs::metadata(path).is_ok()))
79}
80
81pub fn fetch_delete(path: &Value) -> DogeResult {
84 let path = str_arg("fetch", "delete", path)?;
85 match fs::remove_file(path) {
86 Ok(()) => Ok(Value::None),
87 Err(err) => Err(DogeError::io_error(format!("cannot delete {path}: {err}"))),
88 }
89}
90
91pub fn fetch_list(path: &Value) -> DogeResult {
95 let path = str_arg("fetch", "list", path)?;
96 let entries = fs::read_dir(path)
97 .map_err(|err| DogeError::io_error(format!("cannot list {path}: {err}")))?;
98 let mut names = Vec::new();
99 for entry in entries {
100 let entry =
101 entry.map_err(|err| DogeError::io_error(format!("cannot list {path}: {err}")))?;
102 names.push(entry.file_name().to_string_lossy().into_owned());
103 }
104 names.sort();
105 Ok(Value::list(names.into_iter().map(Value::str).collect()))
106}
107
108pub fn fetch_make_dir(path: &Value) -> DogeResult {
112 let path = str_arg("fetch", "make_dir", path)?;
113 match fs::create_dir_all(path) {
114 Ok(()) => Ok(Value::None),
115 Err(err) => Err(DogeError::io_error(format!(
116 "cannot make directory {path}: {err}"
117 ))),
118 }
119}
120
121pub fn fetch_remove_dir(path: &Value) -> DogeResult {
124 let path = str_arg("fetch", "remove_dir", path)?;
125 match fs::remove_dir_all(path) {
126 Ok(()) => Ok(Value::None),
127 Err(err) => Err(DogeError::io_error(format!(
128 "cannot remove directory {path}: {err}"
129 ))),
130 }
131}
132
133pub fn fetch_rename(from: &Value, to: &Value) -> DogeResult {
137 let from = str_arg("fetch", "rename", from)?;
138 let to = str_arg("fetch", "rename", to)?;
139 match fs::rename(from, to) {
140 Ok(()) => Ok(Value::None),
141 Err(err) => Err(DogeError::io_error(format!(
142 "cannot rename {from} to {to}: {err}"
143 ))),
144 }
145}
146
147pub fn fetch_copy(from: &Value, to: &Value) -> DogeResult {
150 let from = str_arg("fetch", "copy", from)?;
151 let to = str_arg("fetch", "copy", to)?;
152 match fs::copy(from, to) {
153 Ok(_) => Ok(Value::None),
154 Err(err) => Err(DogeError::io_error(format!(
155 "cannot copy {from} to {to}: {err}"
156 ))),
157 }
158}
159
160pub fn fetch_stat(path: &Value) -> DogeResult {
165 let path = str_arg("fetch", "stat", path)?;
166 let meta = fs::metadata(path)
167 .map_err(|err| DogeError::io_error(format!("cannot stat {path}: {err}")))?;
168 let modified = meta
169 .modified()
170 .map_err(|err| DogeError::io_error(format!("cannot stat {path}: {err}")))?;
171 let modified = match modified.duration_since(UNIX_EPOCH) {
172 Ok(elapsed) => elapsed.as_secs_f64(),
173 Err(before) => -before.duration().as_secs_f64(),
174 };
175 Value::dict_from_pairs(vec![
176 (Value::str("size"), Value::int(meta.len())),
177 (Value::str("modified"), Value::Float(modified)),
178 (Value::str("is_dir"), Value::Bool(meta.is_dir())),
179 ])
180}
181
182pub fn fetch_join(a: &Value, b: &Value) -> DogeResult {
186 let a = str_arg("fetch", "join", a)?;
187 let b = str_arg("fetch", "join", b)?;
188 let joined = if a.is_empty() || b.starts_with('/') {
189 b.to_string()
190 } else if a.ends_with('/') {
191 format!("{a}{b}")
192 } else {
193 format!("{a}/{b}")
194 };
195 Ok(Value::str(joined))
196}
197
198pub fn fetch_basename(path: &Value) -> DogeResult {
201 let path = str_arg("fetch", "basename", path)?;
202 let name = path.rsplit('/').next().unwrap_or_default();
203 Ok(Value::str(name))
204}
205
206pub fn fetch_ext(path: &Value) -> DogeResult {
210 let path = str_arg("fetch", "ext", path)?;
211 let name = path.rsplit('/').next().unwrap_or_default();
212 let ext = name
213 .rsplit_once('.')
214 .filter(|(stem, _)| !stem.is_empty())
215 .map(|(_, e)| format!(".{e}"))
216 .unwrap_or_default();
217 Ok(Value::str(ext))
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use crate::error::ErrorKind;
224 use std::path::PathBuf;
225
226 fn scratch(tag: &str) -> PathBuf {
229 std::env::temp_dir().join(format!("doge_fetch_{}_{tag}", std::process::id()))
230 }
231
232 #[test]
233 fn write_append_read_round_trip() {
234 let path = scratch("round_trip");
235 let p = Value::str(path.to_string_lossy());
236 fetch_write(&p, &Value::str("much ")).unwrap();
237 fetch_append(&p, &Value::str("wow")).unwrap();
238 assert!(matches!(fetch_read(&p).unwrap(), Value::Str(s) if &*s == "much wow"));
239 assert!(matches!(fetch_exists(&p).unwrap(), Value::Bool(true)));
240 fetch_delete(&p).unwrap();
241 assert!(matches!(fetch_exists(&p).unwrap(), Value::Bool(false)));
242 }
243
244 #[test]
245 fn write_bytes_read_bytes_round_trip() {
246 let path = scratch("bytes_round_trip");
247 let p = Value::str(path.to_string_lossy());
248 let data = Value::bytes([0x00, 0xff, 0x68, 0x69]);
249 fetch_write_bytes(&p, &data).unwrap();
250 assert!(matches!(
251 fetch_read_bytes(&p).unwrap(),
252 Value::Bytes(b) if b[..] == [0x00, 0xff, 0x68, 0x69]
253 ));
254 fetch_delete(&p).unwrap();
255 }
256
257 #[test]
258 fn write_bytes_rejects_a_non_bytes_payload() {
259 let path = scratch("bytes_bad_payload");
260 let p = Value::str(path.to_string_lossy());
261 assert_eq!(
262 fetch_write_bytes(&p, &Value::str("not bytes"))
263 .unwrap_err()
264 .kind,
265 ErrorKind::TypeError
266 );
267 }
268
269 #[test]
270 fn reading_a_missing_file_is_a_catchable_io_error() {
271 let path = scratch("missing");
272 let err = fetch_read(&Value::str(path.to_string_lossy())).unwrap_err();
273 assert_eq!(err.kind, ErrorKind::IOError);
274 }
275
276 #[test]
277 fn non_str_path_is_a_type_error() {
278 assert_eq!(
279 fetch_read(&Value::int(1)).unwrap_err().kind,
280 ErrorKind::TypeError
281 );
282 }
283
284 fn scratch_dir(tag: &str) -> PathBuf {
286 std::env::temp_dir().join(format!("doge_fetch_dir_{}_{tag}", std::process::id()))
287 }
288
289 fn str_of(v: Value) -> String {
290 match v {
291 Value::Str(s) => s.to_string(),
292 other => panic!("expected a Str, got {other:?}"),
293 }
294 }
295
296 #[test]
297 fn make_dir_list_stat_remove_round_trip() {
298 let dir = scratch_dir("tree");
299 let nested = dir.join("sub");
300 let d = Value::str(nested.to_string_lossy());
301 fetch_make_dir(&d).unwrap();
303 fetch_make_dir(&d).unwrap();
304
305 let file = Value::str(nested.join("a.txt").to_string_lossy());
306 fetch_write(&file, &Value::str("wow")).unwrap();
307
308 let listing = fetch_list(&d).unwrap();
309 assert!(matches!(&listing, Value::List(items)
310 if items.borrow().len() == 1
311 && matches!(&items.borrow()[0], Value::Str(s) if &**s == "a.txt")));
312
313 let info = fetch_stat(&file).unwrap();
314 let Value::Dict(map) = &info else {
315 panic!("expected a Dict, got {info:?}");
316 };
317 let map = map.borrow();
318 assert!(map
319 .get("size")
320 .is_some_and(|v| crate::values_equal(v, &Value::int(3))));
321 assert!(matches!(map.get("is_dir"), Some(Value::Bool(false))));
322 assert!(matches!(map.get("modified"), Some(Value::Float(f)) if *f > 0.0));
323
324 let dir_info = fetch_stat(&Value::str(dir.to_string_lossy())).unwrap();
325 let Value::Dict(map) = &dir_info else {
326 panic!("expected a Dict, got {dir_info:?}");
327 };
328 assert!(matches!(
329 map.borrow().get("is_dir"),
330 Some(Value::Bool(true))
331 ));
332
333 fetch_remove_dir(&Value::str(dir.to_string_lossy())).unwrap();
334 assert!(matches!(
335 fetch_exists(&Value::str(dir.to_string_lossy())).unwrap(),
336 Value::Bool(false)
337 ));
338 }
339
340 #[test]
341 fn rename_and_copy_move_file_contents() {
342 let dir = scratch_dir("moves");
343 fetch_make_dir(&Value::str(dir.to_string_lossy())).unwrap();
344 let src = Value::str(dir.join("src.txt").to_string_lossy());
345 let renamed = Value::str(dir.join("renamed.txt").to_string_lossy());
346 let copied = Value::str(dir.join("copied.txt").to_string_lossy());
347
348 fetch_write(&src, &Value::str("much wow")).unwrap();
349 fetch_rename(&src, &renamed).unwrap();
350 assert!(matches!(fetch_exists(&src).unwrap(), Value::Bool(false)));
351 assert!(matches!(fetch_read(&renamed).unwrap(), Value::Str(s) if &*s == "much wow"));
352
353 fetch_copy(&renamed, &copied).unwrap();
354 assert!(matches!(fetch_read(&copied).unwrap(), Value::Str(s) if &*s == "much wow"));
355 assert!(matches!(fetch_exists(&renamed).unwrap(), Value::Bool(true)));
356
357 fetch_remove_dir(&Value::str(dir.to_string_lossy())).unwrap();
358 }
359
360 #[test]
361 fn path_helpers_are_pure_string_ops() {
362 let join = |a, b| str_of(fetch_join(&Value::str(a), &Value::str(b)).unwrap());
365 assert_eq!(join("src", "main.doge"), "src/main.doge");
366 assert_eq!(join("src/", "main.doge"), "src/main.doge");
367 assert_eq!(join("", "main.doge"), "main.doge");
368 assert_eq!(join("src", "/etc/passwd"), "/etc/passwd");
369
370 let basename = |p| str_of(fetch_basename(&Value::str(p)).unwrap());
371 assert_eq!(basename("a/b/c.txt"), "c.txt");
372 assert_eq!(basename("c.txt"), "c.txt");
373 assert_eq!(basename("a/b/"), "");
374
375 let ext = |p| str_of(fetch_ext(&Value::str(p)).unwrap());
376 assert_eq!(ext("a/b/c.txt"), ".txt");
377 assert_eq!(ext("a/b/c"), "");
378 assert_eq!(ext("a.b/c"), "");
379 assert_eq!(ext(".gitignore"), "");
380 }
381
382 #[test]
383 fn stat_and_list_on_missing_paths_are_catchable_io_errors() {
384 let missing = Value::str(scratch_dir("nope").to_string_lossy());
385 assert_eq!(fetch_stat(&missing).unwrap_err().kind, ErrorKind::IOError);
386 assert_eq!(fetch_list(&missing).unwrap_err().kind, ErrorKind::IOError);
387 }
388
389 #[test]
390 fn new_members_reject_non_str_paths() {
391 assert_eq!(
392 fetch_list(&Value::int(1)).unwrap_err().kind,
393 ErrorKind::TypeError
394 );
395 assert_eq!(
396 fetch_join(&Value::str("ok"), &Value::int(1))
397 .unwrap_err()
398 .kind,
399 ErrorKind::TypeError
400 );
401 }
402}