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)]
459#[path = "map_tests.rs"]
460mod tests;