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