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 HowTo,
48 Troubleshooting,
50 Policy,
52 Template,
54 Concept,
56 Runbook,
58 Tutorial,
60 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#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
84#[serde(rename_all = "lowercase")]
85pub enum KnowledgeStatus {
86 #[default]
88 Draft,
89 Review,
91 Published,
93 Archived,
95 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
113#[serde(rename_all = "lowercase")]
114pub enum ReviewFrequency {
115 Monthly,
117 Quarterly,
119 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
135#[serde(rename_all = "lowercase")]
136pub enum SkillLevel {
137 Beginner,
139 Intermediate,
141 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
157#[serde(rename_all = "lowercase")]
158pub enum ArticleRelationship {
159 Related,
161 Prerequisite,
163 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
179#[serde(rename_all = "camelCase")]
180pub struct RelatedArticle {
181 #[serde(alias = "article_id")]
183 pub article_id: Uuid,
184 #[serde(alias = "article_number")]
186 pub article_number: String,
187 pub title: String,
189 pub relationship: ArticleRelationship,
191}
192
193impl RelatedArticle {
194 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
210fn 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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
271#[serde(rename_all = "camelCase")]
272pub struct KnowledgeArticle {
273 pub id: Uuid,
275 #[serde(deserialize_with = "deserialize_knowledge_number")]
278 pub number: u64,
279 pub title: String,
281 #[serde(alias = "article_type")]
283 pub article_type: KnowledgeType,
284 pub status: KnowledgeStatus,
286 #[serde(skip_serializing_if = "Option::is_none")]
288 pub domain: Option<String>,
289 #[serde(skip_serializing_if = "Option::is_none", alias = "domain_id")]
291 pub domain_id: Option<Uuid>,
292 #[serde(skip_serializing_if = "Option::is_none", alias = "workspace_id")]
294 pub workspace_id: Option<Uuid>,
295
296 pub summary: String,
299 pub content: String,
301
302 #[serde(default, skip_serializing_if = "Vec::is_empty")]
305 pub authors: Vec<String>,
306 #[serde(default, skip_serializing_if = "Vec::is_empty")]
308 pub reviewers: Vec<String>,
309 #[serde(skip_serializing_if = "Option::is_none", alias = "last_reviewed")]
311 pub last_reviewed: Option<DateTime<Utc>>,
312 #[serde(skip_serializing_if = "Option::is_none", alias = "reviewed_at")]
314 pub reviewed_at: Option<DateTime<Utc>>,
315 #[serde(skip_serializing_if = "Option::is_none", alias = "published_at")]
317 pub published_at: Option<DateTime<Utc>>,
318 #[serde(skip_serializing_if = "Option::is_none", alias = "archived_at")]
320 pub archived_at: Option<DateTime<Utc>>,
321 #[serde(skip_serializing_if = "Option::is_none", alias = "review_frequency")]
323 pub review_frequency: Option<ReviewFrequency>,
324
325 #[serde(default, skip_serializing_if = "Vec::is_empty")]
328 pub audience: Vec<String>,
329 #[serde(skip_serializing_if = "Option::is_none", alias = "skill_level")]
331 pub skill_level: Option<SkillLevel>,
332
333 #[serde(
336 default,
337 skip_serializing_if = "Vec::is_empty",
338 alias = "linked_assets"
339 )]
340 pub linked_assets: Vec<AssetLink>,
341 #[serde(
343 default,
344 skip_serializing_if = "Vec::is_empty",
345 alias = "linked_decisions"
346 )]
347 pub linked_decisions: Vec<Uuid>,
348 #[serde(
350 default,
351 skip_serializing_if = "Vec::is_empty",
352 alias = "related_decisions"
353 )]
354 pub related_decisions: Vec<Uuid>,
355 #[serde(
357 default,
358 skip_serializing_if = "Vec::is_empty",
359 alias = "related_articles"
360 )]
361 pub related_articles: Vec<RelatedArticle>,
362 #[serde(default, skip_serializing_if = "Vec::is_empty")]
364 pub prerequisites: Vec<Uuid>,
365 #[serde(default, skip_serializing_if = "Vec::is_empty", alias = "see_also")]
367 pub see_also: Vec<Uuid>,
368
369 #[serde(default, skip_serializing_if = "Vec::is_empty")]
372 pub tags: Vec<Tag>,
373 #[serde(skip_serializing_if = "Option::is_none")]
375 pub notes: Option<String>,
376
377 #[serde(alias = "created_at")]
379 pub created_at: DateTime<Utc>,
380 #[serde(alias = "updated_at")]
382 pub updated_at: DateTime<Utc>,
383}
384
385impl KnowledgeArticle {
386 pub fn new(
388 number: u64,
389 title: impl Into<String>,
390 summary: impl Into<String>,
391 content: impl Into<String>,
392 author: impl Into<String>,
393 ) -> Self {
394 let now = Utc::now();
395 Self {
396 id: Self::generate_id(number),
397 number,
398 title: title.into(),
399 article_type: KnowledgeType::Guide,
400 status: KnowledgeStatus::Draft,
401 domain: None,
402 domain_id: None,
403 workspace_id: None,
404 summary: summary.into(),
405 content: content.into(),
406 authors: vec![author.into()],
407 reviewers: Vec::new(),
408 last_reviewed: None,
409 reviewed_at: None,
410 published_at: None,
411 archived_at: None,
412 review_frequency: None,
413 audience: Vec::new(),
414 skill_level: None,
415 linked_assets: Vec::new(),
416 linked_decisions: Vec::new(),
417 related_decisions: Vec::new(),
418 related_articles: Vec::new(),
419 prerequisites: Vec::new(),
420 see_also: Vec::new(),
421 tags: Vec::new(),
422 notes: None,
423 created_at: now,
424 updated_at: now,
425 }
426 }
427
428 pub fn new_with_timestamp(
431 title: impl Into<String>,
432 summary: impl Into<String>,
433 content: impl Into<String>,
434 author: impl Into<String>,
435 ) -> Self {
436 let now = Utc::now();
437 let number = Self::generate_timestamp_number(&now);
438 Self::new(number, title, summary, content, author)
439 }
440
441 pub fn generate_timestamp_number(dt: &DateTime<Utc>) -> u64 {
443 let formatted = dt.format("%y%m%d%H%M").to_string();
444 formatted.parse().unwrap_or(0)
445 }
446
447 pub fn generate_id(number: u64) -> Uuid {
449 let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(); let name = format!("knowledge:{}", number);
452 Uuid::new_v5(&namespace, name.as_bytes())
453 }
454
455 pub fn is_timestamp_number(&self) -> bool {
457 self.number >= 1000000000 && self.number <= 9999999999
458 }
459
460 pub fn formatted_number(&self) -> String {
463 if self.is_timestamp_number() {
464 format!("KB-{}", self.number)
465 } else {
466 format!("KB-{:04}", self.number)
467 }
468 }
469
470 pub fn add_author(mut self, author: impl Into<String>) -> Self {
472 self.authors.push(author.into());
473 self.updated_at = Utc::now();
474 self
475 }
476
477 pub fn with_domain_id(mut self, domain_id: Uuid) -> Self {
479 self.domain_id = Some(domain_id);
480 self.updated_at = Utc::now();
481 self
482 }
483
484 pub fn with_workspace_id(mut self, workspace_id: Uuid) -> Self {
486 self.workspace_id = Some(workspace_id);
487 self.updated_at = Utc::now();
488 self
489 }
490
491 pub fn with_type(mut self, article_type: KnowledgeType) -> Self {
493 self.article_type = article_type;
494 self.updated_at = Utc::now();
495 self
496 }
497
498 pub fn with_status(mut self, status: KnowledgeStatus) -> Self {
500 self.status = status;
501 self.updated_at = Utc::now();
502 self
503 }
504
505 pub fn with_domain(mut self, domain: impl Into<String>) -> Self {
507 self.domain = Some(domain.into());
508 self.updated_at = Utc::now();
509 self
510 }
511
512 pub fn add_reviewer(mut self, reviewer: impl Into<String>) -> Self {
514 self.reviewers.push(reviewer.into());
515 self.updated_at = Utc::now();
516 self
517 }
518
519 pub fn with_review_frequency(mut self, frequency: ReviewFrequency) -> Self {
521 self.review_frequency = Some(frequency);
522 self.updated_at = Utc::now();
523 self
524 }
525
526 pub fn add_audience(mut self, audience: impl Into<String>) -> Self {
528 self.audience.push(audience.into());
529 self.updated_at = Utc::now();
530 self
531 }
532
533 pub fn with_skill_level(mut self, level: SkillLevel) -> Self {
535 self.skill_level = Some(level);
536 self.updated_at = Utc::now();
537 self
538 }
539
540 pub fn add_asset_link(mut self, link: AssetLink) -> Self {
542 self.linked_assets.push(link);
543 self.updated_at = Utc::now();
544 self
545 }
546
547 pub fn link_decision(mut self, decision_id: Uuid) -> Self {
549 if !self.linked_decisions.contains(&decision_id) {
550 self.linked_decisions.push(decision_id);
551 self.updated_at = Utc::now();
552 }
553 self
554 }
555
556 pub fn add_related_article(mut self, article: RelatedArticle) -> Self {
558 self.related_articles.push(article);
559 self.updated_at = Utc::now();
560 self
561 }
562
563 pub fn add_related_decision(mut self, decision_id: Uuid) -> Self {
565 if !self.related_decisions.contains(&decision_id) {
566 self.related_decisions.push(decision_id);
567 self.updated_at = Utc::now();
568 }
569 self
570 }
571
572 pub fn add_prerequisite(mut self, article_id: Uuid) -> Self {
574 if !self.prerequisites.contains(&article_id) {
575 self.prerequisites.push(article_id);
576 self.updated_at = Utc::now();
577 }
578 self
579 }
580
581 pub fn add_see_also(mut self, article_id: Uuid) -> Self {
583 if !self.see_also.contains(&article_id) {
584 self.see_also.push(article_id);
585 self.updated_at = Utc::now();
586 }
587 self
588 }
589
590 pub fn with_published_at(mut self, published_at: DateTime<Utc>) -> Self {
592 self.published_at = Some(published_at);
593 self.updated_at = Utc::now();
594 self
595 }
596
597 pub fn with_archived_at(mut self, archived_at: DateTime<Utc>) -> Self {
599 self.archived_at = Some(archived_at);
600 self.updated_at = Utc::now();
601 self
602 }
603
604 pub fn add_tag(mut self, tag: Tag) -> Self {
606 self.tags.push(tag);
607 self.updated_at = Utc::now();
608 self
609 }
610
611 pub fn mark_reviewed(&mut self) {
613 let now = Utc::now();
614 self.last_reviewed = Some(now);
615 self.reviewed_at = Some(now);
616 self.updated_at = now;
617 }
618
619 pub fn filename(&self, workspace_name: &str) -> String {
621 let number_str = if self.is_timestamp_number() {
622 format!("{}", self.number)
623 } else {
624 format!("{:04}", self.number)
625 };
626
627 match &self.domain {
628 Some(domain) => format!(
629 "{}_{}_kb-{}.kb.yaml",
630 sanitize_name(workspace_name),
631 sanitize_name(domain),
632 number_str
633 ),
634 None => format!(
635 "{}_kb-{}.kb.yaml",
636 sanitize_name(workspace_name),
637 number_str
638 ),
639 }
640 }
641
642 pub fn markdown_filename(&self) -> String {
644 let slug = slugify(&self.title);
645 format!("{}-{}.md", self.formatted_number(), slug)
646 }
647
648 pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
650 serde_yaml::from_str(yaml_content)
651 }
652
653 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
655 serde_yaml::to_string(self)
656 }
657}
658
659#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
661#[serde(rename_all = "camelCase")]
662pub struct KnowledgeIndexEntry {
663 pub number: u64,
665 pub id: Uuid,
667 pub title: String,
669 #[serde(alias = "article_type")]
671 pub article_type: KnowledgeType,
672 pub status: KnowledgeStatus,
674 #[serde(skip_serializing_if = "Option::is_none")]
676 pub domain: Option<String>,
677 pub file: String,
679}
680
681impl From<&KnowledgeArticle> for KnowledgeIndexEntry {
682 fn from(article: &KnowledgeArticle) -> Self {
683 Self {
684 number: article.number,
685 id: article.id,
686 title: article.title.clone(),
687 article_type: article.article_type.clone(),
688 status: article.status.clone(),
689 domain: article.domain.clone(),
690 file: String::new(), }
692 }
693}
694
695#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
697#[serde(rename_all = "camelCase")]
698pub struct KnowledgeIndex {
699 #[serde(alias = "schema_version")]
701 pub schema_version: String,
702 #[serde(skip_serializing_if = "Option::is_none", alias = "last_updated")]
704 pub last_updated: Option<DateTime<Utc>>,
705 #[serde(default)]
707 pub articles: Vec<KnowledgeIndexEntry>,
708 #[serde(alias = "next_number")]
710 pub next_number: u64,
711 #[serde(default, alias = "use_timestamp_numbering")]
713 pub use_timestamp_numbering: bool,
714}
715
716impl Default for KnowledgeIndex {
717 fn default() -> Self {
718 Self::new()
719 }
720}
721
722impl KnowledgeIndex {
723 pub fn new() -> Self {
725 Self {
726 schema_version: "1.0".to_string(),
727 last_updated: Some(Utc::now()),
728 articles: Vec::new(),
729 next_number: 1,
730 use_timestamp_numbering: false,
731 }
732 }
733
734 pub fn new_with_timestamp_numbering() -> Self {
736 Self {
737 schema_version: "1.0".to_string(),
738 last_updated: Some(Utc::now()),
739 articles: Vec::new(),
740 next_number: 1,
741 use_timestamp_numbering: true,
742 }
743 }
744
745 pub fn add_article(&mut self, article: &KnowledgeArticle, filename: String) {
747 let mut entry = KnowledgeIndexEntry::from(article);
748 entry.file = filename;
749
750 self.articles.retain(|a| a.number != article.number);
752 self.articles.push(entry);
753
754 self.articles.sort_by(|a, b| a.number.cmp(&b.number));
756
757 if !self.use_timestamp_numbering && article.number >= self.next_number {
759 self.next_number = article.number + 1;
760 }
761
762 self.last_updated = Some(Utc::now());
763 }
764
765 pub fn get_next_number(&self) -> u64 {
769 if self.use_timestamp_numbering {
770 KnowledgeArticle::generate_timestamp_number(&Utc::now())
771 } else {
772 self.next_number
773 }
774 }
775
776 pub fn find_by_number(&self, number: u64) -> Option<&KnowledgeIndexEntry> {
778 self.articles.iter().find(|a| a.number == number)
779 }
780
781 pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
783 serde_yaml::from_str(yaml_content)
784 }
785
786 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
788 serde_yaml::to_string(self)
789 }
790}
791
792fn sanitize_name(name: &str) -> String {
794 name.chars()
795 .map(|c| match c {
796 ' ' | '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
797 _ => c,
798 })
799 .collect::<String>()
800 .to_lowercase()
801}
802
803fn slugify(title: &str) -> String {
805 title
806 .to_lowercase()
807 .chars()
808 .map(|c| if c.is_alphanumeric() { c } else { '-' })
809 .collect::<String>()
810 .split('-')
811 .filter(|s| !s.is_empty())
812 .collect::<Vec<_>>()
813 .join("-")
814 .chars()
815 .take(50) .collect()
817}
818
819#[cfg(test)]
820mod tests {
821 use super::*;
822
823 #[test]
824 fn test_knowledge_article_new() {
825 let article = KnowledgeArticle::new(
826 1,
827 "Data Classification Guide",
828 "This guide explains classification",
829 "## Overview\n\nData classification is important...",
830 "data-governance@example.com",
831 );
832
833 assert_eq!(article.number, 1);
834 assert_eq!(article.formatted_number(), "KB-0001");
835 assert_eq!(article.title, "Data Classification Guide");
836 assert_eq!(article.status, KnowledgeStatus::Draft);
837 assert_eq!(article.article_type, KnowledgeType::Guide);
838 assert_eq!(article.authors.len(), 1);
839 }
840
841 #[test]
842 fn test_knowledge_article_builder_pattern() {
843 let article = KnowledgeArticle::new(1, "Test", "Summary", "Content", "author@example.com")
844 .with_type(KnowledgeType::Standard)
845 .with_status(KnowledgeStatus::Published)
846 .with_domain("sales")
847 .add_reviewer("reviewer@example.com")
848 .with_review_frequency(ReviewFrequency::Quarterly)
849 .add_audience("data-engineers")
850 .with_skill_level(SkillLevel::Intermediate);
851
852 assert_eq!(article.article_type, KnowledgeType::Standard);
853 assert_eq!(article.status, KnowledgeStatus::Published);
854 assert_eq!(article.domain, Some("sales".to_string()));
855 assert_eq!(article.reviewers.len(), 1);
856 assert_eq!(article.review_frequency, Some(ReviewFrequency::Quarterly));
857 assert_eq!(article.audience.len(), 1);
858 assert_eq!(article.skill_level, Some(SkillLevel::Intermediate));
859 }
860
861 #[test]
862 fn test_knowledge_article_id_generation() {
863 let id1 = KnowledgeArticle::generate_id(1);
864 let id2 = KnowledgeArticle::generate_id(1);
865 let id3 = KnowledgeArticle::generate_id(2);
866
867 assert_eq!(id1, id2);
869 assert_ne!(id1, id3);
871 }
872
873 #[test]
874 fn test_knowledge_article_filename() {
875 let article = KnowledgeArticle::new(1, "Test", "Summary", "Content", "author@example.com");
876 assert_eq!(article.filename("enterprise"), "enterprise_kb-0001.kb.yaml");
877
878 let article_with_domain = article.with_domain("sales");
879 assert_eq!(
880 article_with_domain.filename("enterprise"),
881 "enterprise_sales_kb-0001.kb.yaml"
882 );
883 }
884
885 #[test]
886 fn test_knowledge_article_markdown_filename() {
887 let article = KnowledgeArticle::new(
888 1,
889 "Data Classification Guide",
890 "Summary",
891 "Content",
892 "author@example.com",
893 );
894 let filename = article.markdown_filename();
895 assert!(filename.starts_with("KB-0001-"));
896 assert!(filename.ends_with(".md"));
897 }
898
899 #[test]
900 fn test_knowledge_article_yaml_roundtrip() {
901 let article = KnowledgeArticle::new(
902 1,
903 "Test Article",
904 "Test summary",
905 "Test content",
906 "author@example.com",
907 )
908 .with_status(KnowledgeStatus::Published)
909 .with_domain("test");
910
911 let yaml = article.to_yaml().unwrap();
912 let parsed = KnowledgeArticle::from_yaml(&yaml).unwrap();
913
914 assert_eq!(article.id, parsed.id);
915 assert_eq!(article.title, parsed.title);
916 assert_eq!(article.status, parsed.status);
917 assert_eq!(article.domain, parsed.domain);
918 }
919
920 #[test]
921 fn test_knowledge_index() {
922 let mut index = KnowledgeIndex::new();
923 assert_eq!(index.get_next_number(), 1);
924
925 let article1 =
926 KnowledgeArticle::new(1, "First", "Summary", "Content", "author@example.com");
927 index.add_article(&article1, "test_kb-0001.kb.yaml".to_string());
928
929 assert_eq!(index.articles.len(), 1);
930 assert_eq!(index.get_next_number(), 2);
931
932 let article2 =
933 KnowledgeArticle::new(2, "Second", "Summary", "Content", "author@example.com");
934 index.add_article(&article2, "test_kb-0002.kb.yaml".to_string());
935
936 assert_eq!(index.articles.len(), 2);
937 assert_eq!(index.get_next_number(), 3);
938 }
939
940 #[test]
941 fn test_related_article() {
942 let related = RelatedArticle::new(
943 Uuid::new_v4(),
944 "KB-0002",
945 "PII Handling",
946 ArticleRelationship::Related,
947 );
948
949 assert_eq!(related.article_number, "KB-0002");
950 assert_eq!(related.relationship, ArticleRelationship::Related);
951 }
952
953 #[test]
954 fn test_knowledge_type_display() {
955 assert_eq!(format!("{}", KnowledgeType::Guide), "Guide");
956 assert_eq!(format!("{}", KnowledgeType::Standard), "Standard");
957 assert_eq!(format!("{}", KnowledgeType::HowTo), "How-To");
958 assert_eq!(format!("{}", KnowledgeType::Concept), "Concept");
959 assert_eq!(format!("{}", KnowledgeType::Runbook), "Runbook");
960 }
961
962 #[test]
963 fn test_knowledge_status_display() {
964 assert_eq!(format!("{}", KnowledgeStatus::Draft), "Draft");
965 assert_eq!(format!("{}", KnowledgeStatus::Review), "Review");
966 assert_eq!(format!("{}", KnowledgeStatus::Published), "Published");
967 assert_eq!(format!("{}", KnowledgeStatus::Archived), "Archived");
968 }
969
970 #[test]
971 fn test_timestamp_number_generation() {
972 use chrono::TimeZone;
973 let dt = Utc.with_ymd_and_hms(2026, 1, 10, 14, 30, 0).unwrap();
974 let number = KnowledgeArticle::generate_timestamp_number(&dt);
975 assert_eq!(number, 2601101430);
976 }
977
978 #[test]
979 fn test_is_timestamp_number() {
980 let sequential_article =
981 KnowledgeArticle::new(1, "Test", "Summary", "Content", "author@example.com");
982 assert!(!sequential_article.is_timestamp_number());
983
984 let timestamp_article = KnowledgeArticle::new(
985 2601101430,
986 "Test",
987 "Summary",
988 "Content",
989 "author@example.com",
990 );
991 assert!(timestamp_article.is_timestamp_number());
992 }
993
994 #[test]
995 fn test_timestamp_article_filename() {
996 let article = KnowledgeArticle::new(
997 2601101430,
998 "Test",
999 "Summary",
1000 "Content",
1001 "author@example.com",
1002 );
1003 assert_eq!(
1004 article.filename("enterprise"),
1005 "enterprise_kb-2601101430.kb.yaml"
1006 );
1007 }
1008
1009 #[test]
1010 fn test_timestamp_article_markdown_filename() {
1011 let article = KnowledgeArticle::new(
1012 2601101430,
1013 "Test Article",
1014 "Summary",
1015 "Content",
1016 "author@example.com",
1017 );
1018 let filename = article.markdown_filename();
1019 assert!(filename.starts_with("KB-2601101430-"));
1020 assert!(filename.ends_with(".md"));
1021 }
1022
1023 #[test]
1024 fn test_article_with_multiple_authors() {
1025 let article = KnowledgeArticle::new(1, "Test", "Summary", "Content", "author1@example.com")
1026 .add_author("author2@example.com")
1027 .add_author("author3@example.com");
1028
1029 assert_eq!(article.authors.len(), 3);
1030 }
1031
1032 #[test]
1033 fn test_knowledge_index_with_timestamp_numbering() {
1034 let index = KnowledgeIndex::new_with_timestamp_numbering();
1035 assert!(index.use_timestamp_numbering);
1036
1037 let next = index.get_next_number();
1039 assert!(next >= 1000000000); }
1041}