use std::collections::BTreeMap;
use crate::error::{Error, Result};
use crate::java::parse_java_properties;
pub const MAXIMUM_FOLDER_NAME_LENGTH: usize = 127;
pub const INDEX_DETAILS_FILE_NAME: &str = "index-details.txt";
pub const INDEXER_INFO_FILE_NAME: &str = "indexer-info.properties";
pub const DIRECTORY_MAPPING_PREFIX: &str = "dir.";
pub const MAXIMUM_METADATA_FILE_BYTES: usize = 1 << 20;
#[must_use]
pub fn index_folder_base_name(index_path: &str) -> String {
let mut elements: Vec<&str> = index_path
.split('/')
.filter(|element| !element.is_empty())
.collect();
elements.reverse();
let mut result: Vec<String> = elements
.into_iter()
.take(3)
.filter(|element| *element != "oak:index")
.map(filesystem_safe_name)
.collect();
result.reverse();
let name = result.join("_");
name.chars().take(MAXIMUM_FOLDER_NAME_LENGTH).collect()
}
#[must_use]
pub fn filesystem_safe_name(element: &str) -> String {
element
.chars()
.filter(|character| character.is_ascii_alphanumeric() || *character == '_')
.collect()
}
#[must_use]
pub fn filesystem_directory_name(jcr_name: &str) -> String {
jcr_name.replace(':', "")
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct IndexDetails {
pub meta_format_version: i64,
pub index_path: String,
pub creation_time: i64,
pub directory_mappings: BTreeMap<String, String>,
}
impl IndexDetails {
pub fn parse(content: &str) -> Result<Self> {
let properties = parse_metadata(content, INDEX_DETAILS_FILE_NAME)?;
let mut directory_mappings = BTreeMap::new();
for (key, value) in &properties {
if let Some(filesystem_name) = key.strip_prefix(DIRECTORY_MAPPING_PREFIX) {
directory_mappings.insert(filesystem_name.to_owned(), value.clone());
}
}
Ok(Self {
meta_format_version: numeric(&properties, "metaFormatVersion").unwrap_or(1),
index_path: properties.get("indexPath").cloned().unwrap_or_default(),
creation_time: numeric(&properties, "creationTime").unwrap_or(0),
directory_mappings,
})
}
#[must_use]
pub fn render(&self) -> String {
let mut properties: BTreeMap<String, String> = self
.directory_mappings
.iter()
.map(|(filesystem_name, jcr_name)| {
(
format!("{DIRECTORY_MAPPING_PREFIX}{filesystem_name}"),
jcr_name.clone(),
)
})
.collect();
properties.insert(
"metaFormatVersion".to_owned(),
self.meta_format_version.to_string(),
);
properties.insert("indexPath".to_owned(), self.index_path.clone());
properties.insert("creationTime".to_owned(), self.creation_time.to_string());
render_properties(&properties)
}
#[must_use]
pub fn jcr_name_for(&self, filesystem_name: &str) -> Option<&str> {
self.directory_mappings
.get(filesystem_name)
.map(String::as_str)
}
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct IndexerInfo {
pub checkpoint: String,
}
impl IndexerInfo {
pub fn parse(content: &str) -> Result<Self> {
let properties = parse_metadata(content, INDEXER_INFO_FILE_NAME)?;
let checkpoint =
properties
.get("checkpoint")
.cloned()
.ok_or_else(|| Error::InvalidFormat {
details: format!("{INDEXER_INFO_FILE_NAME} has no checkpoint property"),
})?;
Ok(Self { checkpoint })
}
#[must_use]
pub fn render(&self) -> String {
let mut properties = BTreeMap::new();
properties.insert("checkpoint".to_owned(), self.checkpoint.clone());
render_properties(&properties)
}
}
fn parse_metadata(content: &str, file_name: &str) -> Result<BTreeMap<String, String>> {
if content.len() > MAXIMUM_METADATA_FILE_BYTES {
return Err(Error::InvalidFormat {
details: format!(
"{file_name} is {} bytes, above the {MAXIMUM_METADATA_FILE_BYTES}-byte limit \
for an index metadata file",
content.len()
),
});
}
let mut properties = BTreeMap::new();
for (key, value) in parse_java_properties(content)? {
properties.insert(from_units(&key), from_units(&value));
}
Ok(properties)
}
fn from_units(units: &[u16]) -> String {
String::from_utf16_lossy(units)
}
fn numeric(properties: &BTreeMap<String, String>, key: &str) -> Option<i64> {
properties.get(key)?.trim().parse().ok()
}
fn render_properties(properties: &BTreeMap<String, String>) -> String {
let mut rendered = String::new();
for (key, value) in properties {
rendered.push_str(&escape(key, true));
rendered.push('=');
rendered.push_str(&escape(value, false));
rendered.push('\n');
}
rendered
}
fn escape(text: &str, is_key: bool) -> String {
let mut escaped = String::new();
for (index, character) in text.chars().enumerate() {
match character {
' ' if is_key || index == 0 => escaped.push_str("\\ "),
' ' => escaped.push(' '),
'\\' => escaped.push_str("\\\\"),
'\t' => escaped.push_str("\\t"),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\u{c}' => escaped.push_str("\\f"),
'=' | ':' | '#' | '!' => {
escaped.push('\\');
escaped.push(character);
}
character if !(' '..='\u{ff}').contains(&character) => {
for unit in character.encode_utf16(&mut [0u16; 2]) {
use std::fmt::Write as _;
let _ = write!(escaped, "\\u{unit:04X}");
}
}
character => escaped.push(character),
}
}
escaped
}
#[cfg(test)]
mod tests {
use super::{
IndexDetails, IndexerInfo, MAXIMUM_METADATA_FILE_BYTES, filesystem_directory_name,
filesystem_safe_name, index_folder_base_name,
};
#[test]
fn the_fixtures_index_path_folders_as_its_last_element() {
assert_eq!(index_folder_base_name("/oak:index/lucene"), "lucene");
}
#[test]
fn a_non_root_index_path_joins_its_elements_with_an_underscore() {
assert_eq!(
index_folder_base_name("/content/oak:index/abc"),
"content_abc"
);
}
#[test]
fn at_most_three_trailing_elements_are_taken() {
assert_eq!(
index_folder_base_name("/a/b/c/d/oak:index/e"),
"d_e",
"the limit of three is applied to the reversed elements *before* oak:index is \
dropped — [e, oak:index, d] — so only two survive"
);
}
#[test]
fn the_underscore_survives_the_folder_name_rule_and_the_hyphen_does_not() {
assert_eq!(filesystem_safe_name("my_index"), "my_index");
assert_eq!(filesystem_safe_name("my-index"), "myindex");
assert_eq!(filesystem_safe_name("oak:index"), "oakindex");
}
#[test]
fn the_directory_name_rule_strips_colons_and_nothing_else() {
assert_eq!(filesystem_directory_name(":data"), "data");
assert_eq!(
filesystem_directory_name(":suggest-data"),
"suggest-data",
"the hyphen survives here, unlike in the folder base name"
);
assert_eq!(
filesystem_directory_name(":oak-libs-index-data"),
"oak-libs-index-data"
);
}
#[test]
fn a_long_folder_name_is_truncated_to_a_hundred_and_twenty_seven_characters() {
let long = "a".repeat(200);
assert_eq!(
index_folder_base_name(&format!("/oak:index/{long}")).len(),
127
);
}
#[test]
fn index_details_round_trip_through_their_rendering() {
let details = IndexDetails {
meta_format_version: 1,
index_path: "/oak:index/lucene".to_owned(),
creation_time: 1_700_000_000_000,
directory_mappings: [
("data".to_owned(), ":data".to_owned()),
("suggest-data".to_owned(), ":suggest-data".to_owned()),
]
.into_iter()
.collect(),
};
let rendered = details.render();
assert!(
rendered.contains("indexPath=/oak\\:index/lucene"),
"a colon in a value is escaped — {rendered}"
);
assert_eq!(IndexDetails::parse(&rendered).expect("re-parse"), details);
}
#[test]
fn a_comment_line_and_an_escaped_colon_parse_the_way_java_reads_them() {
let content = "#Index metadata\n\
!another comment\n\
metaFormatVersion=1\n\
indexPath=/oak\\:index/lucene\n\
creationTime=1700000000000\n\
dir.data=\\:data\n";
let details = IndexDetails::parse(content).expect("parse");
assert_eq!(details.index_path, "/oak:index/lucene");
assert_eq!(details.creation_time, 1_700_000_000_000);
assert_eq!(details.jcr_name_for("data"), Some(":data"));
}
#[test]
fn an_empty_details_file_parses_to_the_defaults_rather_than_refusing() {
let details = IndexDetails::parse("").expect("parse");
assert_eq!(details.index_path, "");
assert_eq!(details.meta_format_version, 1);
assert!(details.directory_mappings.is_empty());
}
#[test]
fn a_details_file_above_the_size_bound_is_refused_by_name() {
let oversized = "x".repeat(MAXIMUM_METADATA_FILE_BYTES + 1);
let error = IndexDetails::parse(&oversized).expect_err("refused");
assert!(error.to_string().contains("index-details.txt"), "{error}");
}
#[test]
fn indexer_info_round_trips_and_refuses_a_file_with_no_checkpoint() {
let info = IndexerInfo {
checkpoint: "8b3d5f2a-1c4e-4a7b-9f01-2d3e4f5a6b7c".to_owned(),
};
assert_eq!(IndexerInfo::parse(&info.render()).expect("re-parse"), info);
let error = IndexerInfo::parse("#only a comment\n").expect_err("refused");
assert!(error.to_string().contains("checkpoint"), "{error}");
}
#[test]
fn a_line_continuation_is_spliced_the_way_javas_reader_splices_it() {
let content = "indexPath=/oak\\:index/\\\n lucene\ncreationTime=1\n";
let details = IndexDetails::parse(content).expect("parse");
assert_eq!(
details.index_path, "/oak:index/lucene",
"the continuation's leading whitespace is dropped"
);
}
#[test]
fn a_key_with_a_space_is_escaped_on_the_key_side_only() {
let mut properties = std::collections::BTreeMap::new();
properties.insert("with space".to_owned(), "a b".to_owned());
let rendered = super::render_properties(&properties);
assert_eq!(rendered, "with\\ space=a b\n");
}
#[test]
fn a_character_above_latin1_is_written_as_an_escape() {
let mut properties = std::collections::BTreeMap::new();
properties.insert("cjk".to_owned(), "中".to_owned());
properties.insert("umlaut".to_owned(), "ä".to_owned());
let rendered = super::render_properties(&properties);
assert!(rendered.contains("cjk=\\u4E2D"), "{rendered}");
assert!(
rendered.contains("umlaut=ä"),
"a Latin-1 character is written raw — {rendered}"
);
}
}