use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use loftspec::convert::openspec::Project;
use loftspec::render::openspec::{OpenspecProjection, RENDER_MAP};
use crate::StoreError;
use crate::error::io_error;
use crate::fs::{hidden, holds_entries, write_outputs};
use loftspec::convert::openspec::layout::{ARCHIVE, CHANGES, CONFIG, PROJECT_MD, SPEC_FILE, SPECS};
pub fn read_project(dir: &Path) -> Result<Project, StoreError> {
fs::read_dir(dir).map_err(io_error(dir))?;
let mut specs = Vec::new();
walk(&dir.join(SPECS), "", &mut specs)?;
let changes = dir.join(CHANGES);
Ok(Project {
specs,
project_md: optional(&dir.join(PROJECT_MD))?,
config_yaml: optional(&dir.join(CONFIG))?,
active_changes: directories(&changes)
.filter(|name| name != ARCHIVE)
.collect(),
archived_changes: directories(&changes.join(ARCHIVE)).count()
+ directories(&dir.join(ARCHIVE)).count(),
})
}
fn walk(dir: &Path, prefix: &str, specs: &mut Vec<(String, String)>) -> Result<(), StoreError> {
let Ok(entries) = fs::read_dir(dir) else {
return Ok(());
};
let mut entries: Vec<_> = entries.flatten().collect();
entries.sort_by_key(fs::DirEntry::file_name);
for entry in entries {
let Ok(kind) = entry.file_type() else {
continue;
};
let name = entry.file_name().to_string_lossy().into_owned();
let path = format!("{prefix}{name}");
if kind.is_dir() {
walk(&entry.path(), &format!("{path}/"), specs)?;
} else if kind.is_file() && name == SPEC_FILE {
specs.push((path, text(&entry.path())?));
}
}
Ok(())
}
fn optional(path: &Path) -> Result<Option<String>, StoreError> {
if path.is_file() {
text(path).map(Some)
} else {
Ok(None)
}
}
fn text(path: &Path) -> Result<String, StoreError> {
let bytes = fs::read(path).map_err(io_error(path))?;
String::from_utf8(bytes).map_err(|_| StoreError::NotText(path.to_path_buf()))
}
fn directories(dir: &Path) -> impl Iterator<Item = String> {
let mut names: Vec<String> = fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.filter(|entry| entry.path().is_dir())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect();
names.sort();
names.into_iter()
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct WrittenProjection {
pub written: Vec<PathBuf>,
pub unchanged: Vec<PathBuf>,
pub removed: Vec<PathBuf>,
}
#[derive(Debug, thiserror::Error)]
pub enum WriteProjectionError {
#[error("{0}: the directory is not empty; give `--force` to replace its main specs")]
NotEmpty(PathBuf),
#[error("{0}: a symbolic link; the projection is never written through one")]
Link(PathBuf),
#[error(transparent)]
Store(#[from] StoreError),
}
pub fn write_projection(
dir: &Path,
projection: OpenspecProjection,
replace: bool,
) -> Result<WrittenProjection, WriteProjectionError> {
let roots: Vec<PathBuf> = projection.roots.iter().map(PathBuf::from).collect();
let mut outputs: Vec<(PathBuf, Vec<u8>)> = projection
.files
.into_iter()
.map(|(path, text)| (PathBuf::from(path), text.into_bytes()))
.collect();
outputs.push((PathBuf::from(RENDER_MAP), projection.map.into_bytes()));
let mut paths = roots.iter().chain(outputs.iter().map(|(path, _)| path));
if let Some(link) = paths.find_map(|path| link_on(dir, path)) {
return Err(WriteProjectionError::Link(link));
}
if !replace {
for root in &roots {
let root = dir.join(root);
if root.exists() && holds_entries(&root)? {
return Err(WriteProjectionError::NotEmpty(root));
}
}
}
let kept: BTreeSet<PathBuf> = outputs.iter().map(|(path, _)| dir.join(path)).collect();
let outputs = write_outputs(dir, &outputs)?;
let mut removed = Vec::new();
if replace {
for root in &roots {
remove_others(&dir.join(root), &kept, &mut removed)?;
}
}
let inside = |path: PathBuf| {
path.strip_prefix(dir)
.map_or_else(|_| path.clone(), Path::to_path_buf)
};
Ok(WrittenProjection {
written: outputs.written,
unchanged: outputs.unchanged,
removed: removed.into_iter().map(inside).collect(),
})
}
fn link_on(dir: &Path, path: &Path) -> Option<PathBuf> {
let mut at = dir.to_path_buf();
for part in path.components() {
at.push(part);
match fs::symlink_metadata(&at) {
Ok(metadata) if metadata.file_type().is_symlink() => return Some(at),
Ok(_) => {}
Err(_) => return None,
}
}
None
}
fn remove_others(
dir: &Path,
kept: &BTreeSet<PathBuf>,
removed: &mut Vec<PathBuf>,
) -> Result<(), StoreError> {
let Ok(entries) = fs::read_dir(dir) else {
return Ok(());
};
let mut entries: Vec<_> = entries.flatten().collect();
entries.sort_by_key(fs::DirEntry::file_name);
for entry in entries {
let path = entry.path();
if entry.file_name().to_str().is_none_or(hidden) {
continue;
}
let kind = entry.file_type().map_err(io_error(&path))?;
if kind.is_dir() {
let before = removed.len();
remove_others(&path, kept, removed)?;
let empty = fs::read_dir(&path)
.map_err(io_error(&path))?
.next()
.is_none();
if removed.len() > before && empty {
fs::remove_dir(&path).map_err(io_error(&path))?;
}
} else if kind.is_file() && entry.file_name() == SPEC_FILE && !kept.contains(&path) {
fs::remove_file(&path).map_err(io_error(&path))?;
removed.push(path);
}
}
Ok(())
}