use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::diagnostics::{Diagnostic, DiagnosticCode};
const DIALECT_PATH_ENV: &str = "VYRE_DIALECT_PATH";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DialectManifest {
pub dialect: String,
pub version: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub ops: Vec<OpManifest>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OpManifest {
pub id: String,
pub category: String,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub inputs: Vec<(String, String)>,
#[serde(default)]
pub outputs: Vec<(String, String)>,
#[serde(default)]
pub laws: Vec<String>,
}
#[derive(Debug, Default, Clone)]
pub struct TomlDialectStore {
manifests: BTreeMap<String, DialectManifest>,
diagnostics: Vec<Diagnostic>,
}
impl TomlDialectStore {
#[must_use]
pub fn from_env() -> Self {
let mut store = Self::default();
if let Ok(path) = std::env::var(DIALECT_PATH_ENV) {
for entry in path.split(':') {
let dir = Path::new(entry);
if dir.is_dir() {
store.scan_dir(dir);
}
}
}
store
}
pub fn scan_dir(&mut self, dir: &Path) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("toml") {
continue;
}
self.load_file(&path);
}
}
pub fn load_file(&mut self, path: &Path) {
let Ok(contents) = fs::read_to_string(path) else {
self.diagnostics.push(
Diagnostic::warning("W-TOML-UNREADABLE",
format!("TOML dialect file `{}` is unreadable", path.display()))
.with_fix("confirm file permissions and that VYRE_DIALECT_PATH points at an intended directory"),
);
return;
};
match toml::from_str::<DialectManifest>(&contents) {
Ok(mut manifest) => {
if let Some(existing) = self.manifests.get(&manifest.dialect) {
if existing.version >= manifest.version {
return;
}
self.diagnostics.push(Diagnostic::note(
"N-TOML-DIALECT-SHADOWED",
format!(
"dialect `{}` has multiple manifests; keeping version {} over {}",
manifest.dialect, manifest.version, existing.version
),
));
}
manifest.ops.retain(|op| {
if op.id.starts_with(&format!("{}.", manifest.dialect)) {
true
} else {
self.diagnostics.push(
Diagnostic::warning(
"W-TOML-BAD-OP-ID",
format!(
"op id `{}` does not start with dialect prefix `{}.`",
op.id, manifest.dialect
),
)
.with_fix("rename the op to `<dialect>.<name>`"),
);
false
}
});
self.manifests.insert(manifest.dialect.clone(), manifest);
}
Err(err) => {
self.diagnostics.push(
Diagnostic::error(
"E-TOML-PARSE",
format!("TOML dialect file `{}` is malformed: {err}", path.display()),
)
.with_fix("validate the file against the DialectManifest schema"),
);
}
}
}
#[must_use]
pub fn dialect(&self, id: &str) -> Option<&DialectManifest> {
self.manifests.get(id)
}
#[must_use]
pub fn ops_in(&self, dialect: &str) -> &[OpManifest] {
self.manifests
.get(dialect)
.map(|m| m.ops.as_slice())
.unwrap_or(&[])
}
#[must_use]
pub fn manifests(&self) -> Vec<&DialectManifest> {
self.manifests.values().collect()
}
#[must_use]
pub fn diagnostics(&self) -> &[Diagnostic] {
&self.diagnostics
}
#[must_use]
pub fn contains_op(&self, op_id: &str) -> bool {
self.manifests
.values()
.any(|m| m.ops.iter().any(|op| op.id == op_id))
}
}
pub const CODE_PARSE: DiagnosticCode = DiagnosticCode(std::borrow::Cow::Borrowed("E-TOML-PARSE"));
#[must_use]
pub fn workspace_dialect_fixture_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("dialect")
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn write_tmp(contents: &str) -> tempfile::NamedTempFile {
let mut file = tempfile::Builder::new()
.suffix(".toml")
.tempfile()
.expect("Fix: tmp file");
file.write_all(contents.as_bytes()).expect("Fix: write");
file.flush().expect("Fix: flush");
file
}
#[test]
fn parses_minimal_dialect() {
let file = write_tmp(
r#"
dialect = "community.test"
version = "1.0.0"
ops = [
{ id = "community.test.pass", category = "A", summary = "no-op" },
]
"#,
);
let mut store = TomlDialectStore::default();
store.load_file(file.path());
assert!(store.dialect("community.test").is_some());
assert_eq!(store.ops_in("community.test").len(), 1);
assert!(store.contains_op("community.test.pass"));
assert_eq!(store.diagnostics().len(), 0);
}
#[test]
fn rejects_mismatched_op_prefix_with_diagnostic() {
let file = write_tmp(
r#"
dialect = "community.test"
version = "1.0.0"
ops = [
{ id = "other.not_my_dialect", category = "A" },
]
"#,
);
let mut store = TomlDialectStore::default();
store.load_file(file.path());
assert_eq!(store.ops_in("community.test").len(), 0);
assert!(store
.diagnostics()
.iter()
.any(|d| d.code.as_str() == "W-TOML-BAD-OP-ID"));
}
#[test]
fn malformed_toml_produces_parse_error_diagnostic() {
let file = write_tmp("not-toml-at-all =");
let mut store = TomlDialectStore::default();
store.load_file(file.path());
assert!(store
.diagnostics()
.iter()
.any(|d| d.code.as_str() == "E-TOML-PARSE"));
}
#[test]
fn shadowed_manifest_keeps_highest_version() {
let older = write_tmp(
r#"
dialect = "community.versioned"
version = "1.0.0"
ops = []
"#,
);
let newer = write_tmp(
r#"
dialect = "community.versioned"
version = "2.0.0"
ops = [ { id = "community.versioned.new", category = "B" } ]
"#,
);
let mut store = TomlDialectStore::default();
store.load_file(older.path());
store.load_file(newer.path());
assert_eq!(
store.dialect("community.versioned").unwrap().version,
"2.0.0"
);
assert_eq!(store.ops_in("community.versioned").len(), 1);
}
#[test]
fn env_scan_skips_missing_directories() {
let saved = std::env::var(DIALECT_PATH_ENV).ok();
std::env::set_var(DIALECT_PATH_ENV, "/no/such/dir:/also/not/real");
let store = TomlDialectStore::from_env();
assert!(store.manifests.is_empty());
assert!(store.diagnostics.is_empty());
if let Some(s) = saved {
std::env::set_var(DIALECT_PATH_ENV, s);
} else {
std::env::remove_var(DIALECT_PATH_ENV);
}
}
#[test]
fn code_constants_are_stable() {
assert_eq!(CODE_PARSE.as_str(), "E-TOML-PARSE");
}
}