Skip to main content

relay_knowledge/domain/code/repository_graph/
mod.rs

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/// Indexed, snapshot-bound text used to derive portable repository relationships.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct IndexedRepositoryDocument {
18    pub path: String,
19    pub language_id: String,
20    pub content: String,
21}
22
23/// Validated request for a bounded repository graph neighborhood.
24#[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
170/// Projects OKF v0.2 concepts from indexed Markdown without reading the live worktree.
171pub 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;