loftspec_store/
openspec.rs1use 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
20pub 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
43fn 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
79fn 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#[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#[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
111pub 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
164fn 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
179fn 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}