use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow, bail};
use crate::config::{Config, Dest, Mode};
pub const DEFAULT_DIR_MODE: u32 = 0o755;
pub const DEFAULT_BIN_MODE: u32 = 0o755;
pub const DEFAULT_FILE_MODE: u32 = 0o644;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Item {
Dir { path: String },
File { path: String, source: PathBuf, mode: u32 },
}
impl Item {
pub fn path(&self) -> &str {
match self {
Item::Dir { path } | Item::File { path, .. } => path,
}
}
}
impl Config {
pub fn assets(&self, package_root: &Path, executables: &[PathBuf]) -> Result<Vec<Item>> {
let mut files: BTreeMap<String, Item> = BTreeMap::new();
for asset in &self.assets {
let dest =
Dest::parse(&asset.dest).with_context(|| format!("asset `{}`", asset.source))?;
let mode = asset
.mode
.as_ref()
.map(Mode::bits)
.transpose()
.with_context(|| format!("asset `{}`", asset.source))?;
for source in expand(package_root, &asset.source, &dest)? {
let name = file_name(&source)?;
let path = dest.resolve(&name);
let mode = mode.unwrap_or_else(|| default_mode(&source));
if let Some(Item::File { source: existing, .. }) = files.get(&path) {
bail!(
"two assets both land at `{path}` in the archive: `{}` and `{}`",
existing.display(),
source.display()
);
}
files.insert(path.clone(), Item::File { path, source, mode });
}
}
for executable in executables {
let name = file_name(executable)?;
let path = match self.bin_dir.is_empty() {
true => name,
false => format!("{}/{name}", self.bin_dir),
};
files.entry(path.clone()).or_insert(Item::File {
path,
source: executable.clone(),
mode: DEFAULT_BIN_MODE,
});
}
if files.is_empty() {
bail!("nothing to archive: no binaries and no assets");
}
Ok(with_directories(files))
}
}
fn with_directories(files: BTreeMap<String, Item>) -> Vec<Item> {
let mut items: BTreeMap<String, Item> = BTreeMap::new();
for (path, item) in files {
for (end, _) in path.match_indices('/') {
let prefix = &path[..end];
items
.entry(prefix.to_string())
.or_insert_with(|| Item::Dir { path: prefix.to_string() });
}
items.insert(path, item);
}
items.into_values().collect()
}
fn expand(package_root: &Path, source: &str, dest: &Dest) -> Result<Vec<PathBuf>> {
let pattern = package_root.join(source);
if !is_glob(source) {
if pattern.is_dir() {
bail!(
"asset source `{source}` is a directory; \
use a glob such as `{source}/*` to include its files"
);
}
if !pattern.exists() {
bail!("asset source `{source}` does not exist (looked in {})", package_root.display());
}
return Ok(vec![pattern]);
}
if !dest.is_dir {
bail!(
"asset source `{source}` is a glob, so its dest must end in `/` \
to name a directory"
);
}
let pattern = pattern
.to_str()
.ok_or_else(|| anyhow!("asset source `{source}` is not valid UTF-8"))?;
let mut matches = Vec::new();
for entry in glob::glob(pattern).with_context(|| format!("invalid glob `{source}`"))? {
let entry = entry.with_context(|| format!("failed to read a match for `{source}`"))?;
if entry.is_file() {
matches.push(entry);
}
}
if matches.is_empty() {
bail!("asset source `{source}` matched no files");
}
matches.sort();
Ok(matches)
}
fn is_glob(source: &str) -> bool {
source.contains(['*', '?', '['])
}
fn file_name(path: &Path) -> Result<String> {
match path.file_name().and_then(|name| name.to_str()) {
Some(name) => Ok(name.to_string()),
None => bail!("`{}` has no usable file name", path.display()),
}
}
fn default_mode(source: &Path) -> u32 {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = source.metadata()
&& metadata.permissions().mode() & 0o111 != 0
{
return DEFAULT_BIN_MODE;
}
}
let _ = source;
DEFAULT_FILE_MODE
}
#[cfg(test)]
mod tests {
use super::*;
fn dirs(items: &[Item]) -> Vec<&str> {
items
.iter()
.filter(|item| matches!(item, Item::Dir { .. }))
.map(Item::path)
.collect()
}
fn file(path: &str) -> (String, Item) {
(
path.to_string(),
Item::File {
path: path.to_string(),
source: PathBuf::from(path),
mode: 0o644,
},
)
}
#[test]
fn test_directories_are_synthesized_once_and_precede_their_files() {
let files = BTreeMap::from([
file("share/man/man1/iamb.1"),
file("share/man/man5/iamb.5"),
file("iamb"),
]);
let items = with_directories(files);
let paths: Vec<&str> = items.iter().map(Item::path).collect();
assert_eq!(
paths,
[
"iamb",
"share",
"share/man",
"share/man/man1",
"share/man/man1/iamb.1",
"share/man/man5",
"share/man/man5/iamb.5",
]
);
assert_eq!(dirs(&items), ["share", "share/man", "share/man/man1", "share/man/man5"]);
}
#[test]
fn test_sibling_names_still_nest_correctly() {
let items = with_directories(BTreeMap::from([file("share-extra/a"), file("share/b")]));
let paths: Vec<&str> = items.iter().map(Item::path).collect();
assert_eq!(paths, ["share", "share-extra", "share-extra/a", "share/b"]);
}
#[test]
fn test_globs_require_a_directory_dest() {
let dest = Dest::parse("themes/dark.toml").unwrap();
let message = expand(Path::new("."), "themes/*.toml", &dest).unwrap_err().to_string();
assert!(message.contains("must end in `/`"), "{message}");
}
#[test]
fn test_missing_sources_and_empty_globs_are_errors() {
let dir = Dest::parse("share/").unwrap();
let file = Dest::parse("share/thing").unwrap();
let missing = expand(Path::new("/nonexistent"), "thing", &file).unwrap_err().to_string();
assert!(missing.contains("does not exist"), "{missing}");
let empty = expand(Path::new("/nonexistent"), "*.toml", &dir).unwrap_err().to_string();
assert!(empty.contains("matched no files"), "{empty}");
}
#[test]
fn test_glob_detection() {
assert!(is_glob("themes/*.toml"));
assert!(is_glob("docs/iamb.?"));
assert!(is_glob("docs/[ab].1"));
assert!(!is_glob("docs/iamb.1"));
}
}