Skip to main content

doge_runtime/stdlib/
fetch.rs

1//! `fetch` — the file-I/O stdlib module. Every operation takes a Str path; the
2//! text operations read and write a Str (so non-text bytes are a catchable
3//! IOError), while `read_bytes`/`write_bytes` carry raw Bytes for binary files.
4//! Every OS failure — a missing file, a permission problem — is a catchable
5//! IOError rather than a panic.
6
7use 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
15/// `fetch.read(path)` — the file's whole contents as a Str. A missing file or one
16/// whose bytes are not valid text is a catchable IOError.
17pub 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
25/// `fetch.write(path, text)` — replace the file's contents with `text`, creating
26/// it if needed. Returns `none`.
27pub 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
36/// `fetch.append(path, text)` — add `text` to the end of the file, creating it if
37/// needed. Returns `none`.
38pub 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
54/// `fetch.read_bytes(path)` — the file's whole contents as raw Bytes, for binary
55/// files that are not valid text. A missing file is a catchable IOError.
56pub 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
64/// `fetch.write_bytes(path, bytes)` — replace the file's contents with the raw
65/// `bytes`, creating it if needed. Returns `none`.
66pub 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
75/// `fetch.exists(path)` — whether a file or directory exists at `path`.
76pub 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
81/// `fetch.delete(path)` — remove the file at `path`. A missing file is a catchable
82/// IOError. Returns `none`.
83pub 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
91/// `fetch.list(path)` — the names of the entries in directory `path`, sorted so the
92/// order is stable across runs. A missing path or one that is not a directory is a
93/// catchable IOError.
94pub 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
108/// `fetch.make_dir(path)` — create the directory at `path`, along with any missing
109/// parent directories. Doing this when the directory already exists is not an
110/// error. Returns `none`.
111pub 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
121/// `fetch.remove_dir(path)` — remove the directory at `path` and everything inside
122/// it. A missing path is a catchable IOError. Returns `none`.
123pub 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
133/// `fetch.rename(from, to)` — move or rename the file or directory `from` to `to`,
134/// replacing `to` if it already exists. A missing `from` is a catchable IOError.
135/// Returns `none`.
136pub 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
147/// `fetch.copy(from, to)` — copy the contents of file `from` to `to`, creating or
148/// replacing `to`. A missing `from` is a catchable IOError. Returns `none`.
149pub 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
160/// `fetch.stat(path)` — metadata about `path` as a Dict with `size` (Int bytes),
161/// `modified` (Float unix seconds, negative for a pre-epoch time), and `is_dir`
162/// (Bool). A missing path, or a platform that cannot report the modified time, is a
163/// catchable IOError.
164pub 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
182/// `fetch.join(a, b)` — join two path segments with `/`, Doge's one canonical
183/// separator on every platform. If `b` is absolute (starts with `/`) it replaces
184/// `a` entirely; an empty `a` yields `b` unchanged.
185pub 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
198/// `fetch.basename(path)` — the final `/`-separated component of `path`
199/// (`"a/b/c.txt"` → `"c.txt"`), or `""` when the path ends in `/` (e.g. `"/"`).
200pub 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
206/// `fetch.ext(path)` — the extension of the final component including the leading
207/// dot (`"c.txt"` → `".txt"`), or `""` when there is none. A leading dot on the
208/// component itself (`".gitignore"`) is not an extension.
209pub 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    /// A unique scratch path under the OS temp dir, salted with the process id and
227    /// a caller-supplied tag so parallel tests never collide.
228    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    /// A unique scratch *directory* under the OS temp dir, salted like `scratch`.
285    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        // create_dir_all makes the parent too, and is idempotent.
302        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        // Always `/`, never the platform separator — the result must be identical
363        // on Windows, where std::path::Path would otherwise emit a backslash.
364        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}