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