use globset::{Glob, GlobSet, GlobSetBuilder};
use std::fs;
use std::path::Path;
use crate::config::StaticSpec;
use crate::{Config, Error, Result};
const CONFIG_FILE_NAMES: [&str; 4] = [
"_config.json",
"_fauxrest.json",
".config.json",
".fauxrest.json",
];
struct StaticPolicy {
include: GlobSet,
has_include: bool,
exclude: GlobSet,
allow_all: bool,
}
impl StaticPolicy {
fn build(spec: Option<&StaticSpec>, allow_all: bool) -> Result<Self> {
let (includes, excludes): (&[String], &[String]) = match spec {
Some(s) => (s.include(), s.exclude()),
None => (&[], &[]),
};
Ok(Self {
include: build_glob_set(includes)?,
has_include: !includes.is_empty(),
exclude: build_glob_set(excludes)?,
allow_all,
})
}
fn is_noop(&self) -> bool {
!self.allow_all && !self.has_include
}
fn should_copy(&self, rel: &str) -> bool {
if self.exclude.is_match(rel) {
return false;
}
if self.allow_all {
return true;
}
self.has_include && self.include.is_match(rel)
}
}
fn build_glob_set(patterns: &[String]) -> Result<GlobSet> {
let mut builder = GlobSetBuilder::new();
for pattern in patterns {
let glob = Glob::new(pattern).map_err(|e| {
Error::Config(format!("invalid $static glob pattern '{}': {}", pattern, e))
})?;
builder.add(glob);
}
builder
.build()
.map_err(|e| Error::Config(format!("failed to build $static glob set: {}", e)))
}
pub(crate) fn copy_static_files(config: &Config, data_dir: &Path) -> Result<()> {
let policy = StaticPolicy::build(config.static_files.as_ref(), config.copy_static_all)?;
if policy.is_noop() {
return Ok(());
}
let dests: Vec<&Path> = config
.serializers
.iter()
.map(|s| s.dest.as_path())
.collect();
if dests.is_empty() {
return Ok(());
}
walk_and_copy(data_dir, data_dir, &policy, &dests)
}
fn walk_and_copy(root: &Path, dir: &Path, policy: &StaticPolicy, dests: &[&Path]) -> Result<()> {
let entries = fs::read_dir(dir).map_err(Error::Io)?;
for entry in entries {
let entry = entry.map_err(Error::Io)?;
let path = entry.path();
let file_type = entry.file_type().map_err(Error::Io)?;
if file_type.is_dir() {
walk_and_copy(root, &path, policy, dests)?;
continue;
}
if !file_type.is_file() {
continue;
}
let file_name = entry.file_name();
let file_name = file_name.to_string_lossy();
if is_always_excluded(&file_name) {
continue;
}
let Some(rel) = relative_slash_path(root, &path) else {
continue;
};
if !policy.should_copy(&rel) {
continue;
}
for dest in dests {
let target = dest.join(&rel);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(Error::Io)?;
}
fs::copy(&path, &target).map_err(Error::Io)?;
}
}
Ok(())
}
fn is_always_excluded(file_name: &str) -> bool {
if CONFIG_FILE_NAMES.contains(&file_name) {
return true;
}
Path::new(file_name)
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
}
fn relative_slash_path(root: &Path, path: &Path) -> Option<String> {
let rel = path.strip_prefix(root).ok()?;
let parts: Vec<String> = rel
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect();
if parts.is_empty() {
None
} else {
Some(parts.join("/"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::StaticConfig;
fn spec_include(patterns: &[&str]) -> StaticSpec {
StaticSpec::Include(patterns.iter().map(|s| s.to_string()).collect())
}
fn spec_detailed(include: &[&str], exclude: &[&str]) -> StaticSpec {
StaticSpec::Detailed(StaticConfig {
include: include.iter().map(|s| s.to_string()).collect(),
exclude: exclude.iter().map(|s| s.to_string()).collect(),
})
}
#[test]
fn test_default_policy_copies_nothing() {
let policy = StaticPolicy::build(None, false).unwrap();
assert!(policy.is_noop());
assert!(!policy.should_copy("logo.png"));
}
#[test]
fn test_include_glob_allows_matching_files() {
let spec = spec_include(&["*.png", "css/**"]);
let policy = StaticPolicy::build(Some(&spec), false).unwrap();
assert!(!policy.is_noop());
assert!(policy.should_copy("logo.png"));
assert!(policy.should_copy("css/site.css"));
assert!(!policy.should_copy("notes.txt"));
}
#[test]
fn test_exclude_wins_over_allow_all() {
let spec = spec_detailed(&[], &["secret/**"]);
let policy = StaticPolicy::build(Some(&spec), true).unwrap();
assert!(policy.should_copy("logo.png"));
assert!(!policy.should_copy("secret/key.pem"));
}
#[test]
fn test_exclude_wins_over_include() {
let spec = spec_detailed(&["**/*.png"], &["private/**"]);
let policy = StaticPolicy::build(Some(&spec), false).unwrap();
assert!(policy.should_copy("img/logo.png"));
assert!(!policy.should_copy("private/logo.png"));
}
#[test]
fn test_always_excluded_files() {
assert!(is_always_excluded("users.json"));
assert!(is_always_excluded("_config.json"));
assert!(is_always_excluded(".fauxrest.json"));
assert!(!is_always_excluded("logo.png"));
assert!(!is_always_excluded("style.css"));
}
#[test]
fn test_invalid_glob_reports_config_error() {
let spec = spec_include(&["a[b"]);
let err = match StaticPolicy::build(Some(&spec), false) {
Ok(_) => panic!("invalid glob should be rejected"),
Err(e) => e,
};
assert!(matches!(err, Error::Config(_)));
assert!(format!("{}", err).contains("invalid $static glob"));
}
}