use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::fs;
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use super::git;
use super::sources;
use crate::core::config::{self, Settings};
use crate::core::paths;
use crate::core::report::Source as SourceStatus;
use crate::core::reqfile::{self, Check, GitRef, Location, Origin, Reqfile};
use crate::core::resolve::{self, Assets, Effective, Source};
pub struct Workspace {
pub root: PathBuf,
pub settings: Settings,
pub targets: BTreeSet<String>,
pub asset_files: BTreeSet<String>,
pub reqfiles: Vec<Reqfile>,
pub blocks: Vec<Effective>,
pub sources: Vec<SourceStatus>,
files: Files,
}
enum Files {
Worktree(PathBuf),
Commit(HashMap<String, Vec<u8>>),
}
impl Files {
fn read(&self, path: &str) -> Result<Vec<u8>, String> {
match self {
Files::Worktree(root) => {
fs::read(root.join(path)).map_err(|e| format!("{path}: cannot read: {e}"))
}
Files::Commit(contents) => contents
.get(path)
.cloned()
.ok_or_else(|| format!("{path}: not read from the commit")),
}
}
fn read_text(&self, path: &str) -> Result<String, String> {
String::from_utf8(self.read(path)?).map_err(|_| format!("{path}: not valid UTF-8"))
}
}
struct Discovered {
settings: Settings,
targets: BTreeSet<String>,
asset_files: BTreeSet<String>,
reqfiles: Vec<Reqfile>,
}
impl Workspace {
pub fn load(cwd: &Path) -> Result<Self, Vec<String>> {
let root = git::root(cwd).map_err(|e| vec![e])?;
let listed: Vec<String> = git::files(&root)
.map_err(|e| vec![e])?
.into_iter()
.filter(|p| fs::symlink_metadata(root.join(p)).is_ok_and(|m| m.is_file()))
.collect();
Self::from_files(root.clone(), listed, Files::Worktree(root))
}
pub fn at_commit(root: &Path, commit: &str) -> Result<Self, Vec<String>> {
let listed = git::commit_files(root, commit).map_err(|e| vec![e])?;
let needed: Vec<&str> = listed
.iter()
.map(String::as_str)
.filter(|p| read_ahead(p))
.collect();
let contents = git::read_at(root, commit, &needed).map_err(|e| vec![e])?;
Self::from_files(root.to_path_buf(), listed, Files::Commit(contents))
}
pub fn for_example(
root: PathBuf,
settings: Settings,
block: Effective,
) -> Result<Self, String> {
let targets = git::files(&root)?.into_iter().collect();
Ok(Self {
files: Files::Worktree(root.clone()),
root,
settings,
targets,
asset_files: BTreeSet::new(),
reqfiles: Vec::new(),
blocks: vec![block],
sources: Vec::new(),
})
}
fn from_files(root: PathBuf, listed: Vec<String>, files: Files) -> Result<Self, Vec<String>> {
let discovered = discover(&listed, &files)?;
let mut errors: Vec<String> = resolve::check_unique_definitions(&discovered.reqfiles)
.iter()
.map(ToString::to_string)
.collect();
let mut sources = Vec::new();
let mut statuses = Vec::new();
for (repo, reference) in git_locations(&discovered.reqfiles) {
match load_source(&root, &repo, &reference) {
Ok(source) => {
statuses.push(SourceStatus {
location: format!("{repo}@{}", reference.as_str()),
commit: source.commit.clone(),
offline: source.offline,
});
sources.push(source);
}
Err(e) => errors.extend(e),
}
}
if !errors.is_empty() {
return Err(errors);
}
let blocks = resolve::resolve(&discovered.reqfiles, &sources, &discovered.asset_files)
.map_err(|errors| errors.iter().map(ToString::to_string).collect::<Vec<_>>())?;
Ok(Self {
root,
settings: discovered.settings,
targets: discovered.targets,
asset_files: discovered.asset_files,
reqfiles: discovered.reqfiles,
blocks,
sources: statuses,
files,
})
}
pub fn repository_path(&self, cwd: &Path, path: &Path) -> Result<String, String> {
let cwd = cwd
.canonicalize()
.map_err(|e| format!("cannot resolve the current folder: {e}"))?;
let absolute = cwd.join(path);
let relative = absolute
.strip_prefix(&self.root)
.map_err(|_| format!("{} is outside the repository", path.display()))?;
paths::normalize(&relative.to_string_lossy())
.ok_or_else(|| format!("{} is outside the repository", path.display()))
}
pub fn assets_path(&self, block: &Effective) -> PathBuf {
match &block.assets {
Assets::Repo(dir) => self.root.join(dir),
Assets::External(dir) => dir.clone(),
}
}
pub fn fingerprint(&self, index: usize) -> Result<String, String> {
let block = &self.blocks[index];
let mut hasher = Sha256::new();
match &block.assets {
Assets::Repo(dir) => {
let prefix = format!("{dir}/");
let examples = format!("{dir}/examples/");
for path in self
.asset_files
.iter()
.filter(|p| p.starts_with(&prefix) && !p.starts_with(&examples))
{
hasher.update(format!("{}\n", &path[prefix.len()..]));
hasher.update(Sha256::digest(self.files.read(path)?));
}
}
Assets::External(dir) => {
for (relative, path) in walk(dir)? {
if relative.starts_with("examples/") {
continue;
}
hasher.update(format!("{relative}\n"));
let content = fs::read(&path)
.map_err(|e| format!("{}: cannot read: {e}", path.display()))?;
hasher.update(Sha256::digest(content));
}
}
}
let assets: String = hasher.finalize()[..8]
.iter()
.map(|b| format!("{b:02x}"))
.collect();
Ok(resolve::fingerprint(
block,
&assets,
&self.settings.fingerprint(
&block.dir,
block.checks.iter().any(|c| matches!(c, Check::Decision(_))),
),
))
}
}
fn read_ahead(path: &str) -> bool {
let name = paths::file_name(path);
let in_reqfile_folder = path.split('/').any(|part| part == ".reqfile");
name == reqfile::FILE_NAME
|| reqfile::is_near_miss(path)
|| (in_reqfile_folder && !path.contains("/examples/"))
}
fn git_locations(reqfiles: &[Reqfile]) -> BTreeSet<(String, GitRef)> {
reqfiles
.iter()
.flat_map(|r| &r.blocks)
.filter_map(|b| match &b.origin {
Origin::Use(Location::Git { repo, reference }) => {
Some((repo.clone(), reference.clone()))
}
_ => None,
})
.collect()
}
pub fn load_source(root: &Path, repo: &str, reference: &GitRef) -> Result<Source, Vec<String>> {
let fetched = sources::fetch(root, repo, reference).map_err(|e| vec![e])?;
let listed: Vec<String> = walk(&fetched.dir)
.map_err(|e| vec![e])?
.into_iter()
.map(|(relative, _)| relative)
.collect();
let location = format!("{repo}@{}", reference.as_str());
let discovered =
discover(&listed, &Files::Worktree(fetched.dir.clone())).map_err(|errors| {
errors
.into_iter()
.map(|e| format!("in {location}: {e}"))
.collect::<Vec<_>>()
})?;
Ok(Source {
repo: repo.to_string(),
reference: reference.clone(),
commit: fetched.commit,
offline: fetched.offline,
root: fetched.dir,
reqfiles: discovered.reqfiles,
})
}
fn walk(dir: &Path) -> Result<Vec<(String, PathBuf)>, String> {
let mut found = BTreeMap::new();
let mut pending = vec![dir.to_path_buf()];
while let Some(folder) = pending.pop() {
let entries =
fs::read_dir(&folder).map_err(|e| format!("cannot read {}: {e}", folder.display()))?;
for entry in entries {
let entry = entry.map_err(|e| format!("cannot read {}: {e}", folder.display()))?;
let kind = entry
.file_type()
.map_err(|e| format!("cannot read {}: {e}", entry.path().display()))?;
let path = entry.path();
if kind.is_dir() {
pending.push(path);
} else if kind.is_file() {
let relative = path
.strip_prefix(dir)
.expect("walked paths are under the folder")
.to_string_lossy()
.into_owned();
found.insert(relative, path);
}
}
}
Ok(found.into_iter().collect())
}
fn discover(listed: &[String], files: &Files) -> Result<Discovered, Vec<String>> {
let mut errors = Vec::new();
let settings = load_settings(listed, files, &mut errors);
let targets: BTreeSet<String> = listed
.iter()
.filter(|p| !settings.is_excluded(p))
.cloned()
.collect();
let asset_files = listed
.iter()
.filter(|p| p.split('/').any(|part| part == ".reqfile"))
.filter(|p| settings.excluded_by(p).is_none())
.cloned()
.collect();
let reqfiles = load_reqfiles(&targets, files, &mut errors);
if errors.is_empty() {
Ok(Discovered {
settings,
targets,
asset_files,
reqfiles,
})
} else {
Err(errors)
}
}
fn load_settings(listed: &[String], files: &Files, errors: &mut Vec<String>) -> Settings {
let mut configs: Vec<(&str, &String)> = listed
.iter()
.filter_map(|p| config::config_dir(p).map(|dir| (dir, p)))
.collect();
configs.sort_by_key(|(dir, _)| config::depth(dir));
let mut settings = Settings::default();
for (_, path) in configs {
if settings.excluded_by(path).is_some() {
continue;
}
match files
.read_text(path)
.and_then(|text| config::parse(path, &text).map_err(|e| e.to_string()))
{
Ok(config) => settings.add(config),
Err(e) => errors.push(e),
}
}
for path in listed
.iter()
.filter(|p| config::is_near_miss(p) && settings.excluded_by(p).is_none())
{
errors.push(format!(
"{path}:1: a config file must be named exactly .reqfile/config.yaml"
));
}
settings
}
fn load_reqfiles(
targets: &BTreeSet<String>,
files: &Files,
errors: &mut Vec<String>,
) -> Vec<Reqfile> {
for path in targets.iter().filter(|p| reqfile::is_near_miss(p)) {
match files.read_text(path) {
Ok(text) if !reqfile::looks_like_reqfile(&text) => {}
Ok(_) => errors.push(format!(
"{path}:1: a requirements file must be named exactly {}",
reqfile::FILE_NAME
)),
Err(e) => errors.push(e),
}
}
let mut reqfiles = Vec::new();
for path in targets
.iter()
.filter(|p| paths::file_name(p) == reqfile::FILE_NAME)
{
match files
.read_text(path)
.and_then(|text| reqfile::parse(path, &text).map_err(|e| e.to_string()))
{
Ok(reqfile) => reqfiles.push(reqfile),
Err(e) => errors.push(e),
}
}
reqfiles
}