use std::ffi::{OsStr, OsString};
use std::fs::{self, OpenOptions};
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tracing::trace;
use crate::anchor::{AnchorError, SIRNO_CONTROL_DIR_NAME, entry_fingerprint};
use crate::entry::Entry;
use crate::identifier::{EntryAddress, EntryAtom, EntryAtomError};
use crate::query::{EntryQuery, EntryStructuralMatcher, VagueEntryQuery};
use crate::structural::{StructuralEdgeDirection, StructuralRenderSettings, StructuralSettings};
pub const MIST_SPEC_DIR_NAME: &str = "mist";
pub const MIST_MANIFEST_FILE_NAME: &str = "mist.toml";
pub const DEFAULT_MIST_PROJECTION_PATH: &str = "sirno-lake";
pub const MIST_MANIFEST_SCHEMA: u32 = 2;
const DEFAULT_MIST_NAME: &str = "default";
const MIST_FILE_HEADER: &str = "\
# This file is generated and managed by Sirno.
# It records the mist projection state for this workspace.
";
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct MistRenderSettings {
pub structural: StructuralRenderSettings,
}
impl MistRenderSettings {
pub fn is_empty(&self) -> bool {
self.structural.is_empty()
}
pub fn validate(&self, structural: &StructuralSettings) -> Result<(), MistError> {
validate_structural_render_settings(&self.structural, structural)
}
pub fn structural_settings(
&self, structural: &StructuralSettings,
) -> Result<StructuralSettings, MistError> {
self.validate(structural)?;
Ok(structural.with_render_settings(&self.structural))
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct MistProjectionSettings {
pub path: PathBuf,
pub editable: bool,
}
impl Default for MistProjectionSettings {
fn default() -> Self {
Self { path: PathBuf::from(DEFAULT_MIST_PROJECTION_PATH), editable: true }
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct MistSelectionSettings {
#[serde(skip_serializing_if = "Vec::is_empty")]
pub terms: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub exact_terms: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub has: Vec<MistStructuralTargetFilter>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub is: Vec<MistStructuralStateFilter>,
}
impl MistSelectionSettings {
pub fn select_entries<'a>(
&self, entries: &'a [Entry], structural: &StructuralSettings,
) -> Result<Vec<&'a Entry>, MistError> {
let vague_query = VagueEntryQuery::new().with_text_terms(self.terms.clone());
let mut exact_query = EntryQuery::new().with_text_terms(self.exact_terms.clone());
for (field, matchers) in self.structural_matchers_by_field(structural)? {
for matcher in matchers {
exact_query = exact_query.with_structural_matcher(field.clone(), matcher);
}
}
let vague_matches = vague_query.select_entries(entries);
Ok(exact_query.select_entries(vague_matches))
}
fn structural_matchers_by_field(
&self, structural: &StructuralSettings,
) -> Result<IndexMap<String, Vec<EntryStructuralMatcher>>, MistError> {
let mut matchers_by_field = IndexMap::<String, Vec<EntryStructuralMatcher>>::new();
for filter in &self.has {
if !structural.contains_field(&filter.field) {
return Err(MistError::SelectStructuralField(filter.field.clone()));
}
matchers_by_field
.entry(filter.field.clone())
.or_default()
.push(EntryStructuralMatcher::Targets(filter.targets.clone()));
}
for filter in &self.is {
if !structural.contains_field(&filter.field) {
return Err(MistError::SelectStructuralField(filter.field.clone()));
}
matchers_by_field
.entry(filter.field.clone())
.or_default()
.push(mist_structural_state_to_matcher(filter.state));
}
Ok(matchers_by_field)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MistStructuralTargetFilter {
pub field: String,
pub targets: Vec<EntryAddress>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MistStructuralStateFilter {
pub field: String,
pub state: MistStructuralFieldState,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum MistStructuralFieldState {
Present,
Empty,
Missing,
}
fn mist_structural_state_to_matcher(state: MistStructuralFieldState) -> EntryStructuralMatcher {
match state {
| MistStructuralFieldState::Present => EntryStructuralMatcher::Present,
| MistStructuralFieldState::Empty => EntryStructuralMatcher::Empty,
| MistStructuralFieldState::Missing => EntryStructuralMatcher::Missing,
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct MistSpec {
pub projection: MistProjectionSettings,
pub select: MistSelectionSettings,
pub render: MistRenderSettings,
}
impl MistSpec {
pub fn default_name() -> EntryAtom {
EntryAtom::new(DEFAULT_MIST_NAME).expect("default mist name is a valid entry atom")
}
pub fn path_for_config(config_path: impl AsRef<Path>, name: &EntryAtom) -> PathBuf {
config_path
.as_ref()
.parent()
.unwrap_or_else(|| Path::new("."))
.join(SIRNO_CONTROL_DIR_NAME)
.join(MIST_SPEC_DIR_NAME)
.join(format!("{name}.toml"))
}
pub fn default_for_config(config_path: impl AsRef<Path>) -> Result<Self, MistError> {
let path = Self::path_for_config(config_path, &Self::default_name());
match Self::from_file(&path) {
| Ok(spec) => Ok(spec),
| Err(MistError::Read { source, .. }) if source.kind() == ErrorKind::NotFound => {
Ok(Self::default())
}
| Err(source) => Err(source),
}
}
pub fn named_for_config(
config_path: impl AsRef<Path>, name: &EntryAtom,
) -> Result<Self, MistError> {
Self::from_file(Self::path_for_config(config_path, name))
}
pub fn from_file(path: impl AsRef<Path>) -> Result<Self, MistError> {
let path = path.as_ref();
trace!("sirno mist spec load begin: path={}", path.display());
let source = fs::read_to_string(path)
.map_err(|source| MistError::Read { path: path.to_path_buf(), source })?;
Self::from_source(path, &source)
}
pub fn from_source(path: impl AsRef<Path>, source: &str) -> Result<Self, MistError> {
let path = path.as_ref();
let spec: Self = toml::from_str(source)
.map_err(|source| MistError::Parse { path: path.to_path_buf(), source })?;
trace!("sirno mist spec load end");
Ok(spec)
}
pub fn write(&self, path: impl AsRef<Path>) -> Result<(), MistError> {
let path = path.as_ref();
trace!("sirno mist spec write begin: path={}", path.display());
write_complete_file(path, &toml::to_string_pretty(self).map_err(MistError::Render)?)?;
trace!("sirno mist spec write end");
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct MistManifestEntry {
pub id: String,
pub fingerprint: String,
}
impl MistManifestEntry {
pub fn from_entry(entry: &Entry) -> Result<Self, MistError> {
Ok(Self { id: entry.id.to_string(), fingerprint: entry_fingerprint(entry)? })
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MistManifest {
pub schema: u32,
pub mist: EntryAtom,
pub spec: PathBuf,
pub reservoir: PathBuf,
pub projection: MistProjectionSettings,
pub select: MistSelectionSettings,
pub render: MistRenderSettings,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entries: Vec<MistManifestEntry>,
}
impl MistManifest {
pub fn path_for_projection(lake: impl AsRef<Path>) -> PathBuf {
lake.as_ref().join(SIRNO_CONTROL_DIR_NAME).join(MIST_MANIFEST_FILE_NAME)
}
pub fn from_file(path: impl AsRef<Path>) -> Result<Self, MistError> {
let path = path.as_ref();
let source = fs::read_to_string(path)
.map_err(|source| MistError::Read { path: path.to_path_buf(), source })?;
Self::from_source(path, &source)
}
pub fn from_source(path: impl AsRef<Path>, source: &str) -> Result<Self, MistError> {
let path = path.as_ref();
toml::from_str(source)
.map_err(|source| MistError::Parse { path: path.to_path_buf(), source })
}
pub fn from_entries(
mist: EntryAtom, spec: PathBuf, reservoir: PathBuf, projection: MistProjectionSettings,
select: MistSelectionSettings, render: MistRenderSettings, entries: &[Entry],
) -> Result<Self, MistError> {
let entries = entries
.iter()
.map(MistManifestEntry::from_entry)
.collect::<Result<Vec<_>, MistError>>()?;
Ok(Self {
schema: MIST_MANIFEST_SCHEMA,
mist,
spec,
reservoir,
projection,
select,
render,
entries,
})
}
pub fn write_if_changed(&self, path: impl AsRef<Path>) -> Result<bool, MistError> {
let path = path.as_ref();
let source = self.to_toml()?;
match fs::read_to_string(path) {
| Ok(current) if current == source => return Ok(false),
| Ok(_) => {}
| Err(source) if source.kind() == ErrorKind::NotFound => {}
| Err(source) => {
return Err(MistError::Read { path: path.to_path_buf(), source });
}
}
write_complete_file(path, &source)?;
Ok(true)
}
pub fn remove_if_exists(path: impl AsRef<Path>) -> Result<bool, MistError> {
let path = path.as_ref();
match fs::remove_file(path) {
| Ok(()) => Ok(true),
| Err(source) if source.kind() == ErrorKind::NotFound => Ok(false),
| Err(source) => Err(MistError::Remove { path: path.to_path_buf(), source }),
}
}
fn to_toml(&self) -> Result<String, MistError> {
let mut source = String::from(MIST_FILE_HEADER);
source.push_str(&toml::to_string_pretty(self).map_err(MistError::Render)?);
Ok(source)
}
}
fn validate_structural_render_settings(
render: &StructuralRenderSettings, structural: &StructuralSettings,
) -> Result<(), MistError> {
for (field, directions) in render.fields() {
if !structural.contains_field(field) {
return Err(MistError::RenderStructuralField(field.to_owned()));
}
let mut seen = Vec::new();
for direction in directions {
if seen.contains(direction) {
return Err(MistError::DuplicateRenderStructuralDirection {
field: field.to_owned(),
direction: direction.to_string(),
});
}
seen.push(*direction);
}
}
Ok(())
}
fn write_complete_file(path: &Path, source: &str) -> Result<(), MistError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|source| MistError::CreateDirectory { path: parent.to_path_buf(), source })?;
}
let temporary_path = temporary_path(path);
let mut file =
OpenOptions::new().write(true).create_new(true).open(&temporary_path).map_err(
|source| MistError::CreateTemporary { path: temporary_path.clone(), source },
)?;
if let Err(source) = file.write_all(source.as_bytes()) {
drop(file);
let _ = fs::remove_file(&temporary_path);
return Err(MistError::WriteTemporary { path: temporary_path, source });
}
if let Err(source) = file.sync_all() {
drop(file);
let _ = fs::remove_file(&temporary_path);
return Err(MistError::WriteTemporary { path: temporary_path, source });
}
drop(file);
if let Err(source) = fs::rename(&temporary_path, path) {
let _ = fs::remove_file(&temporary_path);
return Err(MistError::Replace { path: path.to_path_buf(), temporary_path, source });
}
Ok(())
}
fn temporary_path(path: &Path) -> PathBuf {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let file_name = path.file_name().unwrap_or_else(|| OsStr::new(MIST_MANIFEST_FILE_NAME));
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
let mut temporary_name = OsString::from(".");
temporary_name.push(file_name);
temporary_name.push(format!(".{}.{}.tmp", std::process::id(), nonce));
parent.join(temporary_name)
}
#[derive(Debug, Error)]
pub enum MistError {
#[error("mist name is invalid")]
Name(#[from] EntryAtomError),
#[error("failed to read mist file {path}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse mist file {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("failed to render mist file")]
Render(#[source] toml::ser::Error),
#[error("render.structural `{0}` must name a discovered structural relation")]
RenderStructuralField(String),
#[error("select structural field `{0}` must name a discovered structural relation")]
SelectStructuralField(String),
#[error("render.structural `{field}` repeats direction `{direction}`")]
DuplicateRenderStructuralDirection {
field: String,
direction: String,
},
#[error("failed to fingerprint mist source entry")]
Fingerprint(#[from] AnchorError),
#[error("failed to create mist directory {path}")]
CreateDirectory {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to create temporary mist file {path}")]
CreateTemporary {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to write temporary mist file {path}")]
WriteTemporary {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to replace mist file {path} with {temporary_path}")]
Replace {
path: PathBuf,
temporary_path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to remove mist manifest {path}")]
Remove {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
pub type MistStructuralRenderMap = IndexMap<String, Vec<StructuralEdgeDirection>>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_mist_render_settings() {
let spec = MistSpec::from_source(
"default.toml",
r#"
[render.structural]
kind = ["to"]
area = ["to", "from", "clique"]
"#,
)
.unwrap();
assert_eq!(
spec.render.structural,
StructuralRenderSettings::from_fields([
("kind", [StructuralEdgeDirection::To].as_slice().iter().copied()),
(
"area",
[
StructuralEdgeDirection::To,
StructuralEdgeDirection::From,
StructuralEdgeDirection::Clique,
]
.as_slice()
.iter()
.copied(),
),
])
);
}
#[test]
fn applies_mist_render_settings_to_project_relations() {
let structural = StructuralSettings::from_relations([
("kind", crate::EntryAddress::new("kind").unwrap()),
("area", crate::EntryAddress::new("area").unwrap()),
]);
let spec = MistSpec::from_source(
"default.toml",
r#"
[render.structural]
kind = ["to"]
area = ["clique"]
"#,
)
.unwrap();
let effective = spec.render.structural_settings(&structural).unwrap();
let fields = effective.fields().collect::<Vec<_>>();
assert!(fields[0].1.to.render);
assert!(!fields[0].1.from.render);
assert!(fields[1].1.clique.render);
}
#[test]
fn rejects_render_settings_for_undefined_relation() {
let structural = StructuralSettings::from_relations([(
"kind",
crate::EntryAddress::new("kind").unwrap(),
)]);
let spec = MistSpec::from_source(
"default.toml",
r#"
[render.structural]
area = ["to"]
"#,
)
.unwrap();
let error = spec.render.structural_settings(&structural).unwrap_err();
assert!(error.to_string().contains("render.structural `area`"));
}
}