use std::fs;
use std::path::Path;
use warble::{BindingFile, ContextLoader, DimensionInfo, LineageGraph, MetricInfo, ModelInfo};
use warble_cli::{
compile_project_to_ir, compile_project_to_ir_with, default_component_sources,
BuiltinContextResolver, ContextResolver,
};
fn write_project(dir: &Path, binding_body: &str, preconditions: &str) {
fs::create_dir_all(dir.join("context")).unwrap();
fs::create_dir_all(dir.join("components/asker/steps")).unwrap();
fs::write(
dir.join("profile.yml"),
"profile: fixture\ncontext:\n project: ./context/binding.yml\ncomponents:\n - use: asker\n",
)
.unwrap();
fs::write(dir.join("context/binding.yml"), binding_body).unwrap();
fs::write(
dir.join("components/asker/component.yml"),
format!(
r#"
id: asker
verb: asker
type: analytical
realization_kind: skill
binding_mode: runtime_selected
context_precondition:
{preconditions}
params: []
llm_steps:
- {{ name: ask, tier: cheap, prompt_ref: steps/ask.md }}
trigger: {{ kind: one_shot }}
guardrails:
- {{ name: read_only_execution, locked: true }}
required_capabilities: [llm:cheap]
borrowed_actions: []
effect:
render_blocks: []
outcome:
kind: none
"#
),
)
.unwrap();
fs::write(
dir.join("components/asker/steps/ask.md"),
"Ask something.\n",
)
.unwrap();
}
fn write_wren_project(dir: &Path) {
fs::create_dir_all(dir.join("models/widgets")).unwrap();
fs::write(
dir.join("wren_project.yml"),
"schema_version: 2\ndata_source: duckdb\ncatalog: wren\nschema: public\n",
)
.unwrap();
fs::write(
dir.join("models/widgets/metadata.yml"),
"name: widgets\ncolumns:\n - name: id\n type: INT\n - name: amount\n type: DOUBLE\n",
)
.unwrap();
}
#[derive(Default)]
struct AnswersNothing {
lineage: LineageGraph,
}
impl ContextLoader for AnswersNothing {
fn is_parseable(&self) -> bool {
true
}
fn metrics(&self) -> &[MetricInfo] {
&[]
}
fn dimensions(&self) -> &[DimensionInfo] {
&[]
}
fn time_dimensions(&self) -> &[DimensionInfo] {
&[]
}
fn models(&self) -> &[ModelInfo] {
&[]
}
fn lineage(&self) -> &LineageGraph {
&self.lineage
}
fn can_answer(&self, predicate: &str) -> bool {
matches!(predicate, "mdl_parseable" | "wren_project_exists")
}
}
struct RemoteResolver {
expect_project: &'static str,
}
impl ContextResolver for RemoteResolver {
fn resolve(
&self,
binding: &BindingFile,
_project_dir: &Path,
) -> Result<Box<dyn ContextLoader>, String> {
assert_eq!(binding.kind, "remote_service");
assert_eq!(
binding.project, self.expect_project,
"the locator reaches the resolver as authored, uninterpreted"
);
assert_eq!(
binding.extra.get("project_id").and_then(|v| v.as_u64()),
Some(42),
"a host kind's own fields survive parsing"
);
Ok(Box::new(AnswersNothing::default()))
}
}
#[test]
fn a_host_kind_resolves_through_a_supplied_resolver_with_a_non_path_locator() {
let project = tempfile::tempdir().unwrap();
write_project(
project.path(),
"kind: remote_service\nproject: remote-service://analytics\nproject_id: 42\n",
" []",
);
let ir = compile_project_to_ir_with(
project.path(),
&default_component_sources(project.path()).expect("in-repo hub resolves offline"),
&RemoteResolver {
expect_project: "remote-service://analytics",
},
)
.expect("a host kind must compile through its own resolver");
assert_eq!(
ir["context_binding"]["project"], "remote-service://analytics",
"the IR records the locator verbatim, so a self-describing one carries its own provenance"
);
}
#[test]
fn a_context_that_answers_nothing_is_unanswerable_not_unsatisfied() {
let project = tempfile::tempdir().unwrap();
write_project(
project.path(),
"kind: remote_service\nproject: remote-service://analytics\nproject_id: 42\n",
" - { predicate: has_metric }",
);
let err = compile_project_to_ir_with(
project.path(),
&default_component_sources(project.path()).expect("in-repo hub resolves offline"),
&RemoteResolver {
expect_project: "remote-service://analytics",
},
)
.expect_err("a declined probe must refuse rather than answer wrongly");
assert!(
err.contains("cannot be evaluated"),
"expected the unanswerable loud-fail, got: {err}"
);
assert!(
!err.contains("not satisfied"),
"a context that does not know must not report the precondition as false: {err}"
);
}
#[test]
fn a_component_declaring_no_preconditions_compiles_against_a_context_that_answers_nothing() {
let project = tempfile::tempdir().unwrap();
write_project(
project.path(),
"kind: remote_service\nproject: remote-service://analytics\nproject_id: 42\n",
" []",
);
compile_project_to_ir_with(
project.path(),
&default_component_sources(project.path()).expect("in-repo hub resolves offline"),
&RemoteResolver {
expect_project: "remote-service://analytics",
},
)
.expect("declaring no schema preconditions is what makes a delegating profile legal");
}
#[test]
fn a_binding_without_a_kind_still_resolves_as_a_wren_project() {
let wren = tempfile::tempdir().unwrap();
write_wren_project(wren.path());
let wren_abs = wren.path().canonicalize().unwrap();
let project = tempfile::tempdir().unwrap();
write_project(
project.path(),
&format!("project: {}\n", wren_abs.to_string_lossy()),
" - { predicate: mdl_parseable }",
);
compile_project_to_ir(project.path())
.expect("every binding authored before `kind` existed must keep working");
}
#[test]
fn a_wren_project_kind_over_a_raw_source_says_which_kind_to_declare() {
let raw = tempfile::tempdir().unwrap();
fs::write(raw.path().join("schema.json"), "{\"tables\":[]}").unwrap();
let raw_abs = raw.path().canonicalize().unwrap();
let project = tempfile::tempdir().unwrap();
write_project(
project.path(),
&format!("project: {}\n", raw_abs.to_string_lossy()),
" []",
);
let err = compile_project_to_ir(project.path()).expect_err(
"silently accepting a raw source as a wren project is the guess `kind` removes",
);
assert!(
err.contains("raw source") && err.contains("kind: raw_source"),
"the error must name the kind to declare, not just refuse: {err}"
);
}
#[test]
fn a_raw_source_kind_without_a_schema_json_fails_loudly() {
let empty = tempfile::tempdir().unwrap();
let empty_abs = empty.path().canonicalize().unwrap();
let project = tempfile::tempdir().unwrap();
write_project(
project.path(),
&format!(
"kind: raw_source\nproject: {}\n",
empty_abs.to_string_lossy()
),
" []",
);
let err = compile_project_to_ir(project.path()).expect_err(
"a declared raw source that is not one must not fall through to another adapter",
);
assert!(
err.contains("schema.json"),
"the error must name what is missing: {err}"
);
}
#[test]
fn an_unknown_kind_names_the_builtins_and_the_seam() {
let project = tempfile::tempdir().unwrap();
write_project(
project.path(),
"kind: remote_service\nproject: remote-service://analytics\n",
" []",
);
let binding = BindingFile {
kind: "remote_service".to_string(),
project: "remote-service://analytics".to_string(),
extra: Default::default(),
};
let err = BuiltinContextResolver
.resolve(&binding, project.path())
.err()
.expect("the built-in resolver must not guess at a kind it does not implement");
assert!(
err.contains("wren_project") && err.contains("raw_source"),
"the error must list what this build does resolve: {err}"
);
assert!(
err.contains("ContextResolver"),
"and must point at the seam that fixes it: {err}"
);
}
#[test]
fn the_external_kind_resolves_with_no_io_and_answers_nothing() {
let project = tempfile::tempdir().unwrap();
write_project(
project.path(),
"kind: external\nproject: remote-service://analytics\n",
" []",
);
let ir = compile_project_to_ir(project.path())
.expect("`external` is a built-in kind; binding a layer held elsewhere needs no host code");
assert_eq!(
ir["context_binding"]["project"],
"remote-service://analytics"
);
assert!(
ir["context_binding"].get("resolved").is_none(),
"nothing was introspected, so the IR must not carry a resolved block: {}",
ir["context_binding"]
);
let gated = tempfile::tempdir().unwrap();
write_project(
gated.path(),
"kind: external\nproject: remote-service://analytics\n",
" - { predicate: has_metric }",
);
let err = compile_project_to_ir(gated.path())
.expect_err("a schema gate over a layer that was never read must be refused");
assert!(
err.contains("cannot be evaluated") && !err.contains("not satisfied"),
"expected unanswerable, not an answerable false: {err}"
);
}