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