1use std::collections::HashSet;
15
16use serde::Serialize;
17
18use crate::entity::EntityId;
19use crate::graph::query::{ReachedVia, TraversalDirection, reachable_via};
20use crate::runtime_validator::validate_rel_type;
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
25pub struct ChainScope {
26 pub root: EntityId,
28 pub via: Vec<String>,
31 pub direction: TraversalDirection,
33 pub depth: usize,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct ChainSet {
43 pub scope: ChainScope,
44 pub ids: HashSet<EntityId>,
46 pub reached: Vec<ReachedVia>,
48}
49
50impl ChainSet {
51 pub fn contains(&self, id: &EntityId) -> bool {
52 self.ids.contains(id)
53 }
54
55 pub fn describe(&self) -> String {
57 let depth = if self.scope.depth == usize::MAX {
58 "unbounded".to_string()
59 } else {
60 self.scope.depth.to_string()
61 };
62 format!(
63 "root {} via {} direction {} depth {}",
64 self.scope.root,
65 self.scope.via.join(","),
66 self.scope.direction.as_wire(),
67 depth
68 )
69 }
70}
71
72impl TraversalDirection {
73 pub fn as_wire(self) -> &'static str {
75 match self {
76 TraversalDirection::Out => "out",
77 TraversalDirection::In => "in",
78 TraversalDirection::Both => "both",
79 }
80 }
81
82 pub fn from_wire(s: &str) -> Option<Self> {
84 match s {
85 "out" => Some(TraversalDirection::Out),
86 "in" => Some(TraversalDirection::In),
87 "both" => Some(TraversalDirection::Both),
88 _ => None,
89 }
90 }
91
92 pub const WIRE_VALUES: &'static [&'static str] = &["out", "in", "both"];
94}
95
96impl crate::Engine {
97 pub fn chain_set(&self, mem: &str, scope: &ChainScope) -> Result<ChainSet, crate::EngineError> {
103 if self.mount(mem).is_none() {
104 return Err(self.unknown_mem_error(mem));
105 }
106 let root = self
107 .store()
108 .get(&scope.root)
109 .filter(|e| !e.stub)
110 .ok_or_else(|| crate::EngineError::NotFound {
111 id: scope.root.to_string(),
112 })?;
113 if root.mem != mem {
114 return Err(crate::EngineError::InvalidInput(format!(
115 "root {} lives in mem '{}', not in the exported mem '{mem}'",
116 scope.root, root.mem
117 )));
118 }
119 if scope.via.is_empty() {
120 return Err(crate::EngineError::InvalidInput(
121 "a chain needs at least one rel-type in `via`".to_string(),
122 ));
123 }
124 if let Some(schema) = self.schema_for(mem) {
125 for rel in &scope.via {
126 validate_rel_type(rel, &schema).map_err(crate::EngineError::Validation)?;
127 }
128 }
129 let reached = reachable_via(
130 self.store(),
131 &scope.root,
132 &scope.via,
133 scope.depth,
134 scope.direction,
135 );
136 let mut ids: HashSet<EntityId> = reached.iter().map(|r| r.id.clone()).collect();
137 ids.insert(scope.root.clone());
138 Ok(ChainSet {
139 scope: scope.clone(),
140 ids,
141 reached,
142 })
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use crate::graph::query::TraversalDirection;
149 use crate::storage::MemWriter;
150
151 use super::ChainScope;
152
153 fn engine() -> (crate::Engine, tempfile::TempDir) {
156 let tmp = tempfile::TempDir::new().unwrap();
157 let seed = |dir: &std::path::Path, files: &[(&str, &str)]| {
158 std::fs::create_dir_all(dir).unwrap();
159 let writer = crate::storage::FilesystemMemWriter::new(dir.to_path_buf());
160 for (name, body) in files {
161 writer
162 .write_entity(std::path::Path::new(name), body.as_bytes())
163 .unwrap();
164 }
165 writer
166 .commit("seed", &crate::vcs::CommitContext::internal())
167 .unwrap();
168 };
169 let spec = |title: &str, rels: &str| {
170 format!(
171 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# {title}\n\n## Identity\n\n{title}. See [[a2]] and [[c1]].\n{rels}"
172 )
173 };
174 let m = tmp.path().join("m");
175 let other = tmp.path().join("other");
176 seed(
177 &m,
178 &[
179 (
180 "root.md",
181 &spec(
182 "Root",
183 "\n## Relationships\n\n- **USES**: [[a1]]\n- **PART_OF**: [[c1]]\n",
184 ),
185 ),
186 (
187 "a1.md",
188 &spec(
189 "A1",
190 "\n## Relationships\n\n- **DEPENDS_ON**: [[a2]]\n- **USES**: [[other--x]]\n",
191 ),
192 ),
193 ("a2.md", &spec("A2", "")),
194 ("c1.md", &spec("C1", "")),
195 (
196 "back.md",
197 &spec("Back", "\n## Relationships\n\n- **USES**: [[root]]\n"),
198 ),
199 ],
200 );
201 seed(&other, &[("x.md", &spec("X", ""))]);
202 let mount = |mem: &str, path: std::path::PathBuf| {
203 (
204 crate::Mount {
205 mem: mem.to_string(),
206 schema: Some(memstead_schema::SchemaRef::new(
207 "default",
208 semver::Version::new(1, 0, 0),
209 )),
210 storage: crate::MountStorage::Folder { path: path.clone() },
211 capability: crate::MountCapability::Write,
212 lifecycle: crate::MountLifecycle::Eager,
213 cross_linkable: true,
214 migration_target: None,
215 },
216 Box::new(crate::storage::FilesystemMemWriter::new(path))
217 as Box<dyn crate::MemBackend>,
218 )
219 };
220 let engine =
221 crate::Engine::from_mounts(vec![mount("m", m), mount("other", other)]).unwrap();
222 (engine, tmp)
223 }
224
225 fn scope(via: &[&str], direction: TraversalDirection, depth: usize) -> ChainScope {
226 ChainScope {
227 root: crate::EntityId::canonical("m--root"),
228 via: via.iter().map(|s| s.to_string()).collect(),
229 direction,
230 depth,
231 }
232 }
233
234 fn ids(engine: &crate::Engine, s: &ChainScope) -> Vec<String> {
235 let mut v: Vec<String> = engine
236 .chain_set("m", s)
237 .unwrap()
238 .ids
239 .iter()
240 .map(|i| i.to_string())
241 .collect();
242 v.sort();
243 v
244 }
245
246 #[test]
251 fn chain_set_follows_via_and_direction_at_every_hop() {
252 let (engine, _tmp) = engine();
253 assert_eq!(
254 ids(
255 &engine,
256 &scope(&["USES", "DEPENDS_ON"], TraversalDirection::Out, usize::MAX)
257 ),
258 vec!["m--a1", "m--a2", "m--root", "other--x"]
259 );
260 assert_eq!(
261 ids(
262 &engine,
263 &scope(&["USES", "DEPENDS_ON"], TraversalDirection::Out, 1)
264 ),
265 vec!["m--a1", "m--root"]
266 );
267 assert_eq!(
268 ids(
269 &engine,
270 &scope(&["USES"], TraversalDirection::In, usize::MAX)
271 ),
272 vec!["m--back", "m--root"]
273 );
274 assert_eq!(
275 ids(
276 &engine,
277 &scope(&["PART_OF"], TraversalDirection::Out, usize::MAX)
278 ),
279 vec!["m--c1", "m--root"]
280 );
281 let set = engine
282 .chain_set(
283 "m",
284 &scope(&["USES", "DEPENDS_ON"], TraversalDirection::Out, usize::MAX),
285 )
286 .unwrap();
287 assert_eq!(set.reached.len(), 3);
288 assert!(
289 set.describe()
290 .contains("root m--root via USES,DEPENDS_ON direction out depth unbounded")
291 );
292 }
293
294 #[test]
298 fn chain_set_refuses_typed() {
299 let (engine, _tmp) = engine();
300 let err = engine
301 .chain_set("m", &scope(&["NOPE"], TraversalDirection::Out, 3))
302 .unwrap_err();
303 assert_eq!(err.code(), "INVALID_REL_TYPE", "{err}");
304 assert!(
305 err.details().to_string().contains("USES"),
306 "the recovery payload names the vocabulary: {}",
307 err.details()
308 );
309 let missing = ChainScope {
310 root: crate::EntityId::canonical("m--missing"),
311 ..scope(&["USES"], TraversalDirection::Out, 3)
312 };
313 assert_eq!(
314 engine.chain_set("m", &missing).unwrap_err().code(),
315 "ENTITY_NOT_FOUND"
316 );
317 assert_eq!(
318 engine
319 .chain_set("m", &scope(&[], TraversalDirection::Out, 3))
320 .unwrap_err()
321 .code(),
322 "INVALID_INPUT"
323 );
324 assert_eq!(
325 engine
326 .chain_set("other", &scope(&["USES"], TraversalDirection::Out, 3))
327 .unwrap_err()
328 .code(),
329 "INVALID_INPUT",
330 "root in another mem"
331 );
332 assert_eq!(
333 engine
334 .chain_set("ghost", &scope(&["USES"], TraversalDirection::Out, 3))
335 .unwrap_err()
336 .code(),
337 "UNKNOWN_MEM"
338 );
339 }
340
341 #[test]
345 fn scoped_renderers_reduce_and_unscoped_stays_identical() {
346 let (engine, _tmp) = engine();
347 let chain = engine
348 .chain_set(
349 "m",
350 &scope(&["USES", "DEPENDS_ON"], TraversalDirection::Out, usize::MAX),
351 )
352 .unwrap();
353
354 let full = engine.render_html_export("m", "2026-09-02").unwrap();
356 let same = engine
357 .render_html_export_scoped("m", "2026-09-02", None)
358 .unwrap();
359 assert_eq!(full, same, "None is byte-identical to the unscoped export");
360 let reduced = engine
361 .render_html_export_scoped("m", "2026-09-02", Some(&chain))
362 .unwrap();
363 for id in ["m--root", "m--a1", "m--a2"] {
364 assert!(reduced.contains(&format!("id=\"{id}\"")), "{id} rendered");
365 }
366 for id in ["m--c1", "m--back"] {
367 assert!(!reduced.contains(&format!("id=\"{id}\"")), "{id} excluded");
368 }
369 assert!(reduced.contains("Chain:"), "header names the chain");
370 assert!(
371 reduced.contains("unresolved"),
372 "links to excluded entities are marked"
373 );
374 assert!(reduced.contains("3 entities"));
375
376 let ctx = crate::engine::export_llms_txt::LlmsTxtContext {
378 authority: None,
379 href_prefix: String::new(),
380 wider_project: Vec::new(),
381 };
382 let full = engine.render_llms_txt("m", &ctx).unwrap();
383 assert_eq!(
384 full,
385 engine.render_llms_txt_scoped("m", &ctx, None).unwrap()
386 );
387 let reduced = engine
388 .render_llms_txt_scoped("m", &ctx, Some(&chain))
389 .unwrap();
390 assert!(
391 reduced
392 .contains("Chain: root m--root via USES,DEPENDS_ON direction out depth unbounded")
393 );
394 assert!(reduced.contains("Entities: 3"));
395 assert!(reduced.contains("# A2"));
396 assert!(!reduced.contains("# C1"));
397 assert!(!reduced.contains("# Back"));
398 assert!(reduced.contains("[A2](entity/m--a2)"), "{reduced}");
402 assert!(reduced.contains(" and c1."), "{reduced}");
403 assert!(!reduced.contains("entity/m--c1"), "{reduced}");
404
405 let full = engine.mem_topology("m").unwrap();
407 assert_eq!(full, engine.mem_topology_scoped("m", None).unwrap());
408 let reduced = engine.mem_topology_scoped("m", Some(&chain)).unwrap();
409 let node_ids: Vec<&str> = reduced.nodes.iter().map(|n| n.id.as_str()).collect();
410 assert_eq!(node_ids, vec!["m--a1", "m--a2", "m--root"]);
411 let edges: Vec<(String, String, bool)> = reduced
412 .edges
413 .iter()
414 .map(|e| (e.source.clone(), e.target.clone(), e.target_in_mem))
415 .collect();
416 assert_eq!(
417 edges,
418 vec![
419 ("m--a1".to_string(), "m--a2".to_string(), true),
420 ("m--a1".to_string(), "other--x".to_string(), false),
421 ("m--root".to_string(), "m--a1".to_string(), true),
422 ],
423 "edges with both ends in the chain, cross-mem target marked"
424 );
425 }
426}