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 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 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 pub fn validate(&self) -> Result<(), DomainError> {
189 self.validate_state()?;
190 self.validate_history(0)
191 }
192
193 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 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 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 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 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#[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#[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#[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#[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#[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#[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;