1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4
5use super::{CodeRepositorySelector, DomainError};
6
7mod okf;
8
9pub const REPOSITORY_GRAPH_DEFAULT_NODE_LIMIT: usize = 100;
10pub const REPOSITORY_GRAPH_DEFAULT_EDGE_LIMIT: usize = 200;
11pub const REPOSITORY_GRAPH_MAX_NODE_LIMIT: usize = 100;
12pub const REPOSITORY_GRAPH_MAX_EDGE_LIMIT: usize = 200;
13pub const REPOSITORY_GRAPH_MAX_DEPTH: u8 = 2;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct IndexedRepositoryDocument {
18 pub path: String,
19 pub language_id: String,
20 pub content: String,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct RepositoryGraphNeighborhoodRequest {
26 pub repository: CodeRepositorySelector,
27 pub focus_path: String,
28 pub depth: u8,
29 pub node_limit: usize,
30 pub edge_limit: usize,
31}
32
33impl RepositoryGraphNeighborhoodRequest {
34 pub fn new(
35 repository: CodeRepositorySelector,
36 focus_path: impl Into<String>,
37 depth: u8,
38 node_limit: usize,
39 edge_limit: usize,
40 ) -> Result<Self, DomainError> {
41 let focus_path = normalize_repository_path(&focus_path.into()).ok_or_else(|| {
42 DomainError::invalid(
43 "focus_path",
44 "must be a normalized relative repository path",
45 )
46 })?;
47 if depth == 0 || depth > REPOSITORY_GRAPH_MAX_DEPTH {
48 return Err(DomainError::invalid(
49 "depth",
50 format!("must be between 1 and {REPOSITORY_GRAPH_MAX_DEPTH}"),
51 ));
52 }
53 if node_limit == 0 || node_limit > REPOSITORY_GRAPH_MAX_NODE_LIMIT {
54 return Err(DomainError::invalid(
55 "node_limit",
56 format!("must be between 1 and {REPOSITORY_GRAPH_MAX_NODE_LIMIT}"),
57 ));
58 }
59 if edge_limit == 0 || edge_limit > REPOSITORY_GRAPH_MAX_EDGE_LIMIT {
60 return Err(DomainError::invalid(
61 "edge_limit",
62 format!("must be between 1 and {REPOSITORY_GRAPH_MAX_EDGE_LIMIT}"),
63 ));
64 }
65 if repository
66 .language_filters
67 .iter()
68 .any(|value| value != "markdown")
69 {
70 return Err(DomainError::invalid(
71 "language_filter",
72 "repository graph neighborhoods only accept markdown",
73 ));
74 }
75 if repository.path_filters.is_empty()
76 || !repository
77 .path_filters
78 .iter()
79 .any(|root| path_is_within(&focus_path, root))
80 {
81 return Err(DomainError::invalid(
82 "focus_path",
83 "must be inside an explicit repository path filter",
84 ));
85 }
86
87 Ok(Self {
88 repository,
89 focus_path,
90 depth,
91 node_limit,
92 edge_limit,
93 })
94 }
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub struct RepositoryGraphNode {
99 pub id: String,
100 pub kind: String,
101 pub label: String,
102 #[serde(skip_serializing_if = "Option::is_none")]
103 pub path: Option<String>,
104 #[serde(skip_serializing_if = "Option::is_none")]
105 pub resource: Option<String>,
106 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
107 pub details: BTreeMap<String, String>,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct RepositoryGraphEdge {
112 pub id: String,
113 pub kind: String,
114 pub source: String,
115 pub target: String,
116 pub label: String,
117 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
118 pub details: BTreeMap<String, String>,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct RepositoryGraphNeighborhood {
123 pub nodes: Vec<RepositoryGraphNode>,
124 pub edges: Vec<RepositoryGraphEdge>,
125 pub truncated: bool,
126}
127
128struct ConceptSelection {
129 paths: BTreeMap<String, u8>,
130 truncated: bool,
131}
132
133struct NodeAssembly {
134 nodes: Vec<RepositoryGraphNode>,
135 concept_paths: BTreeSet<String>,
136 leaf_resources: BTreeSet<String>,
137 truncated: bool,
138}
139
140#[derive(Clone, Copy)]
141enum EdgeCandidate<'a> {
142 SourceConcept {
143 concept: &'a okf::OkfConcept,
144 source: &'a okf::OkfSource,
145 target: &'a str,
146 },
147 SourceLeaf {
148 concept: &'a okf::OkfConcept,
149 source: &'a okf::OkfSource,
150 },
151 ConceptLink {
152 concept: &'a okf::OkfConcept,
153 target: &'a str,
154 },
155}
156
157#[derive(Eq, Ord, PartialEq, PartialOrd)]
158enum EdgeCandidateKey<'a> {
159 Source {
160 concept_path: &'a str,
161 resource: &'a str,
162 source_id: Option<&'a str>,
163 },
164 Link {
165 concept_path: &'a str,
166 target: &'a str,
167 },
168}
169
170pub fn project_okf_neighborhood(
172 documents: &[IndexedRepositoryDocument],
173 request: &RepositoryGraphNeighborhoodRequest,
174) -> Result<RepositoryGraphNeighborhood, DomainError> {
175 let root = matching_repository_root(&request.focus_path, &request.repository.path_filters)
176 .expect("request validation requires a matching root");
177 let concepts = documents
178 .iter()
179 .filter(|document| {
180 document.language_id == "markdown"
181 && path_is_within(&document.path, root)
182 && !reserved_okf_path(&document.path)
183 })
184 .filter_map(|document| okf::parse_concept(document, root))
185 .map(|concept| (concept.path.clone(), concept))
186 .collect::<BTreeMap<_, _>>();
187 if !concepts.contains_key(&request.focus_path) {
188 return Err(DomainError::invalid(
189 "focus_path",
190 "does not identify an indexed OKF concept",
191 ));
192 }
193
194 let selected = selected_concepts(
195 &concepts,
196 &request.focus_path,
197 request.depth,
198 request.node_limit,
199 );
200 let source_truncated = concepts.values().any(|concept| concept.truncated);
201 let nodes = assemble_nodes(&concepts, &selected, request.node_limit);
202 let (edges, edges_truncated) = assemble_edges(
203 &concepts,
204 &nodes.concept_paths,
205 &nodes.leaf_resources,
206 request.edge_limit,
207 );
208
209 Ok(RepositoryGraphNeighborhood {
210 truncated: source_truncated || selected.truncated || nodes.truncated || edges_truncated,
211 nodes: nodes.nodes,
212 edges,
213 })
214}
215
216fn selected_concepts(
217 concepts: &BTreeMap<String, okf::OkfConcept>,
218 focus: &str,
219 depth: u8,
220 limit: usize,
221) -> ConceptSelection {
222 let mut paths = BTreeMap::from([(focus.to_owned(), 0_u8)]);
223 let mut frontier = BTreeSet::from([focus.to_owned()]);
224 let mut truncated = false;
225 for distance in 1..=depth {
226 let remaining = limit.saturating_sub(paths.len());
227 let candidate_capacity = remaining.saturating_add(1);
228 let mut candidates = BTreeSet::new();
229 for (source_path, concept) in concepts {
230 for target in concept.relationship_targets() {
231 if !concepts.contains_key(target) {
232 continue;
233 }
234 let candidate = if frontier.contains(source_path) && !paths.contains_key(target) {
235 Some(target)
236 } else if frontier.contains(target) && !paths.contains_key(source_path) {
237 Some(source_path.as_str())
238 } else {
239 None
240 };
241 if let Some(candidate) = candidate {
242 truncated |= insert_bounded_set(
243 &mut candidates,
244 candidate.to_owned(),
245 candidate_capacity,
246 );
247 }
248 }
249 }
250 if candidates.len() > remaining {
251 truncated = true;
252 candidates.pop_last();
253 }
254 if candidates.is_empty() {
255 break;
256 }
257 for path in &candidates {
258 paths.insert(path.clone(), distance);
259 }
260 frontier = candidates;
261 }
262 ConceptSelection { paths, truncated }
263}
264
265fn assemble_nodes(
266 concepts: &BTreeMap<String, okf::OkfConcept>,
267 selected: &ConceptSelection,
268 node_limit: usize,
269) -> NodeAssembly {
270 let mut nodes = Vec::with_capacity(node_limit);
271 let mut concept_paths = BTreeSet::new();
272 let mut leaf_resources = BTreeSet::new();
273 let mut truncated = false;
274 let max_distance = selected
275 .paths
276 .values()
277 .copied()
278 .max()
279 .unwrap_or_default()
280 .saturating_add(1);
281
282 for distance in 0..=max_distance {
283 for (path, candidate_distance) in &selected.paths {
284 if *candidate_distance != distance {
285 continue;
286 }
287 if nodes.len() >= node_limit {
288 truncated = true;
289 continue;
290 }
291 nodes.push(concepts[path].node());
292 concept_paths.insert(path.clone());
293 }
294 if distance == 0 {
295 continue;
296 }
297
298 let remaining = node_limit.saturating_sub(nodes.len());
299 let candidate_capacity = remaining.saturating_add(1);
300 let mut candidates = BTreeMap::<(bool, &str), &okf::OkfSource>::new();
301 for path in &concept_paths {
302 if selected.paths[path].saturating_add(1) != distance {
303 continue;
304 }
305 for source in &concepts[path].sources {
306 let targets_concept = source
307 .candidate_path
308 .as_ref()
309 .is_some_and(|target| concepts.contains_key(target));
310 if targets_concept || leaf_resources.contains(source.resource.as_str()) {
311 continue;
312 }
313 truncated |= insert_bounded_map(
314 &mut candidates,
315 (source.bundle_path_hint, source.resource.as_str()),
316 source,
317 candidate_capacity,
318 );
319 }
320 }
321 if candidates.len() > remaining {
322 truncated = true;
323 candidates.pop_last();
324 }
325 for source in candidates.into_values() {
326 nodes.push(source.leaf_node());
327 leaf_resources.insert(source.resource.clone());
328 }
329 }
330
331 NodeAssembly {
332 nodes,
333 concept_paths,
334 leaf_resources,
335 truncated,
336 }
337}
338
339fn assemble_edges(
340 concepts: &BTreeMap<String, okf::OkfConcept>,
341 concept_paths: &BTreeSet<String>,
342 leaf_resources: &BTreeSet<String>,
343 edge_limit: usize,
344) -> (Vec<RepositoryGraphEdge>, bool) {
345 let candidate_capacity = edge_limit.saturating_add(1);
346 let mut candidates = BTreeMap::new();
347 let mut truncated = false;
348 for path in concept_paths {
349 let concept = &concepts[path];
350 for source in &concept.sources {
351 let candidate = if let Some(target) = source
352 .candidate_path
353 .as_deref()
354 .filter(|target| concepts.contains_key(*target))
355 {
356 concept_paths
357 .contains(target)
358 .then_some(EdgeCandidate::SourceConcept {
359 concept,
360 source,
361 target,
362 })
363 } else {
364 leaf_resources
365 .contains(source.resource.as_str())
366 .then_some(EdgeCandidate::SourceLeaf { concept, source })
367 };
368 if let Some(candidate) = candidate {
369 truncated |= insert_bounded_map(
370 &mut candidates,
371 EdgeCandidateKey::Source {
372 concept_path: &concept.path,
373 resource: &source.resource,
374 source_id: source.id.as_deref(),
375 },
376 candidate,
377 candidate_capacity,
378 );
379 }
380 }
381 for target in &concept.links {
382 if concept_paths.contains(target) {
383 truncated |= insert_bounded_map(
384 &mut candidates,
385 EdgeCandidateKey::Link {
386 concept_path: &concept.path,
387 target,
388 },
389 EdgeCandidate::ConceptLink { concept, target },
390 candidate_capacity,
391 );
392 }
393 }
394 }
395 if candidates.len() > edge_limit {
396 truncated = true;
397 candidates.pop_last();
398 }
399 let mut edges = candidates
400 .into_values()
401 .map(|candidate| match candidate {
402 EdgeCandidate::SourceConcept {
403 concept,
404 source,
405 target,
406 } => source.edge_to_concept(concept, target),
407 EdgeCandidate::SourceLeaf { concept, source } => source.edge_to_leaf(concept),
408 EdgeCandidate::ConceptLink { concept, target } => {
409 okf::concept_link_edge(concept, target)
410 }
411 })
412 .collect::<Vec<_>>();
413 edges.sort_by(|left, right| left.id.cmp(&right.id));
414 (edges, truncated)
415}
416
417fn insert_bounded_set<T: Ord>(set: &mut BTreeSet<T>, value: T, capacity: usize) -> bool {
418 if !set.insert(value) || set.len() <= capacity {
419 return false;
420 }
421 set.pop_last();
422 true
423}
424
425fn insert_bounded_map<K: Ord, V>(
426 map: &mut BTreeMap<K, V>,
427 key: K,
428 value: V,
429 capacity: usize,
430) -> bool {
431 if map.contains_key(&key) {
432 return false;
433 }
434 map.insert(key, value);
435 if map.len() <= capacity {
436 return false;
437 }
438 map.pop_last();
439 true
440}
441
442fn matching_repository_root<'a>(focus: &str, roots: &'a [String]) -> Option<&'a str> {
443 roots
444 .iter()
445 .filter(|root| path_is_within(focus, root))
446 .max_by_key(|root| root_specificity(root))
447 .map(String::as_str)
448}
449
450fn root_specificity(root: &str) -> (usize, usize) {
451 normalize_repository_root(root)
452 .map(|root| {
453 (
454 root.split('/')
455 .filter(|component| !component.is_empty())
456 .count(),
457 root.len(),
458 )
459 })
460 .unwrap_or_default()
461}
462
463fn normalize_repository_root(root: &str) -> Option<String> {
464 if root == "." {
465 Some(String::new())
466 } else {
467 normalize_repository_path(root)
468 }
469}
470
471fn reserved_okf_path(path: &str) -> bool {
472 path.rsplit('/')
473 .next()
474 .is_some_and(|name| matches!(name, "index.md" | "log.md"))
475}
476
477pub(super) fn path_is_within(path: &str, root: &str) -> bool {
478 let Some(path) = normalize_repository_path(path) else {
479 return false;
480 };
481 normalize_repository_root(root).is_some_and(|root| {
482 root.is_empty()
483 || path == root
484 || path
485 .strip_prefix(&root)
486 .is_some_and(|rest| rest.starts_with('/'))
487 })
488}
489
490pub(super) fn normalize_repository_path(path: &str) -> Option<String> {
491 if path.is_empty() || path.starts_with('/') || path.contains('\0') || path.contains('\\') {
492 return None;
493 }
494 let mut components = Vec::new();
495 for component in path.split('/') {
496 match component {
497 "" | "." => {}
498 ".." => {
499 components.pop()?;
500 }
501 value => components.push(value),
502 }
503 }
504 (!components.is_empty()).then(|| components.join("/"))
505}
506
507#[cfg(test)]
508#[path = "mod_tests.rs"]
509mod tests;