1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
//! Chicago-TDD end-to-end tests for the declarative manifest's optional
//! `[rules]` section (v26.10.10 §4.1: `rules.n3` / `rules.datalog`),
//! wired at `sync::sync`'s Stage-0 `DeclarativeRules` dispatch arm:
//!
//! 1. `[rules].n3` forward-chaining derivation is visible to a
//! `[[generation.rules]]` SPARQL query (inference actually ran).
//! 2. A fired `=> false` denial fuse in a `[rules].n3` file refuses the
//! sync (typed FM-LAW refusal naming the fuse), never a warning.
//! 3. A `[rules].datalog` entry is a typed UNSUPPORTED refusal.
//! 4. No `[rules]` section (or an empty one) = byte-identical output to
//! today's behavior — zero drift.
//!
//! Real filesystem (`tempfile::TempDir`), real N3 reasoner, real SPARQL,
//! real Tera render, real write — no mocks.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::Path;
use ggen_engine::sync::{sync, SyncOptions};
use tempfile::TempDir;
fn write(root: &Path, rel: &str, content: &str) {
let path = root.join(rel);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("mkdir parent");
}
std::fs::write(path, content).expect("write file");
}
/// Minimal valid declarative-rules manifest with `extra` spliced in before
/// the `[[generation.rules]]` table.
fn write_manifest(root: &Path, extra: &str) {
write(
root,
"ggen.toml",
&format!(
"[project]\nname = \"rules-n3-e2e\"\nversion = \"1.0.0\"\n\n\
[ontology]\nsource = \"ontology.ttl\"\n\n\
{extra}\n\
[[generation.rules]]\n\
name = \"animals\"\n\
query = {{ inline = \"SELECT ?s WHERE {{ ?s a <http://example.org/Animal> }} ORDER BY ?s\" }}\n\
template = {{ inline = \"{{% for row in results %}}{{{{ row.s }}}}\\n{{% endfor %}}\" }}\n\
output_file = \"out/animals.txt\"\n"
),
);
}
/// `ex:rex a ex:Dog` — note `ex:Animal` appears NOWHERE in the ontology,
/// so any `Animal` fact in an output can only come from N3 inference.
const ONTOLOGY_REX_DOG: &str = r"
@prefix ex: <http://example.org/> .
ex:rex a ex:Dog .
";
// ---------------------------------------------------------------------------
// 1. `[rules].n3` derivation is visible to the generation-rule query.
// ---------------------------------------------------------------------------
/// THE load-bearing proof for `[rules].n3` wiring: `ex:rex a ex:Animal`
/// exists nowhere in the project — it can only appear if the declared N3
/// rule was resolved, forward-chained, and its derived facts folded into
/// the graph *before* the generation rule's SELECT ran. A decorative
/// implementation (paths parsed but rules never executed) renders an empty
/// result instead.
#[test]
fn rules_n3_derivation_is_visible_to_generation_rule_query() {
const DERIVE_N3: &str =
"@prefix ex: <http://example.org/>. {?s a ex:Dog} => {?s a ex:Animal} .";
let dir = TempDir::new().expect("tempdir");
write_manifest(
dir.path(),
"[rules]\nn3 = [\"rules/dogs-are-animals.n3\"]\ndatalog = []\n",
);
write_ontology(dir.path());
write(dir.path(), "rules/dogs-are-animals.n3", DERIVE_N3);
let report = sync(dir.path(), SyncOptions::default()).expect("[rules].n3 sync must succeed");
assert_eq!(
report.written,
vec![std::path::PathBuf::from("out/animals.txt")]
);
let content = std::fs::read_to_string(dir.path().join("out/animals.txt")).expect("read output");
assert_eq!(
content, "http://example.org/rex\n",
"generation rule must see the N3-derived ex:Animal fact, not an empty graph"
);
}
// ---------------------------------------------------------------------------
// 2. A fired `=> false` denial fuse refuses the sync.
// ---------------------------------------------------------------------------
/// A `[rules].n3` denial fuse (`{ body } => false.`) that fires is a typed
/// FM-LAW refusal naming the fuse — a fired fuse is a refusal, never a
/// warning — and nothing is written.
#[test]
fn rules_n3_denial_fuse_refuses_sync() {
const DENIAL_N3: &str = "@prefix ex: <http://example.org/>. {?s a ex:Dog} => false.";
let dir = TempDir::new().expect("tempdir");
write_manifest(
dir.path(),
"[rules]\nn3 = [\"rules/no-dogs.n3\"]\ndatalog = []\n",
);
write_ontology(dir.path());
write(dir.path(), "rules/no-dogs.n3", DENIAL_N3);
let err = sync(dir.path(), SyncOptions::default())
.expect_err("asserted dog under a [rules].n3 denial fuse must refuse");
let msg = err.to_string();
assert!(msg.contains("FM-LAW-016"), "{msg}");
assert!(
msg.contains("DENIED"),
"refusal must name the fuse line: {msg}"
);
assert!(
!dir.path().join("out/animals.txt").exists(),
"refused run must write nothing"
);
}
// ---------------------------------------------------------------------------
// 3. `[rules].datalog` is a typed UNSUPPORTED refusal.
// ---------------------------------------------------------------------------
/// A `.datalog` entry under `[rules]` is refused loudly with the UNSUPPORTED
/// text surfaced as a sync error — never a silent skip, never a
/// hand-rolled Datalog parser.
#[test]
fn rules_datalog_is_typed_unsupported_refusal() {
let dir = TempDir::new().expect("tempdir");
write_manifest(
dir.path(),
"[rules]\nn3 = []\ndatalog = [\"rules/legacy.datalog\"]\n",
);
write_ontology(dir.path());
write(dir.path(), "rules/legacy.datalog", "dog(x) <- animal(x).");
let err = sync(dir.path(), SyncOptions::default())
.expect_err("a [rules].datalog entry must be a typed refusal");
let msg = err.to_string();
assert!(msg.contains("UNSUPPORTED"), "{msg}");
assert!(msg.contains("legacy.datalog"), "{msg}");
assert!(
!dir.path().join("out/animals.txt").exists(),
"refused run must write nothing"
);
}
// ---------------------------------------------------------------------------
// 4. Zero drift: absent or empty `[rules]` = exact current behavior.
// ---------------------------------------------------------------------------
/// A project with no `[rules]` section and a project with an explicitly
/// empty `[rules]` section produce byte-identical output files — proving
/// the wiring is purely additive. The generation rule selects `ex:Dog` (a
/// plain ontology fact) so both runs render non-empty, comparable output.
#[test]
fn absent_or_empty_rules_section_is_byte_identical() {
let manifest_for = |root: &Path, extra: &str| {
write(
root,
"ggen.toml",
&format!(
"[project]\nname = \"rules-n3-e2e\"\nversion = \"1.0.0\"\n\n\
[ontology]\nsource = \"ontology.ttl\"\n\n\
{extra}\n\
[[generation.rules]]\n\
name = \"dogs\"\n\
query = {{ inline = \"SELECT ?s WHERE {{ ?s a <http://example.org/Dog> }} ORDER BY ?s\" }}\n\
template = {{ inline = \"{{% for row in results %}}{{{{ row.s }}}}\\n{{% endfor %}}\" }}\n\
output_file = \"out/dogs.txt\"\n"
),
);
write(root, "ontology.ttl", ONTOLOGY_REX_DOG);
};
let without = TempDir::new().expect("tempdir");
manifest_for(without.path(), "");
let with_empty = TempDir::new().expect("tempdir");
manifest_for(with_empty.path(), "[rules]\nn3 = []\ndatalog = []\n");
sync(without.path(), SyncOptions::default()).expect("no-[rules] sync");
sync(with_empty.path(), SyncOptions::default()).expect("empty-[rules] sync");
let a = std::fs::read_to_string(without.path().join("out/dogs.txt")).expect("read A");
let b = std::fs::read_to_string(with_empty.path().join("out/dogs.txt")).expect("read B");
assert_eq!(a, "http://example.org/rex\n", "baseline must be non-empty");
assert_eq!(
a, b,
"an empty [rules] section must not change output bytes"
);
}
fn write_ontology(root: &Path) {
write(root, "ontology.ttl", ONTOLOGY_REX_DOG);
}