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();
}
#[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()))
}
}
const PREPARED_EQUIVALENT: &str = r#"{
"context_version": 2,
"parseable": true,
"metrics": [
{"name": "id", "owner": "widgets", "declared": false},
{"name": "amount", "owner": "widgets", "declared": false}
],
"dimensions": [],
"models": [
{"name": "widgets", "has_timestamp": false, "columns": ["id", "amount"]}
],
"lineage": {"nodes": [{"id": "model:widgets", "kind": "model"}], "edges": []}
}"#;
#[test]
fn a_prepared_context_that_declares_itself_unparseable_fails_the_coarse_floor() {
let project = tempfile::tempdir().unwrap();
write_project(
project.path(),
"kind: prepared\nproject: widgets-layer\ndocument: ./context.json\n",
" - { predicate: mdl_parseable }",
);
fs::write(
project.path().join("context.json"),
r#"{"context_version": 2, "parseable": false,
"parse_error": "models/widgets/metadata.yml: missing `columns`"}"#,
)
.unwrap();
let err = compile_project_to_ir(project.path())
.expect_err("an unparseable prepared context must not compile");
let text = err.to_string();
assert!(
text.contains("missing `columns`"),
"the host's own parse error must survive into the failure, got: {text}"
);
}
#[test]
fn a_prepared_binding_keeps_project_as_identity_not_as_the_document_path() {
let project = tempfile::tempdir().unwrap();
write_project(
project.path(),
"kind: prepared\nproject: widgets-layer\ndocument: ./context.json\n",
" []",
);
fs::write(project.path().join("context.json"), PREPARED_EQUIVALENT).unwrap();
let ir = compile_project_to_ir(project.path()).expect("the binding resolves");
assert_eq!(
ir["context_binding"]["project"], "widgets-layer",
"the IR must carry the layer's identity, never the document's filename"
);
}
#[test]
fn a_prepared_binding_without_a_document_says_what_to_add() {
let project = tempfile::tempdir().unwrap();
write_project(
project.path(),
"kind: prepared\nproject: widgets-layer\n",
" []",
);
let err = compile_project_to_ir(project.path()).expect_err("a document is required");
let text = err.to_string();
assert!(
text.contains("document"),
"the failure must name the missing field, got: {text}"
);
}
#[test]
fn a_missing_prepared_document_is_a_broken_pipeline_not_an_empty_context() {
let project = tempfile::tempdir().unwrap();
write_project(
project.path(),
"kind: prepared\nproject: widgets-layer\ndocument: ./context.json\n",
" []",
);
let err = compile_project_to_ir(project.path())
.expect_err("a missing prepared document must be loud");
assert!(
err.to_string().contains("context.json"),
"the failure must name the document that was not there, got: {err}"
);
}
#[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_is_refused_rather_than_guessed_at() {
let project = tempfile::tempdir().unwrap();
write_project(project.path(), "project: ./somewhere\n", " []");
let err = compile_project_to_ir(project.path())
.expect_err("a binding that declares no kind must not resolve as some assumed default");
assert!(
err.contains("missing field") && err.contains("kind"),
"the binding must fail to parse for want of `kind`, not fail later for some other reason: \
{err}"
);
}
#[test]
fn a_retired_wren_project_kind_says_what_to_write_instead() {
let project = tempfile::tempdir().unwrap();
write_project(
project.path(),
"kind: wren_project\nproject: ./wren\n",
" []",
);
let err = compile_project_to_ir(project.path())
.expect_err("warble reads no semantic format, so it cannot resolve a wren project");
assert!(
err.contains("no longer resolved"),
"the error must say the kind is retired, not merely unknown: {err}"
);
assert!(
err.contains("kind: prepared") && err.contains("document:"),
"and must name the replacement and the field it needs: {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("raw_source") && err.contains("prepared"),
"the error must list what this build does resolve: {err}"
);
assert!(
!err.contains("wren_project"),
"and must not advertise the retired kind as resolvable: {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}"
);
}