use std::path::{Path, PathBuf};
use henad_core::explore::fingerprint::Fnv1a64;
use super::git::{Repository, list_sources};
const VCS_INFO: &str = ".cargo_vcs_info.json";
const ORIGINAL_MANIFEST: &str = "Cargo.toml.orig";
const GENERATED_MANIFEST_HEADER: &str = "# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO";
#[derive(Debug, Clone)]
pub(crate) struct CrateFiles {
directory: PathBuf,
label_prefix: String,
}
impl CrateFiles {
pub(crate) fn new(directory: &Path) -> Self {
Self {
directory: directory.to_path_buf(),
label_prefix: String::new(),
}
}
pub(crate) fn in_repository(repository: &Repository, package: &str) -> Self {
Self {
directory: repository.toplevel().join("crates").join(package),
label_prefix: format!("crates/{package}/"),
}
}
pub(crate) fn directory(&self) -> &Path {
&self.directory
}
pub(crate) fn is_package(&self) -> bool {
let generated = || {
std::fs::read_to_string(self.directory.join("Cargo.toml"))
.is_ok_and(|manifest| manifest.starts_with(GENERATED_MANIFEST_HEADER))
};
self.directory.join(VCS_INFO).is_file() || (self.directory.join(ORIGINAL_MANIFEST).is_file() && generated())
}
pub(crate) fn sources(&self, repository: Option<&Repository>) -> Vec<(String, PathBuf)> {
let listed = match repository {
Some(_) => list_sources(&self.directory),
None => walk(&self.directory.join("src")),
};
let mut sources: Vec<(String, PathBuf)> = listed
.into_iter()
.filter(|below_src| !is_excluded(below_src))
.map(|below_src| {
let path = self.directory.join("src").join(&below_src);
(format!("{}src/{below_src}", self.label_prefix), path)
})
.collect();
let manifest = if self.is_package() {
ORIGINAL_MANIFEST
} else {
"Cargo.toml"
};
sources.push((
format!("{}Cargo.toml", self.label_prefix),
self.directory.join(manifest),
));
sources
}
pub(crate) fn watched(&self) -> Vec<PathBuf> {
["src", "Cargo.toml"]
.into_iter()
.map(|name| self.directory.join(name))
.filter(|path| path.exists())
.collect()
}
pub(crate) fn pathspecs(&self, repository: &Repository) -> Vec<String> {
let below_root = if self.label_prefix.is_empty() {
repository.prefix()
} else {
&self.label_prefix
};
["src", "Cargo.toml"]
.into_iter()
.map(|name| format!("{below_root}{name}"))
.collect()
}
}
pub(crate) fn is_excluded(below_src: &str) -> bool {
let mut components = below_src.split('/');
let name = below_src.rsplit('/').next().unwrap_or(below_src);
let autosave = name.len() > 1 && name.starts_with('#') && name.ends_with('#');
components.any(|component| component.starts_with('.'))
|| autosave
|| ["~", ".swp", ".orig", ".rej"]
.iter()
.any(|suffix| name.ends_with(suffix))
}
pub(crate) fn is_link_to_no_file(path: &Path) -> bool {
std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink())
&& !std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file())
}
fn walk(src: &Path) -> Vec<String> {
let mut found = Vec::new();
let mut pending = vec![(src.to_path_buf(), String::new())];
while let Some((directory, prefix)) = pending.pop() {
let Ok(entries) = std::fs::read_dir(&directory) else {
continue;
};
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
let relative = format!("{prefix}{name}");
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
pending.push((entry.path(), format!("{relative}/")));
} else {
found.push(relative);
}
}
}
found
}
pub(crate) fn hash_sources(mut sources: Vec<(String, PathBuf)>) -> Option<u64> {
sources.sort_by(|left, right| left.0.cmp(&right.0));
sources.dedup_by(|left, right| left.0 == right.0);
let mut hasher = Fnv1a64::new();
for (label, path) in &sources {
if !std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) {
continue;
}
let bytes = match std::fs::read(path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(_) => return None,
};
let normalized = crlf_to_lf(&bytes);
hasher.write_str(label);
hasher.write_u64(normalized.len() as u64);
hasher.write(&normalized);
}
Some(hasher.finish())
}
fn crlf_to_lf(bytes: &[u8]) -> Vec<u8> {
let mut normalized = Vec::with_capacity(bytes.len());
let mut iterator = bytes.iter().peekable();
while let Some(&byte) = iterator.next() {
if byte == b'\r' && iterator.peek() == Some(&&b'\n') {
continue;
}
normalized.push(byte);
}
normalized
}
pub(crate) fn nearest_lockfile(directory: &Path) -> Option<PathBuf> {
directory
.ancestors()
.map(|ancestor| ancestor.join("Cargo.lock"))
.find(|lockfile| lockfile.is_file())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct VcsInfo {
pub(crate) sha1: String,
pub(crate) dirty: bool,
}
impl VcsInfo {
pub(crate) fn read(directory: &Path) -> Option<Self> {
Self::parse(&std::fs::read_to_string(directory.join(VCS_INFO)).ok()?)
}
pub(crate) fn parse(text: &str) -> Option<Self> {
let sha1 = field_value(text, "sha1")?
.strip_prefix('"')?
.split('"')
.next()?
.to_owned();
if sha1.is_empty() || !sha1.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return None;
}
let dirty = field_value(text, "dirty").is_some_and(|value| value.starts_with("true"));
Some(Self { sha1, dirty })
}
pub(crate) fn short_commit(&self) -> &str {
self.sha1.get(..8).unwrap_or(&self.sha1)
}
}
fn field_value<'a>(text: &'a str, key: &str) -> Option<&'a str> {
let after_key = &text[text.find(&format!("\"{key}\""))? + key.len() + 2..];
Some(after_key.trim_start().strip_prefix(':')?.trim_start())
}