Skip to main content

relay_knowledge/domain/knowledge/
map.rs

1use std::collections::HashSet;
2
3use serde::{Deserialize, Serialize};
4
5use super::{DomainError, SourceScope, error::required_text};
6
7/// Versioned repository contract that tells agents where project knowledge lives.
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub struct KnowledgeMap {
10    pub schema_version: u16,
11    pub map_version: u64,
12    pub updated_at: String,
13    #[serde(default)]
14    pub topics: Vec<KnowledgeMapTopic>,
15    #[serde(default)]
16    pub sources: Vec<KnowledgeMapSource>,
17    #[serde(default)]
18    pub routes: Vec<KnowledgeMapRoute>,
19    #[serde(default)]
20    pub history: Vec<KnowledgeMapHistoryEntry>,
21}
22
23impl KnowledgeMap {
24    pub const SCHEMA_VERSION: u16 = 1;
25
26    /// Creates the smallest valid shared contract.
27    pub fn initial(updated_at: String) -> Self {
28        Self {
29            schema_version: Self::SCHEMA_VERSION,
30            map_version: 1,
31            updated_at,
32            topics: Vec::new(),
33            sources: Vec::new(),
34            routes: Vec::new(),
35            history: vec![KnowledgeMapHistoryEntry {
36                version: 1,
37                action: "init".to_owned(),
38                actor: "cli".to_owned(),
39                summary: "Created knowledge map.".to_owned(),
40            }],
41        }
42    }
43
44    /// Validates the cross-reference invariants that keep the map navigable.
45    pub fn validate(&self) -> Result<(), DomainError> {
46        if self.schema_version != Self::SCHEMA_VERSION {
47            return Err(DomainError::invalid(
48                "schema_version",
49                format!("must be {}", Self::SCHEMA_VERSION),
50            ));
51        }
52        if self.map_version == 0 {
53            return Err(DomainError::invalid(
54                "map_version",
55                "must be greater than zero",
56            ));
57        }
58
59        let mut topic_ids = HashSet::new();
60        for topic in &self.topics {
61            topic.validate()?;
62            if !topic_ids.insert(topic.id.as_str()) {
63                return Err(DomainError::invalid("topics", "topic ids must be unique"));
64            }
65        }
66
67        let mut source_ids = HashSet::new();
68        for source in &self.sources {
69            source.validate()?;
70            if !topic_ids.contains(source.topic.as_str()) {
71                return Err(DomainError::invalid(
72                    "sources",
73                    format!("source '{}' references unknown topic", source.id),
74                ));
75            }
76            if !source_ids.insert(source.id.as_str()) {
77                return Err(DomainError::invalid("sources", "source ids must be unique"));
78            }
79        }
80
81        let mut route_topics = HashSet::new();
82        let mut routed_sources = HashSet::new();
83        for route in &self.routes {
84            route.validate()?;
85            let mut route_sources = HashSet::new();
86            if !route_topics.insert(route.topic.as_str()) {
87                return Err(DomainError::invalid(
88                    "routes",
89                    "route topics must be unique",
90                ));
91            }
92            if !topic_ids.contains(route.topic.as_str()) {
93                return Err(DomainError::invalid(
94                    "routes",
95                    format!("route '{}' references unknown topic", route.topic),
96                ));
97            }
98            for source_id in &route.source_order {
99                if !route_sources.insert(source_id.as_str()) {
100                    return Err(DomainError::invalid(
101                        "routes",
102                        format!("route '{}' repeats source '{}'", route.topic, source_id),
103                    ));
104                }
105                let Some(source) = self.sources.iter().find(|source| source.id == *source_id)
106                else {
107                    return Err(DomainError::invalid(
108                        "routes",
109                        format!(
110                            "route '{}' references unknown source '{}'",
111                            route.topic, source_id
112                        ),
113                    ));
114                };
115                if source.topic != route.topic {
116                    return Err(DomainError::invalid(
117                        "routes",
118                        format!(
119                            "route '{}' references source '{}' from topic '{}'",
120                            route.topic, source_id, source.topic
121                        ),
122                    ));
123                }
124                if !routed_sources.insert(source_id.as_str()) {
125                    return Err(DomainError::invalid(
126                        "routes",
127                        format!("source '{}' appears in more than one route", source_id),
128                    ));
129                }
130            }
131        }
132        for source in &self.sources {
133            if !routed_sources.contains(source.id.as_str()) {
134                return Err(DomainError::invalid(
135                    "routes",
136                    format!("source '{}' is not routed", source.id),
137                ));
138            }
139        }
140
141        self.validate_history()?;
142
143        Ok(())
144    }
145
146    fn validate_history(&self) -> Result<(), DomainError> {
147        if self.history.is_empty() {
148            return Err(DomainError::invalid("history", "must not be empty"));
149        }
150        for (index, entry) in self.history.iter().enumerate() {
151            entry.validate()?;
152            let expected_version = u64::try_from(index)
153                .ok()
154                .and_then(|value| value.checked_add(1))
155                .ok_or_else(|| DomainError::invalid("history", "too many entries"))?;
156            if entry.version != expected_version {
157                return Err(DomainError::invalid(
158                    "history",
159                    "history versions must start at 1 and be contiguous",
160                ));
161            }
162        }
163        let latest_version = self
164            .history
165            .last()
166            .map(|entry| entry.version)
167            .expect("history is checked as non-empty");
168        if latest_version != self.map_version {
169            return Err(DomainError::invalid(
170                "history",
171                format!(
172                    "latest history version {latest_version} must match map_version {}",
173                    self.map_version
174                ),
175            ));
176        }
177        Ok(())
178    }
179
180    /// Adds a source to the map and creates a simple route for its topic when missing.
181    pub fn add_source(&mut self, source: KnowledgeMapSource) -> Result<(), DomainError> {
182        source.validate()?;
183        if self.sources.iter().any(|entry| entry.id == source.id) {
184            return Err(DomainError::invalid("id", "source already exists"));
185        }
186        if !self.topics.iter().any(|topic| topic.id == source.topic) {
187            self.topics.push(KnowledgeMapTopic::new(
188                source.topic.clone(),
189                source.topic.clone(),
190                "Added by CLI source registration.".to_owned(),
191            )?);
192        }
193        let source_id = source.id.clone();
194        let topic_id = source.topic.clone();
195        self.sources.push(source);
196        self.ensure_route_contains(&topic_id, &source_id)?;
197        self.sort_entries();
198        self.validate()
199    }
200
201    /// Applies supported source field updates without changing its identity.
202    pub fn update_source(&mut self, change: KnowledgeMapChange) -> Result<(), DomainError> {
203        let Some(source) = self.sources.iter_mut().find(|entry| entry.id == change.id) else {
204            return Err(DomainError::invalid("id", "source does not exist"));
205        };
206        let previous_topic = source.topic.clone();
207        if let Some(topic) = change.topic {
208            source.topic = required_text("topic", topic)?;
209        }
210        if let Some(kind) = change.kind {
211            source.kind = kind;
212        }
213        if let Some(uri) = change.uri {
214            source.uri = required_text("uri", uri)?;
215        }
216        if let Some(scope) = change.source_scope {
217            SourceScope::parse(scope.as_str())?;
218            source.source_scope = Some(scope);
219        }
220        if let Some(description) = change.description {
221            source.description = Some(required_text("description", description)?);
222        }
223        source.version = source.version.saturating_add(1);
224
225        if !self.topics.iter().any(|topic| topic.id == source.topic) {
226            self.topics.push(KnowledgeMapTopic::new(
227                source.topic.clone(),
228                source.topic.clone(),
229                "Added by CLI source update.".to_owned(),
230            )?);
231        }
232        let topic_id = source.topic.clone();
233        let source_id = source.id.clone();
234        if previous_topic != topic_id {
235            self.prune_source_from_other_routes(&source_id, &topic_id);
236        }
237        self.ensure_route_contains(&topic_id, &source_id)?;
238        self.sort_entries();
239        self.validate()
240    }
241
242    /// Removes a source and prunes routes that referenced it.
243    pub fn remove_source(&mut self, id: &str) -> Result<(), DomainError> {
244        let before = self.sources.len();
245        self.sources.retain(|source| source.id != id);
246        if self.sources.len() == before {
247            return Err(DomainError::invalid("id", "source does not exist"));
248        }
249        for route in &mut self.routes {
250            route.source_order.retain(|source_id| source_id != id);
251        }
252        self.sort_entries();
253        self.validate()
254    }
255
256    /// Advances the map version and records the mutation in history.
257    pub fn record_change(&mut self, action: &str, summary: String, updated_at: String) {
258        self.map_version = self.map_version.saturating_add(1);
259        self.updated_at = updated_at;
260        self.history.push(KnowledgeMapHistoryEntry {
261            version: self.map_version,
262            action: action.to_owned(),
263            actor: "cli".to_owned(),
264            summary,
265        });
266    }
267
268    fn ensure_route_contains(&mut self, topic: &str, source_id: &str) -> Result<(), DomainError> {
269        if let Some(route) = self.routes.iter_mut().find(|route| route.topic == topic) {
270            if !route.source_order.iter().any(|id| id == source_id) {
271                route.source_order.push(source_id.to_owned());
272            }
273            return Ok(());
274        }
275        self.routes.push(KnowledgeMapRoute {
276            topic: topic.to_owned(),
277            source_order: vec![source_id.to_owned()],
278            fallback: Some("bounded-search".to_owned()),
279        });
280        Ok(())
281    }
282
283    fn prune_source_from_other_routes(&mut self, source_id: &str, current_topic: &str) {
284        for route in &mut self.routes {
285            if route.topic != current_topic {
286                route.source_order.retain(|id| id != source_id);
287            }
288        }
289    }
290
291    fn sort_entries(&mut self) {
292        self.topics.sort_by(|left, right| left.id.cmp(&right.id));
293        self.sources.sort_by(|left, right| left.id.cmp(&right.id));
294        self.routes
295            .sort_by(|left, right| left.topic.cmp(&right.topic));
296    }
297}
298
299/// Human-readable topic bucket used by agents for routing.
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
301pub struct KnowledgeMapTopic {
302    pub id: String,
303    pub title: String,
304    pub description: String,
305}
306
307impl KnowledgeMapTopic {
308    pub fn new(id: String, title: String, description: String) -> Result<Self, DomainError> {
309        let topic = Self {
310            id: required_text("topic", id)?,
311            title: required_text("title", title)?,
312            description: required_text("description", description)?,
313        };
314        topic.validate()?;
315        Ok(topic)
316    }
317
318    fn validate(&self) -> Result<(), DomainError> {
319        required_text("topic", self.id.as_str())?;
320        required_text("title", self.title.as_str())?;
321        required_text("description", self.description.as_str())?;
322        Ok(())
323    }
324}
325
326/// Addressable knowledge source that remains authoritative outside the map.
327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
328pub struct KnowledgeMapSource {
329    pub id: String,
330    pub topic: String,
331    pub kind: KnowledgeMapSourceKind,
332    pub uri: String,
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub source_scope: Option<String>,
335    pub read_policy: String,
336    pub write_policy: String,
337    pub status: String,
338    pub version: u64,
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub description: Option<String>,
341}
342
343impl KnowledgeMapSource {
344    pub fn new(
345        id: String,
346        topic: String,
347        kind: KnowledgeMapSourceKind,
348        uri: String,
349        source_scope: Option<String>,
350        description: Option<String>,
351    ) -> Result<Self, DomainError> {
352        if let Some(scope) = source_scope.as_deref() {
353            SourceScope::parse(scope)?;
354        }
355        let source = Self {
356            id: required_text("id", id)?,
357            topic: required_text("topic", topic)?,
358            kind,
359            uri: required_text("uri", uri)?,
360            source_scope,
361            read_policy: "direct".to_owned(),
362            write_policy: "manual-review".to_owned(),
363            status: "active".to_owned(),
364            version: 1,
365            description,
366        };
367        source.validate()?;
368        Ok(source)
369    }
370
371    fn validate(&self) -> Result<(), DomainError> {
372        required_text("id", self.id.as_str())?;
373        required_text("topic", self.topic.as_str())?;
374        required_text("uri", self.uri.as_str())?;
375        required_text("read_policy", self.read_policy.as_str())?;
376        required_text("write_policy", self.write_policy.as_str())?;
377        required_text("status", self.status.as_str())?;
378        if self.version == 0 {
379            return Err(DomainError::invalid("version", "must be greater than zero"));
380        }
381        if let Some(scope) = self.source_scope.as_deref() {
382            SourceScope::parse(scope)?;
383        }
384        Ok(())
385    }
386}
387
388/// Supported source category labels in the YAML contract.
389#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
390#[serde(rename_all = "kebab-case")]
391pub enum KnowledgeMapSourceKind {
392    Repo,
393    File,
394    Doc,
395    Config,
396    Db,
397    Ci,
398    Runtime,
399    Wiki,
400    Monitoring,
401}
402
403/// Ordered source route for a topic.
404#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
405pub struct KnowledgeMapRoute {
406    pub topic: String,
407    #[serde(default)]
408    pub source_order: Vec<String>,
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub fallback: Option<String>,
411}
412
413impl KnowledgeMapRoute {
414    fn validate(&self) -> Result<(), DomainError> {
415        required_text("topic", self.topic.as_str())?;
416        for source_id in &self.source_order {
417            required_text("source_order", source_id.as_str())?;
418        }
419        Ok(())
420    }
421}
422
423/// Version history entry written after CLI mutations.
424#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
425pub struct KnowledgeMapHistoryEntry {
426    pub version: u64,
427    pub action: String,
428    pub actor: String,
429    pub summary: String,
430}
431
432impl KnowledgeMapHistoryEntry {
433    fn validate(&self) -> Result<(), DomainError> {
434        if self.version == 0 {
435            return Err(DomainError::invalid(
436                "history",
437                "version must be greater than zero",
438            ));
439        }
440        required_text("action", self.action.as_str())?;
441        required_text("actor", self.actor.as_str())?;
442        required_text("summary", self.summary.as_str())?;
443        Ok(())
444    }
445}
446
447/// Optional source changes accepted by the update command.
448#[derive(Debug, Clone, PartialEq, Eq)]
449pub struct KnowledgeMapChange {
450    pub id: String,
451    pub topic: Option<String>,
452    pub kind: Option<KnowledgeMapSourceKind>,
453    pub uri: Option<String>,
454    pub source_scope: Option<String>,
455    pub description: Option<String>,
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    #[test]
463    fn adds_source_and_route() {
464        let mut map = KnowledgeMap::initial("now".to_owned());
465        map.add_source(
466            KnowledgeMapSource::new(
467                "build-cargo".to_owned(),
468                "build".to_owned(),
469                KnowledgeMapSourceKind::Config,
470                "Cargo.toml".to_owned(),
471                Some("repo".to_owned()),
472                None,
473            )
474            .expect("source should parse"),
475        )
476        .expect("source should add");
477
478        assert_eq!(map.topics[0].id, "build");
479        assert_eq!(map.routes[0].source_order, ["build-cargo"]);
480        map.validate().expect("map should validate");
481    }
482
483    #[test]
484    fn keeps_multiple_sources_under_one_topic() {
485        let mut map = KnowledgeMap::initial("now".to_owned());
486        for (id, uri) in [
487            (
488                "cli-reference",
489                "docs/zh/01-user-guide/03-cli-command-reference.md",
490            ),
491            (
492                "cli-skill",
493                "skills/relay-knowledge-cli/references/knowledge-map-workflows.md",
494            ),
495        ] {
496            map.add_source(
497                KnowledgeMapSource::new(
498                    id.to_owned(),
499                    "cli".to_owned(),
500                    KnowledgeMapSourceKind::Doc,
501                    uri.to_owned(),
502                    Some("docs".to_owned()),
503                    None,
504                )
505                .expect("source should parse"),
506            )
507            .expect("source should add");
508        }
509
510        assert_eq!(
511            map.routes[0].source_order,
512            ["cli-reference".to_owned(), "cli-skill".to_owned()]
513        );
514        assert_eq!(
515            map.sources
516                .iter()
517                .filter(|source| source.topic == "cli")
518                .count(),
519            2
520        );
521    }
522
523    #[test]
524    fn moving_source_prunes_old_topic_route() {
525        let mut map = KnowledgeMap::initial("now".to_owned());
526        map.add_source(
527            KnowledgeMapSource::new(
528                "shared-doc".to_owned(),
529                "build".to_owned(),
530                KnowledgeMapSourceKind::Doc,
531                "docs/build.md".to_owned(),
532                None,
533                None,
534            )
535            .expect("source should parse"),
536        )
537        .expect("source should add");
538
539        map.update_source(KnowledgeMapChange {
540            id: "shared-doc".to_owned(),
541            topic: Some("cli".to_owned()),
542            kind: None,
543            uri: None,
544            source_scope: None,
545            description: None,
546        })
547        .expect("source should move");
548
549        assert!(
550            map.routes
551                .iter()
552                .find(|route| route.topic == "build")
553                .is_none_or(|route| route.source_order.is_empty())
554        );
555        assert_eq!(
556            map.routes
557                .iter()
558                .find(|route| route.topic == "cli")
559                .expect("new route should exist")
560                .source_order,
561            ["shared-doc".to_owned()]
562        );
563    }
564
565    #[test]
566    fn rejects_duplicate_sources_and_bad_routes() {
567        let mut map = KnowledgeMap::initial("now".to_owned());
568        let source = KnowledgeMapSource::new(
569            "docs".to_owned(),
570            "architecture".to_owned(),
571            KnowledgeMapSourceKind::Doc,
572            "docs/README.md".to_owned(),
573            None,
574            None,
575        )
576        .expect("source should parse");
577        map.add_source(source.clone())
578            .expect("first add should work");
579        assert!(map.add_source(source).is_err());
580
581        map.routes[0].source_order.push("missing".to_owned());
582        assert!(map.validate().is_err());
583    }
584
585    #[test]
586    fn rejects_duplicate_route_topics() {
587        let mut map = routed_map();
588        map.routes.push(KnowledgeMapRoute {
589            topic: "architecture".to_owned(),
590            source_order: Vec::new(),
591            fallback: None,
592        });
593
594        let error = map.validate().expect_err("duplicate route should fail");
595
596        assert!(error.to_string().contains("route topics must be unique"));
597    }
598
599    #[test]
600    fn rejects_duplicate_sources_inside_route_order() {
601        let mut map = routed_map();
602        map.routes[0].source_order.push("docs".to_owned());
603
604        let error = map
605            .validate()
606            .expect_err("duplicate route source should fail");
607
608        assert!(error.to_string().contains("repeats source 'docs'"));
609    }
610
611    #[test]
612    fn rejects_unrouted_sources() {
613        let mut map = routed_map();
614        map.sources.push(
615            KnowledgeMapSource::new(
616                "unrouted".to_owned(),
617                "architecture".to_owned(),
618                KnowledgeMapSourceKind::Doc,
619                "docs/unrouted.md".to_owned(),
620                None,
621                None,
622            )
623            .expect("source should parse"),
624        );
625
626        let error = map.validate().expect_err("unrouted source should fail");
627
628        assert!(
629            error
630                .to_string()
631                .contains("source 'unrouted' is not routed")
632        );
633    }
634
635    #[test]
636    fn rejects_invalid_history_contracts() {
637        let mut map = routed_map();
638        map.history[0].summary.clear();
639        assert!(map.validate().is_err());
640
641        let mut map = routed_map();
642        map.history.push(KnowledgeMapHistoryEntry {
643            version: 3,
644            action: "source.add".to_owned(),
645            actor: "cli".to_owned(),
646            summary: "Skipped a version.".to_owned(),
647        });
648        map.map_version = 3;
649        let error = map.validate().expect_err("skipped history should fail");
650        assert!(error.to_string().contains("contiguous"));
651
652        let mut map = routed_map();
653        map.map_version = 2;
654        let error = map
655            .validate()
656            .expect_err("mismatched map version should fail");
657        assert!(error.to_string().contains("must match map_version 2"));
658    }
659
660    fn routed_map() -> KnowledgeMap {
661        let mut map = KnowledgeMap::initial("now".to_owned());
662        map.add_source(
663            KnowledgeMapSource::new(
664                "docs".to_owned(),
665                "architecture".to_owned(),
666                KnowledgeMapSourceKind::Doc,
667                "docs/README.md".to_owned(),
668                None,
669                None,
670            )
671            .expect("source should parse"),
672        )
673        .expect("source should add");
674        map
675    }
676}