data_modelling_sdk/models/
knowledge.rs

1//! Knowledge Base model for domain-partitioned knowledge articles
2//!
3//! Implements the Knowledge Base (KB) feature for storing and organizing
4//! documentation, guides, standards, and other knowledge resources.
5//!
6//! ## File Format
7//!
8//! Knowledge articles are stored as `.kb.yaml` files following the naming convention:
9//! `{workspace}_{domain}_kb-{number}.kb.yaml`
10//!
11//! ## Example
12//!
13//! ```yaml
14//! id: 660e8400-e29b-41d4-a716-446655440000
15//! number: "KB-0001"
16//! title: "Data Classification Guide"
17//! article_type: guide
18//! status: published
19//! domain: sales
20//! summary: |
21//!   This guide explains data classification.
22//! content: |
23//!   ## Overview
24//!   Data classification is essential...
25//! author: data-governance@company.com
26//! ```
27
28use chrono::{DateTime, Utc};
29use serde::{Deserialize, Serialize};
30use uuid::Uuid;
31
32use super::Tag;
33use super::decision::AssetLink;
34
35/// Knowledge article type
36#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
37#[serde(rename_all = "lowercase")]
38pub enum KnowledgeType {
39    /// How-to guide or tutorial
40    #[default]
41    Guide,
42    /// Standard or specification
43    Standard,
44    /// Reference documentation
45    Reference,
46    /// Glossary of terms
47    Glossary,
48    /// Step-by-step how-to
49    HowTo,
50    /// Troubleshooting guide
51    Troubleshooting,
52    /// Policy document
53    Policy,
54    /// Template or boilerplate
55    Template,
56}
57
58impl std::fmt::Display for KnowledgeType {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        match self {
61            KnowledgeType::Guide => write!(f, "Guide"),
62            KnowledgeType::Standard => write!(f, "Standard"),
63            KnowledgeType::Reference => write!(f, "Reference"),
64            KnowledgeType::Glossary => write!(f, "Glossary"),
65            KnowledgeType::HowTo => write!(f, "How-To"),
66            KnowledgeType::Troubleshooting => write!(f, "Troubleshooting"),
67            KnowledgeType::Policy => write!(f, "Policy"),
68            KnowledgeType::Template => write!(f, "Template"),
69        }
70    }
71}
72
73/// Knowledge article status
74#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
75#[serde(rename_all = "lowercase")]
76pub enum KnowledgeStatus {
77    /// Article is being drafted
78    #[default]
79    Draft,
80    /// Article is published and active
81    Published,
82    /// Article is archived (historical reference)
83    Archived,
84    /// Article is deprecated (should not be used)
85    Deprecated,
86}
87
88impl std::fmt::Display for KnowledgeStatus {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        match self {
91            KnowledgeStatus::Draft => write!(f, "Draft"),
92            KnowledgeStatus::Published => write!(f, "Published"),
93            KnowledgeStatus::Archived => write!(f, "Archived"),
94            KnowledgeStatus::Deprecated => write!(f, "Deprecated"),
95        }
96    }
97}
98
99/// Review frequency for knowledge articles
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
101#[serde(rename_all = "lowercase")]
102pub enum ReviewFrequency {
103    /// Review monthly
104    Monthly,
105    /// Review quarterly
106    Quarterly,
107    /// Review yearly
108    Yearly,
109}
110
111impl std::fmt::Display for ReviewFrequency {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        match self {
114            ReviewFrequency::Monthly => write!(f, "Monthly"),
115            ReviewFrequency::Quarterly => write!(f, "Quarterly"),
116            ReviewFrequency::Yearly => write!(f, "Yearly"),
117        }
118    }
119}
120
121/// Skill level required for the article
122#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
123#[serde(rename_all = "lowercase")]
124pub enum SkillLevel {
125    /// For beginners
126    Beginner,
127    /// For intermediate users
128    Intermediate,
129    /// For advanced users
130    Advanced,
131}
132
133impl std::fmt::Display for SkillLevel {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        match self {
136            SkillLevel::Beginner => write!(f, "Beginner"),
137            SkillLevel::Intermediate => write!(f, "Intermediate"),
138            SkillLevel::Advanced => write!(f, "Advanced"),
139        }
140    }
141}
142
143/// Relationship type between articles
144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
145#[serde(rename_all = "lowercase")]
146pub enum ArticleRelationship {
147    /// General related article
148    Related,
149    /// Prerequisite article (should be read first)
150    Prerequisite,
151    /// This article supersedes the referenced one
152    Supersedes,
153}
154
155impl std::fmt::Display for ArticleRelationship {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        match self {
158            ArticleRelationship::Related => write!(f, "Related"),
159            ArticleRelationship::Prerequisite => write!(f, "Prerequisite"),
160            ArticleRelationship::Supersedes => write!(f, "Supersedes"),
161        }
162    }
163}
164
165/// Reference to a related article
166#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
167pub struct RelatedArticle {
168    /// UUID of the related article
169    pub article_id: Uuid,
170    /// Article number (e.g., "KB-0001")
171    pub article_number: String,
172    /// Article title
173    pub title: String,
174    /// Type of relationship
175    pub relationship: ArticleRelationship,
176}
177
178impl RelatedArticle {
179    /// Create a new related article reference
180    pub fn new(
181        article_id: Uuid,
182        article_number: impl Into<String>,
183        title: impl Into<String>,
184        relationship: ArticleRelationship,
185    ) -> Self {
186        Self {
187            article_id,
188            article_number: article_number.into(),
189            title: title.into(),
190            relationship,
191        }
192    }
193}
194
195/// Knowledge Base Article
196///
197/// Represents a knowledge article that can be categorized by domain,
198/// type, and audience.
199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
200pub struct KnowledgeArticle {
201    /// Unique identifier for the article
202    pub id: Uuid,
203    /// Article number (KB-0001, KB-0002, etc.)
204    pub number: String,
205    /// Article title
206    pub title: String,
207    /// Type of article
208    pub article_type: KnowledgeType,
209    /// Publication status
210    pub status: KnowledgeStatus,
211    /// Domain this article belongs to (optional)
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub domain: Option<String>,
214
215    // Content
216    /// Brief summary of the article
217    pub summary: String,
218    /// Full article content in Markdown
219    pub content: String,
220
221    // Authorship
222    /// Article author (email or name)
223    pub author: String,
224    /// List of reviewers
225    #[serde(default, skip_serializing_if = "Vec::is_empty")]
226    pub reviewers: Vec<String>,
227    /// Date of last review
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub last_reviewed: Option<DateTime<Utc>>,
230    /// How often the article should be reviewed
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub review_frequency: Option<ReviewFrequency>,
233
234    // Classification
235    /// Target audience for the article
236    #[serde(default, skip_serializing_if = "Vec::is_empty")]
237    pub audience: Vec<String>,
238    /// Required skill level
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub skill_level: Option<SkillLevel>,
241
242    // Linking
243    /// Assets referenced by this article
244    #[serde(default, skip_serializing_if = "Vec::is_empty")]
245    pub linked_assets: Vec<AssetLink>,
246    /// UUIDs of related decisions
247    #[serde(default, skip_serializing_if = "Vec::is_empty")]
248    pub linked_decisions: Vec<Uuid>,
249    /// Related articles
250    #[serde(default, skip_serializing_if = "Vec::is_empty")]
251    pub related_articles: Vec<RelatedArticle>,
252
253    // Standard metadata
254    /// Tags for categorization
255    #[serde(default, skip_serializing_if = "Vec::is_empty")]
256    pub tags: Vec<Tag>,
257    /// Additional notes
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub notes: Option<String>,
260
261    /// Creation timestamp
262    pub created_at: DateTime<Utc>,
263    /// Last modification timestamp
264    pub updated_at: DateTime<Utc>,
265}
266
267impl KnowledgeArticle {
268    /// Create a new knowledge article with required fields
269    pub fn new(
270        number: u32,
271        title: impl Into<String>,
272        summary: impl Into<String>,
273        content: impl Into<String>,
274        author: impl Into<String>,
275    ) -> Self {
276        let now = Utc::now();
277        let number_str = format!("KB-{:04}", number);
278        Self {
279            id: Self::generate_id(number),
280            number: number_str,
281            title: title.into(),
282            article_type: KnowledgeType::Guide,
283            status: KnowledgeStatus::Draft,
284            domain: None,
285            summary: summary.into(),
286            content: content.into(),
287            author: author.into(),
288            reviewers: Vec::new(),
289            last_reviewed: None,
290            review_frequency: None,
291            audience: Vec::new(),
292            skill_level: None,
293            linked_assets: Vec::new(),
294            linked_decisions: Vec::new(),
295            related_articles: Vec::new(),
296            tags: Vec::new(),
297            notes: None,
298            created_at: now,
299            updated_at: now,
300        }
301    }
302
303    /// Generate a deterministic UUID for an article based on its number
304    pub fn generate_id(number: u32) -> Uuid {
305        // Use UUID v5 with a namespace for knowledge articles
306        let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(); // URL namespace
307        let name = format!("knowledge:{}", number);
308        Uuid::new_v5(&namespace, name.as_bytes())
309    }
310
311    /// Parse the numeric part of the article number
312    pub fn parse_number(&self) -> Option<u32> {
313        self.number.strip_prefix("KB-").and_then(|s| s.parse().ok())
314    }
315
316    /// Set the article type
317    pub fn with_type(mut self, article_type: KnowledgeType) -> Self {
318        self.article_type = article_type;
319        self.updated_at = Utc::now();
320        self
321    }
322
323    /// Set the article status
324    pub fn with_status(mut self, status: KnowledgeStatus) -> Self {
325        self.status = status;
326        self.updated_at = Utc::now();
327        self
328    }
329
330    /// Set the domain
331    pub fn with_domain(mut self, domain: impl Into<String>) -> Self {
332        self.domain = Some(domain.into());
333        self.updated_at = Utc::now();
334        self
335    }
336
337    /// Add a reviewer
338    pub fn add_reviewer(mut self, reviewer: impl Into<String>) -> Self {
339        self.reviewers.push(reviewer.into());
340        self.updated_at = Utc::now();
341        self
342    }
343
344    /// Set review frequency
345    pub fn with_review_frequency(mut self, frequency: ReviewFrequency) -> Self {
346        self.review_frequency = Some(frequency);
347        self.updated_at = Utc::now();
348        self
349    }
350
351    /// Add an audience
352    pub fn add_audience(mut self, audience: impl Into<String>) -> Self {
353        self.audience.push(audience.into());
354        self.updated_at = Utc::now();
355        self
356    }
357
358    /// Set skill level
359    pub fn with_skill_level(mut self, level: SkillLevel) -> Self {
360        self.skill_level = Some(level);
361        self.updated_at = Utc::now();
362        self
363    }
364
365    /// Add an asset link
366    pub fn add_asset_link(mut self, link: AssetLink) -> Self {
367        self.linked_assets.push(link);
368        self.updated_at = Utc::now();
369        self
370    }
371
372    /// Link to a decision
373    pub fn link_decision(mut self, decision_id: Uuid) -> Self {
374        if !self.linked_decisions.contains(&decision_id) {
375            self.linked_decisions.push(decision_id);
376            self.updated_at = Utc::now();
377        }
378        self
379    }
380
381    /// Add a related article
382    pub fn add_related_article(mut self, article: RelatedArticle) -> Self {
383        self.related_articles.push(article);
384        self.updated_at = Utc::now();
385        self
386    }
387
388    /// Add a tag
389    pub fn add_tag(mut self, tag: Tag) -> Self {
390        self.tags.push(tag);
391        self.updated_at = Utc::now();
392        self
393    }
394
395    /// Mark the article as reviewed
396    pub fn mark_reviewed(&mut self) {
397        self.last_reviewed = Some(Utc::now());
398        self.updated_at = Utc::now();
399    }
400
401    /// Generate the YAML filename for this article
402    pub fn filename(&self, workspace_name: &str) -> String {
403        let number = self.parse_number().unwrap_or(0);
404        match &self.domain {
405            Some(domain) => format!(
406                "{}_{}_kb-{:04}.kb.yaml",
407                sanitize_name(workspace_name),
408                sanitize_name(domain),
409                number
410            ),
411            None => format!("{}_kb-{:04}.kb.yaml", sanitize_name(workspace_name), number),
412        }
413    }
414
415    /// Generate the Markdown filename for this article
416    pub fn markdown_filename(&self) -> String {
417        let slug = slugify(&self.title);
418        format!("{}-{}.md", self.number, slug)
419    }
420
421    /// Import from YAML
422    pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
423        serde_yaml::from_str(yaml_content)
424    }
425
426    /// Export to YAML
427    pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
428        serde_yaml::to_string(self)
429    }
430}
431
432/// Knowledge article index entry for the knowledge.yaml file
433#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
434pub struct KnowledgeIndexEntry {
435    /// Article number (e.g., "KB-0001")
436    pub number: String,
437    /// Article UUID
438    pub id: Uuid,
439    /// Article title
440    pub title: String,
441    /// Article type
442    pub article_type: KnowledgeType,
443    /// Article status
444    pub status: KnowledgeStatus,
445    /// Domain (if applicable)
446    #[serde(skip_serializing_if = "Option::is_none")]
447    pub domain: Option<String>,
448    /// Filename of the article YAML file
449    pub file: String,
450}
451
452impl From<&KnowledgeArticle> for KnowledgeIndexEntry {
453    fn from(article: &KnowledgeArticle) -> Self {
454        Self {
455            number: article.number.clone(),
456            id: article.id,
457            title: article.title.clone(),
458            article_type: article.article_type.clone(),
459            status: article.status.clone(),
460            domain: article.domain.clone(),
461            file: String::new(), // Set by caller
462        }
463    }
464}
465
466/// Knowledge base index (knowledge.yaml)
467#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
468pub struct KnowledgeIndex {
469    /// Schema version
470    pub schema_version: String,
471    /// Last update timestamp
472    #[serde(skip_serializing_if = "Option::is_none")]
473    pub last_updated: Option<DateTime<Utc>>,
474    /// List of articles
475    #[serde(default)]
476    pub articles: Vec<KnowledgeIndexEntry>,
477    /// Next available article number
478    pub next_number: u32,
479}
480
481impl Default for KnowledgeIndex {
482    fn default() -> Self {
483        Self::new()
484    }
485}
486
487impl KnowledgeIndex {
488    /// Create a new empty knowledge index
489    pub fn new() -> Self {
490        Self {
491            schema_version: "1.0".to_string(),
492            last_updated: Some(Utc::now()),
493            articles: Vec::new(),
494            next_number: 1,
495        }
496    }
497
498    /// Add an article to the index
499    pub fn add_article(&mut self, article: &KnowledgeArticle, filename: String) {
500        let mut entry = KnowledgeIndexEntry::from(article);
501        entry.file = filename;
502
503        // Remove existing entry with same number if present
504        self.articles.retain(|a| a.number != article.number);
505        self.articles.push(entry);
506
507        // Sort by number
508        self.articles.sort_by(|a, b| a.number.cmp(&b.number));
509
510        // Update next number
511        if let Some(num) = article.parse_number()
512            && num >= self.next_number
513        {
514            self.next_number = num + 1;
515        }
516
517        self.last_updated = Some(Utc::now());
518    }
519
520    /// Get the next available article number
521    pub fn get_next_number(&self) -> u32 {
522        self.next_number
523    }
524
525    /// Find an article by number
526    pub fn find_by_number(&self, number: &str) -> Option<&KnowledgeIndexEntry> {
527        self.articles.iter().find(|a| a.number == number)
528    }
529
530    /// Import from YAML
531    pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
532        serde_yaml::from_str(yaml_content)
533    }
534
535    /// Export to YAML
536    pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
537        serde_yaml::to_string(self)
538    }
539}
540
541/// Sanitize a name for use in filenames
542fn sanitize_name(name: &str) -> String {
543    name.chars()
544        .map(|c| match c {
545            ' ' | '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
546            _ => c,
547        })
548        .collect::<String>()
549        .to_lowercase()
550}
551
552/// Create a URL-friendly slug from a title
553fn slugify(title: &str) -> String {
554    title
555        .to_lowercase()
556        .chars()
557        .map(|c| if c.is_alphanumeric() { c } else { '-' })
558        .collect::<String>()
559        .split('-')
560        .filter(|s| !s.is_empty())
561        .collect::<Vec<_>>()
562        .join("-")
563        .chars()
564        .take(50) // Limit slug length
565        .collect()
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    #[test]
573    fn test_knowledge_article_new() {
574        let article = KnowledgeArticle::new(
575            1,
576            "Data Classification Guide",
577            "This guide explains classification",
578            "## Overview\n\nData classification is important...",
579            "data-governance@example.com",
580        );
581
582        assert_eq!(article.number, "KB-0001");
583        assert_eq!(article.title, "Data Classification Guide");
584        assert_eq!(article.status, KnowledgeStatus::Draft);
585        assert_eq!(article.article_type, KnowledgeType::Guide);
586    }
587
588    #[test]
589    fn test_knowledge_article_builder_pattern() {
590        let article = KnowledgeArticle::new(1, "Test", "Summary", "Content", "author@example.com")
591            .with_type(KnowledgeType::Standard)
592            .with_status(KnowledgeStatus::Published)
593            .with_domain("sales")
594            .add_reviewer("reviewer@example.com")
595            .with_review_frequency(ReviewFrequency::Quarterly)
596            .add_audience("data-engineers")
597            .with_skill_level(SkillLevel::Intermediate);
598
599        assert_eq!(article.article_type, KnowledgeType::Standard);
600        assert_eq!(article.status, KnowledgeStatus::Published);
601        assert_eq!(article.domain, Some("sales".to_string()));
602        assert_eq!(article.reviewers.len(), 1);
603        assert_eq!(article.review_frequency, Some(ReviewFrequency::Quarterly));
604        assert_eq!(article.audience.len(), 1);
605        assert_eq!(article.skill_level, Some(SkillLevel::Intermediate));
606    }
607
608    #[test]
609    fn test_knowledge_article_id_generation() {
610        let id1 = KnowledgeArticle::generate_id(1);
611        let id2 = KnowledgeArticle::generate_id(1);
612        let id3 = KnowledgeArticle::generate_id(2);
613
614        // Same number should generate same ID
615        assert_eq!(id1, id2);
616        // Different numbers should generate different IDs
617        assert_ne!(id1, id3);
618    }
619
620    #[test]
621    fn test_knowledge_article_filename() {
622        let article = KnowledgeArticle::new(1, "Test", "Summary", "Content", "author@example.com");
623        assert_eq!(article.filename("enterprise"), "enterprise_kb-0001.kb.yaml");
624
625        let article_with_domain = article.with_domain("sales");
626        assert_eq!(
627            article_with_domain.filename("enterprise"),
628            "enterprise_sales_kb-0001.kb.yaml"
629        );
630    }
631
632    #[test]
633    fn test_knowledge_article_markdown_filename() {
634        let article = KnowledgeArticle::new(
635            1,
636            "Data Classification Guide",
637            "Summary",
638            "Content",
639            "author@example.com",
640        );
641        let filename = article.markdown_filename();
642        assert!(filename.starts_with("KB-0001-"));
643        assert!(filename.ends_with(".md"));
644    }
645
646    #[test]
647    fn test_knowledge_article_yaml_roundtrip() {
648        let article = KnowledgeArticle::new(
649            1,
650            "Test Article",
651            "Test summary",
652            "Test content",
653            "author@example.com",
654        )
655        .with_status(KnowledgeStatus::Published)
656        .with_domain("test");
657
658        let yaml = article.to_yaml().unwrap();
659        let parsed = KnowledgeArticle::from_yaml(&yaml).unwrap();
660
661        assert_eq!(article.id, parsed.id);
662        assert_eq!(article.title, parsed.title);
663        assert_eq!(article.status, parsed.status);
664        assert_eq!(article.domain, parsed.domain);
665    }
666
667    #[test]
668    fn test_knowledge_article_parse_number() {
669        let article = KnowledgeArticle::new(42, "Test", "Summary", "Content", "author");
670        assert_eq!(article.parse_number(), Some(42));
671    }
672
673    #[test]
674    fn test_knowledge_index() {
675        let mut index = KnowledgeIndex::new();
676        assert_eq!(index.get_next_number(), 1);
677
678        let article1 =
679            KnowledgeArticle::new(1, "First", "Summary", "Content", "author@example.com");
680        index.add_article(&article1, "test_kb-0001.kb.yaml".to_string());
681
682        assert_eq!(index.articles.len(), 1);
683        assert_eq!(index.get_next_number(), 2);
684
685        let article2 =
686            KnowledgeArticle::new(2, "Second", "Summary", "Content", "author@example.com");
687        index.add_article(&article2, "test_kb-0002.kb.yaml".to_string());
688
689        assert_eq!(index.articles.len(), 2);
690        assert_eq!(index.get_next_number(), 3);
691    }
692
693    #[test]
694    fn test_related_article() {
695        let related = RelatedArticle::new(
696            Uuid::new_v4(),
697            "KB-0002",
698            "PII Handling",
699            ArticleRelationship::Related,
700        );
701
702        assert_eq!(related.article_number, "KB-0002");
703        assert_eq!(related.relationship, ArticleRelationship::Related);
704    }
705
706    #[test]
707    fn test_knowledge_type_display() {
708        assert_eq!(format!("{}", KnowledgeType::Guide), "Guide");
709        assert_eq!(format!("{}", KnowledgeType::Standard), "Standard");
710        assert_eq!(format!("{}", KnowledgeType::HowTo), "How-To");
711    }
712
713    #[test]
714    fn test_knowledge_status_display() {
715        assert_eq!(format!("{}", KnowledgeStatus::Draft), "Draft");
716        assert_eq!(format!("{}", KnowledgeStatus::Published), "Published");
717        assert_eq!(format!("{}", KnowledgeStatus::Archived), "Archived");
718    }
719}