Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
12
crates/adapters/assets/Cargo.toml
Normal file
12
crates/adapters/assets/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "assets"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
application = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
application = { workspace = true }
|
||||
113
crates/adapters/assets/src/lib.rs
Normal file
113
crates/adapters/assets/src/lib.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
use std::{
|
||||
env, fmt, fs, io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use application::{ApplicationError, LogoSourcePort};
|
||||
|
||||
const LOGO_ENV: &str = "DVD_LOGO";
|
||||
const CONFIG_DIR: &str = "dvd-thing";
|
||||
const LOGO_FILE: &str = "logo.png";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Platform {
|
||||
Xdg,
|
||||
MacOs,
|
||||
Windows,
|
||||
}
|
||||
|
||||
impl Platform {
|
||||
pub const CURRENT: Platform = if cfg!(windows) {
|
||||
Platform::Windows
|
||||
} else if cfg!(target_os = "macos") {
|
||||
Platform::MacOs
|
||||
} else {
|
||||
Platform::Xdg
|
||||
};
|
||||
}
|
||||
|
||||
pub fn config_path(platform: Platform, env: impl Fn(&str) -> Option<PathBuf>) -> Option<PathBuf> {
|
||||
let lookup = |key: &str| set(env(key));
|
||||
|
||||
let base = match platform {
|
||||
Platform::Xdg => absolute(lookup("XDG_CONFIG_HOME"))
|
||||
.or_else(|| lookup("HOME").map(|home| home.join(".config")))?,
|
||||
Platform::MacOs => absolute(lookup("XDG_CONFIG_HOME")).or_else(|| {
|
||||
lookup("HOME").map(|home| home.join("Library").join("Application Support"))
|
||||
})?,
|
||||
Platform::Windows => lookup("APPDATA")
|
||||
.or_else(|| lookup("USERPROFILE").map(|home| home.join("AppData").join("Roaming")))?,
|
||||
};
|
||||
|
||||
Some(base.join(CONFIG_DIR).join(LOGO_FILE))
|
||||
}
|
||||
|
||||
fn set(path: Option<PathBuf>) -> Option<PathBuf> {
|
||||
path.filter(|path| !path.as_os_str().is_empty())
|
||||
}
|
||||
|
||||
fn absolute(path: Option<PathBuf>) -> Option<PathBuf> {
|
||||
path.filter(|path| path.is_absolute())
|
||||
}
|
||||
|
||||
pub struct FileLogoSource {
|
||||
bytes: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl FileLogoSource {
|
||||
pub fn from_env() -> Result<Self, ApplicationError> {
|
||||
FileLogoSource::from_environment(Platform::CURRENT, &|key| {
|
||||
env::var_os(key).map(PathBuf::from)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn from_environment(
|
||||
platform: Platform,
|
||||
env: &impl Fn(&str) -> Option<PathBuf>,
|
||||
) -> Result<Self, ApplicationError> {
|
||||
FileLogoSource::new(set(env(LOGO_ENV)), config_path(platform, env))
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
explicit: Option<PathBuf>,
|
||||
configured: Option<PathBuf>,
|
||||
) -> Result<Self, ApplicationError> {
|
||||
let bytes = match (&explicit, &configured) {
|
||||
(Some(path), _) => Some(
|
||||
read(path)
|
||||
.map_err(|e| failed(path, &format!("{e} (from ${LOGO_ENV})")))?
|
||||
.ok_or_else(|| failed(path, &format!("no such file (from ${LOGO_ENV})")))?,
|
||||
),
|
||||
(None, Some(path)) => read(path).map_err(|e| failed(path, &e.to_string()))?,
|
||||
(None, None) => None,
|
||||
};
|
||||
|
||||
Ok(FileLogoSource { bytes })
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for FileLogoSource {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("FileLogoSource")
|
||||
.field("bytes", &self.bytes.as_ref().map(Vec::len))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl LogoSourcePort for FileLogoSource {
|
||||
fn load(&self) -> Result<Option<&[u8]>, ApplicationError> {
|
||||
Ok(self.bytes.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> io::Result<Option<Vec<u8>>> {
|
||||
match fs::read(path) {
|
||||
Ok(bytes) => Ok(Some(bytes)),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn failed(path: &Path, reason: &str) -> ApplicationError {
|
||||
ApplicationError::logo_source(format_args!("{}: {reason}", path.display()))
|
||||
}
|
||||
41
crates/adapters/assets/tests/common/mod.rs
Normal file
41
crates/adapters/assets/tests/common/mod.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
pub const LOGO_BYTES: &[u8] = b"pretend png";
|
||||
|
||||
pub fn path(value: &str) -> Option<PathBuf> {
|
||||
Some(PathBuf::from(value))
|
||||
}
|
||||
|
||||
pub fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<PathBuf> + use<> {
|
||||
let pairs: Vec<(String, PathBuf)> = pairs
|
||||
.iter()
|
||||
.map(|(key, value)| ((*key).to_owned(), PathBuf::from(value)))
|
||||
.collect();
|
||||
|
||||
move |key| {
|
||||
pairs
|
||||
.iter()
|
||||
.find(|(name, _)| name == key)
|
||||
.map(|(_, value)| value.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scratch(name: &str) -> PathBuf {
|
||||
let directory = std::env::temp_dir().join(format!("dvd-thing-test-{name}"));
|
||||
|
||||
let _ = fs::remove_dir_all(&directory);
|
||||
fs::create_dir_all(&directory).expect("temp directory must be creatable");
|
||||
|
||||
directory
|
||||
}
|
||||
|
||||
pub fn write_logo(directory: &Path) -> PathBuf {
|
||||
let file = directory.join("logo.png");
|
||||
fs::write(&file, LOGO_BYTES).expect("temp file must be writable");
|
||||
file
|
||||
}
|
||||
117
crates/adapters/assets/tests/config_path.rs
Normal file
117
crates/adapters/assets/tests/config_path.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
mod common;
|
||||
|
||||
use assets::{Platform, config_path};
|
||||
use common::{env_of, path};
|
||||
|
||||
#[test]
|
||||
fn xdg_prefers_config_home() {
|
||||
let env = env_of(&[("XDG_CONFIG_HOME", "/xdg"), ("HOME", "/home/user")]);
|
||||
|
||||
assert_eq!(
|
||||
config_path(Platform::Xdg, env),
|
||||
path("/xdg/dvd-thing/logo.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xdg_falls_back_to_home_dot_config() {
|
||||
let env = env_of(&[("HOME", "/home/user")]);
|
||||
|
||||
assert_eq!(
|
||||
config_path(Platform::Xdg, env),
|
||||
path("/home/user/.config/dvd-thing/logo.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xdg_ignores_a_relative_config_home() {
|
||||
let env = env_of(&[("XDG_CONFIG_HOME", "relative/dir"), ("HOME", "/home/user")]);
|
||||
|
||||
assert_eq!(
|
||||
config_path(Platform::Xdg, env),
|
||||
path("/home/user/.config/dvd-thing/logo.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_uses_application_support() {
|
||||
let env = env_of(&[("HOME", "/Users/gabriel")]);
|
||||
|
||||
assert_eq!(
|
||||
config_path(Platform::MacOs, env),
|
||||
path("/Users/gabriel/Library/Application Support/dvd-thing/logo.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_still_honours_an_explicit_xdg_config_home() {
|
||||
let env = env_of(&[("XDG_CONFIG_HOME", "/Users/gabriel/.config")]);
|
||||
|
||||
assert_eq!(
|
||||
config_path(Platform::MacOs, env),
|
||||
path("/Users/gabriel/.config/dvd-thing/logo.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_prefers_appdata() {
|
||||
let env = env_of(&[
|
||||
("APPDATA", r"C:\Users\gabriel\AppData\Roaming"),
|
||||
("USERPROFILE", r"C:\Users\gabriel"),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
config_path(Platform::Windows, env),
|
||||
path(r"C:\Users\gabriel\AppData\Roaming/dvd-thing/logo.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_falls_back_to_user_profile() {
|
||||
let env = env_of(&[("USERPROFILE", r"C:\Users\gabriel")]);
|
||||
|
||||
assert_eq!(
|
||||
config_path(Platform::Windows, env),
|
||||
path(r"C:\Users\gabriel/AppData/Roaming/dvd-thing/logo.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_ignores_unix_variables() {
|
||||
let env = env_of(&[("HOME", "/home/user"), ("XDG_CONFIG_HOME", "/xdg")]);
|
||||
|
||||
assert_eq!(config_path(Platform::Windows, env), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_environment_yields_no_path() {
|
||||
for platform in [Platform::Xdg, Platform::MacOs, Platform::Windows] {
|
||||
assert_eq!(config_path(platform, env_of(&[])), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_variable_counts_as_unset() {
|
||||
let env = env_of(&[("XDG_CONFIG_HOME", ""), ("HOME", "/home/user")]);
|
||||
|
||||
assert_eq!(
|
||||
config_path(Platform::Xdg, env),
|
||||
path("/home/user/.config/dvd-thing/logo.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_home_does_not_produce_a_relative_path() {
|
||||
assert_eq!(config_path(Platform::Xdg, env_of(&[("HOME", "")])), None);
|
||||
assert_eq!(config_path(Platform::MacOs, env_of(&[("HOME", "")])), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_appdata_falls_back_to_user_profile() {
|
||||
let env = env_of(&[("APPDATA", ""), ("USERPROFILE", r"C:\Users\gabriel")]);
|
||||
|
||||
assert_eq!(
|
||||
config_path(Platform::Windows, env),
|
||||
path(r"C:\Users\gabriel/AppData/Roaming/dvd-thing/logo.png")
|
||||
);
|
||||
}
|
||||
86
crates/adapters/assets/tests/loading.rs
Normal file
86
crates/adapters/assets/tests/loading.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
mod common;
|
||||
|
||||
use application::LogoSourcePort;
|
||||
use assets::{FileLogoSource, Platform};
|
||||
use common::{LOGO_BYTES, env_of, scratch, write_logo};
|
||||
|
||||
#[test]
|
||||
fn reads_an_explicit_path() {
|
||||
let logo = write_logo(&scratch("explicit"));
|
||||
|
||||
let source = FileLogoSource::new(Some(logo), None).unwrap();
|
||||
|
||||
assert_eq!(source.load().unwrap(), Some(LOGO_BYTES));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_explicit_path_is_an_error() {
|
||||
let missing = scratch("explicit-missing").join("absent.png");
|
||||
|
||||
let error = FileLogoSource::new(Some(missing), None).unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("DVD_LOGO"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_path_wins_over_the_configured_one() {
|
||||
let directory = scratch("explicit-wins");
|
||||
let explicit = directory.join("chosen.png");
|
||||
std::fs::write(&explicit, b"chosen").unwrap();
|
||||
|
||||
let source = FileLogoSource::new(Some(explicit), Some(write_logo(&directory))).unwrap();
|
||||
|
||||
assert_eq!(source.load().unwrap(), Some(b"chosen".as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_configured_path() {
|
||||
let logo = write_logo(&scratch("configured"));
|
||||
|
||||
let source = FileLogoSource::new(None, Some(logo)).unwrap();
|
||||
|
||||
assert_eq!(source.load().unwrap(), Some(LOGO_BYTES));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_configured_path_is_not_an_error() {
|
||||
let missing = scratch("configured-missing").join("absent.png");
|
||||
|
||||
let source = FileLogoSource::new(None, Some(missing)).unwrap();
|
||||
|
||||
assert_eq!(source.load().unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_paths_at_all_yields_nothing() {
|
||||
let source = FileLogoSource::new(None, None).unwrap();
|
||||
|
||||
assert_eq!(source.load().unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_configured_path_is_an_error() {
|
||||
let directory = scratch("configured-unreadable");
|
||||
std::fs::create_dir_all(directory.join("logo.png")).unwrap();
|
||||
|
||||
assert!(FileLogoSource::new(None, Some(directory.join("logo.png"))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_dvd_logo_variable_is_treated_as_unset() {
|
||||
let env = env_of(&[("DVD_LOGO", ""), ("HOME", "")]);
|
||||
|
||||
let source = FileLogoSource::from_environment(Platform::Xdg, &env).unwrap();
|
||||
|
||||
assert_eq!(source.load().unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_set_dvd_logo_variable_still_wins() {
|
||||
let logo = write_logo(&scratch("env-explicit"));
|
||||
let env = env_of(&[("DVD_LOGO", logo.to_str().unwrap()), ("HOME", "")]);
|
||||
|
||||
let source = FileLogoSource::from_environment(Platform::Xdg, &env).unwrap();
|
||||
|
||||
assert_eq!(source.load().unwrap(), Some(LOGO_BYTES));
|
||||
}
|
||||
Reference in New Issue
Block a user