Skip to main content

loftspec_store/
fs.rs

1//! The paths of the Format on disk, and the outputs a command writes outside the tree.
2
3use std::path::{Component, Path, PathBuf};
4use std::{fs, process};
5
6use crate::error::{StoreError, io_error};
7
8/// The directory under the root that holds the tree.
9pub const SPEC: &str = "spec";
10
11/// Whether `path`, taken under `root` when relative, lies under `<root>/spec/` (RQ-171). Both
12/// are made absolute with `.` and `..` resolved, and the part of each that exists is resolved on
13/// disk, so an absolute path, a symbolic link or, on a file system that ignores case, another
14/// letter case cannot reach under `spec/` unseen.
15#[must_use]
16pub fn is_under_spec(root: &Path, path: &Path) -> bool {
17    resolved(&root.join(path)).starts_with(resolved(&root.join(SPEC)))
18}
19
20/// `path` made absolute, `.` and `..` resolved, with its longest existing part resolved on disk.
21fn resolved(path: &Path) -> PathBuf {
22    let absolute = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
23    let mut normal = PathBuf::new();
24    for part in absolute.components() {
25        match part {
26            Component::CurDir => {}
27            Component::ParentDir => {
28                normal.pop();
29            }
30            other => normal.push(other),
31        }
32    }
33    let mut missing = Vec::new();
34    let mut existing = normal.as_path();
35    while !existing.exists() {
36        let (Some(name), Some(parent)) = (existing.file_name(), existing.parent()) else {
37            break;
38        };
39        missing.push(name);
40        existing = parent;
41    }
42    let mut resolved = existing
43        .canonicalize()
44        .unwrap_or_else(|_| existing.to_path_buf());
45    resolved.extend(missing.iter().rev());
46    resolved
47}
48
49/// What [`write_outputs`] did with each output: wrote it, or left it as the file already held
50/// its bytes.
51#[derive(Debug, Default, PartialEq, Eq)]
52pub struct WrittenOutputs {
53    pub written: Vec<PathBuf>,
54    pub unchanged: Vec<PathBuf>,
55}
56
57/// Writes each output whose bytes differ from the file at its path, through a temporary file
58/// beside it and a rename (AC-548). Relative paths are taken under `root`.
59///
60/// # Errors
61///
62/// A file that cannot be read, written or renamed; outputs written before it stay written.
63pub fn write_outputs(
64    root: &Path,
65    outputs: &[(PathBuf, Vec<u8>)],
66) -> Result<WrittenOutputs, StoreError> {
67    let mut done = WrittenOutputs::default();
68    for (path, bytes) in outputs {
69        let target = root.join(path);
70        if fs::read(&target).is_ok_and(|current| current == *bytes) {
71            done.unchanged.push(path.clone());
72            continue;
73        }
74        if let Some(parent) = target.parent() {
75            fs::create_dir_all(parent).map_err(io_error(parent))?;
76        }
77        let name = target
78            .file_name()
79            .map_or_else(Default::default, |name| name.to_string_lossy());
80        let temporary = target.with_file_name(format!(".{name}.loft-{}", process::id()));
81        fs::write(&temporary, bytes).map_err(io_error(&temporary))?;
82        fs::rename(&temporary, &target).map_err(io_error(&target))?;
83        done.written.push(path.clone());
84    }
85    Ok(done)
86}
87
88/// Whether `dir` holds an entry that is not [`hidden`].
89///
90/// # Errors
91///
92/// `dir` or one of its entries cannot be read.
93pub(crate) fn holds_entries(dir: &Path) -> Result<bool, StoreError> {
94    for entry in fs::read_dir(dir).map_err(io_error(dir))? {
95        let entry = entry.map_err(io_error(dir))?;
96        if !entry.file_name().to_str().is_some_and(hidden) {
97            return Ok(true);
98        }
99    }
100    Ok(false)
101}
102
103/// Whether an entry under `spec/` is left out of the tree: its name begins with `.`, as an
104/// operating system's or an editor's own files do (`.DS_Store`, swap files).
105pub(crate) fn hidden(name: &str) -> bool {
106    name.starts_with('.')
107}