1use std::collections::HashSet;
2
3use serde::{Deserialize, Serialize};
4
5use super::{DomainError, SourceScope, error::required_text};
6
7#[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 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 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 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 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 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 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#[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#[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#[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#[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#[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#[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}