1use chrono::{DateTime, Utc};
29use serde::{Deserialize, Serialize};
30use uuid::Uuid;
31
32use super::Tag;
33use super::decision::AssetLink;
34
35#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
37#[serde(rename_all = "lowercase")]
38pub enum KnowledgeType {
39 #[default]
41 Guide,
42 Standard,
44 Reference,
46 Glossary,
48 HowTo,
50 Troubleshooting,
52 Policy,
54 Template,
56}
57
58impl std::fmt::Display for KnowledgeType {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 match self {
61 KnowledgeType::Guide => write!(f, "Guide"),
62 KnowledgeType::Standard => write!(f, "Standard"),
63 KnowledgeType::Reference => write!(f, "Reference"),
64 KnowledgeType::Glossary => write!(f, "Glossary"),
65 KnowledgeType::HowTo => write!(f, "How-To"),
66 KnowledgeType::Troubleshooting => write!(f, "Troubleshooting"),
67 KnowledgeType::Policy => write!(f, "Policy"),
68 KnowledgeType::Template => write!(f, "Template"),
69 }
70 }
71}
72
73#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
75#[serde(rename_all = "lowercase")]
76pub enum KnowledgeStatus {
77 #[default]
79 Draft,
80 Published,
82 Archived,
84 Deprecated,
86}
87
88impl std::fmt::Display for KnowledgeStatus {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 match self {
91 KnowledgeStatus::Draft => write!(f, "Draft"),
92 KnowledgeStatus::Published => write!(f, "Published"),
93 KnowledgeStatus::Archived => write!(f, "Archived"),
94 KnowledgeStatus::Deprecated => write!(f, "Deprecated"),
95 }
96 }
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
101#[serde(rename_all = "lowercase")]
102pub enum ReviewFrequency {
103 Monthly,
105 Quarterly,
107 Yearly,
109}
110
111impl std::fmt::Display for ReviewFrequency {
112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 match self {
114 ReviewFrequency::Monthly => write!(f, "Monthly"),
115 ReviewFrequency::Quarterly => write!(f, "Quarterly"),
116 ReviewFrequency::Yearly => write!(f, "Yearly"),
117 }
118 }
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
123#[serde(rename_all = "lowercase")]
124pub enum SkillLevel {
125 Beginner,
127 Intermediate,
129 Advanced,
131}
132
133impl std::fmt::Display for SkillLevel {
134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 match self {
136 SkillLevel::Beginner => write!(f, "Beginner"),
137 SkillLevel::Intermediate => write!(f, "Intermediate"),
138 SkillLevel::Advanced => write!(f, "Advanced"),
139 }
140 }
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
145#[serde(rename_all = "lowercase")]
146pub enum ArticleRelationship {
147 Related,
149 Prerequisite,
151 Supersedes,
153}
154
155impl std::fmt::Display for ArticleRelationship {
156 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157 match self {
158 ArticleRelationship::Related => write!(f, "Related"),
159 ArticleRelationship::Prerequisite => write!(f, "Prerequisite"),
160 ArticleRelationship::Supersedes => write!(f, "Supersedes"),
161 }
162 }
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
167pub struct RelatedArticle {
168 pub article_id: Uuid,
170 pub article_number: String,
172 pub title: String,
174 pub relationship: ArticleRelationship,
176}
177
178impl RelatedArticle {
179 pub fn new(
181 article_id: Uuid,
182 article_number: impl Into<String>,
183 title: impl Into<String>,
184 relationship: ArticleRelationship,
185 ) -> Self {
186 Self {
187 article_id,
188 article_number: article_number.into(),
189 title: title.into(),
190 relationship,
191 }
192 }
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
200pub struct KnowledgeArticle {
201 pub id: Uuid,
203 pub number: String,
205 pub title: String,
207 pub article_type: KnowledgeType,
209 pub status: KnowledgeStatus,
211 #[serde(skip_serializing_if = "Option::is_none")]
213 pub domain: Option<String>,
214
215 pub summary: String,
218 pub content: String,
220
221 pub author: String,
224 #[serde(default, skip_serializing_if = "Vec::is_empty")]
226 pub reviewers: Vec<String>,
227 #[serde(skip_serializing_if = "Option::is_none")]
229 pub last_reviewed: Option<DateTime<Utc>>,
230 #[serde(skip_serializing_if = "Option::is_none")]
232 pub review_frequency: Option<ReviewFrequency>,
233
234 #[serde(default, skip_serializing_if = "Vec::is_empty")]
237 pub audience: Vec<String>,
238 #[serde(skip_serializing_if = "Option::is_none")]
240 pub skill_level: Option<SkillLevel>,
241
242 #[serde(default, skip_serializing_if = "Vec::is_empty")]
245 pub linked_assets: Vec<AssetLink>,
246 #[serde(default, skip_serializing_if = "Vec::is_empty")]
248 pub linked_decisions: Vec<Uuid>,
249 #[serde(default, skip_serializing_if = "Vec::is_empty")]
251 pub related_articles: Vec<RelatedArticle>,
252
253 #[serde(default, skip_serializing_if = "Vec::is_empty")]
256 pub tags: Vec<Tag>,
257 #[serde(skip_serializing_if = "Option::is_none")]
259 pub notes: Option<String>,
260
261 pub created_at: DateTime<Utc>,
263 pub updated_at: DateTime<Utc>,
265}
266
267impl KnowledgeArticle {
268 pub fn new(
270 number: u32,
271 title: impl Into<String>,
272 summary: impl Into<String>,
273 content: impl Into<String>,
274 author: impl Into<String>,
275 ) -> Self {
276 let now = Utc::now();
277 let number_str = format!("KB-{:04}", number);
278 Self {
279 id: Self::generate_id(number),
280 number: number_str,
281 title: title.into(),
282 article_type: KnowledgeType::Guide,
283 status: KnowledgeStatus::Draft,
284 domain: None,
285 summary: summary.into(),
286 content: content.into(),
287 author: author.into(),
288 reviewers: Vec::new(),
289 last_reviewed: None,
290 review_frequency: None,
291 audience: Vec::new(),
292 skill_level: None,
293 linked_assets: Vec::new(),
294 linked_decisions: Vec::new(),
295 related_articles: Vec::new(),
296 tags: Vec::new(),
297 notes: None,
298 created_at: now,
299 updated_at: now,
300 }
301 }
302
303 pub fn generate_id(number: u32) -> Uuid {
305 let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(); let name = format!("knowledge:{}", number);
308 Uuid::new_v5(&namespace, name.as_bytes())
309 }
310
311 pub fn parse_number(&self) -> Option<u32> {
313 self.number.strip_prefix("KB-").and_then(|s| s.parse().ok())
314 }
315
316 pub fn with_type(mut self, article_type: KnowledgeType) -> Self {
318 self.article_type = article_type;
319 self.updated_at = Utc::now();
320 self
321 }
322
323 pub fn with_status(mut self, status: KnowledgeStatus) -> Self {
325 self.status = status;
326 self.updated_at = Utc::now();
327 self
328 }
329
330 pub fn with_domain(mut self, domain: impl Into<String>) -> Self {
332 self.domain = Some(domain.into());
333 self.updated_at = Utc::now();
334 self
335 }
336
337 pub fn add_reviewer(mut self, reviewer: impl Into<String>) -> Self {
339 self.reviewers.push(reviewer.into());
340 self.updated_at = Utc::now();
341 self
342 }
343
344 pub fn with_review_frequency(mut self, frequency: ReviewFrequency) -> Self {
346 self.review_frequency = Some(frequency);
347 self.updated_at = Utc::now();
348 self
349 }
350
351 pub fn add_audience(mut self, audience: impl Into<String>) -> Self {
353 self.audience.push(audience.into());
354 self.updated_at = Utc::now();
355 self
356 }
357
358 pub fn with_skill_level(mut self, level: SkillLevel) -> Self {
360 self.skill_level = Some(level);
361 self.updated_at = Utc::now();
362 self
363 }
364
365 pub fn add_asset_link(mut self, link: AssetLink) -> Self {
367 self.linked_assets.push(link);
368 self.updated_at = Utc::now();
369 self
370 }
371
372 pub fn link_decision(mut self, decision_id: Uuid) -> Self {
374 if !self.linked_decisions.contains(&decision_id) {
375 self.linked_decisions.push(decision_id);
376 self.updated_at = Utc::now();
377 }
378 self
379 }
380
381 pub fn add_related_article(mut self, article: RelatedArticle) -> Self {
383 self.related_articles.push(article);
384 self.updated_at = Utc::now();
385 self
386 }
387
388 pub fn add_tag(mut self, tag: Tag) -> Self {
390 self.tags.push(tag);
391 self.updated_at = Utc::now();
392 self
393 }
394
395 pub fn mark_reviewed(&mut self) {
397 self.last_reviewed = Some(Utc::now());
398 self.updated_at = Utc::now();
399 }
400
401 pub fn filename(&self, workspace_name: &str) -> String {
403 let number = self.parse_number().unwrap_or(0);
404 match &self.domain {
405 Some(domain) => format!(
406 "{}_{}_kb-{:04}.kb.yaml",
407 sanitize_name(workspace_name),
408 sanitize_name(domain),
409 number
410 ),
411 None => format!("{}_kb-{:04}.kb.yaml", sanitize_name(workspace_name), number),
412 }
413 }
414
415 pub fn markdown_filename(&self) -> String {
417 let slug = slugify(&self.title);
418 format!("{}-{}.md", self.number, slug)
419 }
420
421 pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
423 serde_yaml::from_str(yaml_content)
424 }
425
426 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
428 serde_yaml::to_string(self)
429 }
430}
431
432#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
434pub struct KnowledgeIndexEntry {
435 pub number: String,
437 pub id: Uuid,
439 pub title: String,
441 pub article_type: KnowledgeType,
443 pub status: KnowledgeStatus,
445 #[serde(skip_serializing_if = "Option::is_none")]
447 pub domain: Option<String>,
448 pub file: String,
450}
451
452impl From<&KnowledgeArticle> for KnowledgeIndexEntry {
453 fn from(article: &KnowledgeArticle) -> Self {
454 Self {
455 number: article.number.clone(),
456 id: article.id,
457 title: article.title.clone(),
458 article_type: article.article_type.clone(),
459 status: article.status.clone(),
460 domain: article.domain.clone(),
461 file: String::new(), }
463 }
464}
465
466#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
468pub struct KnowledgeIndex {
469 pub schema_version: String,
471 #[serde(skip_serializing_if = "Option::is_none")]
473 pub last_updated: Option<DateTime<Utc>>,
474 #[serde(default)]
476 pub articles: Vec<KnowledgeIndexEntry>,
477 pub next_number: u32,
479}
480
481impl Default for KnowledgeIndex {
482 fn default() -> Self {
483 Self::new()
484 }
485}
486
487impl KnowledgeIndex {
488 pub fn new() -> Self {
490 Self {
491 schema_version: "1.0".to_string(),
492 last_updated: Some(Utc::now()),
493 articles: Vec::new(),
494 next_number: 1,
495 }
496 }
497
498 pub fn add_article(&mut self, article: &KnowledgeArticle, filename: String) {
500 let mut entry = KnowledgeIndexEntry::from(article);
501 entry.file = filename;
502
503 self.articles.retain(|a| a.number != article.number);
505 self.articles.push(entry);
506
507 self.articles.sort_by(|a, b| a.number.cmp(&b.number));
509
510 if let Some(num) = article.parse_number()
512 && num >= self.next_number
513 {
514 self.next_number = num + 1;
515 }
516
517 self.last_updated = Some(Utc::now());
518 }
519
520 pub fn get_next_number(&self) -> u32 {
522 self.next_number
523 }
524
525 pub fn find_by_number(&self, number: &str) -> Option<&KnowledgeIndexEntry> {
527 self.articles.iter().find(|a| a.number == number)
528 }
529
530 pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
532 serde_yaml::from_str(yaml_content)
533 }
534
535 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
537 serde_yaml::to_string(self)
538 }
539}
540
541fn sanitize_name(name: &str) -> String {
543 name.chars()
544 .map(|c| match c {
545 ' ' | '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
546 _ => c,
547 })
548 .collect::<String>()
549 .to_lowercase()
550}
551
552fn slugify(title: &str) -> String {
554 title
555 .to_lowercase()
556 .chars()
557 .map(|c| if c.is_alphanumeric() { c } else { '-' })
558 .collect::<String>()
559 .split('-')
560 .filter(|s| !s.is_empty())
561 .collect::<Vec<_>>()
562 .join("-")
563 .chars()
564 .take(50) .collect()
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571
572 #[test]
573 fn test_knowledge_article_new() {
574 let article = KnowledgeArticle::new(
575 1,
576 "Data Classification Guide",
577 "This guide explains classification",
578 "## Overview\n\nData classification is important...",
579 "data-governance@example.com",
580 );
581
582 assert_eq!(article.number, "KB-0001");
583 assert_eq!(article.title, "Data Classification Guide");
584 assert_eq!(article.status, KnowledgeStatus::Draft);
585 assert_eq!(article.article_type, KnowledgeType::Guide);
586 }
587
588 #[test]
589 fn test_knowledge_article_builder_pattern() {
590 let article = KnowledgeArticle::new(1, "Test", "Summary", "Content", "author@example.com")
591 .with_type(KnowledgeType::Standard)
592 .with_status(KnowledgeStatus::Published)
593 .with_domain("sales")
594 .add_reviewer("reviewer@example.com")
595 .with_review_frequency(ReviewFrequency::Quarterly)
596 .add_audience("data-engineers")
597 .with_skill_level(SkillLevel::Intermediate);
598
599 assert_eq!(article.article_type, KnowledgeType::Standard);
600 assert_eq!(article.status, KnowledgeStatus::Published);
601 assert_eq!(article.domain, Some("sales".to_string()));
602 assert_eq!(article.reviewers.len(), 1);
603 assert_eq!(article.review_frequency, Some(ReviewFrequency::Quarterly));
604 assert_eq!(article.audience.len(), 1);
605 assert_eq!(article.skill_level, Some(SkillLevel::Intermediate));
606 }
607
608 #[test]
609 fn test_knowledge_article_id_generation() {
610 let id1 = KnowledgeArticle::generate_id(1);
611 let id2 = KnowledgeArticle::generate_id(1);
612 let id3 = KnowledgeArticle::generate_id(2);
613
614 assert_eq!(id1, id2);
616 assert_ne!(id1, id3);
618 }
619
620 #[test]
621 fn test_knowledge_article_filename() {
622 let article = KnowledgeArticle::new(1, "Test", "Summary", "Content", "author@example.com");
623 assert_eq!(article.filename("enterprise"), "enterprise_kb-0001.kb.yaml");
624
625 let article_with_domain = article.with_domain("sales");
626 assert_eq!(
627 article_with_domain.filename("enterprise"),
628 "enterprise_sales_kb-0001.kb.yaml"
629 );
630 }
631
632 #[test]
633 fn test_knowledge_article_markdown_filename() {
634 let article = KnowledgeArticle::new(
635 1,
636 "Data Classification Guide",
637 "Summary",
638 "Content",
639 "author@example.com",
640 );
641 let filename = article.markdown_filename();
642 assert!(filename.starts_with("KB-0001-"));
643 assert!(filename.ends_with(".md"));
644 }
645
646 #[test]
647 fn test_knowledge_article_yaml_roundtrip() {
648 let article = KnowledgeArticle::new(
649 1,
650 "Test Article",
651 "Test summary",
652 "Test content",
653 "author@example.com",
654 )
655 .with_status(KnowledgeStatus::Published)
656 .with_domain("test");
657
658 let yaml = article.to_yaml().unwrap();
659 let parsed = KnowledgeArticle::from_yaml(&yaml).unwrap();
660
661 assert_eq!(article.id, parsed.id);
662 assert_eq!(article.title, parsed.title);
663 assert_eq!(article.status, parsed.status);
664 assert_eq!(article.domain, parsed.domain);
665 }
666
667 #[test]
668 fn test_knowledge_article_parse_number() {
669 let article = KnowledgeArticle::new(42, "Test", "Summary", "Content", "author");
670 assert_eq!(article.parse_number(), Some(42));
671 }
672
673 #[test]
674 fn test_knowledge_index() {
675 let mut index = KnowledgeIndex::new();
676 assert_eq!(index.get_next_number(), 1);
677
678 let article1 =
679 KnowledgeArticle::new(1, "First", "Summary", "Content", "author@example.com");
680 index.add_article(&article1, "test_kb-0001.kb.yaml".to_string());
681
682 assert_eq!(index.articles.len(), 1);
683 assert_eq!(index.get_next_number(), 2);
684
685 let article2 =
686 KnowledgeArticle::new(2, "Second", "Summary", "Content", "author@example.com");
687 index.add_article(&article2, "test_kb-0002.kb.yaml".to_string());
688
689 assert_eq!(index.articles.len(), 2);
690 assert_eq!(index.get_next_number(), 3);
691 }
692
693 #[test]
694 fn test_related_article() {
695 let related = RelatedArticle::new(
696 Uuid::new_v4(),
697 "KB-0002",
698 "PII Handling",
699 ArticleRelationship::Related,
700 );
701
702 assert_eq!(related.article_number, "KB-0002");
703 assert_eq!(related.relationship, ArticleRelationship::Related);
704 }
705
706 #[test]
707 fn test_knowledge_type_display() {
708 assert_eq!(format!("{}", KnowledgeType::Guide), "Guide");
709 assert_eq!(format!("{}", KnowledgeType::Standard), "Standard");
710 assert_eq!(format!("{}", KnowledgeType::HowTo), "How-To");
711 }
712
713 #[test]
714 fn test_knowledge_status_display() {
715 assert_eq!(format!("{}", KnowledgeStatus::Draft), "Draft");
716 assert_eq!(format!("{}", KnowledgeStatus::Published), "Published");
717 assert_eq!(format!("{}", KnowledgeStatus::Archived), "Archived");
718 }
719}