Skip to main content

loftspec_store/
openspec.rs

1//! Reading an `OpenSpec` directory into the files the `OpenSpec` converter takes: each `spec.md`
2//! below `specs/`, never through a symbolic link, `project.md` and `config.yaml`, the active
3//! changes and the number of archived ones. And writing an `OpenSpec` projection into a
4//! directory: its main specs under each of its roots and its render map. A root that already
5//! holds an entry is refused unless the caller replaces the main specs, which removes each
6//! `spec.md` under the root that the projection does not write.
7
8use std::collections::BTreeSet;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use loftspec::convert::openspec::Project;
13use loftspec::render::openspec::{OpenspecProjection, RENDER_MAP};
14
15use crate::StoreError;
16use crate::error::io_error;
17use crate::fs::{hidden, holds_entries, write_outputs};
18use loftspec::convert::openspec::layout::{ARCHIVE, CHANGES, CONFIG, PROJECT_MD, SPEC_FILE, SPECS};
19
20/// The files of the `OpenSpec` directory `dir` that the converter takes.
21///
22/// # Errors
23///
24/// `dir` cannot be read as a directory, or a file the converter takes cannot be read or is not
25/// UTF-8.
26pub fn read_project(dir: &Path) -> Result<Project, StoreError> {
27    fs::read_dir(dir).map_err(io_error(dir))?;
28    let mut specs = Vec::new();
29    walk(&dir.join(SPECS), "", &mut specs)?;
30    let changes = dir.join(CHANGES);
31    Ok(Project {
32        specs,
33        project_md: optional(&dir.join(PROJECT_MD))?,
34        config_yaml: optional(&dir.join(CONFIG))?,
35        active_changes: directories(&changes)
36            .filter(|name| name != ARCHIVE)
37            .collect(),
38        archived_changes: directories(&changes.join(ARCHIVE)).count()
39            + directories(&dir.join(ARCHIVE)).count(),
40    })
41}
42
43/// Each `spec.md` below `dir`, by its path below the walk's start, never through a symbolic
44/// link.
45fn walk(dir: &Path, prefix: &str, specs: &mut Vec<(String, String)>) -> Result<(), StoreError> {
46    let Ok(entries) = fs::read_dir(dir) else {
47        return Ok(());
48    };
49    let mut entries: Vec<_> = entries.flatten().collect();
50    entries.sort_by_key(fs::DirEntry::file_name);
51    for entry in entries {
52        let Ok(kind) = entry.file_type() else {
53            continue;
54        };
55        let name = entry.file_name().to_string_lossy().into_owned();
56        let path = format!("{prefix}{name}");
57        if kind.is_dir() {
58            walk(&entry.path(), &format!("{path}/"), specs)?;
59        } else if kind.is_file() && name == SPEC_FILE {
60            specs.push((path, text(&entry.path())?));
61        }
62    }
63    Ok(())
64}
65
66fn optional(path: &Path) -> Result<Option<String>, StoreError> {
67    if path.is_file() {
68        text(path).map(Some)
69    } else {
70        Ok(None)
71    }
72}
73
74fn text(path: &Path) -> Result<String, StoreError> {
75    let bytes = fs::read(path).map_err(io_error(path))?;
76    String::from_utf8(bytes).map_err(|_| StoreError::NotText(path.to_path_buf()))
77}
78
79/// The names of the directories directly in `dir`, sorted; none when `dir` does not exist.
80fn directories(dir: &Path) -> impl Iterator<Item = String> {
81    let mut names: Vec<String> = fs::read_dir(dir)
82        .into_iter()
83        .flatten()
84        .flatten()
85        .filter(|entry| entry.path().is_dir())
86        .map(|entry| entry.file_name().to_string_lossy().into_owned())
87        .collect();
88    names.sort();
89    names.into_iter()
90}
91
92/// What writing a projection did, each path inside the directory written to.
93#[derive(Debug, Default, PartialEq, Eq)]
94pub struct WrittenProjection {
95    pub written: Vec<PathBuf>,
96    pub unchanged: Vec<PathBuf>,
97    pub removed: Vec<PathBuf>,
98}
99
100/// Why a projection was not written.
101#[derive(Debug, thiserror::Error)]
102pub enum WriteProjectionError {
103    #[error("{0}: the directory is not empty; give `--force` to replace its main specs")]
104    NotEmpty(PathBuf),
105    #[error("{0}: a symbolic link; the projection is never written through one")]
106    Link(PathBuf),
107    #[error(transparent)]
108    Store(#[from] StoreError),
109}
110
111/// Writes `projection` into `dir`. A path to a file it writes that passes a symbolic link below
112/// `dir` is refused before anything is written, and so, without `replace`, is a root of the
113/// projection that holds an entry whose name does not begin with `.`. With `replace`, once every
114/// file is written, each `spec.md` under a root that the projection does not write is removed,
115/// never below an entry whose name begins with `.`, and each directory that leaves empty.
116///
117/// # Errors
118///
119/// A root that is not empty without `replace`, a symbolic link on the way to a file, a root that
120/// cannot be read, or a file that cannot be written or removed.
121pub fn write_projection(
122    dir: &Path,
123    projection: OpenspecProjection,
124    replace: bool,
125) -> Result<WrittenProjection, WriteProjectionError> {
126    let roots: Vec<PathBuf> = projection.roots.iter().map(PathBuf::from).collect();
127    let mut outputs: Vec<(PathBuf, Vec<u8>)> = projection
128        .files
129        .into_iter()
130        .map(|(path, text)| (PathBuf::from(path), text.into_bytes()))
131        .collect();
132    outputs.push((PathBuf::from(RENDER_MAP), projection.map.into_bytes()));
133    let mut paths = roots.iter().chain(outputs.iter().map(|(path, _)| path));
134    if let Some(link) = paths.find_map(|path| link_on(dir, path)) {
135        return Err(WriteProjectionError::Link(link));
136    }
137    if !replace {
138        for root in &roots {
139            let root = dir.join(root);
140            if root.exists() && holds_entries(&root)? {
141                return Err(WriteProjectionError::NotEmpty(root));
142            }
143        }
144    }
145    let kept: BTreeSet<PathBuf> = outputs.iter().map(|(path, _)| dir.join(path)).collect();
146    let outputs = write_outputs(dir, &outputs)?;
147    let mut removed = Vec::new();
148    if replace {
149        for root in &roots {
150            remove_others(&dir.join(root), &kept, &mut removed)?;
151        }
152    }
153    let inside = |path: PathBuf| {
154        path.strip_prefix(dir)
155            .map_or_else(|_| path.clone(), Path::to_path_buf)
156    };
157    Ok(WrittenProjection {
158        written: outputs.written,
159        unchanged: outputs.unchanged,
160        removed: removed.into_iter().map(inside).collect(),
161    })
162}
163
164/// The first symbolic link on the way from `dir` to `path` below it, `path` included; none when
165/// the way holds none or reaches a part that does not exist yet.
166fn link_on(dir: &Path, path: &Path) -> Option<PathBuf> {
167    let mut at = dir.to_path_buf();
168    for part in path.components() {
169        at.push(part);
170        match fs::symlink_metadata(&at) {
171            Ok(metadata) if metadata.file_type().is_symlink() => return Some(at),
172            Ok(_) => {}
173            Err(_) => return None,
174        }
175    }
176    None
177}
178
179/// Removes each `spec.md` below `dir` that is not `kept`, never through a symbolic link nor
180/// below an entry whose name begins with `.`, and each directory below `dir` that leaves empty.
181fn remove_others(
182    dir: &Path,
183    kept: &BTreeSet<PathBuf>,
184    removed: &mut Vec<PathBuf>,
185) -> Result<(), StoreError> {
186    let Ok(entries) = fs::read_dir(dir) else {
187        return Ok(());
188    };
189    let mut entries: Vec<_> = entries.flatten().collect();
190    entries.sort_by_key(fs::DirEntry::file_name);
191    for entry in entries {
192        let path = entry.path();
193        if entry.file_name().to_str().is_none_or(hidden) {
194            continue;
195        }
196        let kind = entry.file_type().map_err(io_error(&path))?;
197        if kind.is_dir() {
198            let before = removed.len();
199            remove_others(&path, kept, removed)?;
200            let empty = fs::read_dir(&path)
201                .map_err(io_error(&path))?
202                .next()
203                .is_none();
204            if removed.len() > before && empty {
205                fs::remove_dir(&path).map_err(io_error(&path))?;
206            }
207        } else if kind.is_file() && entry.file_name() == SPEC_FILE && !kept.contains(&path) {
208            fs::remove_file(&path).map_err(io_error(&path))?;
209            removed.push(path);
210        }
211    }
212    Ok(())
213}