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    /// Step-by-step how-to
47    HowTo,
48    /// Troubleshooting guide
49    Troubleshooting,
50    /// Policy document
51    Policy,
52    /// Template or boilerplate
53    Template,
54    /// Conceptual documentation
55    Concept,
56    /// Runbook for operations
57    Runbook,
58    /// Tutorial (step-by-step learning)
59    Tutorial,
60    /// Glossary of terms
61    Glossary,
62}
63
64impl std::fmt::Display for KnowledgeType {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        match self {
67            KnowledgeType::Guide => write!(f, "Guide"),
68            KnowledgeType::Standard => write!(f, "Standard"),
69            KnowledgeType::Reference => write!(f, "Reference"),
70            KnowledgeType::HowTo => write!(f, "How-To"),
71            KnowledgeType::Troubleshooting => write!(f, "Troubleshooting"),
72            KnowledgeType::Policy => write!(f, "Policy"),
73            KnowledgeType::Template => write!(f, "Template"),
74            KnowledgeType::Concept => write!(f, "Concept"),
75            KnowledgeType::Runbook => write!(f, "Runbook"),
76            KnowledgeType::Tutorial => write!(f, "Tutorial"),
77            KnowledgeType::Glossary => write!(f, "Glossary"),
78        }
79    }
80}
81
82/// Knowledge article status
83#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
84#[serde(rename_all = "lowercase")]
85pub enum KnowledgeStatus {
86    /// Article is being drafted
87    #[default]
88    Draft,
89    /// Article is under review
90    Review,
91    /// Article is published and active
92    Published,
93    /// Article is archived (historical reference)
94    Archived,
95    /// Article is deprecated (should not be used)
96    Deprecated,
97}
98
99impl std::fmt::Display for KnowledgeStatus {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        match self {
102            KnowledgeStatus::Draft => write!(f, "Draft"),
103            KnowledgeStatus::Review => write!(f, "Review"),
104            KnowledgeStatus::Published => write!(f, "Published"),
105            KnowledgeStatus::Archived => write!(f, "Archived"),
106            KnowledgeStatus::Deprecated => write!(f, "Deprecated"),
107        }
108    }
109}
110
111/// Review frequency for knowledge articles
112#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
113#[serde(rename_all = "lowercase")]
114pub enum ReviewFrequency {
115    /// Review monthly
116    Monthly,
117    /// Review quarterly
118    Quarterly,
119    /// Review yearly
120    Yearly,
121}
122
123impl std::fmt::Display for ReviewFrequency {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        match self {
126            ReviewFrequency::Monthly => write!(f, "Monthly"),
127            ReviewFrequency::Quarterly => write!(f, "Quarterly"),
128            ReviewFrequency::Yearly => write!(f, "Yearly"),
129        }
130    }
131}
132
133/// Skill level required for the article
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
135#[serde(rename_all = "lowercase")]
136pub enum SkillLevel {
137    /// For beginners
138    Beginner,
139    /// For intermediate users
140    Intermediate,
141    /// For advanced users
142    Advanced,
143}
144
145impl std::fmt::Display for SkillLevel {
146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        match self {
148            SkillLevel::Beginner => write!(f, "Beginner"),
149            SkillLevel::Intermediate => write!(f, "Intermediate"),
150            SkillLevel::Advanced => write!(f, "Advanced"),
151        }
152    }
153}
154
155/// Relationship type between articles
156#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
157#[serde(rename_all = "lowercase")]
158pub enum ArticleRelationship {
159    /// General related article
160    Related,
161    /// Prerequisite article (should be read first)
162    Prerequisite,
163    /// This article supersedes the referenced one
164    Supersedes,
165}
166
167impl std::fmt::Display for ArticleRelationship {
168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169        match self {
170            ArticleRelationship::Related => write!(f, "Related"),
171            ArticleRelationship::Prerequisite => write!(f, "Prerequisite"),
172            ArticleRelationship::Supersedes => write!(f, "Supersedes"),
173        }
174    }
175}
176
177/// Reference to a related article
178#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
179pub struct RelatedArticle {
180    /// UUID of the related article
181    pub article_id: Uuid,
182    /// Article number (e.g., "KB-0001")
183    pub article_number: String,
184    /// Article title
185    pub title: String,
186    /// Type of relationship
187    pub relationship: ArticleRelationship,
188}
189
190impl RelatedArticle {
191    /// Create a new related article reference
192    pub fn new(
193        article_id: Uuid,
194        article_number: impl Into<String>,
195        title: impl Into<String>,
196        relationship: ArticleRelationship,
197    ) -> Self {
198        Self {
199            article_id,
200            article_number: article_number.into(),
201            title: title.into(),
202            relationship,
203        }
204    }
205}
206
207/// Custom deserializer for knowledge article number that supports both:
208/// - Legacy string format: "KB-0001"
209/// - New numeric format: 1 or 2601101234 (timestamp)
210fn deserialize_knowledge_number<'de, D>(deserializer: D) -> Result<u64, D::Error>
211where
212    D: serde::Deserializer<'de>,
213{
214    use serde::de::{self, Visitor};
215
216    struct NumberVisitor;
217
218    impl<'de> Visitor<'de> for NumberVisitor {
219        type Value = u64;
220
221        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
222            formatter.write_str("a number or a string like 'KB-0001'")
223        }
224
225        fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
226        where
227            E: de::Error,
228        {
229            Ok(value)
230        }
231
232        fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
233        where
234            E: de::Error,
235        {
236            if value >= 0 {
237                Ok(value as u64)
238            } else {
239                Err(E::custom("negative numbers are not allowed"))
240            }
241        }
242
243        fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
244        where
245            E: de::Error,
246        {
247            // Handle "KB-0001" format
248            let num_str = value
249                .to_uppercase()
250                .strip_prefix("KB-")
251                .map(|s| s.to_string())
252                .unwrap_or_else(|| value.to_string());
253
254            num_str
255                .parse::<u64>()
256                .map_err(|_| E::custom(format!("invalid knowledge number format: {}", value)))
257        }
258    }
259
260    deserializer.deserialize_any(NumberVisitor)
261}
262
263/// Knowledge Base Article
264///
265/// Represents a knowledge article that can be categorized by domain,
266/// type, and audience.
267#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
268#[serde(rename_all = "camelCase")]
269pub struct KnowledgeArticle {
270    /// Unique identifier for the article
271    pub id: Uuid,
272    /// Article number - can be sequential (1, 2, 3) or timestamp-based (YYMMDDHHmm format)
273    /// Timestamp format prevents merge conflicts in distributed Git workflows
274    #[serde(deserialize_with = "deserialize_knowledge_number")]
275    pub number: u64,
276    /// Article title
277    pub title: String,
278    /// Type of article
279    pub article_type: KnowledgeType,
280    /// Publication status
281    pub status: KnowledgeStatus,
282    /// Domain this article belongs to (optional, string name)
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub domain: Option<String>,
285    /// Domain UUID reference (optional)
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub domain_id: Option<Uuid>,
288    /// Workspace UUID reference (optional)
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub workspace_id: Option<Uuid>,
291
292    // Content
293    /// Brief summary of the article
294    pub summary: String,
295    /// Full article content in Markdown
296    pub content: String,
297
298    // Authorship
299    /// Article authors (emails or names) - changed from single author to array
300    #[serde(default, skip_serializing_if = "Vec::is_empty")]
301    pub authors: Vec<String>,
302    /// List of reviewers
303    #[serde(default, skip_serializing_if = "Vec::is_empty")]
304    pub reviewers: Vec<String>,
305    /// Date of last review (legacy field name)
306    #[serde(skip_serializing_if = "Option::is_none")]
307    pub last_reviewed: Option<DateTime<Utc>>,
308    /// Last review timestamp (camelCase alias)
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub reviewed_at: Option<DateTime<Utc>>,
311    /// When the article was published
312    #[serde(skip_serializing_if = "Option::is_none")]
313    pub published_at: Option<DateTime<Utc>>,
314    /// When the article was archived
315    #[serde(skip_serializing_if = "Option::is_none")]
316    pub archived_at: Option<DateTime<Utc>>,
317    /// How often the article should be reviewed
318    #[serde(skip_serializing_if = "Option::is_none")]
319    pub review_frequency: Option<ReviewFrequency>,
320
321    // Classification
322    /// Target audience for the article
323    #[serde(default, skip_serializing_if = "Vec::is_empty")]
324    pub audience: Vec<String>,
325    /// Required skill level
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub skill_level: Option<SkillLevel>,
328
329    // Linking
330    /// Assets referenced by this article
331    #[serde(default, skip_serializing_if = "Vec::is_empty")]
332    pub linked_assets: Vec<AssetLink>,
333    /// UUIDs of related decisions (legacy field)
334    #[serde(default, skip_serializing_if = "Vec::is_empty")]
335    pub linked_decisions: Vec<Uuid>,
336    /// IDs of related decision records
337    #[serde(default, skip_serializing_if = "Vec::is_empty")]
338    pub related_decisions: Vec<Uuid>,
339    /// Related articles (detailed info)
340    #[serde(default, skip_serializing_if = "Vec::is_empty")]
341    pub related_articles: Vec<RelatedArticle>,
342    /// IDs of prerequisite articles (must read first)
343    #[serde(default, skip_serializing_if = "Vec::is_empty")]
344    pub prerequisites: Vec<Uuid>,
345    /// IDs of 'See Also' articles for further reading
346    #[serde(default, skip_serializing_if = "Vec::is_empty")]
347    pub see_also: Vec<Uuid>,
348
349    // Standard metadata
350    /// Tags for categorization
351    #[serde(default, skip_serializing_if = "Vec::is_empty")]
352    pub tags: Vec<Tag>,
353    /// Additional notes
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub notes: Option<String>,
356
357    /// Creation timestamp
358    pub created_at: DateTime<Utc>,
359    /// Last modification timestamp
360    pub updated_at: DateTime<Utc>,
361}
362
363impl KnowledgeArticle {
364    /// Create a new knowledge article with required fields
365    pub fn new(
366        number: u64,
367        title: impl Into<String>,
368        summary: impl Into<String>,
369        content: impl Into<String>,
370        author: impl Into<String>,
371    ) -> Self {
372        let now = Utc::now();
373        Self {
374            id: Self::generate_id(number),
375            number,
376            title: title.into(),
377            article_type: KnowledgeType::Guide,
378            status: KnowledgeStatus::Draft,
379            domain: None,
380            domain_id: None,
381            workspace_id: None,
382            summary: summary.into(),
383            content: content.into(),
384            authors: vec![author.into()],
385            reviewers: Vec::new(),
386            last_reviewed: None,
387            reviewed_at: None,
388            published_at: None,
389            archived_at: None,
390            review_frequency: None,
391            audience: Vec::new(),
392            skill_level: None,
393            linked_assets: Vec::new(),
394            linked_decisions: Vec::new(),
395            related_decisions: Vec::new(),
396            related_articles: Vec::new(),
397            prerequisites: Vec::new(),
398            see_also: Vec::new(),
399            tags: Vec::new(),
400            notes: None,
401            created_at: now,
402            updated_at: now,
403        }
404    }
405
406    /// Create a new knowledge article with a timestamp-based number (YYMMDDHHmm format)
407    /// This format prevents merge conflicts in distributed Git workflows
408    pub fn new_with_timestamp(
409        title: impl Into<String>,
410        summary: impl Into<String>,
411        content: impl Into<String>,
412        author: impl Into<String>,
413    ) -> Self {
414        let now = Utc::now();
415        let number = Self::generate_timestamp_number(&now);
416        Self::new(number, title, summary, content, author)
417    }
418
419    /// Generate a timestamp-based article number in YYMMDDHHmm format
420    pub fn generate_timestamp_number(dt: &DateTime<Utc>) -> u64 {
421        let formatted = dt.format("%y%m%d%H%M").to_string();
422        formatted.parse().unwrap_or(0)
423    }
424
425    /// Generate a deterministic UUID for an article based on its number
426    pub fn generate_id(number: u64) -> Uuid {
427        // Use UUID v5 with a namespace for knowledge articles
428        let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(); // URL namespace
429        let name = format!("knowledge:{}", number);
430        Uuid::new_v5(&namespace, name.as_bytes())
431    }
432
433    /// Check if the article number is timestamp-based (YYMMDDHHmm format - 10 digits)
434    pub fn is_timestamp_number(&self) -> bool {
435        self.number >= 1000000000 && self.number <= 9999999999
436    }
437
438    /// Format the article number for display
439    /// Returns "KB-0001" for sequential or "KB-2601101234" for timestamp-based
440    pub fn formatted_number(&self) -> String {
441        if self.is_timestamp_number() {
442            format!("KB-{}", self.number)
443        } else {
444            format!("KB-{:04}", self.number)
445        }
446    }
447
448    /// Add an author
449    pub fn add_author(mut self, author: impl Into<String>) -> Self {
450        self.authors.push(author.into());
451        self.updated_at = Utc::now();
452        self
453    }
454
455    /// Set the domain ID
456    pub fn with_domain_id(mut self, domain_id: Uuid) -> Self {
457        self.domain_id = Some(domain_id);
458        self.updated_at = Utc::now();
459        self
460    }
461
462    /// Set the workspace ID
463    pub fn with_workspace_id(mut self, workspace_id: Uuid) -> Self {
464        self.workspace_id = Some(workspace_id);
465        self.updated_at = Utc::now();
466        self
467    }
468
469    /// Set the article type
470    pub fn with_type(mut self, article_type: KnowledgeType) -> Self {
471        self.article_type = article_type;
472        self.updated_at = Utc::now();
473        self
474    }
475
476    /// Set the article status
477    pub fn with_status(mut self, status: KnowledgeStatus) -> Self {
478        self.status = status;
479        self.updated_at = Utc::now();
480        self
481    }
482
483    /// Set the domain
484    pub fn with_domain(mut self, domain: impl Into<String>) -> Self {
485        self.domain = Some(domain.into());
486        self.updated_at = Utc::now();
487        self
488    }
489
490    /// Add a reviewer
491    pub fn add_reviewer(mut self, reviewer: impl Into<String>) -> Self {
492        self.reviewers.push(reviewer.into());
493        self.updated_at = Utc::now();
494        self
495    }
496
497    /// Set review frequency
498    pub fn with_review_frequency(mut self, frequency: ReviewFrequency) -> Self {
499        self.review_frequency = Some(frequency);
500        self.updated_at = Utc::now();
501        self
502    }
503
504    /// Add an audience
505    pub fn add_audience(mut self, audience: impl Into<String>) -> Self {
506        self.audience.push(audience.into());
507        self.updated_at = Utc::now();
508        self
509    }
510
511    /// Set skill level
512    pub fn with_skill_level(mut self, level: SkillLevel) -> Self {
513        self.skill_level = Some(level);
514        self.updated_at = Utc::now();
515        self
516    }
517
518    /// Add an asset link
519    pub fn add_asset_link(mut self, link: AssetLink) -> Self {
520        self.linked_assets.push(link);
521        self.updated_at = Utc::now();
522        self
523    }
524
525    /// Link to a decision
526    pub fn link_decision(mut self, decision_id: Uuid) -> Self {
527        if !self.linked_decisions.contains(&decision_id) {
528            self.linked_decisions.push(decision_id);
529            self.updated_at = Utc::now();
530        }
531        self
532    }
533
534    /// Add a related article
535    pub fn add_related_article(mut self, article: RelatedArticle) -> Self {
536        self.related_articles.push(article);
537        self.updated_at = Utc::now();
538        self
539    }
540
541    /// Add a related decision
542    pub fn add_related_decision(mut self, decision_id: Uuid) -> Self {
543        if !self.related_decisions.contains(&decision_id) {
544            self.related_decisions.push(decision_id);
545            self.updated_at = Utc::now();
546        }
547        self
548    }
549
550    /// Add a prerequisite article
551    pub fn add_prerequisite(mut self, article_id: Uuid) -> Self {
552        if !self.prerequisites.contains(&article_id) {
553            self.prerequisites.push(article_id);
554            self.updated_at = Utc::now();
555        }
556        self
557    }
558
559    /// Add a "see also" article reference
560    pub fn add_see_also(mut self, article_id: Uuid) -> Self {
561        if !self.see_also.contains(&article_id) {
562            self.see_also.push(article_id);
563            self.updated_at = Utc::now();
564        }
565        self
566    }
567
568    /// Set the published timestamp
569    pub fn with_published_at(mut self, published_at: DateTime<Utc>) -> Self {
570        self.published_at = Some(published_at);
571        self.updated_at = Utc::now();
572        self
573    }
574
575    /// Set the archived timestamp
576    pub fn with_archived_at(mut self, archived_at: DateTime<Utc>) -> Self {
577        self.archived_at = Some(archived_at);
578        self.updated_at = Utc::now();
579        self
580    }
581
582    /// Add a tag
583    pub fn add_tag(mut self, tag: Tag) -> Self {
584        self.tags.push(tag);
585        self.updated_at = Utc::now();
586        self
587    }
588
589    /// Mark the article as reviewed
590    pub fn mark_reviewed(&mut self) {
591        let now = Utc::now();
592        self.last_reviewed = Some(now);
593        self.reviewed_at = Some(now);
594        self.updated_at = now;
595    }
596
597    /// Generate the YAML filename for this article
598    pub fn filename(&self, workspace_name: &str) -> String {
599        let number_str = if self.is_timestamp_number() {
600            format!("{}", self.number)
601        } else {
602            format!("{:04}", self.number)
603        };
604
605        match &self.domain {
606            Some(domain) => format!(
607                "{}_{}_kb-{}.kb.yaml",
608                sanitize_name(workspace_name),
609                sanitize_name(domain),
610                number_str
611            ),
612            None => format!(
613                "{}_kb-{}.kb.yaml",
614                sanitize_name(workspace_name),
615                number_str
616            ),
617        }
618    }
619
620    /// Generate the Markdown filename for this article
621    pub fn markdown_filename(&self) -> String {
622        let slug = slugify(&self.title);
623        format!("{}-{}.md", self.formatted_number(), slug)
624    }
625
626    /// Import from YAML
627    pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
628        serde_yaml::from_str(yaml_content)
629    }
630
631    /// Export to YAML
632    pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
633        serde_yaml::to_string(self)
634    }
635}
636
637/// Knowledge article index entry for the knowledge.yaml file
638#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
639#[serde(rename_all = "camelCase")]
640pub struct KnowledgeIndexEntry {
641    /// Article number (can be sequential or timestamp-based)
642    pub number: u64,
643    /// Article UUID
644    pub id: Uuid,
645    /// Article title
646    pub title: String,
647    /// Article type
648    pub article_type: KnowledgeType,
649    /// Article status
650    pub status: KnowledgeStatus,
651    /// Domain (if applicable)
652    #[serde(skip_serializing_if = "Option::is_none")]
653    pub domain: Option<String>,
654    /// Filename of the article YAML file
655    pub file: String,
656}
657
658impl From<&KnowledgeArticle> for KnowledgeIndexEntry {
659    fn from(article: &KnowledgeArticle) -> Self {
660        Self {
661            number: article.number,
662            id: article.id,
663            title: article.title.clone(),
664            article_type: article.article_type.clone(),
665            status: article.status.clone(),
666            domain: article.domain.clone(),
667            file: String::new(), // Set by caller
668        }
669    }
670}
671
672/// Knowledge base index (knowledge.yaml)
673#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
674#[serde(rename_all = "camelCase")]
675pub struct KnowledgeIndex {
676    /// Schema version
677    pub schema_version: String,
678    /// Last update timestamp
679    #[serde(skip_serializing_if = "Option::is_none")]
680    pub last_updated: Option<DateTime<Utc>>,
681    /// List of articles
682    #[serde(default)]
683    pub articles: Vec<KnowledgeIndexEntry>,
684    /// Next available article number (for sequential numbering)
685    pub next_number: u64,
686    /// Whether to use timestamp-based numbering (YYMMDDHHmm format)
687    #[serde(default)]
688    pub use_timestamp_numbering: bool,
689}
690
691impl Default for KnowledgeIndex {
692    fn default() -> Self {
693        Self::new()
694    }
695}
696
697impl KnowledgeIndex {
698    /// Create a new empty knowledge index
699    pub fn new() -> Self {
700        Self {
701            schema_version: "1.0".to_string(),
702            last_updated: Some(Utc::now()),
703            articles: Vec::new(),
704            next_number: 1,
705            use_timestamp_numbering: false,
706        }
707    }
708
709    /// Create a new knowledge index with timestamp-based numbering
710    pub fn new_with_timestamp_numbering() -> Self {
711        Self {
712            schema_version: "1.0".to_string(),
713            last_updated: Some(Utc::now()),
714            articles: Vec::new(),
715            next_number: 1,
716            use_timestamp_numbering: true,
717        }
718    }
719
720    /// Add an article to the index
721    pub fn add_article(&mut self, article: &KnowledgeArticle, filename: String) {
722        let mut entry = KnowledgeIndexEntry::from(article);
723        entry.file = filename;
724
725        // Remove existing entry with same number if present
726        self.articles.retain(|a| a.number != article.number);
727        self.articles.push(entry);
728
729        // Sort by number
730        self.articles.sort_by(|a, b| a.number.cmp(&b.number));
731
732        // Update next number only for sequential numbering
733        if !self.use_timestamp_numbering && article.number >= self.next_number {
734            self.next_number = article.number + 1;
735        }
736
737        self.last_updated = Some(Utc::now());
738    }
739
740    /// Get the next available article number
741    /// For timestamp-based numbering, generates a new timestamp
742    /// For sequential numbering, returns the next sequential number
743    pub fn get_next_number(&self) -> u64 {
744        if self.use_timestamp_numbering {
745            KnowledgeArticle::generate_timestamp_number(&Utc::now())
746        } else {
747            self.next_number
748        }
749    }
750
751    /// Find an article by number
752    pub fn find_by_number(&self, number: u64) -> Option<&KnowledgeIndexEntry> {
753        self.articles.iter().find(|a| a.number == number)
754    }
755
756    /// Import from YAML
757    pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
758        serde_yaml::from_str(yaml_content)
759    }
760
761    /// Export to YAML
762    pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
763        serde_yaml::to_string(self)
764    }
765}
766
767/// Sanitize a name for use in filenames
768fn sanitize_name(name: &str) -> String {
769    name.chars()
770        .map(|c| match c {
771            ' ' | '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
772            _ => c,
773        })
774        .collect::<String>()
775        .to_lowercase()
776}
777
778/// Create a URL-friendly slug from a title
779fn slugify(title: &str) -> String {
780    title
781        .to_lowercase()
782        .chars()
783        .map(|c| if c.is_alphanumeric() { c } else { '-' })
784        .collect::<String>()
785        .split('-')
786        .filter(|s| !s.is_empty())
787        .collect::<Vec<_>>()
788        .join("-")
789        .chars()
790        .take(50) // Limit slug length
791        .collect()
792}
793
794#[cfg(test)]
795mod tests {
796    use super::*;
797
798    #[test]
799    fn test_knowledge_article_new() {
800        let article = KnowledgeArticle::new(
801            1,
802            "Data Classification Guide",
803            "This guide explains classification",
804            "## Overview\n\nData classification is important...",
805            "data-governance@example.com",
806        );
807
808        assert_eq!(article.number, 1);
809        assert_eq!(article.formatted_number(), "KB-0001");
810        assert_eq!(article.title, "Data Classification Guide");
811        assert_eq!(article.status, KnowledgeStatus::Draft);
812        assert_eq!(article.article_type, KnowledgeType::Guide);
813        assert_eq!(article.authors.len(), 1);
814    }
815
816    #[test]
817    fn test_knowledge_article_builder_pattern() {
818        let article = KnowledgeArticle::new(1, "Test", "Summary", "Content", "author@example.com")
819            .with_type(KnowledgeType::Standard)
820            .with_status(KnowledgeStatus::Published)
821            .with_domain("sales")
822            .add_reviewer("reviewer@example.com")
823            .with_review_frequency(ReviewFrequency::Quarterly)
824            .add_audience("data-engineers")
825            .with_skill_level(SkillLevel::Intermediate);
826
827        assert_eq!(article.article_type, KnowledgeType::Standard);
828        assert_eq!(article.status, KnowledgeStatus::Published);
829        assert_eq!(article.domain, Some("sales".to_string()));
830        assert_eq!(article.reviewers.len(), 1);
831        assert_eq!(article.review_frequency, Some(ReviewFrequency::Quarterly));
832        assert_eq!(article.audience.len(), 1);
833        assert_eq!(article.skill_level, Some(SkillLevel::Intermediate));
834    }
835
836    #[test]
837    fn test_knowledge_article_id_generation() {
838        let id1 = KnowledgeArticle::generate_id(1);
839        let id2 = KnowledgeArticle::generate_id(1);
840        let id3 = KnowledgeArticle::generate_id(2);
841
842        // Same number should generate same ID
843        assert_eq!(id1, id2);
844        // Different numbers should generate different IDs
845        assert_ne!(id1, id3);
846    }
847
848    #[test]
849    fn test_knowledge_article_filename() {
850        let article = KnowledgeArticle::new(1, "Test", "Summary", "Content", "author@example.com");
851        assert_eq!(article.filename("enterprise"), "enterprise_kb-0001.kb.yaml");
852
853        let article_with_domain = article.with_domain("sales");
854        assert_eq!(
855            article_with_domain.filename("enterprise"),
856            "enterprise_sales_kb-0001.kb.yaml"
857        );
858    }
859
860    #[test]
861    fn test_knowledge_article_markdown_filename() {
862        let article = KnowledgeArticle::new(
863            1,
864            "Data Classification Guide",
865            "Summary",
866            "Content",
867            "author@example.com",
868        );
869        let filename = article.markdown_filename();
870        assert!(filename.starts_with("KB-0001-"));
871        assert!(filename.ends_with(".md"));
872    }
873
874    #[test]
875    fn test_knowledge_article_yaml_roundtrip() {
876        let article = KnowledgeArticle::new(
877            1,
878            "Test Article",
879            "Test summary",
880            "Test content",
881            "author@example.com",
882        )
883        .with_status(KnowledgeStatus::Published)
884        .with_domain("test");
885
886        let yaml = article.to_yaml().unwrap();
887        let parsed = KnowledgeArticle::from_yaml(&yaml).unwrap();
888
889        assert_eq!(article.id, parsed.id);
890        assert_eq!(article.title, parsed.title);
891        assert_eq!(article.status, parsed.status);
892        assert_eq!(article.domain, parsed.domain);
893    }
894
895    #[test]
896    fn test_knowledge_index() {
897        let mut index = KnowledgeIndex::new();
898        assert_eq!(index.get_next_number(), 1);
899
900        let article1 =
901            KnowledgeArticle::new(1, "First", "Summary", "Content", "author@example.com");
902        index.add_article(&article1, "test_kb-0001.kb.yaml".to_string());
903
904        assert_eq!(index.articles.len(), 1);
905        assert_eq!(index.get_next_number(), 2);
906
907        let article2 =
908            KnowledgeArticle::new(2, "Second", "Summary", "Content", "author@example.com");
909        index.add_article(&article2, "test_kb-0002.kb.yaml".to_string());
910
911        assert_eq!(index.articles.len(), 2);
912        assert_eq!(index.get_next_number(), 3);
913    }
914
915    #[test]
916    fn test_related_article() {
917        let related = RelatedArticle::new(
918            Uuid::new_v4(),
919            "KB-0002",
920            "PII Handling",
921            ArticleRelationship::Related,
922        );
923
924        assert_eq!(related.article_number, "KB-0002");
925        assert_eq!(related.relationship, ArticleRelationship::Related);
926    }
927
928    #[test]
929    fn test_knowledge_type_display() {
930        assert_eq!(format!("{}", KnowledgeType::Guide), "Guide");
931        assert_eq!(format!("{}", KnowledgeType::Standard), "Standard");
932        assert_eq!(format!("{}", KnowledgeType::HowTo), "How-To");
933        assert_eq!(format!("{}", KnowledgeType::Concept), "Concept");
934        assert_eq!(format!("{}", KnowledgeType::Runbook), "Runbook");
935    }
936
937    #[test]
938    fn test_knowledge_status_display() {
939        assert_eq!(format!("{}", KnowledgeStatus::Draft), "Draft");
940        assert_eq!(format!("{}", KnowledgeStatus::Review), "Review");
941        assert_eq!(format!("{}", KnowledgeStatus::Published), "Published");
942        assert_eq!(format!("{}", KnowledgeStatus::Archived), "Archived");
943    }
944
945    #[test]
946    fn test_timestamp_number_generation() {
947        use chrono::TimeZone;
948        let dt = Utc.with_ymd_and_hms(2026, 1, 10, 14, 30, 0).unwrap();
949        let number = KnowledgeArticle::generate_timestamp_number(&dt);
950        assert_eq!(number, 2601101430);
951    }
952
953    #[test]
954    fn test_is_timestamp_number() {
955        let sequential_article =
956            KnowledgeArticle::new(1, "Test", "Summary", "Content", "author@example.com");
957        assert!(!sequential_article.is_timestamp_number());
958
959        let timestamp_article = KnowledgeArticle::new(
960            2601101430,
961            "Test",
962            "Summary",
963            "Content",
964            "author@example.com",
965        );
966        assert!(timestamp_article.is_timestamp_number());
967    }
968
969    #[test]
970    fn test_timestamp_article_filename() {
971        let article = KnowledgeArticle::new(
972            2601101430,
973            "Test",
974            "Summary",
975            "Content",
976            "author@example.com",
977        );
978        assert_eq!(
979            article.filename("enterprise"),
980            "enterprise_kb-2601101430.kb.yaml"
981        );
982    }
983
984    #[test]
985    fn test_timestamp_article_markdown_filename() {
986        let article = KnowledgeArticle::new(
987            2601101430,
988            "Test Article",
989            "Summary",
990            "Content",
991            "author@example.com",
992        );
993        let filename = article.markdown_filename();
994        assert!(filename.starts_with("KB-2601101430-"));
995        assert!(filename.ends_with(".md"));
996    }
997
998    #[test]
999    fn test_article_with_multiple_authors() {
1000        let article = KnowledgeArticle::new(1, "Test", "Summary", "Content", "author1@example.com")
1001            .add_author("author2@example.com")
1002            .add_author("author3@example.com");
1003
1004        assert_eq!(article.authors.len(), 3);
1005    }
1006
1007    #[test]
1008    fn test_knowledge_index_with_timestamp_numbering() {
1009        let index = KnowledgeIndex::new_with_timestamp_numbering();
1010        assert!(index.use_timestamp_numbering);
1011
1012        // The next number should be a timestamp
1013        let next = index.get_next_number();
1014        assert!(next >= 1000000000); // Timestamp format check
1015    }
1016}