use anyhow::{Context, Result, bail};
use serde::Deserialize;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use thiserror::Error;
pub const SEMANTICS_KIND: &str = "semantics";
#[derive(Debug, Clone, Deserialize)]
pub struct SemanticsFile {
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
#[allow(dead_code)]
pub metadata: serde_yaml::Value,
#[serde(default)]
pub spec: SemanticsSpec,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct SemanticsSpec {
#[serde(default)]
pub sources: Vec<SourceSemantics>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SourceSemantics {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub columns: Vec<ColumnSemantics>,
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
enum SemanticsKey {
Source(String),
Qualified {
catalog: String,
schema: String,
table: String,
},
}
impl SemanticsKey {
fn parse(name: &str) -> Result<Self> {
let parts: Vec<&str> = name.split('.').collect();
match parts.as_slice() {
[single] if !single.is_empty() => Ok(Self::Source((*single).to_string())),
[catalog, schema, table]
if !catalog.is_empty() && !schema.is_empty() && !table.is_empty() =>
{
Ok(Self::Qualified {
catalog: (*catalog).to_string(),
schema: (*schema).to_string(),
table: (*table).to_string(),
})
}
_ => bail!(
"semantics source name `{name}` must be either a bare \
source name (e.g. `products`) or a fully-qualified \
`catalog.schema.table` path (e.g. `mydb.public.users`)"
),
}
}
fn referenced_source(&self) -> &str {
match self {
Self::Source(name) => name,
Self::Qualified { catalog, .. } => catalog,
}
}
fn display(&self) -> String {
match self {
Self::Source(name) => name.clone(),
Self::Qualified {
catalog,
schema,
table,
} => format!("{catalog}.{schema}.{table}"),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct ColumnSemantics {
pub name: String,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Error, Debug)]
pub enum SemanticsError {
#[error("Semantics path not found: {path:?}")]
PathNotFound { path: PathBuf },
#[error("Failed to parse semantics file {path:?}: {error}")]
ParseError { path: PathBuf, error: String },
#[error(
"Duplicate semantics entry for source `{source_name}` (already defined in {first:?}, redefined in {second:?})"
)]
DuplicateSource {
source_name: String,
first: PathBuf,
second: PathBuf,
},
#[error(
"Duplicate semantics entry for column `{source_name}.{column}` (already defined in {first:?}, redefined in {second:?})"
)]
DuplicateColumn {
source_name: String,
column: String,
first: PathBuf,
second: PathBuf,
},
#[error(
"Ambiguous semantics auto-discovery: both {dir:?} and {file:?} exist next to the ctx file. \
Remove one (or pass --semantics explicitly) so the loader knows which to use."
)]
AmbiguousAutoDiscovery { dir: PathBuf, file: PathBuf },
}
#[derive(Debug, Clone, Default)]
pub struct SemanticsRegistry {
entries: Arc<HashMap<SemanticsKey, SourceEntry>>,
}
#[derive(Debug, Clone, Default)]
struct SourceEntry {
description: Option<String>,
columns: HashMap<String, String>,
description_origin: Option<PathBuf>,
column_origins: HashMap<String, PathBuf>,
}
impl SemanticsRegistry {
pub fn build(
semantics_path: Option<&Path>,
ctx_descriptions: &[(String, Option<String>)],
) -> Result<Self> {
let mut entries: HashMap<SemanticsKey, SourceEntry> = HashMap::new();
for (name, desc) in ctx_descriptions {
if let Some(d) = desc.as_deref()
&& !d.is_empty()
{
entries
.entry(SemanticsKey::Source(name.clone()))
.or_default()
.description = Some(d.to_string());
}
}
if let Some(path) = semantics_path {
let files = resolve_semantics_files(path)?;
for file_path in &files {
let Some(loaded) = load_semantics_file(file_path)? else {
tracing::debug!("Skipping {:?}: no `kind: semantics` at root", file_path);
continue;
};
merge_into(&mut entries, loaded.spec, file_path)?;
}
}
warn_on_dangling_refs(&entries, ctx_descriptions);
Ok(Self {
entries: Arc::new(entries),
})
}
pub fn table_description(&self, source: &str) -> Option<&str> {
self.entries
.get(&SemanticsKey::Source(source.to_string()))
.and_then(|e| e.description.as_deref())
}
pub fn column_description(&self, source: &str, column: &str) -> Option<&str> {
self.entries
.get(&SemanticsKey::Source(source.to_string()))
.and_then(|e| e.columns.get(column).map(String::as_str))
}
pub fn qualified_table_description(
&self,
catalog: &str,
schema: &str,
table: &str,
) -> Option<&str> {
self.entries
.get(&SemanticsKey::Qualified {
catalog: catalog.to_string(),
schema: schema.to_string(),
table: table.to_string(),
})
.and_then(|e| e.description.as_deref())
}
pub fn qualified_column_description(
&self,
catalog: &str,
schema: &str,
table: &str,
column: &str,
) -> Option<&str> {
self.entries
.get(&SemanticsKey::Qualified {
catalog: catalog.to_string(),
schema: schema.to_string(),
table: table.to_string(),
})
.and_then(|e| e.columns.get(column).map(String::as_str))
}
pub fn resolve_table_description(
&self,
catalog: &str,
schema: &str,
table: &str,
source: Option<&str>,
) -> Option<&str> {
self.qualified_table_description(catalog, schema, table)
.or_else(|| source.and_then(|s| self.table_description(s)))
}
pub fn resolve_column_description(
&self,
catalog: &str,
schema: &str,
table: &str,
source: Option<&str>,
column: &str,
) -> Option<&str> {
self.qualified_column_description(catalog, schema, table, column)
.or_else(|| source.and_then(|s| self.column_description(s, column)))
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
fn merge_into(
entries: &mut HashMap<SemanticsKey, SourceEntry>,
spec: SemanticsSpec,
origin: &Path,
) -> Result<()> {
for source in spec.sources {
let key = SemanticsKey::parse(&source.name)
.with_context(|| format!("In semantics file {origin:?}"))?;
let key_display = key.display();
let entry = entries.entry(key.clone()).or_default();
if let Some(desc) = source.description.filter(|d| !d.is_empty()) {
if let Some(prior) = &entry.description_origin {
return Err(SemanticsError::DuplicateSource {
source_name: key_display.clone(),
first: prior.clone(),
second: origin.to_path_buf(),
}
.into());
}
entry.description = Some(desc);
entry.description_origin = Some(origin.to_path_buf());
}
for col in source.columns {
if let Some(desc) = col.description.filter(|d| !d.is_empty()) {
if let Some(prior) = entry.column_origins.get(&col.name) {
return Err(SemanticsError::DuplicateColumn {
source_name: key_display.clone(),
column: col.name.clone(),
first: prior.clone(),
second: origin.to_path_buf(),
}
.into());
}
entry.columns.insert(col.name.clone(), desc);
entry.column_origins.insert(col.name, origin.to_path_buf());
}
}
}
Ok(())
}
fn warn_on_dangling_refs(
entries: &HashMap<SemanticsKey, SourceEntry>,
ctx_descriptions: &[(String, Option<String>)],
) {
let known: HashSet<&str> = ctx_descriptions.iter().map(|(n, _)| n.as_str()).collect();
for key in entries.keys() {
let referenced = key.referenced_source();
if !known.contains(referenced) {
let key_str = key.display();
tracing::warn!(
"Semantics references unknown data source `{key_str}`; entry will be ignored \
until a matching source is added to the context"
);
}
}
}
pub fn resolve_semantics_source(
ctx_dir: Option<&Path>,
override_path: Option<&Path>,
) -> Result<Option<PathBuf>> {
if let Some(p) = override_path {
return Ok(Some(p.to_path_buf()));
}
let Some(dir) = ctx_dir else {
return Ok(None);
};
let dir_path = dir.join("semantics");
let yaml_path = dir.join("semantics.yaml");
let yml_path = dir.join("semantics.yml");
let dir_exists = dir_path.is_dir();
let single_file = if yaml_path.is_file() {
Some(yaml_path)
} else if yml_path.is_file() {
Some(yml_path)
} else {
None
};
match (dir_exists, single_file) {
(true, Some(file)) => Err(SemanticsError::AmbiguousAutoDiscovery {
dir: dir_path,
file,
}
.into()),
(true, None) => Ok(Some(dir_path)),
(false, Some(file)) => Ok(Some(file)),
(false, None) => Ok(None),
}
}
fn resolve_semantics_files(path: &Path) -> Result<Vec<PathBuf>> {
if !path.exists() {
return Err(SemanticsError::PathNotFound {
path: path.to_path_buf(),
}
.into());
}
if path.is_file() {
return Ok(vec![path.to_path_buf()]);
}
if path.is_dir() {
let mut out = Vec::new();
for entry in std::fs::read_dir(path)
.with_context(|| format!("Failed to read semantics directory: {:?}", path))?
{
let entry = entry.with_context(|| "Failed to read directory entry")?;
let p = entry.path();
if p.is_file()
&& let Some(ext) = p.extension()
{
let ext = ext.to_string_lossy().to_lowercase();
if ext == "yaml" || ext == "yml" {
out.push(p);
}
}
}
out.sort();
return Ok(out);
}
Err(SemanticsError::PathNotFound {
path: path.to_path_buf(),
}
.into())
}
fn load_semantics_file(path: &Path) -> Result<Option<SemanticsFile>> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read semantics file: {:?}", path))?;
#[derive(Deserialize)]
struct KindOnly {
#[serde(default)]
kind: Option<String>,
}
let peek: KindOnly = serde_yaml::from_str(&raw).map_err(|e| SemanticsError::ParseError {
path: path.to_path_buf(),
error: e.to_string(),
})?;
match peek.kind.as_deref() {
Some(SEMANTICS_KIND) => {}
Some(_) | None => return Ok(None),
}
let parsed: SemanticsFile =
serde_yaml::from_str(&raw).map_err(|e| SemanticsError::ParseError {
path: path.to_path_buf(),
error: e.to_string(),
})?;
Ok(Some(parsed))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
fn ctx(name: &str, description: Option<&str>) -> (String, Option<String>) {
(name.to_string(), description.map(str::to_string))
}
fn write_yaml(dir: &Path, name: &str, content: &str) -> PathBuf {
let p = dir.join(name);
let mut f = std::fs::File::create(&p).unwrap();
f.write_all(content.as_bytes()).unwrap();
p
}
#[test]
fn loads_single_semantics_file() {
let tmp = TempDir::new().unwrap();
let path = write_yaml(
tmp.path(),
"sem.yaml",
r#"
kind: semantics
metadata:
name: t
version: 1.0.0
spec:
sources:
- name: products
description: "Product catalog"
columns:
- name: price_usd
description: "Retail price in USD"
"#,
);
let sources = vec![ctx("products", None)];
let reg = SemanticsRegistry::build(Some(&path), &sources).unwrap();
assert_eq!(reg.table_description("products"), Some("Product catalog"));
assert_eq!(
reg.column_description("products", "price_usd"),
Some("Retail price in USD")
);
assert_eq!(reg.column_description("products", "missing"), None);
}
#[test]
fn ctx_inline_description_used_as_fallback() {
let sources = vec![ctx("products", Some("From ctx"))];
let reg = SemanticsRegistry::build(None, &sources).unwrap();
assert_eq!(reg.table_description("products"), Some("From ctx"));
}
#[test]
fn semantics_file_overrides_ctx_inline() {
let tmp = TempDir::new().unwrap();
let path = write_yaml(
tmp.path(),
"sem.yaml",
r#"
kind: semantics
metadata:
name: t
spec:
sources:
- name: products
description: "Override"
"#,
);
let sources = vec![ctx("products", Some("From ctx"))];
let reg = SemanticsRegistry::build(Some(&path), &sources).unwrap();
assert_eq!(reg.table_description("products"), Some("Override"));
}
#[test]
fn empty_string_description_in_file_does_not_override_ctx_fallback() {
let tmp = TempDir::new().unwrap();
let path = write_yaml(
tmp.path(),
"sem.yaml",
r#"
kind: semantics
metadata: { name: t }
spec:
sources:
- name: products
description: ""
columns:
- name: id
description: ""
"#,
);
let sources = vec![ctx("products", Some("From ctx"))];
let reg = SemanticsRegistry::build(Some(&path), &sources).unwrap();
assert_eq!(
reg.table_description("products"),
Some("From ctx"),
"empty file description must not stomp the ctx fallback"
);
assert_eq!(reg.column_description("products", "id"), None);
}
#[test]
fn empty_string_description_does_not_trigger_duplicate_error() {
let tmp = TempDir::new().unwrap();
write_yaml(
tmp.path(),
"a.yaml",
r#"
kind: semantics
metadata: { name: a }
spec:
sources:
- name: products
description: "first"
columns:
- name: price
description: "real price"
"#,
);
write_yaml(
tmp.path(),
"b.yaml",
r#"
kind: semantics
metadata: { name: b }
spec:
sources:
- name: products
description: ""
columns:
- name: price
description: ""
"#,
);
let sources = vec![ctx("products", None)];
let reg = SemanticsRegistry::build(Some(tmp.path()), &sources).unwrap();
assert_eq!(reg.table_description("products"), Some("first"));
assert_eq!(
reg.column_description("products", "price"),
Some("real price")
);
}
#[test]
fn semantics_key_parse_accepts_bare_and_qualified() {
assert!(matches!(
SemanticsKey::parse("products").unwrap(),
SemanticsKey::Source(s) if s == "products"
));
let qualified = SemanticsKey::parse("mydb.public.users").unwrap();
match qualified {
SemanticsKey::Qualified {
catalog,
schema,
table,
} => {
assert_eq!(catalog, "mydb");
assert_eq!(schema, "public");
assert_eq!(table, "users");
}
_ => panic!("expected Qualified, got {qualified:?}"),
}
}
#[test]
fn semantics_key_parse_rejects_two_part_path() {
let err = SemanticsKey::parse("schema.table").unwrap_err();
assert!(
format!("{err}").contains("must be either a bare"),
"unexpected error: {err}"
);
}
#[test]
fn semantics_key_parse_rejects_too_many_parts() {
let err = SemanticsKey::parse("a.b.c.d").unwrap_err();
assert!(format!("{err}").contains("must be either a bare"));
}
#[test]
fn semantics_key_parse_rejects_empty_segments() {
assert!(SemanticsKey::parse("").is_err());
assert!(SemanticsKey::parse("..").is_err());
assert!(SemanticsKey::parse("mydb..users").is_err());
assert!(SemanticsKey::parse(".mydb.public.users").is_err());
}
#[test]
fn malformed_name_in_semantics_file_is_hard_error() {
let tmp = TempDir::new().unwrap();
let path = write_yaml(
tmp.path(),
"sem.yaml",
r#"
kind: semantics
metadata: { name: t }
spec:
sources:
- name: schema.table # 2-part is invalid
description: "x"
"#,
);
let sources = vec![ctx("anything", None)];
let err = SemanticsRegistry::build(Some(&path), &sources).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("must be either a bare"),
"unexpected error: {msg}"
);
}
#[test]
fn qualified_path_resolves_specific_inner_table() {
let tmp = TempDir::new().unwrap();
let path = write_yaml(
tmp.path(),
"sem.yaml",
r#"
kind: semantics
metadata: { name: t }
spec:
sources:
- name: wiki.main.pages
description: "Wiki page contents"
columns:
- name: title
description: "Page title"
"#,
);
let sources = vec![ctx("wiki", None)];
let reg = SemanticsRegistry::build(Some(&path), &sources).unwrap();
assert_eq!(
reg.qualified_table_description("wiki", "main", "pages"),
Some("Wiki page contents")
);
assert_eq!(
reg.qualified_column_description("wiki", "main", "pages", "title"),
Some("Page title")
);
assert_eq!(reg.table_description("wiki"), None);
assert_eq!(reg.column_description("wiki", "title"), None);
}
#[test]
fn resolve_table_description_prefers_qualified_over_bare() {
let tmp = TempDir::new().unwrap();
let path = write_yaml(
tmp.path(),
"sem.yaml",
r#"
kind: semantics
metadata: { name: t }
spec:
sources:
- name: wiki
description: "broad fallback"
columns:
- name: title
description: "broad title"
- name: wiki.main.pages
description: "specific to pages"
columns:
- name: title
description: "specific title"
"#,
);
let sources = vec![ctx("wiki", None)];
let reg = SemanticsRegistry::build(Some(&path), &sources).unwrap();
assert_eq!(
reg.resolve_table_description("wiki", "main", "pages", Some("wiki")),
Some("specific to pages")
);
assert_eq!(
reg.resolve_column_description("wiki", "main", "pages", Some("wiki"), "title"),
Some("specific title")
);
assert_eq!(
reg.resolve_table_description("wiki", "main", "revisions", Some("wiki")),
Some("broad fallback")
);
assert_eq!(
reg.resolve_column_description("wiki", "main", "revisions", Some("wiki"), "title"),
Some("broad title")
);
}
#[test]
fn resolve_falls_back_to_none_when_neither_form_present() {
let sources = vec![ctx("wiki", None)];
let reg = SemanticsRegistry::build(None, &sources).unwrap();
assert_eq!(
reg.resolve_table_description("wiki", "main", "pages", Some("wiki")),
None
);
assert_eq!(
reg.resolve_table_description("wiki", "main", "pages", None),
None
);
}
#[test]
fn duplicate_qualified_entry_is_hard_error() {
let tmp = TempDir::new().unwrap();
write_yaml(
tmp.path(),
"a.yaml",
r#"
kind: semantics
metadata: { name: a }
spec:
sources:
- name: wiki.main.pages
description: "first"
"#,
);
write_yaml(
tmp.path(),
"b.yaml",
r#"
kind: semantics
metadata: { name: b }
spec:
sources:
- name: wiki.main.pages
description: "second"
"#,
);
let sources = vec![ctx("wiki", None)];
let err = SemanticsRegistry::build(Some(tmp.path()), &sources).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("Duplicate semantics entry for source `wiki.main.pages`"),
"unexpected error: {msg}"
);
}
#[test]
fn bare_and_qualified_for_same_source_coexist() {
let tmp = TempDir::new().unwrap();
let path = write_yaml(
tmp.path(),
"sem.yaml",
r#"
kind: semantics
metadata: { name: t }
spec:
sources:
- name: wiki
description: "broad"
- name: wiki.main.pages
description: "specific"
"#,
);
let sources = vec![ctx("wiki", None)];
let reg = SemanticsRegistry::build(Some(&path), &sources).unwrap();
assert_eq!(reg.table_description("wiki"), Some("broad"));
assert_eq!(
reg.qualified_table_description("wiki", "main", "pages"),
Some("specific")
);
}
#[test]
fn qualified_dangling_reference_warns_via_catalog_segment() {
let tmp = TempDir::new().unwrap();
let path = write_yaml(
tmp.path(),
"sem.yaml",
r#"
kind: semantics
metadata: { name: t }
spec:
sources:
- name: nope.public.users
description: "stale"
"#,
);
let sources = vec![ctx("wiki", None)];
let reg = SemanticsRegistry::build(Some(&path), &sources).unwrap();
assert_eq!(
reg.qualified_table_description("nope", "public", "users"),
Some("stale")
);
}
#[test]
fn directory_skips_non_semantics_yamls() {
let tmp = TempDir::new().unwrap();
write_yaml(
tmp.path(),
"sem.yaml",
r#"
kind: semantics
metadata: { name: t }
spec:
sources:
- name: products
description: "Product catalog"
"#,
);
write_yaml(
tmp.path(),
"pipeline.yaml",
r#"
kind: pipeline
metadata: { name: p, version: 1.0.0 }
spec:
query: SELECT 1
"#,
);
let sources = vec![ctx("products", None)];
let reg = SemanticsRegistry::build(Some(tmp.path()), &sources).unwrap();
assert_eq!(reg.table_description("products"), Some("Product catalog"));
}
#[test]
fn duplicate_source_description_is_hard_error() {
let tmp = TempDir::new().unwrap();
write_yaml(
tmp.path(),
"a.yaml",
r#"
kind: semantics
metadata: { name: a }
spec:
sources:
- name: products
description: "first"
"#,
);
write_yaml(
tmp.path(),
"b.yaml",
r#"
kind: semantics
metadata: { name: b }
spec:
sources:
- name: products
description: "second"
"#,
);
let sources = vec![ctx("products", None)];
let err = SemanticsRegistry::build(Some(tmp.path()), &sources).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("Duplicate semantics entry for source `products`"),
"unexpected error: {msg}"
);
}
#[test]
fn duplicate_column_description_is_hard_error() {
let tmp = TempDir::new().unwrap();
write_yaml(
tmp.path(),
"a.yaml",
r#"
kind: semantics
metadata: { name: a }
spec:
sources:
- name: products
columns:
- name: price_usd
description: "first"
"#,
);
write_yaml(
tmp.path(),
"b.yaml",
r#"
kind: semantics
metadata: { name: b }
spec:
sources:
- name: products
columns:
- name: price_usd
description: "second"
"#,
);
let sources = vec![ctx("products", None)];
let err = SemanticsRegistry::build(Some(tmp.path()), &sources).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("Duplicate semantics entry for column `products.price_usd`"),
"unexpected error: {msg}"
);
}
#[test]
fn dangling_reference_warns_but_does_not_fail() {
let tmp = TempDir::new().unwrap();
let path = write_yaml(
tmp.path(),
"sem.yaml",
r#"
kind: semantics
metadata: { name: t }
spec:
sources:
- name: orphan
description: "no matching source"
"#,
);
let sources = vec![ctx("products", None)];
let reg = SemanticsRegistry::build(Some(&path), &sources).unwrap();
assert_eq!(reg.table_description("orphan"), Some("no matching source"));
assert_eq!(reg.table_description("products"), None);
}
#[test]
fn missing_kind_in_explicit_file_is_treated_as_skip_for_directory_scan() {
let tmp = TempDir::new().unwrap();
let path = write_yaml(
tmp.path(),
"stray.yaml",
r#"
metadata: { name: not-a-semantics }
spec:
sources:
- name: products
description: "ignored"
"#,
);
let sources = vec![ctx("products", None)];
let reg = SemanticsRegistry::build(Some(&path), &sources).unwrap();
assert_eq!(reg.table_description("products"), None);
}
#[test]
fn empty_path_input_returns_empty_registry() {
let sources: Vec<(String, Option<String>)> = Vec::new();
let reg = SemanticsRegistry::build(None, &sources).unwrap();
assert_eq!(reg.table_description("anything"), None);
assert!(reg.is_empty());
}
#[test]
fn resolver_returns_override_when_provided() {
let tmp = TempDir::new().unwrap();
let explicit = tmp.path().join("custom.yaml");
let resolved = resolve_semantics_source(Some(tmp.path()), Some(&explicit)).unwrap();
assert_eq!(resolved.as_deref(), Some(explicit.as_path()));
}
#[test]
fn resolver_returns_none_when_no_ctx_dir_and_no_override() {
let resolved = resolve_semantics_source(None, None).unwrap();
assert!(resolved.is_none());
}
#[test]
fn resolver_picks_directory_when_present() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir(tmp.path().join("semantics")).unwrap();
let resolved = resolve_semantics_source(Some(tmp.path()), None)
.unwrap()
.unwrap();
assert_eq!(resolved, tmp.path().join("semantics"));
}
#[test]
fn resolver_picks_yaml_file_when_present() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("semantics.yaml");
std::fs::File::create(&file).unwrap();
let resolved = resolve_semantics_source(Some(tmp.path()), None)
.unwrap()
.unwrap();
assert_eq!(resolved, file);
}
#[test]
fn resolver_picks_yml_file_when_yaml_missing() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("semantics.yml");
std::fs::File::create(&file).unwrap();
let resolved = resolve_semantics_source(Some(tmp.path()), None)
.unwrap()
.unwrap();
assert_eq!(resolved, file);
}
#[test]
fn resolver_returns_none_when_neither_present() {
let tmp = TempDir::new().unwrap();
let resolved = resolve_semantics_source(Some(tmp.path()), None).unwrap();
assert!(resolved.is_none());
}
#[test]
fn resolver_hard_errors_when_dir_and_file_both_present() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir(tmp.path().join("semantics")).unwrap();
std::fs::File::create(tmp.path().join("semantics.yaml")).unwrap();
let err = resolve_semantics_source(Some(tmp.path()), None).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("Ambiguous semantics auto-discovery"),
"unexpected error: {msg}"
);
}
#[test]
fn resolver_override_skips_collision_check() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir(tmp.path().join("semantics")).unwrap();
std::fs::File::create(tmp.path().join("semantics.yaml")).unwrap();
let explicit = tmp.path().join("custom.yaml");
let resolved = resolve_semantics_source(Some(tmp.path()), Some(&explicit))
.unwrap()
.unwrap();
assert_eq!(resolved, explicit);
}
}