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#[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 pub const SCHEMA_VERSION: u16 = 1;
38
39 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 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 pub fn ensure_software_model_route(&mut self) -> Result<bool, DomainError> {
82 let changed = self.ensure_software_model_route_state()?;
83 self.validate()?;
84 Ok(changed)
85 }
86
87 fn ensure_software_model_route_state(&mut self) -> Result<bool, DomainError> {
88 if let Some(source) = self
89 .sources
90 .iter()
91 .find(|source| source.id == SOFTWARE_MODEL_SOURCE_ID)
92 {
93 validate_software_model_source(source)?;
94 return self.ensure_route_contains(SOFTWARE_MODEL_TOPIC_ID, SOFTWARE_MODEL_SOURCE_ID);
95 }
96
97 if !self
98 .topics
99 .iter()
100 .any(|topic| topic.id == SOFTWARE_MODEL_TOPIC_ID)
101 {
102 self.topics.push(KnowledgeMapTopic::new(
103 SOFTWARE_MODEL_TOPIC_ID.to_owned(),
104 "Whole-software model".to_owned(),
105 "Code-map-backed architecture, build, deployment, dependency, configuration, and design knowledge."
106 .to_owned(),
107 )?);
108 }
109 self.add_source_state(KnowledgeMapSource::new(
110 SOFTWARE_MODEL_SOURCE_ID.to_owned(),
111 SOFTWARE_MODEL_TOPIC_ID.to_owned(),
112 KnowledgeMapSourceKind::Repo,
113 SOFTWARE_MODEL_SOURCE_URI.to_owned(),
114 Some(SOFTWARE_MODEL_SOURCE_SCOPE.to_owned()),
115 Some(
116 "Primary repository code map; consume snapshot-bound repo software and repo view projections with freshness and evidence."
117 .to_owned(),
118 ),
119 )?)?;
120 Ok(true)
121 }
122
123 pub fn ensure_business_knowledge_route(&mut self) -> Result<bool, DomainError> {
125 let changed = self.ensure_business_knowledge_route_state()?;
126 self.validate()?;
127 Ok(changed)
128 }
129
130 fn ensure_business_knowledge_route_state(&mut self) -> Result<bool, DomainError> {
131 if let Some(source) = self
132 .sources
133 .iter()
134 .find(|source| source.id == BUSINESS_KNOWLEDGE_SOURCE_ID)
135 {
136 validate_business_knowledge_source(source)?;
137 return self
138 .ensure_route_contains(BUSINESS_KNOWLEDGE_TOPIC_ID, BUSINESS_KNOWLEDGE_SOURCE_ID);
139 }
140 if !self
141 .topics
142 .iter()
143 .any(|topic| topic.id == BUSINESS_KNOWLEDGE_TOPIC_ID)
144 {
145 self.topics.push(KnowledgeMapTopic::new(
146 BUSINESS_KNOWLEDGE_TOPIC_ID.to_owned(),
147 "Business knowledge".to_owned(),
148 "Version-controlled business domains, terminology, aliases, semantics, and technical mappings."
149 .to_owned(),
150 )?);
151 }
152 self.add_source_state(KnowledgeMapSource::new(
153 BUSINESS_KNOWLEDGE_SOURCE_ID.to_owned(),
154 BUSINESS_KNOWLEDGE_TOPIC_ID.to_owned(),
155 KnowledgeMapSourceKind::File,
156 BUSINESS_GLOSSARY_RELATIVE_PATH.to_owned(),
157 Some(BUSINESS_KNOWLEDGE_SOURCE_SCOPE.to_owned()),
158 Some(
159 "Authored business glossary projected by the repository index writer at an immutable commit."
160 .to_owned(),
161 ),
162 )?)?;
163 Ok(true)
164 }
165
166 pub(crate) fn ensure_reserved_repository_routes(
168 &mut self,
169 ) -> Result<(bool, bool), DomainError> {
170 let software_changed = self.ensure_software_model_route_state()?;
171 let business_changed = self.ensure_business_knowledge_route_state()?;
172 self.validate()?;
173 Ok((software_changed, business_changed))
174 }
175
176 pub(crate) fn ensure_reserved_repository_routes_snapshot(
177 &mut self,
178 omitted_through: u64,
179 ) -> Result<(bool, bool), DomainError> {
180 let software_changed = self.ensure_software_model_route_state()?;
181 let business_changed = self.ensure_business_knowledge_route_state()?;
182 self.validate_snapshot(omitted_through)?;
183 Ok((software_changed, business_changed))
184 }
185
186 pub fn validate(&self) -> Result<(), DomainError> {
188 self.validate_state()?;
189 self.validate_history(0)
190 }
191
192 pub(crate) fn validate_snapshot(&self, omitted_through: u64) -> Result<(), DomainError> {
194 self.validate_state()?;
195 self.validate_history(omitted_through)
196 }
197
198 pub(crate) fn validate_reserved_repository_routes(&self) -> Result<(), DomainError> {
200 let software_source = self
201 .sources
202 .iter()
203 .find(|source| source.id == SOFTWARE_MODEL_SOURCE_ID)
204 .ok_or_else(|| {
205 DomainError::invalid(
206 "sources",
207 format!("required reserved source '{SOFTWARE_MODEL_SOURCE_ID}' is missing"),
208 )
209 })?;
210 validate_software_model_source(software_source)?;
211 validate_reserved_route(
212 &self.routes,
213 SOFTWARE_MODEL_TOPIC_ID,
214 SOFTWARE_MODEL_SOURCE_ID,
215 )?;
216
217 let business_source = self
218 .sources
219 .iter()
220 .find(|source| source.id == BUSINESS_KNOWLEDGE_SOURCE_ID)
221 .ok_or_else(|| {
222 DomainError::invalid(
223 "sources",
224 format!("required reserved source '{BUSINESS_KNOWLEDGE_SOURCE_ID}' is missing"),
225 )
226 })?;
227 validate_business_knowledge_source(business_source)?;
228 validate_reserved_route(
229 &self.routes,
230 BUSINESS_KNOWLEDGE_TOPIC_ID,
231 BUSINESS_KNOWLEDGE_SOURCE_ID,
232 )
233 }
234
235 fn validate_state(&self) -> Result<(), DomainError> {
236 if self.schema_version != Self::SCHEMA_VERSION {
237 return Err(DomainError::invalid(
238 "schema_version",
239 format!("must be {}", Self::SCHEMA_VERSION),
240 ));
241 }
242 if self.map_version == 0 {
243 return Err(DomainError::invalid(
244 "map_version",
245 "must be greater than zero",
246 ));
247 }
248
249 let mut topic_ids = HashSet::new();
250 let mut folded_topic_ids = HashSet::new();
251 for topic in &self.topics {
252 topic.validate()?;
253 if !topic_ids.insert(topic.id.as_str())
254 || !folded_topic_ids.insert(topic.id.to_lowercase())
255 {
256 return Err(DomainError::invalid(
257 "topics",
258 "topic ids must be unique without case collisions",
259 ));
260 }
261 }
262
263 let mut source_ids = HashSet::new();
264 for source in &self.sources {
265 source.validate()?;
266 if source.id == SOFTWARE_MODEL_SOURCE_ID {
267 validate_software_model_source(source)?;
268 }
269 if source.id == BUSINESS_KNOWLEDGE_SOURCE_ID {
270 validate_business_knowledge_source(source)?;
271 }
272 if !topic_ids.contains(source.topic.as_str()) {
273 return Err(DomainError::invalid(
274 "sources",
275 format!("source '{}' references unknown topic", source.id),
276 ));
277 }
278 if !source_ids.insert(source.id.as_str()) {
279 return Err(DomainError::invalid("sources", "source ids must be unique"));
280 }
281 }
282
283 let mut route_topics = HashSet::new();
284 let mut routed_sources = HashSet::new();
285 for route in &self.routes {
286 route.validate()?;
287 let mut route_sources = HashSet::new();
288 if !route_topics.insert(route.topic.as_str()) {
289 return Err(DomainError::invalid(
290 "routes",
291 "route topics must be unique",
292 ));
293 }
294 if !topic_ids.contains(route.topic.as_str()) {
295 return Err(DomainError::invalid(
296 "routes",
297 format!("route '{}' references unknown topic", route.topic),
298 ));
299 }
300 for source_id in &route.source_order {
301 if !route_sources.insert(source_id.as_str()) {
302 return Err(DomainError::invalid(
303 "routes",
304 format!("route '{}' repeats source '{}'", route.topic, source_id),
305 ));
306 }
307 let Some(source) = self.sources.iter().find(|source| source.id == *source_id)
308 else {
309 return Err(DomainError::invalid(
310 "routes",
311 format!(
312 "route '{}' references unknown source '{}'",
313 route.topic, source_id
314 ),
315 ));
316 };
317 if source.topic != route.topic {
318 return Err(DomainError::invalid(
319 "routes",
320 format!(
321 "route '{}' references source '{}' from topic '{}'",
322 route.topic, source_id, source.topic
323 ),
324 ));
325 }
326 if !routed_sources.insert(source_id.as_str()) {
327 return Err(DomainError::invalid(
328 "routes",
329 format!("source '{}' appears in more than one route", source_id),
330 ));
331 }
332 }
333 }
334 for source in &self.sources {
335 if !routed_sources.contains(source.id.as_str()) {
336 return Err(DomainError::invalid(
337 "routes",
338 format!("source '{}' is not routed", source.id),
339 ));
340 }
341 }
342
343 Ok(())
344 }
345
346 fn validate_history(&self, omitted_through: u64) -> Result<(), DomainError> {
347 if self.history.is_empty() {
348 return Err(DomainError::invalid("history", "must not be empty"));
349 }
350 for (index, entry) in self.history.iter().enumerate() {
351 entry.validate()?;
352 let expected_version = u64::try_from(index)
353 .ok()
354 .and_then(|value| value.checked_add(omitted_through))
355 .and_then(|value| value.checked_add(1))
356 .ok_or_else(|| DomainError::invalid("history", "too many entries"))?;
357 if entry.version != expected_version {
358 return Err(DomainError::invalid(
359 "history",
360 "history versions must start at 1 and be contiguous",
361 ));
362 }
363 }
364 let latest_version = self
365 .history
366 .last()
367 .map(|entry| entry.version)
368 .expect("history is checked as non-empty");
369 if latest_version != self.map_version {
370 return Err(DomainError::invalid(
371 "history",
372 format!(
373 "latest history version {latest_version} must match map_version {}",
374 self.map_version
375 ),
376 ));
377 }
378 Ok(())
379 }
380
381 pub fn add_source(&mut self, source: KnowledgeMapSource) -> Result<(), DomainError> {
383 self.validate()?;
384 self.add_source_state(source)?;
385 self.validate()
386 }
387
388 pub(crate) fn add_source_snapshot(
389 &mut self,
390 source: KnowledgeMapSource,
391 omitted_through: u64,
392 ) -> Result<(), DomainError> {
393 self.validate_snapshot(omitted_through)?;
394 self.add_source_state(source)?;
395 self.validate_snapshot(omitted_through)
396 }
397
398 fn add_source_state(&mut self, source: KnowledgeMapSource) -> Result<(), DomainError> {
399 source.validate()?;
400 if self.sources.iter().any(|entry| entry.id == source.id) {
401 return Err(DomainError::invalid("id", "source already exists"));
402 }
403 if !self.topics.iter().any(|topic| topic.id == source.topic) {
404 self.topics.push(KnowledgeMapTopic::new(
405 source.topic.clone(),
406 source.topic.clone(),
407 "Added by CLI source registration.".to_owned(),
408 )?);
409 }
410 let source_id = source.id.clone();
411 let topic_id = source.topic.clone();
412 self.sources.push(source);
413 self.ensure_route_contains(&topic_id, &source_id)?;
414 self.sort_entries();
415 Ok(())
416 }
417
418 pub fn update_source(&mut self, change: KnowledgeMapChange) -> Result<(), DomainError> {
420 self.validate()?;
421 self.update_source_state(change)?;
422 self.validate()
423 }
424
425 pub(crate) fn update_source_snapshot(
426 &mut self,
427 change: KnowledgeMapChange,
428 omitted_through: u64,
429 ) -> Result<bool, DomainError> {
430 self.validate_snapshot(omitted_through)?;
431 let changed = self.update_source_state(change)?;
432 self.validate_snapshot(omitted_through)?;
433 Ok(changed)
434 }
435
436 fn update_source_state(&mut self, change: KnowledgeMapChange) -> Result<bool, DomainError> {
437 let Some(source) = self.sources.iter_mut().find(|entry| entry.id == change.id) else {
438 return Err(DomainError::invalid("id", "source does not exist"));
439 };
440 let previous = source.clone();
441 if let Some(topic) = change.topic {
442 source.topic = required_text("topic", topic)?;
443 }
444 if let Some(kind) = change.kind {
445 source.kind = kind;
446 }
447 if let Some(uri) = change.uri {
448 source.uri = required_text("uri", uri)?;
449 }
450 if let Some(scope) = change.source_scope {
451 SourceScope::parse(scope.as_str())?;
452 source.source_scope = Some(scope);
453 }
454 if let Some(description) = change.description {
455 source.description = Some(required_text("description", description)?);
456 }
457 if *source == previous {
458 return Ok(false);
459 }
460 source.version = source.version.saturating_add(1);
461
462 if !self.topics.iter().any(|topic| topic.id == source.topic) {
463 self.topics.push(KnowledgeMapTopic::new(
464 source.topic.clone(),
465 source.topic.clone(),
466 "Added by CLI source update.".to_owned(),
467 )?);
468 }
469 let topic_id = source.topic.clone();
470 let source_id = source.id.clone();
471 if previous.topic != topic_id {
472 self.prune_source_from_other_routes(&source_id, &topic_id);
473 }
474 self.ensure_route_contains(&topic_id, &source_id)?;
475 self.sort_entries();
476 Ok(true)
477 }
478
479 pub fn remove_source(&mut self, id: &str) -> Result<(), DomainError> {
481 self.validate()?;
482 self.remove_source_state(id)?;
483 self.validate()
484 }
485
486 pub(crate) fn remove_source_snapshot(
487 &mut self,
488 id: &str,
489 omitted_through: u64,
490 ) -> Result<(), DomainError> {
491 self.validate_snapshot(omitted_through)?;
492 self.remove_source_state(id)?;
493 self.validate_snapshot(omitted_through)
494 }
495
496 fn remove_source_state(&mut self, id: &str) -> Result<(), DomainError> {
497 let before = self.sources.len();
498 self.sources.retain(|source| source.id != id);
499 if self.sources.len() == before {
500 return Err(DomainError::invalid("id", "source does not exist"));
501 }
502 for route in &mut self.routes {
503 route.source_order.retain(|source_id| source_id != id);
504 }
505 self.sort_entries();
506 Ok(())
507 }
508
509 pub fn record_change(&mut self, action: &str, summary: String, updated_at: String) {
511 self.map_version = self.map_version.saturating_add(1);
512 self.updated_at = updated_at;
513 self.history.push(KnowledgeMapHistoryEntry {
514 version: self.map_version,
515 action: action.to_owned(),
516 actor: "cli".to_owned(),
517 summary,
518 });
519 }
520
521 fn ensure_route_contains(&mut self, topic: &str, source_id: &str) -> Result<bool, DomainError> {
522 if let Some(route) = self.routes.iter_mut().find(|route| route.topic == topic) {
523 if !route.source_order.iter().any(|id| id == source_id) {
524 route.source_order.push(source_id.to_owned());
525 return Ok(true);
526 }
527 return Ok(false);
528 }
529 self.routes.push(KnowledgeMapRoute {
530 topic: topic.to_owned(),
531 source_order: vec![source_id.to_owned()],
532 fallback: Some("bounded-search".to_owned()),
533 });
534 Ok(true)
535 }
536
537 fn prune_source_from_other_routes(&mut self, source_id: &str, current_topic: &str) {
538 for route in &mut self.routes {
539 if route.topic != current_topic {
540 route.source_order.retain(|id| id != source_id);
541 }
542 }
543 }
544
545 fn sort_entries(&mut self) {
546 self.topics.sort_by(|left, right| left.id.cmp(&right.id));
547 self.sources.sort_by(|left, right| left.id.cmp(&right.id));
548 self.routes
549 .sort_by(|left, right| left.topic.cmp(&right.topic));
550 }
551}
552
553fn validate_software_model_source(source: &KnowledgeMapSource) -> Result<(), DomainError> {
554 let compatible = source.topic == SOFTWARE_MODEL_TOPIC_ID
555 && source.kind == KnowledgeMapSourceKind::Repo
556 && source.uri == SOFTWARE_MODEL_SOURCE_URI
557 && source.source_scope.as_deref() == Some(SOFTWARE_MODEL_SOURCE_SCOPE);
558 if compatible {
559 return Ok(());
560 }
561 Err(DomainError::invalid(
562 "sources",
563 format!(
564 "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}'"
565 ),
566 ))
567}
568
569fn validate_business_knowledge_source(source: &KnowledgeMapSource) -> Result<(), DomainError> {
570 let compatible = source.topic == BUSINESS_KNOWLEDGE_TOPIC_ID
571 && source.kind == KnowledgeMapSourceKind::File
572 && source.uri == BUSINESS_GLOSSARY_RELATIVE_PATH
573 && source.source_scope.as_deref() == Some(BUSINESS_KNOWLEDGE_SOURCE_SCOPE);
574 if compatible {
575 return Ok(());
576 }
577 Err(DomainError::invalid(
578 "sources",
579 format!(
580 "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}'"
581 ),
582 ))
583}
584
585fn validate_reserved_route(
586 routes: &[KnowledgeMapRoute],
587 topic: &str,
588 source_id: &str,
589) -> Result<(), DomainError> {
590 let route = routes
591 .iter()
592 .find(|route| route.topic == topic)
593 .ok_or_else(|| {
594 DomainError::invalid(
595 "routes",
596 format!("required reserved route '{topic}' is missing"),
597 )
598 })?;
599 if route.source_order.iter().any(|id| id == source_id) {
600 return Ok(());
601 }
602 Err(DomainError::invalid(
603 "routes",
604 format!("reserved route '{topic}' must include source '{source_id}'"),
605 ))
606}
607
608#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
610pub struct KnowledgeMapTopic {
611 pub id: String,
612 pub title: String,
613 pub description: String,
614}
615
616impl KnowledgeMapTopic {
617 pub fn new(id: String, title: String, description: String) -> Result<Self, DomainError> {
618 let topic = Self {
619 id: required_text("topic", id)?,
620 title: required_text("title", title)?,
621 description: required_text("description", description)?,
622 };
623 topic.validate()?;
624 Ok(topic)
625 }
626
627 fn validate(&self) -> Result<(), DomainError> {
628 required_text("topic", self.id.as_str())?;
629 required_text("title", self.title.as_str())?;
630 required_text("description", self.description.as_str())?;
631 Ok(())
632 }
633}
634
635#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
637pub struct KnowledgeMapSource {
638 pub id: String,
639 pub topic: String,
640 pub kind: KnowledgeMapSourceKind,
641 pub uri: String,
642 #[serde(default, skip_serializing_if = "Option::is_none")]
643 pub source_scope: Option<String>,
644 pub read_policy: String,
645 pub write_policy: String,
646 pub status: String,
647 pub version: u64,
648 #[serde(default, skip_serializing_if = "Option::is_none")]
649 pub description: Option<String>,
650}
651
652impl KnowledgeMapSource {
653 pub fn new(
654 id: String,
655 topic: String,
656 kind: KnowledgeMapSourceKind,
657 uri: String,
658 source_scope: Option<String>,
659 description: Option<String>,
660 ) -> Result<Self, DomainError> {
661 if let Some(scope) = source_scope.as_deref() {
662 SourceScope::parse(scope)?;
663 }
664 let source = Self {
665 id: required_text("id", id)?,
666 topic: required_text("topic", topic)?,
667 kind,
668 uri: required_text("uri", uri)?,
669 source_scope,
670 read_policy: "direct".to_owned(),
671 write_policy: "manual-review".to_owned(),
672 status: "active".to_owned(),
673 version: 1,
674 description,
675 };
676 source.validate()?;
677 Ok(source)
678 }
679
680 fn validate(&self) -> Result<(), DomainError> {
681 required_text("id", self.id.as_str())?;
682 required_text("topic", self.topic.as_str())?;
683 required_text("uri", self.uri.as_str())?;
684 required_text("read_policy", self.read_policy.as_str())?;
685 required_text("write_policy", self.write_policy.as_str())?;
686 required_text("status", self.status.as_str())?;
687 if self.version == 0 {
688 return Err(DomainError::invalid("version", "must be greater than zero"));
689 }
690 if let Some(scope) = self.source_scope.as_deref() {
691 SourceScope::parse(scope)?;
692 }
693 Ok(())
694 }
695}
696
697#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
699#[serde(rename_all = "kebab-case")]
700pub enum KnowledgeMapSourceKind {
701 Repo,
702 File,
703 Doc,
704 Config,
705 Db,
706 Ci,
707 Runtime,
708 Wiki,
709 Monitoring,
710}
711
712#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
714pub struct KnowledgeMapRoute {
715 pub topic: String,
716 #[serde(default)]
717 pub source_order: Vec<String>,
718 #[serde(default, skip_serializing_if = "Option::is_none")]
719 pub fallback: Option<String>,
720}
721
722impl KnowledgeMapRoute {
723 fn validate(&self) -> Result<(), DomainError> {
724 required_text("topic", self.topic.as_str())?;
725 for source_id in &self.source_order {
726 required_text("source_order", source_id.as_str())?;
727 }
728 Ok(())
729 }
730}
731
732#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
734pub struct KnowledgeMapHistoryEntry {
735 pub version: u64,
736 pub action: String,
737 pub actor: String,
738 pub summary: String,
739}
740
741impl KnowledgeMapHistoryEntry {
742 pub(crate) fn validate(&self) -> Result<(), DomainError> {
743 if self.version == 0 {
744 return Err(DomainError::invalid(
745 "history",
746 "version must be greater than zero",
747 ));
748 }
749 required_text("action", self.action.as_str())?;
750 required_text("actor", self.actor.as_str())?;
751 required_text("summary", self.summary.as_str())?;
752 Ok(())
753 }
754}
755
756#[derive(Debug, Clone, PartialEq, Eq)]
758pub struct KnowledgeMapChange {
759 pub id: String,
760 pub topic: Option<String>,
761 pub kind: Option<KnowledgeMapSourceKind>,
762 pub uri: Option<String>,
763 pub source_scope: Option<String>,
764 pub description: Option<String>,
765}
766
767#[cfg(test)]
768#[path = "map_tests.rs"]
769mod tests;