Skip to main content

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