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 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    /// UUIDs of related sketches
369    #[serde(
370        default,
371        skip_serializing_if = "Vec::is_empty",
372        alias = "linked_sketches"
373    )]
374    pub linked_sketches: Vec<Uuid>,
375
376    // Standard metadata
377    /// Tags for categorization
378    #[serde(default, skip_serializing_if = "Vec::is_empty")]
379    pub tags: Vec<Tag>,
380    /// Additional notes
381    #[serde(skip_serializing_if = "Option::is_none")]
382    pub notes: Option<String>,
383
384    /// Creation timestamp
385    #[serde(alias = "created_at")]
386    pub created_at: DateTime<Utc>,
387    /// Last modification timestamp
388    #[serde(alias = "updated_at")]
389    pub updated_at: DateTime<Utc>,
390}
391
392impl KnowledgeArticle {
393    /// Create a new knowledge article with required fields
394    pub fn new(
395        number: u64,
396        title: impl Into<String>,
397        summary: impl Into<String>,
398        content: impl Into<String>,
399        author: impl Into<String>,
400    ) -> Self {
401        let now = Utc::now();
402        Self {
403            id: Self::generate_id(number),
404            number,
405            title: title.into(),
406            article_type: KnowledgeType::Guide,
407            status: KnowledgeStatus::Draft,
408            domain: None,
409            domain_id: None,
410            workspace_id: None,
411            summary: summary.into(),
412            content: content.into(),
413            authors: vec![author.into()],
414            reviewers: Vec::new(),
415            last_reviewed: None,
416            reviewed_at: None,
417            published_at: None,
418            archived_at: None,
419            review_frequency: None,
420            audience: Vec::new(),
421            skill_level: None,
422            linked_assets: Vec::new(),
423            linked_decisions: Vec::new(),
424            related_decisions: Vec::new(),
425            related_articles: Vec::new(),
426            prerequisites: Vec::new(),
427            see_also: Vec::new(),
428            linked_sketches: Vec::new(),
429            tags: Vec::new(),
430            notes: None,
431            created_at: now,
432            updated_at: now,
433        }
434    }
435
436    /// Create a new knowledge article with a timestamp-based number (YYMMDDHHmm format)
437    /// This format prevents merge conflicts in distributed Git workflows
438    pub fn new_with_timestamp(
439        title: impl Into<String>,
440        summary: impl Into<String>,
441        content: impl Into<String>,
442        author: impl Into<String>,
443    ) -> Self {
444        let now = Utc::now();
445        let number = Self::generate_timestamp_number(&now);
446        Self::new(number, title, summary, content, author)
447    }
448
449    /// Generate a timestamp-based article number in YYMMDDHHmm format
450    pub fn generate_timestamp_number(dt: &DateTime<Utc>) -> u64 {
451        let formatted = dt.format("%y%m%d%H%M").to_string();
452        formatted.parse().unwrap_or(0)
453    }
454
455    /// Generate a deterministic UUID for an article based on its number
456    pub fn generate_id(number: u64) -> Uuid {
457        // Use UUID v5 with a namespace for knowledge articles
458        let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(); // URL namespace
459        let name = format!("knowledge:{}", number);
460        Uuid::new_v5(&namespace, name.as_bytes())
461    }
462
463    /// Check if the article number is timestamp-based (YYMMDDHHmm format - 10 digits)
464    pub fn is_timestamp_number(&self) -> bool {
465        self.number >= 1000000000 && self.number <= 9999999999
466    }
467
468    /// Format the article number for display
469    /// Returns "KB-0001" for sequential or "KB-2601101234" for timestamp-based
470    pub fn formatted_number(&self) -> String {
471        if self.is_timestamp_number() {
472            format!("KB-{}", self.number)
473        } else {
474            format!("KB-{:04}", self.number)
475        }
476    }
477
478    /// Add an author
479    pub fn add_author(mut self, author: impl Into<String>) -> Self {
480        self.authors.push(author.into());
481        self.updated_at = Utc::now();
482        self
483    }
484
485    /// Set the domain ID
486    pub fn with_domain_id(mut self, domain_id: Uuid) -> Self {
487        self.domain_id = Some(domain_id);
488        self.updated_at = Utc::now();
489        self
490    }
491
492    /// Set the workspace ID
493    pub fn with_workspace_id(mut self, workspace_id: Uuid) -> Self {
494        self.workspace_id = Some(workspace_id);
495        self.updated_at = Utc::now();
496        self
497    }
498
499    /// Set the article type
500    pub fn with_type(mut self, article_type: KnowledgeType) -> Self {
501        self.article_type = article_type;
502        self.updated_at = Utc::now();
503        self
504    }
505
506    /// Set the article status
507    pub fn with_status(mut self, status: KnowledgeStatus) -> Self {
508        self.status = status;
509        self.updated_at = Utc::now();
510        self
511    }
512
513    /// Set the domain
514    pub fn with_domain(mut self, domain: impl Into<String>) -> Self {
515        self.domain = Some(domain.into());
516        self.updated_at = Utc::now();
517        self
518    }
519
520    /// Add a reviewer
521    pub fn add_reviewer(mut self, reviewer: impl Into<String>) -> Self {
522        self.reviewers.push(reviewer.into());
523        self.updated_at = Utc::now();
524        self
525    }
526
527    /// Set review frequency
528    pub fn with_review_frequency(mut self, frequency: ReviewFrequency) -> Self {
529        self.review_frequency = Some(frequency);
530        self.updated_at = Utc::now();
531        self
532    }
533
534    /// Add an audience
535    pub fn add_audience(mut self, audience: impl Into<String>) -> Self {
536        self.audience.push(audience.into());
537        self.updated_at = Utc::now();
538        self
539    }
540
541    /// Set skill level
542    pub fn with_skill_level(mut self, level: SkillLevel) -> Self {
543        self.skill_level = Some(level);
544        self.updated_at = Utc::now();
545        self
546    }
547
548    /// Add an asset link
549    pub fn add_asset_link(mut self, link: AssetLink) -> Self {
550        self.linked_assets.push(link);
551        self.updated_at = Utc::now();
552        self
553    }
554
555    /// Link to a decision
556    pub fn link_decision(mut self, decision_id: Uuid) -> Self {
557        if !self.linked_decisions.contains(&decision_id) {
558            self.linked_decisions.push(decision_id);
559            self.updated_at = Utc::now();
560        }
561        self
562    }
563
564    /// Add a related article
565    pub fn add_related_article(mut self, article: RelatedArticle) -> Self {
566        self.related_articles.push(article);
567        self.updated_at = Utc::now();
568        self
569    }
570
571    /// Add a related decision
572    pub fn add_related_decision(mut self, decision_id: Uuid) -> Self {
573        if !self.related_decisions.contains(&decision_id) {
574            self.related_decisions.push(decision_id);
575            self.updated_at = Utc::now();
576        }
577        self
578    }
579
580    /// Add a prerequisite article
581    pub fn add_prerequisite(mut self, article_id: Uuid) -> Self {
582        if !self.prerequisites.contains(&article_id) {
583            self.prerequisites.push(article_id);
584            self.updated_at = Utc::now();
585        }
586        self
587    }
588
589    /// Add a "see also" article reference
590    pub fn add_see_also(mut self, article_id: Uuid) -> Self {
591        if !self.see_also.contains(&article_id) {
592            self.see_also.push(article_id);
593            self.updated_at = Utc::now();
594        }
595        self
596    }
597
598    /// Link to a sketch
599    pub fn link_sketch(mut self, sketch_id: Uuid) -> Self {
600        if !self.linked_sketches.contains(&sketch_id) {
601            self.linked_sketches.push(sketch_id);
602            self.updated_at = Utc::now();
603        }
604        self
605    }
606
607    /// Set the published timestamp
608    pub fn with_published_at(mut self, published_at: DateTime<Utc>) -> Self {
609        self.published_at = Some(published_at);
610        self.updated_at = Utc::now();
611        self
612    }
613
614    /// Set the archived timestamp
615    pub fn with_archived_at(mut self, archived_at: DateTime<Utc>) -> Self {
616        self.archived_at = Some(archived_at);
617        self.updated_at = Utc::now();
618        self
619    }
620
621    /// Add a tag
622    pub fn add_tag(mut self, tag: Tag) -> Self {
623        self.tags.push(tag);
624        self.updated_at = Utc::now();
625        self
626    }
627
628    /// Mark the article as reviewed
629    pub fn mark_reviewed(&mut self) {
630        let now = Utc::now();
631        self.last_reviewed = Some(now);
632        self.reviewed_at = Some(now);
633        self.updated_at = now;
634    }
635
636    /// Generate the YAML filename for this article
637    pub fn filename(&self, workspace_name: &str) -> String {
638        let number_str = if self.is_timestamp_number() {
639            format!("{}", self.number)
640        } else {
641            format!("{:04}", self.number)
642        };
643
644        match &self.domain {
645            Some(domain) => format!(
646                "{}_{}_kb-{}.kb.yaml",
647                sanitize_name(workspace_name),
648                sanitize_name(domain),
649                number_str
650            ),
651            None => format!(
652                "{}_kb-{}.kb.yaml",
653                sanitize_name(workspace_name),
654                number_str
655            ),
656        }
657    }
658
659    /// Generate the Markdown filename for this article
660    pub fn markdown_filename(&self) -> String {
661        let slug = slugify(&self.title);
662        format!("{}-{}.md", self.formatted_number(), slug)
663    }
664
665    /// Import from YAML
666    pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
667        serde_yaml::from_str(yaml_content)
668    }
669
670    /// Export to YAML
671    pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
672        serde_yaml::to_string(self)
673    }
674}
675
676/// Knowledge article index entry for the knowledge.yaml file
677#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
678#[serde(rename_all = "camelCase")]
679pub struct KnowledgeIndexEntry {
680    /// Article number (can be sequential or timestamp-based)
681    pub number: u64,
682    /// Article UUID
683    pub id: Uuid,
684    /// Article title
685    pub title: String,
686    /// Article type
687    #[serde(alias = "article_type")]
688    pub article_type: KnowledgeType,
689    /// Article status
690    pub status: KnowledgeStatus,
691    /// Domain (if applicable)
692    #[serde(skip_serializing_if = "Option::is_none")]
693    pub domain: Option<String>,
694    /// Filename of the article YAML file
695    pub file: String,
696}
697
698impl From<&KnowledgeArticle> for KnowledgeIndexEntry {
699    fn from(article: &KnowledgeArticle) -> Self {
700        Self {
701            number: article.number,
702            id: article.id,
703            title: article.title.clone(),
704            article_type: article.article_type.clone(),
705            status: article.status.clone(),
706            domain: article.domain.clone(),
707            file: String::new(), // Set by caller
708        }
709    }
710}
711
712/// Knowledge base index (knowledge.yaml)
713#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
714#[serde(rename_all = "camelCase")]
715pub struct KnowledgeIndex {
716    /// Schema version
717    #[serde(alias = "schema_version")]
718    pub schema_version: String,
719    /// Last update timestamp
720    #[serde(skip_serializing_if = "Option::is_none", alias = "last_updated")]
721    pub last_updated: Option<DateTime<Utc>>,
722    /// List of articles
723    #[serde(default)]
724    pub articles: Vec<KnowledgeIndexEntry>,
725    /// Next available article number (for sequential numbering)
726    #[serde(alias = "next_number")]
727    pub next_number: u64,
728    /// Whether to use timestamp-based numbering (YYMMDDHHmm format)
729    #[serde(default, alias = "use_timestamp_numbering")]
730    pub use_timestamp_numbering: bool,
731}
732
733impl Default for KnowledgeIndex {
734    fn default() -> Self {
735        Self::new()
736    }
737}
738
739impl KnowledgeIndex {
740    /// Create a new empty knowledge index
741    pub fn new() -> Self {
742        Self {
743            schema_version: "1.0".to_string(),
744            last_updated: Some(Utc::now()),
745            articles: Vec::new(),
746            next_number: 1,
747            use_timestamp_numbering: false,
748        }
749    }
750
751    /// Create a new knowledge index with timestamp-based numbering
752    pub fn new_with_timestamp_numbering() -> Self {
753        Self {
754            schema_version: "1.0".to_string(),
755            last_updated: Some(Utc::now()),
756            articles: Vec::new(),
757            next_number: 1,
758            use_timestamp_numbering: true,
759        }
760    }
761
762    /// Add an article to the index
763    pub fn add_article(&mut self, article: &KnowledgeArticle, filename: String) {
764        let mut entry = KnowledgeIndexEntry::from(article);
765        entry.file = filename;
766
767        // Remove existing entry with same number if present
768        self.articles.retain(|a| a.number != article.number);
769        self.articles.push(entry);
770
771        // Sort by number
772        self.articles.sort_by(|a, b| a.number.cmp(&b.number));
773
774        // Update next number only for sequential numbering
775        if !self.use_timestamp_numbering && article.number >= self.next_number {
776            self.next_number = article.number + 1;
777        }
778
779        self.last_updated = Some(Utc::now());
780    }
781
782    /// Get the next available article number
783    /// For timestamp-based numbering, generates a new timestamp
784    /// For sequential numbering, returns the next sequential number
785    pub fn get_next_number(&self) -> u64 {
786        if self.use_timestamp_numbering {
787            KnowledgeArticle::generate_timestamp_number(&Utc::now())
788        } else {
789            self.next_number
790        }
791    }
792
793    /// Find an article by number
794    pub fn find_by_number(&self, number: u64) -> Option<&KnowledgeIndexEntry> {
795        self.articles.iter().find(|a| a.number == number)
796    }
797
798    /// Import from YAML
799    pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
800        serde_yaml::from_str(yaml_content)
801    }
802
803    /// Export to YAML
804    pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
805        serde_yaml::to_string(self)
806    }
807}
808
809/// Sanitize a name for use in filenames
810fn sanitize_name(name: &str) -> String {
811    name.chars()
812        .map(|c| match c {
813            ' ' | '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
814            _ => c,
815        })
816        .collect::<String>()
817        .to_lowercase()
818}
819
820/// Create a URL-friendly slug from a title
821fn slugify(title: &str) -> String {
822    title
823        .to_lowercase()
824        .chars()
825        .map(|c| if c.is_alphanumeric() { c } else { '-' })
826        .collect::<String>()
827        .split('-')
828        .filter(|s| !s.is_empty())
829        .collect::<Vec<_>>()
830        .join("-")
831        .chars()
832        .take(50) // Limit slug length
833        .collect()
834}
835
836#[cfg(test)]
837mod tests {
838    use super::*;
839
840    #[test]
841    fn test_knowledge_article_new() {
842        let article = KnowledgeArticle::new(
843            1,
844            "Data Classification Guide",
845            "This guide explains classification",
846            "## Overview\n\nData classification is important...",
847            "data-governance@example.com",
848        );
849
850        assert_eq!(article.number, 1);
851        assert_eq!(article.formatted_number(), "KB-0001");
852        assert_eq!(article.title, "Data Classification Guide");
853        assert_eq!(article.status, KnowledgeStatus::Draft);
854        assert_eq!(article.article_type, KnowledgeType::Guide);
855        assert_eq!(article.authors.len(), 1);
856    }
857
858    #[test]
859    fn test_knowledge_article_builder_pattern() {
860        let article = KnowledgeArticle::new(1, "Test", "Summary", "Content", "author@example.com")
861            .with_type(KnowledgeType::Standard)
862            .with_status(KnowledgeStatus::Published)
863            .with_domain("sales")
864            .add_reviewer("reviewer@example.com")
865            .with_review_frequency(ReviewFrequency::Quarterly)
866            .add_audience("data-engineers")
867            .with_skill_level(SkillLevel::Intermediate);
868
869        assert_eq!(article.article_type, KnowledgeType::Standard);
870        assert_eq!(article.status, KnowledgeStatus::Published);
871        assert_eq!(article.domain, Some("sales".to_string()));
872        assert_eq!(article.reviewers.len(), 1);
873        assert_eq!(article.review_frequency, Some(ReviewFrequency::Quarterly));
874        assert_eq!(article.audience.len(), 1);
875        assert_eq!(article.skill_level, Some(SkillLevel::Intermediate));
876    }
877
878    #[test]
879    fn test_knowledge_article_id_generation() {
880        let id1 = KnowledgeArticle::generate_id(1);
881        let id2 = KnowledgeArticle::generate_id(1);
882        let id3 = KnowledgeArticle::generate_id(2);
883
884        // Same number should generate same ID
885        assert_eq!(id1, id2);
886        // Different numbers should generate different IDs
887        assert_ne!(id1, id3);
888    }
889
890    #[test]
891    fn test_knowledge_article_filename() {
892        let article = KnowledgeArticle::new(1, "Test", "Summary", "Content", "author@example.com");
893        assert_eq!(article.filename("enterprise"), "enterprise_kb-0001.kb.yaml");
894
895        let article_with_domain = article.with_domain("sales");
896        assert_eq!(
897            article_with_domain.filename("enterprise"),
898            "enterprise_sales_kb-0001.kb.yaml"
899        );
900    }
901
902    #[test]
903    fn test_knowledge_article_markdown_filename() {
904        let article = KnowledgeArticle::new(
905            1,
906            "Data Classification Guide",
907            "Summary",
908            "Content",
909            "author@example.com",
910        );
911        let filename = article.markdown_filename();
912        assert!(filename.starts_with("KB-0001-"));
913        assert!(filename.ends_with(".md"));
914    }
915
916    #[test]
917    fn test_knowledge_article_yaml_roundtrip() {
918        let article = KnowledgeArticle::new(
919            1,
920            "Test Article",
921            "Test summary",
922            "Test content",
923            "author@example.com",
924        )
925        .with_status(KnowledgeStatus::Published)
926        .with_domain("test");
927
928        let yaml = article.to_yaml().unwrap();
929        let parsed = KnowledgeArticle::from_yaml(&yaml).unwrap();
930
931        assert_eq!(article.id, parsed.id);
932        assert_eq!(article.title, parsed.title);
933        assert_eq!(article.status, parsed.status);
934        assert_eq!(article.domain, parsed.domain);
935    }
936
937    #[test]
938    fn test_knowledge_index() {
939        let mut index = KnowledgeIndex::new();
940        assert_eq!(index.get_next_number(), 1);
941
942        let article1 =
943            KnowledgeArticle::new(1, "First", "Summary", "Content", "author@example.com");
944        index.add_article(&article1, "test_kb-0001.kb.yaml".to_string());
945
946        assert_eq!(index.articles.len(), 1);
947        assert_eq!(index.get_next_number(), 2);
948
949        let article2 =
950            KnowledgeArticle::new(2, "Second", "Summary", "Content", "author@example.com");
951        index.add_article(&article2, "test_kb-0002.kb.yaml".to_string());
952
953        assert_eq!(index.articles.len(), 2);
954        assert_eq!(index.get_next_number(), 3);
955    }
956
957    #[test]
958    fn test_related_article() {
959        let related = RelatedArticle::new(
960            Uuid::new_v4(),
961            "KB-0002",
962            "PII Handling",
963            ArticleRelationship::Related,
964        );
965
966        assert_eq!(related.article_number, "KB-0002");
967        assert_eq!(related.relationship, ArticleRelationship::Related);
968    }
969
970    #[test]
971    fn test_knowledge_type_display() {
972        assert_eq!(format!("{}", KnowledgeType::Guide), "Guide");
973        assert_eq!(format!("{}", KnowledgeType::Standard), "Standard");
974        assert_eq!(format!("{}", KnowledgeType::HowTo), "How-To");
975        assert_eq!(format!("{}", KnowledgeType::Concept), "Concept");
976        assert_eq!(format!("{}", KnowledgeType::Runbook), "Runbook");
977    }
978
979    #[test]
980    fn test_knowledge_status_display() {
981        assert_eq!(format!("{}", KnowledgeStatus::Draft), "Draft");
982        assert_eq!(format!("{}", KnowledgeStatus::Review), "Review");
983        assert_eq!(format!("{}", KnowledgeStatus::Published), "Published");
984        assert_eq!(format!("{}", KnowledgeStatus::Archived), "Archived");
985    }
986
987    #[test]
988    fn test_timestamp_number_generation() {
989        use chrono::TimeZone;
990        let dt = Utc.with_ymd_and_hms(2026, 1, 10, 14, 30, 0).unwrap();
991        let number = KnowledgeArticle::generate_timestamp_number(&dt);
992        assert_eq!(number, 2601101430);
993    }
994
995    #[test]
996    fn test_is_timestamp_number() {
997        let sequential_article =
998            KnowledgeArticle::new(1, "Test", "Summary", "Content", "author@example.com");
999        assert!(!sequential_article.is_timestamp_number());
1000
1001        let timestamp_article = KnowledgeArticle::new(
1002            2601101430,
1003            "Test",
1004            "Summary",
1005            "Content",
1006            "author@example.com",
1007        );
1008        assert!(timestamp_article.is_timestamp_number());
1009    }
1010
1011    #[test]
1012    fn test_timestamp_article_filename() {
1013        let article = KnowledgeArticle::new(
1014            2601101430,
1015            "Test",
1016            "Summary",
1017            "Content",
1018            "author@example.com",
1019        );
1020        assert_eq!(
1021            article.filename("enterprise"),
1022            "enterprise_kb-2601101430.kb.yaml"
1023        );
1024    }
1025
1026    #[test]
1027    fn test_timestamp_article_markdown_filename() {
1028        let article = KnowledgeArticle::new(
1029            2601101430,
1030            "Test Article",
1031            "Summary",
1032            "Content",
1033            "author@example.com",
1034        );
1035        let filename = article.markdown_filename();
1036        assert!(filename.starts_with("KB-2601101430-"));
1037        assert!(filename.ends_with(".md"));
1038    }
1039
1040    #[test]
1041    fn test_article_with_multiple_authors() {
1042        let article = KnowledgeArticle::new(1, "Test", "Summary", "Content", "author1@example.com")
1043            .add_author("author2@example.com")
1044            .add_author("author3@example.com");
1045
1046        assert_eq!(article.authors.len(), 3);
1047    }
1048
1049    #[test]
1050    fn test_knowledge_index_with_timestamp_numbering() {
1051        let index = KnowledgeIndex::new_with_timestamp_numbering();
1052        assert!(index.use_timestamp_numbering);
1053
1054        // The next number should be a timestamp
1055        let next = index.get_next_number();
1056        assert!(next >= 1000000000); // Timestamp format check
1057    }
1058}