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        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    /// Ensures the stable authored repository glossary route.
124    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    /// Repairs both reserved repository routes before enforcing the complete map invariants.
167    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    /// Validates the cross-reference invariants that keep the map navigable.
187    pub fn validate(&self) -> Result<(), DomainError> {
188        self.validate_state()?;
189        self.validate_history(0)
190    }
191
192    /// Validates a snapshot whose older history precedes an omission checkpoint.
193    pub(crate) fn validate_snapshot(&self, omitted_through: u64) -> Result<(), DomainError> {
194        self.validate_state()?;
195        self.validate_history(omitted_through)
196    }
197
198    /// Requires the two stable repository entry points without excluding ordinary sources.
199    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    /// Adds a source to the map and creates a simple route for its topic when missing.
382    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    /// Applies supported source field updates without changing its identity.
419    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    /// Removes a source and prunes routes that referenced it.
480    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    /// Advances the map version and records the mutation in history.
510    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/// Human-readable topic bucket used by agents for routing.
609#[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/// Addressable knowledge source that remains authoritative outside the map.
636#[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/// Supported source category labels in the YAML contract.
698#[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/// Ordered source route for a topic.
713#[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/// Version history entry written after CLI mutations.
733#[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/// Optional source changes accepted by the update command.
757#[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;