1use chrono::{DateTime, Utc};
29use serde::{Deserialize, Serialize};
30use std::collections::HashMap;
31use uuid::Uuid;
32
33use super::Tag;
34use super::decision::AssetLink;
35
36#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
38#[serde(rename_all = "lowercase")]
39pub enum KnowledgeType {
40 #[default]
42 Guide,
43 Standard,
45 Reference,
47 HowTo,
49 Troubleshooting,
51 Policy,
53 Template,
55 Concept,
57 Runbook,
59 Tutorial,
61 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#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
85#[serde(rename_all = "lowercase")]
86pub enum KnowledgeStatus {
87 #[default]
89 Draft,
90 Review,
92 Published,
94 Archived,
96 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
114#[serde(rename_all = "lowercase")]
115pub enum ReviewFrequency {
116 Monthly,
118 Quarterly,
120 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
136#[serde(rename_all = "lowercase")]
137pub enum SkillLevel {
138 Beginner,
140 Intermediate,
142 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
158#[serde(rename_all = "lowercase")]
159pub enum ArticleRelationship {
160 Related,
162 Prerequisite,
164 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
180#[serde(rename_all = "camelCase")]
181pub struct RelatedArticle {
182 #[serde(alias = "article_id")]
184 pub article_id: Uuid,
185 #[serde(alias = "article_number")]
187 pub article_number: String,
188 pub title: String,
190 pub relationship: ArticleRelationship,
192}
193
194impl RelatedArticle {
195 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
211fn 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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
272#[serde(rename_all = "camelCase")]
273pub struct KnowledgeArticle {
274 pub id: Uuid,
276 #[serde(deserialize_with = "deserialize_knowledge_number")]
279 pub number: u64,
280 pub title: String,
282 #[serde(alias = "article_type")]
284 pub article_type: KnowledgeType,
285 pub status: KnowledgeStatus,
287 #[serde(skip_serializing_if = "Option::is_none")]
289 pub domain: Option<String>,
290 #[serde(skip_serializing_if = "Option::is_none", alias = "domain_id")]
292 pub domain_id: Option<Uuid>,
293 #[serde(skip_serializing_if = "Option::is_none", alias = "workspace_id")]
295 pub workspace_id: Option<Uuid>,
296
297 pub summary: String,
300 pub content: String,
302
303 #[serde(default, skip_serializing_if = "Vec::is_empty")]
306 pub authors: Vec<String>,
307 #[serde(default, skip_serializing_if = "Vec::is_empty")]
309 pub reviewers: Vec<String>,
310 #[serde(skip_serializing_if = "Option::is_none", alias = "last_reviewed")]
312 pub last_reviewed: Option<DateTime<Utc>>,
313 #[serde(skip_serializing_if = "Option::is_none", alias = "reviewed_at")]
315 pub reviewed_at: Option<DateTime<Utc>>,
316 #[serde(skip_serializing_if = "Option::is_none", alias = "published_at")]
318 pub published_at: Option<DateTime<Utc>>,
319 #[serde(skip_serializing_if = "Option::is_none", alias = "archived_at")]
321 pub archived_at: Option<DateTime<Utc>>,
322 #[serde(skip_serializing_if = "Option::is_none", alias = "review_frequency")]
324 pub review_frequency: Option<ReviewFrequency>,
325
326 #[serde(default, skip_serializing_if = "Vec::is_empty")]
329 pub audience: Vec<String>,
330 #[serde(skip_serializing_if = "Option::is_none", alias = "skill_level")]
332 pub skill_level: Option<SkillLevel>,
333
334 #[serde(
337 default,
338 skip_serializing_if = "Vec::is_empty",
339 alias = "linked_assets"
340 )]
341 pub linked_assets: Vec<AssetLink>,
342 #[serde(
344 default,
345 skip_serializing_if = "Vec::is_empty",
346 alias = "linked_decisions"
347 )]
348 pub linked_decisions: Vec<Uuid>,
349 #[serde(
351 default,
352 skip_serializing_if = "Vec::is_empty",
353 alias = "related_decisions"
354 )]
355 pub related_decisions: Vec<Uuid>,
356 #[serde(
358 default,
359 skip_serializing_if = "Vec::is_empty",
360 alias = "related_articles"
361 )]
362 pub related_articles: Vec<RelatedArticle>,
363 #[serde(default, skip_serializing_if = "Vec::is_empty")]
365 pub prerequisites: Vec<Uuid>,
366 #[serde(default, skip_serializing_if = "Vec::is_empty", alias = "see_also")]
368 pub see_also: Vec<Uuid>,
369 #[serde(
371 default,
372 skip_serializing_if = "Vec::is_empty",
373 alias = "linked_sketches"
374 )]
375 pub linked_sketches: Vec<Uuid>,
376
377 #[serde(default, skip_serializing_if = "Vec::is_empty")]
380 pub tags: Vec<Tag>,
381 #[serde(skip_serializing_if = "Option::is_none")]
383 pub notes: Option<String>,
384 #[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 #[serde(alias = "created_at")]
394 pub created_at: DateTime<Utc>,
395 #[serde(alias = "updated_at")]
397 pub updated_at: DateTime<Utc>,
398}
399
400impl KnowledgeArticle {
401 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 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 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 pub fn generate_id(number: u64) -> Uuid {
466 let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(); let name = format!("knowledge:{}", number);
469 Uuid::new_v5(&namespace, name.as_bytes())
470 }
471
472 pub fn is_timestamp_number(&self) -> bool {
474 self.number >= 1000000000 && self.number <= 9999999999
475 }
476
477 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 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 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 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 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 pub fn with_status(mut self, status: KnowledgeStatus) -> Self {
517 self.status = status;
518 self.updated_at = Utc::now();
519 self
520 }
521
522 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn markdown_filename(&self) -> String {
670 let slug = slugify(&self.title);
671 format!("{}-{}.md", self.formatted_number(), slug)
672 }
673
674 pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
676 serde_yaml::from_str(yaml_content)
677 }
678
679 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
681 serde_yaml::to_string(self)
682 }
683}
684
685#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
687#[serde(rename_all = "camelCase")]
688pub struct KnowledgeIndexEntry {
689 pub number: u64,
691 pub id: Uuid,
693 pub title: String,
695 #[serde(alias = "article_type")]
697 pub article_type: KnowledgeType,
698 pub status: KnowledgeStatus,
700 #[serde(skip_serializing_if = "Option::is_none")]
702 pub domain: Option<String>,
703 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(), }
718 }
719}
720
721#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
723#[serde(rename_all = "camelCase")]
724pub struct KnowledgeIndex {
725 #[serde(alias = "schema_version")]
727 pub schema_version: String,
728 #[serde(skip_serializing_if = "Option::is_none", alias = "last_updated")]
730 pub last_updated: Option<DateTime<Utc>>,
731 #[serde(default)]
733 pub articles: Vec<KnowledgeIndexEntry>,
734 #[serde(alias = "next_number")]
736 pub next_number: u64,
737 #[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 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 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 pub fn add_article(&mut self, article: &KnowledgeArticle, filename: String) {
773 let mut entry = KnowledgeIndexEntry::from(article);
774 entry.file = filename;
775
776 self.articles.retain(|a| a.number != article.number);
778 self.articles.push(entry);
779
780 self.articles.sort_by(|a, b| a.number.cmp(&b.number));
782
783 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 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 pub fn find_by_number(&self, number: u64) -> Option<&KnowledgeIndexEntry> {
804 self.articles.iter().find(|a| a.number == number)
805 }
806
807 pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
809 serde_yaml::from_str(yaml_content)
810 }
811
812 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
814 serde_yaml::to_string(self)
815 }
816}
817
818fn 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
829fn 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) .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 assert_eq!(id1, id2);
895 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 let next = index.get_next_number();
1065 assert!(next >= 1000000000); }
1067}