use std::path::{Path, PathBuf};
use crate::library::{Id, Layout, Shape};
use crate::locale;
pub(crate) const ANCESTORS: usize = 8;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Located {
pub(crate) path: PathBuf,
pub(crate) name: String,
pub(crate) locale: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Reading {
pub(crate) files: Vec<Located>,
pub(crate) shape: Shape,
pub(crate) keys_are_source: bool,
}
pub(crate) fn read(inputs: &[PathBuf], anchor: &Path, library: Id) -> Result<Reading, String> {
let row = library.library();
let mut refusal = None;
for layout in row.layouts {
match read_layout(inputs, anchor, layout) {
Ok(Some(reading)) => return Ok(reading),
Ok(None) => {}
Err(problem) => refusal = Some(problem),
}
}
if let Some(problem) = refusal {
return Err(problem);
}
let empty = row.layouts.iter().all(|layout| match layout.shape {
Shape::Shared { extension }
| Shape::Fixed { extension, .. }
| Shape::Namespaced { extension } => collect(inputs, extension).is_empty(),
});
if empty && let Some(layout) = row.layouts.first() {
return Ok(Reading {
files: Vec::new(),
shape: layout.shape,
keys_are_source: layout.keys_are_source,
});
}
Err(format!(
"{} was identified, but none of its layouts reads the files here ({}).",
library.as_str(),
row.layouts
.iter()
.map(|layout| describe_shape(layout.shape))
.collect::<Vec<_>>()
.join(", ")
))
}
pub(crate) fn read_layout(
inputs: &[PathBuf],
anchor: &Path,
layout: &Layout,
) -> Result<Option<Reading>, String> {
match layout.shape {
Shape::Shared { extension } => shared(inputs, extension, layout),
Shape::Fixed { prefix, extension } => fixed(inputs, anchor, prefix, extension, layout),
Shape::Namespaced { extension } => namespaced(inputs, extension, layout),
}
}
pub(crate) fn describe_shape(shape: Shape) -> String {
match shape {
Shape::Shared { extension } => format!("a directory of <locale>.{extension}"),
Shape::Fixed { prefix, extension } => format!("{prefix}.<locale>.{extension}"),
Shape::Namespaced { extension } => format!("<locale>/<namespace>.{extension}"),
}
}
fn shared(inputs: &[PathBuf], extension: &str, layout: &Layout) -> Result<Option<Reading>, String> {
let files = collect(inputs, extension);
if files.is_empty() {
return Ok(None);
}
one_directory(&files)?;
let names: Vec<String> = files.iter().map(|path| name_of(path)).collect();
let locales = locale::locales_of(&names)?;
Ok(Some(Reading {
files: zip(&files, names, locales),
shape: layout.shape,
keys_are_source: layout.keys_are_source,
}))
}
fn fixed(
inputs: &[PathBuf],
anchor: &Path,
prefix: &str,
extension: &str,
layout: &Layout,
) -> Result<Option<Reading>, String> {
let mut files: Vec<PathBuf> = collect(inputs, extension)
.into_iter()
.filter(|path| name_of(path).starts_with(prefix))
.collect();
if files.is_empty() {
return Ok(None);
}
let base = format!("{prefix}.{extension}");
if !files.iter().any(|path| name_of(path) == base) {
let found = anchor
.ancestors()
.take(ANCESTORS)
.map(|directory| directory.join(&base))
.find(|candidate| candidate.is_file());
if let Some(found) = found {
files.push(found);
}
}
files.sort();
files.dedup();
let names: Vec<String> = files.iter().map(|path| name_of(path)).collect();
let locales = names
.iter()
.map(|name| locale::from_prefix(name, prefix))
.collect::<Result<Vec<_>, _>>()?;
Ok(Some(Reading {
files: zip(&files, names, locales),
shape: layout.shape,
keys_are_source: layout.keys_are_source,
}))
}
fn namespaced(
inputs: &[PathBuf],
extension: &str,
layout: &Layout,
) -> Result<Option<Reading>, String> {
let [root] = inputs else {
return Ok(None);
};
if !root.is_dir() {
return Ok(None);
}
let Some(directories) = locale_directories(root) else {
return Ok(None);
};
let mut namespaces: Vec<String> = Vec::new();
let mut files = Vec::new();
for directory in &directories {
for path in catalogues_in(directory)
.into_iter()
.filter(|path| has_extension(path, extension))
{
let name = name_of(&path);
if !namespaces.contains(&name) {
namespaces.push(name);
}
files.push(path);
}
}
if files.is_empty() {
return Ok(None);
}
if namespaces.len() > 1 {
namespaces.sort();
return Err(format!(
"this set has {} namespaces ({}) and a namespace is its own set. \
Name one namespace's files.",
namespaces.len(),
namespaces.join(", ")
));
}
Ok(Some(Reading {
files: files.iter().map(|path| in_locale_directory(path)).collect(),
shape: layout.shape,
keys_are_source: layout.keys_are_source,
}))
}
fn locale_directories(root: &Path) -> Option<Vec<PathBuf>> {
let mut directories = Vec::new();
for entry in std::fs::read_dir(root).into_iter().flatten().flatten() {
if !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
continue;
}
locale::canonicalise(&entry.file_name().to_string_lossy())?;
directories.push(entry.path());
}
if directories.is_empty() {
return None;
}
directories.sort();
Some(directories)
}
fn in_locale_directory(path: &Path) -> Located {
let tag = path
.parent()
.and_then(Path::file_name)
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
Located {
name: format!("{tag}/{}", name_of(path)),
locale: locale::canonicalise(&tag),
path: path.to_path_buf(),
}
}
fn collect(inputs: &[PathBuf], extension: &str) -> Vec<PathBuf> {
let mut files = Vec::new();
for input in inputs {
if input.is_file() {
files.push(input.clone());
continue;
}
files.extend(
catalogues_in(input)
.into_iter()
.filter(|path| has_extension(path, extension)),
);
}
files.sort();
files.dedup();
files
}
pub(crate) fn catalogues_in(directory: &Path) -> Vec<PathBuf> {
let mut found = Vec::new();
for entry in std::fs::read_dir(directory).into_iter().flatten().flatten() {
if !entry.file_type().is_ok_and(|kind| kind.is_file()) {
continue;
}
let path = entry.path();
if path
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension == "json" || extension == "arb")
{
found.push(path);
}
}
found.sort();
found
}
fn has_extension(path: &Path, extension: &str) -> bool {
path.extension()
.and_then(|found| found.to_str())
.is_some_and(|found| found == extension)
}
fn zip(files: &[PathBuf], names: Vec<String>, locales: Vec<Option<String>>) -> Vec<Located> {
files
.iter()
.zip(names)
.zip(locales)
.map(|((path, name), locale)| Located {
path: path.clone(),
name,
locale,
})
.collect()
}
fn one_directory(files: &[PathBuf]) -> Result<(), String> {
let directories: Vec<PathBuf> =
files
.iter()
.map(|file| directory_of(file))
.fold(Vec::new(), |mut seen, parent| {
if !seen.contains(&parent) {
seen.push(parent);
}
seen
});
if directories.len() > 1 {
return Err(format!(
"these files are in {} directories, and a set read by what its names share is one \
directory. Audit them one directory at a time.",
directories.len()
));
}
Ok(())
}
fn directory_of(file: &Path) -> PathBuf {
match file.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
_ => PathBuf::from("."),
}
}
fn name_of(path: &Path) -> String {
path.file_name()
.unwrap_or(path.as_os_str())
.to_string_lossy()
.into_owned()
}
pub(crate) fn read_text(path: &Path) -> Result<String, String> {
let bytes = std::fs::read(path).map_err(|error| error.to_string())?;
String::from_utf8(bytes).map_err(|_| "not UTF-8 text".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn two_spellings_of_here_are_one_directory() {
let files = vec![PathBuf::from("en.json"), PathBuf::from("./de.json")];
assert!(one_directory(&files).is_ok(), "{:?}", one_directory(&files));
let split = vec![PathBuf::from("en.json"), PathBuf::from("locales/de.json")];
assert!(
one_directory(&split).is_err(),
"a real split is still refused"
);
}
}