Skip to main content

nenjo_knowledge/
lib.rs

1//! Shared knowledge pack primitives and embedded Nenjo knowledge.
2//!
3//! Knowledge packs expose a common metadata/search/read API for builtin,
4//! project, filesystem, or remote document sets.
5
6use std::borrow::Cow;
7use std::collections::{BTreeMap, BTreeSet};
8
9use serde::{Deserialize, Serialize};
10
11#[cfg(feature = "nenjo")]
12pub mod builtin;
13pub mod tools;
14
15/// Shared read-only metadata contract for any knowledge pack manifest.
16///
17/// This trait intentionally covers only pack identity and document metadata.
18/// Concrete pack manifests, such as project or remote manifests, should expose
19/// their own sync/cache mutation methods on their concrete types.
20pub trait KnowledgePackManifest: Send + Sync {
21    fn pack_id(&self) -> &str;
22    fn pack_version(&self) -> &str;
23    fn schema_version(&self) -> u32;
24    fn root_uri(&self) -> &str;
25    fn content_hash(&self) -> &str;
26    fn docs(&self) -> &[KnowledgeDocManifest];
27
28    fn read_doc_manifest(&self, path: &str) -> Option<&KnowledgeDocManifest> {
29        self.docs()
30            .iter()
31            .find(|doc| doc.id == path || doc.virtual_path == path || doc.source_path == path)
32    }
33}
34
35/// Serializable base manifest used by read-only packs and generic consumers.
36///
37/// Project and remote packs may deserialize into richer concrete types, but
38/// their document entries should still use [`KnowledgeDocManifest`] so agents
39/// and MCP tools see one metadata schema across all pack sources.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct KnowledgePackManifestData {
42    pub pack_id: String,
43    pub pack_version: String,
44    pub schema_version: u32,
45    pub root_uri: String,
46    #[serde(default)]
47    pub content_hash: String,
48    pub docs: Vec<KnowledgeDocManifest>,
49}
50
51impl KnowledgePackManifest for KnowledgePackManifestData {
52    fn pack_id(&self) -> &str {
53        &self.pack_id
54    }
55
56    fn pack_version(&self) -> &str {
57        &self.pack_version
58    }
59
60    fn schema_version(&self) -> u32 {
61        self.schema_version
62    }
63
64    fn root_uri(&self) -> &str {
65        &self.root_uri
66    }
67
68    fn content_hash(&self) -> &str {
69        &self.content_hash
70    }
71
72    fn docs(&self) -> &[KnowledgeDocManifest] {
73        &self.docs
74    }
75}
76
77/// Shared document metadata visible through knowledge pack APIs.
78///
79/// `size_bytes` and `updated_at` are sync hints used by local project caches.
80/// Builtin and remote manifests may leave them empty/defaulted.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct KnowledgeDocManifest {
83    pub id: String,
84    pub virtual_path: String,
85    pub source_path: String,
86    pub title: String,
87    pub summary: String,
88    pub description: Option<String>,
89    pub kind: KnowledgeDocKind,
90    pub authority: KnowledgeDocAuthority,
91    pub status: KnowledgeDocStatus,
92    pub tags: Vec<String>,
93    pub aliases: Vec<String>,
94    pub keywords: Vec<String>,
95    pub related: Vec<KnowledgeDocEdge>,
96    #[serde(default)]
97    pub size_bytes: i64,
98    #[serde(default)]
99    pub updated_at: String,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "snake_case")]
104pub enum KnowledgeDocEdgeType {
105    PartOf,
106    Defines,
107    Governs,
108    Classifies,
109    References,
110    DependsOn,
111    Extends,
112    RelatedTo,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum KnowledgeDocKind {
118    Guide,
119    Reference,
120    Taxonomy,
121    Domain,
122    Entity,
123    Policy,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(rename_all = "snake_case")]
128pub enum KnowledgeDocAuthority {
129    Canonical,
130    Supporting,
131    Pattern,
132    Reference,
133    Advisory,
134    Example,
135    Draft,
136    Deprecated,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
140#[serde(rename_all = "snake_case")]
141pub enum KnowledgeDocStatus {
142    Stable,
143    Draft,
144    Deprecated,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct KnowledgeDocEdge {
149    #[serde(rename = "type", alias = "edge_type")]
150    pub edge_type: KnowledgeDocEdgeType,
151    pub target: String,
152    pub description: Option<String>,
153}
154
155#[derive(Debug, Clone, Default, Serialize, Deserialize)]
156pub struct KnowledgeDocFilter {
157    pub tags: Vec<String>,
158    pub kind: Option<KnowledgeDocKind>,
159    pub authority: Option<KnowledgeDocAuthority>,
160    pub status: Option<KnowledgeDocStatus>,
161    pub path_prefix: Option<String>,
162    pub related_to: Option<String>,
163    pub edge_type: Option<KnowledgeDocEdgeType>,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct KnowledgeDocRead {
168    pub manifest: KnowledgeDocManifest,
169    pub content: String,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173pub struct KnowledgeDocNeighbor {
174    pub target: String,
175    pub edges: Vec<KnowledgeDocNeighborEdge>,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179pub struct KnowledgeDocNeighborEdge {
180    pub edge_type: KnowledgeDocEdgeType,
181    pub source: String,
182    pub target: String,
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub note: Option<String>,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct KnowledgeDocSearchHit {
189    pub id: String,
190    pub virtual_path: String,
191    pub title: String,
192    pub summary: String,
193    pub kind: KnowledgeDocKind,
194    pub authority: KnowledgeDocAuthority,
195    pub tags: Vec<String>,
196    pub score: usize,
197    pub matched: Vec<String>,
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub content: Option<String>,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct KnowledgeDocTree {
204    pub root_uri: String,
205    pub entries: Vec<KnowledgeDocTreeEntry>,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct KnowledgeDocTreeEntry {
210    pub path: String,
211    pub title: String,
212    pub kind: KnowledgeDocKind,
213    pub tags: Vec<String>,
214}
215
216enum SearchMode {
217    MetadataOnly,
218    FullText,
219}
220
221/// Runtime access to a knowledge pack's metadata and lazy document content.
222pub trait KnowledgePack: Send + Sync {
223    fn manifest(&self) -> &dyn KnowledgePackManifest;
224
225    fn doc_content(&self, manifest: &KnowledgeDocManifest) -> Option<Cow<'_, str>>;
226
227    fn list_tree(&self, prefix: Option<&str>) -> KnowledgeDocTree {
228        let mut entries: Vec<_> = self
229            .manifest()
230            .docs()
231            .iter()
232            .filter(|doc| {
233                prefix
234                    .map(|prefix| doc.virtual_path.starts_with(prefix))
235                    .unwrap_or(true)
236            })
237            .map(|doc| KnowledgeDocTreeEntry {
238                path: doc.virtual_path.clone(),
239                title: doc.title.clone(),
240                kind: doc.kind,
241                tags: doc.tags.clone(),
242            })
243            .collect();
244        entries.sort_by(|a, b| a.path.cmp(&b.path));
245        KnowledgeDocTree {
246            root_uri: self.manifest().root_uri().to_string(),
247            entries,
248        }
249    }
250
251    fn list_docs(&self, filter: KnowledgeDocFilter) -> Vec<&KnowledgeDocManifest> {
252        self.manifest()
253            .docs()
254            .iter()
255            .filter(|doc| matches_filter(self, doc, &filter))
256            .collect()
257    }
258
259    fn read_manifest(&self, path: &str) -> Option<&KnowledgeDocManifest> {
260        self.manifest().read_doc_manifest(path)
261    }
262
263    fn read_doc(&self, path: &str) -> Option<KnowledgeDocRead> {
264        let manifest = self.read_manifest(path)?.clone();
265        let content = self.doc_content(&manifest)?.into_owned();
266        Some(KnowledgeDocRead { manifest, content })
267    }
268
269    fn search_paths(&self, query: &str, filter: KnowledgeDocFilter) -> Vec<KnowledgeDocSearchHit> {
270        search_pack(self, query, filter, SearchMode::MetadataOnly)
271    }
272
273    fn search_docs(&self, query: &str, filter: KnowledgeDocFilter) -> Vec<KnowledgeDocSearchHit> {
274        search_pack(self, query, filter, SearchMode::FullText)
275    }
276
277    fn neighbors(
278        &self,
279        path: &str,
280        edge_type: Option<KnowledgeDocEdgeType>,
281    ) -> Vec<KnowledgeDocNeighbor> {
282        let Some(source) = self.read_manifest(path) else {
283            return Vec::new();
284        };
285
286        let mut neighbors: BTreeMap<String, KnowledgeDocNeighbor> = BTreeMap::new();
287
288        for edge in &source.related {
289            if let Some(expected) = edge_type
290                && edge.edge_type != expected
291            {
292                continue;
293            }
294            if let Some(target) = self.read_manifest(&edge.target) {
295                push_neighbor_edge(
296                    &mut neighbors,
297                    target.virtual_path.clone(),
298                    KnowledgeDocNeighborEdge {
299                        edge_type: edge.edge_type,
300                        source: source.virtual_path.clone(),
301                        target: target.virtual_path.clone(),
302                        note: edge.description.clone(),
303                    },
304                );
305            }
306        }
307
308        for candidate in self.manifest().docs() {
309            for edge in &candidate.related {
310                let points_to_source = self
311                    .read_manifest(&edge.target)
312                    .map(|target| {
313                        target.id == source.id || target.virtual_path == source.virtual_path
314                    })
315                    .unwrap_or_else(|| {
316                        edge.target == source.id || edge.target == source.virtual_path
317                    });
318                if !points_to_source {
319                    continue;
320                }
321                if let Some(expected) = edge_type
322                    && edge.edge_type != expected
323                {
324                    continue;
325                }
326                push_neighbor_edge(
327                    &mut neighbors,
328                    candidate.virtual_path.clone(),
329                    KnowledgeDocNeighborEdge {
330                        edge_type: edge.edge_type,
331                        source: candidate.virtual_path.clone(),
332                        target: source.virtual_path.clone(),
333                        note: edge.description.clone(),
334                    },
335                );
336            }
337        }
338
339        neighbors.into_values().collect()
340    }
341}
342
343impl KnowledgeDocEdgeType {
344    pub fn as_str(self) -> &'static str {
345        match self {
346            KnowledgeDocEdgeType::PartOf => "part_of",
347            KnowledgeDocEdgeType::Defines => "defines",
348            KnowledgeDocEdgeType::Governs => "governs",
349            KnowledgeDocEdgeType::Classifies => "classifies",
350            KnowledgeDocEdgeType::References => "references",
351            KnowledgeDocEdgeType::DependsOn => "depends_on",
352            KnowledgeDocEdgeType::Extends => "extends",
353            KnowledgeDocEdgeType::RelatedTo => "related_to",
354        }
355    }
356}
357
358impl KnowledgeDocKind {
359    pub fn as_str(self) -> &'static str {
360        match self {
361            KnowledgeDocKind::Guide => "guide",
362            KnowledgeDocKind::Reference => "reference",
363            KnowledgeDocKind::Taxonomy => "taxonomy",
364            KnowledgeDocKind::Domain => "domain",
365            KnowledgeDocKind::Entity => "entity",
366            KnowledgeDocKind::Policy => "policy",
367        }
368    }
369}
370
371impl KnowledgeDocAuthority {
372    pub fn as_str(self) -> &'static str {
373        match self {
374            KnowledgeDocAuthority::Canonical => "canonical",
375            KnowledgeDocAuthority::Supporting => "supporting",
376            KnowledgeDocAuthority::Pattern => "pattern",
377            KnowledgeDocAuthority::Reference => "reference",
378            KnowledgeDocAuthority::Advisory => "advisory",
379            KnowledgeDocAuthority::Example => "example",
380            KnowledgeDocAuthority::Draft => "draft",
381            KnowledgeDocAuthority::Deprecated => "deprecated",
382        }
383    }
384}
385
386impl KnowledgeDocStatus {
387    pub fn as_str(self) -> &'static str {
388        match self {
389            KnowledgeDocStatus::Stable => "stable",
390            KnowledgeDocStatus::Draft => "draft",
391            KnowledgeDocStatus::Deprecated => "deprecated",
392        }
393    }
394}
395
396fn search_pack<P: KnowledgePack + ?Sized>(
397    pack: &P,
398    query: &str,
399    filter: KnowledgeDocFilter,
400    mode: SearchMode,
401) -> Vec<KnowledgeDocSearchHit> {
402    let needle = normalize(query);
403    let mut hits = Vec::new();
404
405    for manifest in pack.list_docs(filter) {
406        let mut score = 0;
407        let mut matched = BTreeSet::new();
408
409        score += score_field(&needle, &manifest.id, 100, "id", &mut matched);
410        score += score_field(
411            &needle,
412            &manifest.virtual_path,
413            90,
414            "virtual_path",
415            &mut matched,
416        );
417        score += score_field(&needle, &manifest.title, 80, "title", &mut matched);
418        score += score_field(&needle, &manifest.summary, 60, "summary", &mut matched);
419
420        for alias in &manifest.aliases {
421            score += score_field(&needle, alias, 75, "alias", &mut matched);
422        }
423        for tag in &manifest.tags {
424            score += score_field(&needle, tag, 70, "tag", &mut matched);
425        }
426        for keyword in &manifest.keywords {
427            score += score_field(&needle, keyword, 65, "keyword", &mut matched);
428        }
429
430        let content = match mode {
431            SearchMode::MetadataOnly => None,
432            SearchMode::FullText => pack.doc_content(manifest),
433        };
434        if let Some(content) = content.as_ref() {
435            score += score_field(&needle, content, 20, "content", &mut matched);
436        }
437
438        if score > 0 || needle.is_empty() {
439            hits.push(KnowledgeDocSearchHit {
440                id: manifest.id.clone(),
441                virtual_path: manifest.virtual_path.clone(),
442                title: manifest.title.clone(),
443                summary: manifest.summary.clone(),
444                kind: manifest.kind,
445                authority: manifest.authority,
446                tags: manifest.tags.clone(),
447                score,
448                matched: matched.into_iter().collect(),
449                content: matches!(mode, SearchMode::FullText)
450                    .then(|| content.map(Cow::into_owned).unwrap_or_default()),
451            });
452        }
453    }
454
455    hits.sort_by(|a, b| {
456        b.score
457            .cmp(&a.score)
458            .then_with(|| a.virtual_path.cmp(&b.virtual_path))
459    });
460    hits
461}
462
463fn matches_filter<P: KnowledgePack + ?Sized>(
464    pack: &P,
465    doc: &KnowledgeDocManifest,
466    filter: &KnowledgeDocFilter,
467) -> bool {
468    if let Some(kind) = filter.kind
469        && doc.kind != kind
470    {
471        return false;
472    }
473    if let Some(authority) = filter.authority
474        && doc.authority != authority
475    {
476        return false;
477    }
478    if let Some(status) = filter.status
479        && doc.status != status
480    {
481        return false;
482    }
483    if let Some(prefix) = &filter.path_prefix
484        && !doc.virtual_path.starts_with(prefix)
485    {
486        return false;
487    }
488    if !filter.tags.is_empty()
489        && !filter
490            .tags
491            .iter()
492            .all(|tag| doc.tags.iter().any(|doc_tag| doc_tag == tag))
493    {
494        return false;
495    }
496    if let Some(target) = &filter.related_to {
497        let has_edge = doc.related.iter().any(|edge| {
498            let edge_matches_target = edge.target == *target
499                || pack
500                    .read_manifest(&edge.target)
501                    .map(|edge_target| {
502                        edge_target.id == *target || edge_target.virtual_path == *target
503                    })
504                    .unwrap_or(false);
505            edge_matches_target
506                && filter
507                    .edge_type
508                    .as_ref()
509                    .map(|expected| edge.edge_type == *expected)
510                    .unwrap_or(true)
511        });
512        if !has_edge {
513            return false;
514        }
515    }
516    true
517}
518
519fn push_neighbor_edge(
520    neighbors: &mut BTreeMap<String, KnowledgeDocNeighbor>,
521    neighbor_target: String,
522    edge: KnowledgeDocNeighborEdge,
523) {
524    let neighbor =
525        neighbors
526            .entry(neighbor_target.clone())
527            .or_insert_with(|| KnowledgeDocNeighbor {
528                target: neighbor_target,
529                edges: Vec::new(),
530            });
531    if !neighbor.edges.contains(&edge) {
532        neighbor.edges.push(edge);
533        neighbor.edges.sort_by(|left, right| {
534            left.source
535                .cmp(&right.source)
536                .then_with(|| left.target.cmp(&right.target))
537                .then_with(|| left.edge_type.as_str().cmp(right.edge_type.as_str()))
538                .then_with(|| left.note.cmp(&right.note))
539        });
540    }
541}
542
543fn score_field(
544    needle: &str,
545    haystack: &str,
546    weight: usize,
547    label: &str,
548    matched: &mut BTreeSet<String>,
549) -> usize {
550    if needle.is_empty() {
551        return 1;
552    }
553    let haystack = normalize(haystack);
554    if haystack == needle {
555        matched.insert(label.to_string());
556        weight * 2
557    } else if haystack.contains(needle) {
558        matched.insert(label.to_string());
559        weight
560    } else {
561        0
562    }
563}
564
565fn normalize(value: &str) -> String {
566    value.trim().to_lowercase()
567}