Skip to main content

mars_agents/platform/
fs.rs

1//! Atomic filesystem operations for durable writes and directory replacement.
2//!
3//! All durable Mars writes should go through this module.
4
5use std::fs;
6use std::path::Path;
7
8use crate::error::MarsError;
9use crate::fs::atomic_write;
10
11#[cfg(windows)]
12use crate::fs::clear_readonly;
13
14/// Result of cache directory publication.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum CachePublishResult {
17    /// The directory was published (renamed from temp to destination).
18    Published,
19    /// The destination already existed; temp was removed.
20    AlreadyPresent,
21}
22
23/// Replace a generated directory with rollback semantics.
24pub fn replace_generated_dir(src: &Path, dest: &Path) -> Result<(), MarsError> {
25    let parent = dest.parent().unwrap_or(Path::new("."));
26    fs::create_dir_all(parent).map_err(|e| io_context("create generated parent", parent, e))?;
27
28    let old_path = parent.join(format!(
29        ".{}.old",
30        dest.file_name().unwrap_or_default().to_string_lossy()
31    ));
32
33    // Clean stale rollback content from prior crashes.
34    if old_path.symlink_metadata().is_ok() {
35        safe_remove(&old_path)?;
36    }
37
38    if dest.exists() {
39        #[cfg(windows)]
40        clear_readonly_recursive(dest)?;
41
42        fs::rename(dest, &old_path)
43            .map_err(|e| io_context("rename destination to backup", dest, e))?;
44
45        if let Err(e) = fs::rename(src, dest) {
46            let _ = fs::rename(&old_path, dest);
47            let _ = safe_remove(src);
48            return Err(io_context("rename source to destination", src, e));
49        }
50
51        let _ = safe_remove(&old_path);
52    } else {
53        fs::rename(src, dest).map_err(|e| io_context("rename source to destination", src, e))?;
54    }
55
56    Ok(())
57}
58
59/// Publish a cache directory iff destination is absent.
60pub fn publish_cache_dir_if_absent(
61    src: &Path,
62    dest: &Path,
63) -> Result<CachePublishResult, MarsError> {
64    if dest.exists() {
65        safe_remove(src)?;
66        return Ok(CachePublishResult::AlreadyPresent);
67    }
68
69    if let Some(parent) = dest.parent() {
70        fs::create_dir_all(parent).map_err(|e| io_context("create cache parent", parent, e))?;
71    }
72
73    match fs::rename(src, dest) {
74        Ok(()) => Ok(CachePublishResult::Published),
75        Err(_err) if dest.exists() => {
76            let _ = safe_remove(src);
77            Ok(CachePublishResult::AlreadyPresent)
78        }
79        Err(e) => Err(io_context("publish cache directory", src, e)),
80    }
81}
82
83/// Remove a file or directory tree safely.
84pub fn safe_remove(path: &Path) -> Result<(), MarsError> {
85    let metadata = match path.symlink_metadata() {
86        Ok(metadata) => metadata,
87        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
88        Err(e) => return Err(io_context("read metadata for removal", path, e)),
89    };
90
91    #[cfg(windows)]
92    if metadata.is_dir() {
93        clear_readonly_recursive(path)?;
94    } else {
95        clear_readonly(path).map_err(|e| io_context("clear readonly bit", path, e))?;
96    }
97
98    if metadata.is_dir() {
99        fs::remove_dir_all(path).map_err(|e| io_context("remove directory", path, e))?;
100    } else {
101        fs::remove_file(path).map_err(|e| io_context("remove file", path, e))?;
102    }
103
104    Ok(())
105}
106
107#[cfg(windows)]
108fn clear_readonly_recursive(path: &Path) -> Result<(), MarsError> {
109    for entry in walkdir::WalkDir::new(path)
110        .into_iter()
111        .filter_map(|entry| entry.ok())
112    {
113        clear_readonly(entry.path())
114            .map_err(|e| io_context("clear readonly bit", entry.path(), e))?;
115    }
116    Ok(())
117}
118
119fn io_context(operation: &str, path: &Path, source: std::io::Error) -> MarsError {
120    MarsError::Io {
121        operation: operation.to_string(),
122        path: path.to_path_buf(),
123        source,
124    }
125}
126
127/// Atomic file copy: read source (following symlinks), write to tmp, rename to dest.
128pub fn atomic_copy_file(source: &Path, dest: &Path) -> Result<(), MarsError> {
129    let content = fs::read(source)?;
130    #[cfg(windows)]
131    if dest.exists() {
132        crate::fs::clear_readonly(dest)?;
133    }
134    atomic_write(dest, &content)
135}
136
137/// Atomic directory copy: deep copy source tree (following symlinks) to tmp, rename to dest.
138pub fn atomic_copy_dir(source: &Path, dest: &Path) -> Result<(), MarsError> {
139    let parent = dest.parent().unwrap_or(Path::new("."));
140    fs::create_dir_all(parent)?;
141
142    let tmp_dir = tempfile::TempDir::new_in(parent)?;
143    copy_dir_following_symlinks(source, tmp_dir.path())?;
144    let tmp_path = tmp_dir.keep();
145
146    replace_generated_dir(&tmp_path, dest)
147}
148
149/// Whether two regular files have identical byte content.
150///
151/// Returns `false` (not an error) when either path is missing, not a regular file,
152/// or a symlink — target sync installs copies, so symlink destinations must be rewritten.
153pub fn file_content_equal(left: &Path, right: &Path) -> Result<bool, MarsError> {
154    let left_meta = match fs::symlink_metadata(left) {
155        Ok(m) => m,
156        Err(_) => return Ok(false),
157    };
158    let right_meta = match fs::symlink_metadata(right) {
159        Ok(m) => m,
160        Err(_) => return Ok(false),
161    };
162    if left_meta.file_type().is_symlink() || right_meta.file_type().is_symlink() {
163        return Ok(false);
164    }
165    if !left_meta.is_file() || !right_meta.is_file() {
166        return Ok(false);
167    }
168    Ok(fs::read(left)? == fs::read(right)?)
169}
170
171/// Whether two directory trees have identical structure (paths + entry kinds) and file bytes.
172///
173/// Returns `false` when either root is a symlink, any nested entry is a symlink or
174/// non-regular type, directory structure differs (including empty directories), or
175/// any regular file's bytes differ.
176pub fn directory_trees_content_equal(left: &Path, right: &Path) -> Result<bool, MarsError> {
177    let Some(left) = strict_directory_tree_snapshot(left)? else {
178        return Ok(false);
179    };
180    let Some(right) = strict_directory_tree_snapshot(right)? else {
181        return Ok(false);
182    };
183    Ok(left == right)
184}
185
186/// Complete regular-directory structure and file content hashes.
187///
188/// The snapshot includes empty directories. Construction from disk rejects
189/// symlinks and unsupported entry types so copied projections cannot compare
190/// equal to user-controlled indirection.
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub(crate) struct DirectoryTreeSnapshot {
193    entries: std::collections::BTreeMap<String, TreeEntryKind>,
194}
195
196impl DirectoryTreeSnapshot {
197    pub(crate) fn new() -> Self {
198        Self {
199            entries: std::collections::BTreeMap::new(),
200        }
201    }
202
203    pub(crate) fn insert_directory(&mut self, rel_path: String) {
204        self.entries.insert(rel_path, TreeEntryKind::Directory);
205    }
206
207    pub(crate) fn insert_file_hash(&mut self, rel_path: String, hash: String) {
208        let mut parent = Path::new(&rel_path).parent();
209        while let Some(path) = parent.filter(|path| !path.as_os_str().is_empty()) {
210            let normalized = path
211                .components()
212                .map(|component| component.as_os_str().to_string_lossy())
213                .collect::<Vec<_>>()
214                .join("/");
215            self.entries
216                .entry(normalized)
217                .or_insert(TreeEntryKind::Directory);
218            parent = path.parent();
219        }
220        self.entries.insert(rel_path, TreeEntryKind::File(hash));
221    }
222
223    pub(crate) fn file_hash_manifest_entries(&self) -> Vec<(String, String)> {
224        self.entries
225            .iter()
226            .filter_map(|(path, kind)| match kind {
227                TreeEntryKind::Directory => None,
228                TreeEntryKind::File(hash) => Some((path.clone(), hash.clone())),
229            })
230            .collect()
231    }
232}
233
234#[derive(Debug, Clone, PartialEq, Eq)]
235enum TreeEntryKind {
236    Directory,
237    File(String),
238}
239
240pub(crate) fn directory_tree_matches_snapshot(
241    path: &Path,
242    expected: &DirectoryTreeSnapshot,
243) -> Result<bool, MarsError> {
244    Ok(strict_directory_tree_snapshot(path)?.as_ref() == Some(expected))
245}
246
247fn strict_directory_tree_snapshot(root: &Path) -> Result<Option<DirectoryTreeSnapshot>, MarsError> {
248    let metadata = match fs::symlink_metadata(root) {
249        Ok(metadata) => metadata,
250        Err(_) => return Ok(None),
251    };
252    if metadata.file_type().is_symlink() || !metadata.is_dir() {
253        return Ok(None);
254    }
255    let mut snapshot = DirectoryTreeSnapshot::new();
256    if collect_relative_tree_entries(root, root, &mut snapshot)? {
257        Ok(Some(snapshot))
258    } else {
259        Ok(None)
260    }
261}
262
263fn collect_relative_tree_entries(
264    root: &Path,
265    current: &Path,
266    snapshot: &mut DirectoryTreeSnapshot,
267) -> Result<bool, MarsError> {
268    for entry in fs::read_dir(current)? {
269        let entry = entry?;
270        let path = entry.path();
271        let file_type = entry.file_type()?;
272        let rel = path.strip_prefix(root).expect("path is always under root");
273        let rel_path: String = rel
274            .components()
275            .map(|c| c.as_os_str().to_string_lossy())
276            .collect::<Vec<_>>()
277            .join("/");
278
279        if file_type.is_symlink() {
280            return Ok(false);
281        }
282        if file_type.is_dir() {
283            snapshot.insert_directory(rel_path);
284            if !collect_relative_tree_entries(root, &path, snapshot)? {
285                return Ok(false);
286            }
287        } else if file_type.is_file() {
288            snapshot.insert_file_hash(rel_path, crate::hash::hash_bytes(&fs::read(path)?));
289        } else {
290            return Ok(false);
291        }
292    }
293    Ok(true)
294}
295
296/// Recursively copy a directory, following symlinks on the source side.
297///
298/// Uses `fs::metadata` (not `symlink_metadata`) to follow symlinks.
299/// Files are copied with plain `fs::read`+`fs::write` because the destination
300/// is inside a temp dir — the atomicity guarantee comes from the final rename
301/// of the enclosing temp dir, not from per-file atomics.
302fn copy_dir_following_symlinks(source: &Path, dest: &Path) -> Result<(), MarsError> {
303    fs::create_dir_all(dest)?;
304
305    for entry in fs::read_dir(source)? {
306        let entry = entry?;
307        let source_path = entry.path();
308        let dest_path = dest.join(entry.file_name());
309
310        // Follow symlinks — fs::metadata resolves through symlinks
311        let metadata = match fs::metadata(&source_path) {
312            Ok(m) => m,
313            Err(e) => {
314                // If it's a broken symlink, give a descriptive error
315                if entry.file_type()?.is_symlink() {
316                    return Err(std::io::Error::new(
317                        std::io::ErrorKind::NotFound,
318                        format!("broken symlink in source tree: {}", source_path.display()),
319                    )
320                    .into());
321                }
322                return Err(e.into());
323            }
324        };
325
326        if metadata.is_dir() {
327            copy_dir_following_symlinks(&source_path, &dest_path)?;
328        } else if metadata.is_file() {
329            let content = fs::read(&source_path)?;
330            fs::write(&dest_path, &content)?;
331            #[cfg(unix)]
332            {
333                use std::os::unix::fs::PermissionsExt;
334                fs::set_permissions(&dest_path, fs::Permissions::from_mode(0o644))?;
335            }
336        } else {
337            return Err(std::io::Error::new(
338                std::io::ErrorKind::InvalidData,
339                format!("unsupported filesystem entry: {}", source_path.display()),
340            )
341            .into());
342        }
343    }
344
345    Ok(())
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use tempfile::TempDir;
352
353    #[test]
354    fn replace_generated_dir_basic() {
355        let tmp = TempDir::new().unwrap();
356        let src = tmp.path().join("src");
357        let dest = tmp.path().join("dest");
358
359        fs::create_dir(&src).unwrap();
360        fs::write(src.join("file.txt"), "content").unwrap();
361
362        replace_generated_dir(&src, &dest).unwrap();
363
364        assert!(!src.exists());
365        assert!(dest.join("file.txt").exists());
366    }
367
368    #[test]
369    fn replace_generated_dir_replaces_existing() {
370        let tmp = TempDir::new().unwrap();
371        let src = tmp.path().join("src");
372        let dest = tmp.path().join("dest");
373
374        fs::create_dir(&dest).unwrap();
375        fs::write(dest.join("old.txt"), "old").unwrap();
376
377        fs::create_dir(&src).unwrap();
378        fs::write(src.join("new.txt"), "new").unwrap();
379
380        replace_generated_dir(&src, &dest).unwrap();
381
382        assert!(!dest.join("old.txt").exists());
383        assert!(dest.join("new.txt").exists());
384    }
385
386    #[test]
387    fn publish_cache_dir_if_absent_publishes() {
388        let tmp = TempDir::new().unwrap();
389        let src = tmp.path().join("src");
390        let dest = tmp.path().join("dest");
391
392        fs::create_dir(&src).unwrap();
393        fs::write(src.join("file.txt"), "content").unwrap();
394
395        let result = publish_cache_dir_if_absent(&src, &dest).unwrap();
396
397        assert_eq!(result, CachePublishResult::Published);
398        assert!(!src.exists());
399        assert!(dest.join("file.txt").exists());
400    }
401
402    #[test]
403    fn publish_cache_dir_if_absent_accepts_existing() {
404        let tmp = TempDir::new().unwrap();
405        let src = tmp.path().join("src");
406        let dest = tmp.path().join("dest");
407
408        fs::create_dir(&dest).unwrap();
409        fs::write(dest.join("existing.txt"), "existing").unwrap();
410
411        fs::create_dir(&src).unwrap();
412        fs::write(src.join("new.txt"), "new").unwrap();
413
414        let result = publish_cache_dir_if_absent(&src, &dest).unwrap();
415
416        assert_eq!(result, CachePublishResult::AlreadyPresent);
417        assert!(!src.exists());
418        assert!(dest.join("existing.txt").exists());
419        assert!(!dest.join("new.txt").exists());
420    }
421
422    #[test]
423    fn safe_remove_handles_nonexistent() {
424        let tmp = TempDir::new().unwrap();
425        let path = tmp.path().join("nonexistent");
426
427        safe_remove(&path).unwrap();
428    }
429
430    #[test]
431    fn safe_remove_removes_file_and_directory_tree() {
432        let tmp = TempDir::new().unwrap();
433        let file = tmp.path().join("file.txt");
434        fs::write(&file, "content").unwrap();
435
436        safe_remove(&file).unwrap();
437        assert!(!file.exists());
438
439        let dir = tmp.path().join("dir");
440        fs::create_dir_all(dir.join("nested")).unwrap();
441        fs::write(dir.join("nested").join("file.txt"), "content").unwrap();
442
443        safe_remove(&dir).unwrap();
444        assert!(!dir.exists());
445    }
446
447    #[test]
448    fn replace_generated_dir_cleans_stale_backup_before_replace() {
449        let tmp = TempDir::new().unwrap();
450        let src = tmp.path().join("src");
451        let dest = tmp.path().join("dest");
452        let old = tmp.path().join(".dest.old");
453
454        fs::create_dir(&dest).unwrap();
455        fs::write(dest.join("old.txt"), "old").unwrap();
456        fs::create_dir(&old).unwrap();
457        fs::write(old.join("stale.txt"), "stale").unwrap();
458        fs::create_dir(&src).unwrap();
459        fs::write(src.join("new.txt"), "new").unwrap();
460
461        replace_generated_dir(&src, &dest).unwrap();
462
463        assert!(!old.exists());
464        assert!(!dest.join("old.txt").exists());
465        assert_eq!(fs::read_to_string(dest.join("new.txt")).unwrap(), "new");
466    }
467}
468
469#[cfg(test)]
470mod content_tests {
471    use super::*;
472    use tempfile::TempDir;
473
474    #[test]
475    fn directory_trees_content_equal_detects_identical_and_different_trees() {
476        let dir = TempDir::new().expect("temp dir");
477        let left = dir.path().join("left");
478        let right = dir.path().join("right");
479        let other = dir.path().join("other");
480        fs::create_dir_all(left.join("nested")).expect("create left");
481        fs::create_dir_all(right.join("nested")).expect("create right");
482        fs::create_dir_all(other.join("nested")).expect("create other");
483        fs::write(left.join("root.txt"), "root").expect("write left root");
484        fs::write(left.join("nested/child.txt"), "child").expect("write left child");
485        fs::write(right.join("root.txt"), "root").expect("write right root");
486        fs::write(right.join("nested/child.txt"), "child").expect("write right child");
487        fs::write(other.join("root.txt"), "different").expect("write other root");
488        fs::write(other.join("nested/child.txt"), "child").expect("write other child");
489
490        assert!(directory_trees_content_equal(&left, &right).expect("compare equal"));
491        assert!(!directory_trees_content_equal(&left, &other).expect("compare different"));
492    }
493
494    #[test]
495    fn file_content_equal_compares_regular_files() {
496        let dir = TempDir::new().expect("temp dir");
497        let left = dir.path().join("left.txt");
498        let right = dir.path().join("right.txt");
499        let other = dir.path().join("other.txt");
500        fs::write(&left, "same").expect("write left");
501        fs::write(&right, "same").expect("write right");
502        fs::write(&other, "different").expect("write other");
503
504        assert!(file_content_equal(&left, &right).expect("compare equal"));
505        assert!(!file_content_equal(&left, &other).expect("compare different"));
506    }
507
508    #[cfg(unix)]
509    #[test]
510    fn file_content_equal_rejects_symlink_dest_even_when_bytes_match() {
511        let dir = TempDir::new().expect("temp dir");
512        let target = dir.path().join("target.txt");
513        fs::write(&target, "same bytes").expect("write target");
514
515        let regular = dir.path().join("regular.txt");
516        fs::write(&regular, "same bytes").expect("write regular");
517
518        let symlink = dir.path().join("link.txt");
519        std::os::unix::fs::symlink(&target, &symlink).expect("create symlink");
520
521        assert!(
522            !file_content_equal(&regular, &symlink).expect("compare symlink dest"),
523            "symlink dest must force rewrite even when target bytes match"
524        );
525    }
526
527    #[test]
528    fn directory_trees_content_equal_detects_empty_directory_delta() {
529        let dir = TempDir::new().expect("temp dir");
530        let left = dir.path().join("left");
531        let right = dir.path().join("right");
532        fs::create_dir_all(left.join("empty-only")).expect("create left empty dir");
533        fs::create_dir_all(&right).expect("create right root");
534        fs::write(left.join("root.txt"), "root").expect("write left root");
535        fs::write(right.join("root.txt"), "root").expect("write right root");
536
537        assert!(
538            !directory_trees_content_equal(&left, &right).expect("compare empty-dir delta"),
539            "empty-directory-only structural delta must not compare equal"
540        );
541    }
542
543    #[cfg(unix)]
544    #[test]
545    fn directory_trees_content_equal_rejects_symlink_entry() {
546        let dir = TempDir::new().expect("temp dir");
547        let left = dir.path().join("left");
548        let right = dir.path().join("right");
549        let shared = dir.path().join("shared.txt");
550        fs::write(&shared, "shared").expect("write shared");
551
552        fs::create_dir_all(&left).expect("create left");
553        fs::create_dir_all(&right).expect("create right");
554        fs::write(left.join("root.txt"), "root").expect("write left root");
555        fs::write(right.join("root.txt"), "root").expect("write right root");
556        std::os::unix::fs::symlink(&shared, right.join("link.txt")).expect("create symlink");
557
558        assert!(
559            !directory_trees_content_equal(&left, &right).expect("compare symlink entry"),
560            "symlink entry in tree must force rewrite"
561        );
562    }
563
564    #[test]
565    fn directory_trees_content_equal_identical_regular_file_trees_still_equal() {
566        let dir = TempDir::new().expect("temp dir");
567        let left = dir.path().join("left");
568        let right = dir.path().join("right");
569        fs::create_dir_all(left.join("nested")).expect("create left nested");
570        fs::create_dir_all(right.join("nested")).expect("create right nested");
571        fs::write(left.join("root.txt"), "root").expect("write left root");
572        fs::write(left.join("nested/child.txt"), "child").expect("write left child");
573        fs::write(right.join("root.txt"), "root").expect("write right root");
574        fs::write(right.join("nested/child.txt"), "child").expect("write right child");
575
576        assert!(
577            directory_trees_content_equal(&left, &right).expect("compare identical trees"),
578            "identical all-regular-file trees must still compare equal"
579        );
580    }
581
582    #[test]
583    fn atomic_copy_file_copies_regular_file() {
584        let dir = TempDir::new().expect("temp dir");
585        let source = dir.path().join("source.txt");
586        let dest = dir.path().join("dest").join("copied.txt");
587        fs::write(&source, "hello").expect("write source");
588
589        atomic_copy_file(&source, &dest).expect("copy file");
590
591        assert_eq!(fs::read_to_string(dest).expect("read dest"), "hello");
592    }
593
594    #[cfg(unix)]
595    #[test]
596    fn atomic_copy_file_follows_source_symlink() {
597        let dir = TempDir::new().expect("temp dir");
598        let real = dir.path().join("real.txt");
599        fs::write(&real, "from-real").expect("write real");
600
601        let source_link = dir.path().join("source-link.txt");
602        std::os::unix::fs::symlink(&real, &source_link).expect("create symlink");
603
604        let dest = dir.path().join("dest").join("copied.txt");
605        atomic_copy_file(&source_link, &dest).expect("copy through symlink");
606
607        let dest_meta = fs::symlink_metadata(&dest).expect("dest metadata");
608        assert!(
609            !dest_meta.file_type().is_symlink(),
610            "dest should be a regular file"
611        );
612        assert_eq!(fs::read_to_string(dest).expect("read dest"), "from-real");
613    }
614
615    #[test]
616    fn atomic_copy_dir_copies_tree() {
617        let dir = TempDir::new().expect("temp dir");
618        let source = dir.path().join("source");
619        fs::create_dir_all(source.join("nested")).expect("create source tree");
620        fs::write(source.join("root.txt"), "root").expect("write root");
621        fs::write(source.join("nested").join("child.txt"), "child").expect("write child");
622
623        let dest = dir.path().join("dest");
624        atomic_copy_dir(&source, &dest).expect("copy dir");
625
626        assert_eq!(
627            fs::read_to_string(dest.join("root.txt")).expect("read root"),
628            "root"
629        );
630        assert_eq!(
631            fs::read_to_string(dest.join("nested").join("child.txt")).expect("read child"),
632            "child"
633        );
634    }
635
636    #[cfg(unix)]
637    #[test]
638    fn atomic_copy_dir_follows_symlinks() {
639        let dir = TempDir::new().expect("temp dir");
640        let shared = dir.path().join("shared");
641        fs::create_dir_all(shared.join("docs")).expect("create shared tree");
642        fs::write(shared.join("docs").join("guide.md"), "guide").expect("write guide");
643        fs::write(shared.join("main.txt"), "main").expect("write main");
644
645        let source = dir.path().join("source");
646        fs::create_dir_all(&source).expect("create source");
647        std::os::unix::fs::symlink(shared.join("main.txt"), source.join("main-link.txt"))
648            .expect("file symlink");
649        std::os::unix::fs::symlink(shared.join("docs"), source.join("docs-link"))
650            .expect("dir symlink");
651
652        let dest = dir.path().join("dest");
653        atomic_copy_dir(&source, &dest).expect("copy dir through symlinks");
654
655        let main_meta = fs::symlink_metadata(dest.join("main-link.txt")).expect("main metadata");
656        assert!(
657            !main_meta.file_type().is_symlink(),
658            "copied file entry should be regular"
659        );
660        assert_eq!(
661            fs::read_to_string(dest.join("main-link.txt")).expect("read copied main"),
662            "main"
663        );
664
665        let docs_meta = fs::symlink_metadata(dest.join("docs-link")).expect("docs metadata");
666        assert!(
667            !docs_meta.file_type().is_symlink(),
668            "copied dir entry should be regular directory"
669        );
670        assert_eq!(
671            fs::read_to_string(dest.join("docs-link").join("guide.md")).expect("read guide"),
672            "guide"
673        );
674    }
675}