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)]
179pub struct RelatedArticle {
180 pub article_id: Uuid,
182 pub article_number: String,
184 pub title: String,
186 pub relationship: ArticleRelationship,
188}
189
190impl RelatedArticle {
191 pub fn new(
193 article_id: Uuid,
194 article_number: impl Into<String>,
195 title: impl Into<String>,
196 relationship: ArticleRelationship,
197 ) -> Self {
198 Self {
199 article_id,
200 article_number: article_number.into(),
201 title: title.into(),
202 relationship,
203 }
204 }
205}
206
207fn deserialize_knowledge_number<'de, D>(deserializer: D) -> Result<u64, D::Error>
211where
212 D: serde::Deserializer<'de>,
213{
214 use serde::de::{self, Visitor};
215
216 struct NumberVisitor;
217
218 impl<'de> Visitor<'de> for NumberVisitor {
219 type Value = u64;
220
221 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
222 formatter.write_str("a number or a string like 'KB-0001'")
223 }
224
225 fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
226 where
227 E: de::Error,
228 {
229 Ok(value)
230 }
231
232 fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
233 where
234 E: de::Error,
235 {
236 if value >= 0 {
237 Ok(value as u64)
238 } else {
239 Err(E::custom("negative numbers are not allowed"))
240 }
241 }
242
243 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
244 where
245 E: de::Error,
246 {
247 let num_str = value
249 .to_uppercase()
250 .strip_prefix("KB-")
251 .map(|s| s.to_string())
252 .unwrap_or_else(|| value.to_string());
253
254 num_str
255 .parse::<u64>()
256 .map_err(|_| E::custom(format!("invalid knowledge number format: {}", value)))
257 }
258 }
259
260 deserializer.deserialize_any(NumberVisitor)
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
268#[serde(rename_all = "camelCase")]
269pub struct KnowledgeArticle {
270 pub id: Uuid,
272 #[serde(deserialize_with = "deserialize_knowledge_number")]
275 pub number: u64,
276 pub title: String,
278 pub article_type: KnowledgeType,
280 pub status: KnowledgeStatus,
282 #[serde(skip_serializing_if = "Option::is_none")]
284 pub domain: Option<String>,
285 #[serde(skip_serializing_if = "Option::is_none")]
287 pub domain_id: Option<Uuid>,
288 #[serde(skip_serializing_if = "Option::is_none")]
290 pub workspace_id: Option<Uuid>,
291
292 pub summary: String,
295 pub content: String,
297
298 #[serde(default, skip_serializing_if = "Vec::is_empty")]
301 pub authors: Vec<String>,
302 #[serde(default, skip_serializing_if = "Vec::is_empty")]
304 pub reviewers: Vec<String>,
305 #[serde(skip_serializing_if = "Option::is_none")]
307 pub last_reviewed: Option<DateTime<Utc>>,
308 #[serde(skip_serializing_if = "Option::is_none")]
310 pub reviewed_at: Option<DateTime<Utc>>,
311 #[serde(skip_serializing_if = "Option::is_none")]
313 pub published_at: Option<DateTime<Utc>>,
314 #[serde(skip_serializing_if = "Option::is_none")]
316 pub archived_at: Option<DateTime<Utc>>,
317 #[serde(skip_serializing_if = "Option::is_none")]
319 pub review_frequency: Option<ReviewFrequency>,
320
321 #[serde(default, skip_serializing_if = "Vec::is_empty")]
324 pub audience: Vec<String>,
325 #[serde(skip_serializing_if = "Option::is_none")]
327 pub skill_level: Option<SkillLevel>,
328
329 #[serde(default, skip_serializing_if = "Vec::is_empty")]
332 pub linked_assets: Vec<AssetLink>,
333 #[serde(default, skip_serializing_if = "Vec::is_empty")]
335 pub linked_decisions: Vec<Uuid>,
336 #[serde(default, skip_serializing_if = "Vec::is_empty")]
338 pub related_decisions: Vec<Uuid>,
339 #[serde(default, skip_serializing_if = "Vec::is_empty")]
341 pub related_articles: Vec<RelatedArticle>,
342 #[serde(default, skip_serializing_if = "Vec::is_empty")]
344 pub prerequisites: Vec<Uuid>,
345 #[serde(default, skip_serializing_if = "Vec::is_empty")]
347 pub see_also: Vec<Uuid>,
348
349 #[serde(default, skip_serializing_if = "Vec::is_empty")]
352 pub tags: Vec<Tag>,
353 #[serde(skip_serializing_if = "Option::is_none")]
355 pub notes: Option<String>,
356
357 pub created_at: DateTime<Utc>,
359 pub updated_at: DateTime<Utc>,
361}
362
363impl KnowledgeArticle {
364 pub fn new(
366 number: u64,
367 title: impl Into<String>,
368 summary: impl Into<String>,
369 content: impl Into<String>,
370 author: impl Into<String>,
371 ) -> Self {
372 let now = Utc::now();
373 Self {
374 id: Self::generate_id(number),
375 number,
376 title: title.into(),
377 article_type: KnowledgeType::Guide,
378 status: KnowledgeStatus::Draft,
379 domain: None,
380 domain_id: None,
381 workspace_id: None,
382 summary: summary.into(),
383 content: content.into(),
384 authors: vec![author.into()],
385 reviewers: Vec::new(),
386 last_reviewed: None,
387 reviewed_at: None,
388 published_at: None,
389 archived_at: None,
390 review_frequency: None,
391 audience: Vec::new(),
392 skill_level: None,
393 linked_assets: Vec::new(),
394 linked_decisions: Vec::new(),
395 related_decisions: Vec::new(),
396 related_articles: Vec::new(),
397 prerequisites: Vec::new(),
398 see_also: Vec::new(),
399 tags: Vec::new(),
400 notes: None,
401 created_at: now,
402 updated_at: now,
403 }
404 }
405
406 pub fn new_with_timestamp(
409 title: impl Into<String>,
410 summary: impl Into<String>,
411 content: impl Into<String>,
412 author: impl Into<String>,
413 ) -> Self {
414 let now = Utc::now();
415 let number = Self::generate_timestamp_number(&now);
416 Self::new(number, title, summary, content, author)
417 }
418
419 pub fn generate_timestamp_number(dt: &DateTime<Utc>) -> u64 {
421 let formatted = dt.format("%y%m%d%H%M").to_string();
422 formatted.parse().unwrap_or(0)
423 }
424
425 pub fn generate_id(number: u64) -> Uuid {
427 let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(); let name = format!("knowledge:{}", number);
430 Uuid::new_v5(&namespace, name.as_bytes())
431 }
432
433 pub fn is_timestamp_number(&self) -> bool {
435 self.number >= 1000000000 && self.number <= 9999999999
436 }
437
438 pub fn formatted_number(&self) -> String {
441 if self.is_timestamp_number() {
442 format!("KB-{}", self.number)
443 } else {
444 format!("KB-{:04}", self.number)
445 }
446 }
447
448 pub fn add_author(mut self, author: impl Into<String>) -> Self {
450 self.authors.push(author.into());
451 self.updated_at = Utc::now();
452 self
453 }
454
455 pub fn with_domain_id(mut self, domain_id: Uuid) -> Self {
457 self.domain_id = Some(domain_id);
458 self.updated_at = Utc::now();
459 self
460 }
461
462 pub fn with_workspace_id(mut self, workspace_id: Uuid) -> Self {
464 self.workspace_id = Some(workspace_id);
465 self.updated_at = Utc::now();
466 self
467 }
468
469 pub fn with_type(mut self, article_type: KnowledgeType) -> Self {
471 self.article_type = article_type;
472 self.updated_at = Utc::now();
473 self
474 }
475
476 pub fn with_status(mut self, status: KnowledgeStatus) -> Self {
478 self.status = status;
479 self.updated_at = Utc::now();
480 self
481 }
482
483 pub fn with_domain(mut self, domain: impl Into<String>) -> Self {
485 self.domain = Some(domain.into());
486 self.updated_at = Utc::now();
487 self
488 }
489
490 pub fn add_reviewer(mut self, reviewer: impl Into<String>) -> Self {
492 self.reviewers.push(reviewer.into());
493 self.updated_at = Utc::now();
494 self
495 }
496
497 pub fn with_review_frequency(mut self, frequency: ReviewFrequency) -> Self {
499 self.review_frequency = Some(frequency);
500 self.updated_at = Utc::now();
501 self
502 }
503
504 pub fn add_audience(mut self, audience: impl Into<String>) -> Self {
506 self.audience.push(audience.into());
507 self.updated_at = Utc::now();
508 self
509 }
510
511 pub fn with_skill_level(mut self, level: SkillLevel) -> Self {
513 self.skill_level = Some(level);
514 self.updated_at = Utc::now();
515 self
516 }
517
518 pub fn add_asset_link(mut self, link: AssetLink) -> Self {
520 self.linked_assets.push(link);
521 self.updated_at = Utc::now();
522 self
523 }
524
525 pub fn link_decision(mut self, decision_id: Uuid) -> Self {
527 if !self.linked_decisions.contains(&decision_id) {
528 self.linked_decisions.push(decision_id);
529 self.updated_at = Utc::now();
530 }
531 self
532 }
533
534 pub fn add_related_article(mut self, article: RelatedArticle) -> Self {
536 self.related_articles.push(article);
537 self.updated_at = Utc::now();
538 self
539 }
540
541 pub fn add_related_decision(mut self, decision_id: Uuid) -> Self {
543 if !self.related_decisions.contains(&decision_id) {
544 self.related_decisions.push(decision_id);
545 self.updated_at = Utc::now();
546 }
547 self
548 }
549
550 pub fn add_prerequisite(mut self, article_id: Uuid) -> Self {
552 if !self.prerequisites.contains(&article_id) {
553 self.prerequisites.push(article_id);
554 self.updated_at = Utc::now();
555 }
556 self
557 }
558
559 pub fn add_see_also(mut self, article_id: Uuid) -> Self {
561 if !self.see_also.contains(&article_id) {
562 self.see_also.push(article_id);
563 self.updated_at = Utc::now();
564 }
565 self
566 }
567
568 pub fn with_published_at(mut self, published_at: DateTime<Utc>) -> Self {
570 self.published_at = Some(published_at);
571 self.updated_at = Utc::now();
572 self
573 }
574
575 pub fn with_archived_at(mut self, archived_at: DateTime<Utc>) -> Self {
577 self.archived_at = Some(archived_at);
578 self.updated_at = Utc::now();
579 self
580 }
581
582 pub fn add_tag(mut self, tag: Tag) -> Self {
584 self.tags.push(tag);
585 self.updated_at = Utc::now();
586 self
587 }
588
589 pub fn mark_reviewed(&mut self) {
591 let now = Utc::now();
592 self.last_reviewed = Some(now);
593 self.reviewed_at = Some(now);
594 self.updated_at = now;
595 }
596
597 pub fn filename(&self, workspace_name: &str) -> String {
599 let number_str = if self.is_timestamp_number() {
600 format!("{}", self.number)
601 } else {
602 format!("{:04}", self.number)
603 };
604
605 match &self.domain {
606 Some(domain) => format!(
607 "{}_{}_kb-{}.kb.yaml",
608 sanitize_name(workspace_name),
609 sanitize_name(domain),
610 number_str
611 ),
612 None => format!(
613 "{}_kb-{}.kb.yaml",
614 sanitize_name(workspace_name),
615 number_str
616 ),
617 }
618 }
619
620 pub fn markdown_filename(&self) -> String {
622 let slug = slugify(&self.title);
623 format!("{}-{}.md", self.formatted_number(), slug)
624 }
625
626 pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
628 serde_yaml::from_str(yaml_content)
629 }
630
631 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
633 serde_yaml::to_string(self)
634 }
635}
636
637#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
639#[serde(rename_all = "camelCase")]
640pub struct KnowledgeIndexEntry {
641 pub number: u64,
643 pub id: Uuid,
645 pub title: String,
647 pub article_type: KnowledgeType,
649 pub status: KnowledgeStatus,
651 #[serde(skip_serializing_if = "Option::is_none")]
653 pub domain: Option<String>,
654 pub file: String,
656}
657
658impl From<&KnowledgeArticle> for KnowledgeIndexEntry {
659 fn from(article: &KnowledgeArticle) -> Self {
660 Self {
661 number: article.number,
662 id: article.id,
663 title: article.title.clone(),
664 article_type: article.article_type.clone(),
665 status: article.status.clone(),
666 domain: article.domain.clone(),
667 file: String::new(), }
669 }
670}
671
672#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
674#[serde(rename_all = "camelCase")]
675pub struct KnowledgeIndex {
676 pub schema_version: String,
678 #[serde(skip_serializing_if = "Option::is_none")]
680 pub last_updated: Option<DateTime<Utc>>,
681 #[serde(default)]
683 pub articles: Vec<KnowledgeIndexEntry>,
684 pub next_number: u64,
686 #[serde(default)]
688 pub use_timestamp_numbering: bool,
689}
690
691impl Default for KnowledgeIndex {
692 fn default() -> Self {
693 Self::new()
694 }
695}
696
697impl KnowledgeIndex {
698 pub fn new() -> Self {
700 Self {
701 schema_version: "1.0".to_string(),
702 last_updated: Some(Utc::now()),
703 articles: Vec::new(),
704 next_number: 1,
705 use_timestamp_numbering: false,
706 }
707 }
708
709 pub fn new_with_timestamp_numbering() -> Self {
711 Self {
712 schema_version: "1.0".to_string(),
713 last_updated: Some(Utc::now()),
714 articles: Vec::new(),
715 next_number: 1,
716 use_timestamp_numbering: true,
717 }
718 }
719
720 pub fn add_article(&mut self, article: &KnowledgeArticle, filename: String) {
722 let mut entry = KnowledgeIndexEntry::from(article);
723 entry.file = filename;
724
725 self.articles.retain(|a| a.number != article.number);
727 self.articles.push(entry);
728
729 self.articles.sort_by(|a, b| a.number.cmp(&b.number));
731
732 if !self.use_timestamp_numbering && article.number >= self.next_number {
734 self.next_number = article.number + 1;
735 }
736
737 self.last_updated = Some(Utc::now());
738 }
739
740 pub fn get_next_number(&self) -> u64 {
744 if self.use_timestamp_numbering {
745 KnowledgeArticle::generate_timestamp_number(&Utc::now())
746 } else {
747 self.next_number
748 }
749 }
750
751 pub fn find_by_number(&self, number: u64) -> Option<&KnowledgeIndexEntry> {
753 self.articles.iter().find(|a| a.number == number)
754 }
755
756 pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
758 serde_yaml::from_str(yaml_content)
759 }
760
761 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
763 serde_yaml::to_string(self)
764 }
765}
766
767fn sanitize_name(name: &str) -> String {
769 name.chars()
770 .map(|c| match c {
771 ' ' | '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
772 _ => c,
773 })
774 .collect::<String>()
775 .to_lowercase()
776}
777
778fn slugify(title: &str) -> String {
780 title
781 .to_lowercase()
782 .chars()
783 .map(|c| if c.is_alphanumeric() { c } else { '-' })
784 .collect::<String>()
785 .split('-')
786 .filter(|s| !s.is_empty())
787 .collect::<Vec<_>>()
788 .join("-")
789 .chars()
790 .take(50) .collect()
792}
793
794#[cfg(test)]
795mod tests {
796 use super::*;
797
798 #[test]
799 fn test_knowledge_article_new() {
800 let article = KnowledgeArticle::new(
801 1,
802 "Data Classification Guide",
803 "This guide explains classification",
804 "## Overview\n\nData classification is important...",
805 "data-governance@example.com",
806 );
807
808 assert_eq!(article.number, 1);
809 assert_eq!(article.formatted_number(), "KB-0001");
810 assert_eq!(article.title, "Data Classification Guide");
811 assert_eq!(article.status, KnowledgeStatus::Draft);
812 assert_eq!(article.article_type, KnowledgeType::Guide);
813 assert_eq!(article.authors.len(), 1);
814 }
815
816 #[test]
817 fn test_knowledge_article_builder_pattern() {
818 let article = KnowledgeArticle::new(1, "Test", "Summary", "Content", "author@example.com")
819 .with_type(KnowledgeType::Standard)
820 .with_status(KnowledgeStatus::Published)
821 .with_domain("sales")
822 .add_reviewer("reviewer@example.com")
823 .with_review_frequency(ReviewFrequency::Quarterly)
824 .add_audience("data-engineers")
825 .with_skill_level(SkillLevel::Intermediate);
826
827 assert_eq!(article.article_type, KnowledgeType::Standard);
828 assert_eq!(article.status, KnowledgeStatus::Published);
829 assert_eq!(article.domain, Some("sales".to_string()));
830 assert_eq!(article.reviewers.len(), 1);
831 assert_eq!(article.review_frequency, Some(ReviewFrequency::Quarterly));
832 assert_eq!(article.audience.len(), 1);
833 assert_eq!(article.skill_level, Some(SkillLevel::Intermediate));
834 }
835
836 #[test]
837 fn test_knowledge_article_id_generation() {
838 let id1 = KnowledgeArticle::generate_id(1);
839 let id2 = KnowledgeArticle::generate_id(1);
840 let id3 = KnowledgeArticle::generate_id(2);
841
842 assert_eq!(id1, id2);
844 assert_ne!(id1, id3);
846 }
847
848 #[test]
849 fn test_knowledge_article_filename() {
850 let article = KnowledgeArticle::new(1, "Test", "Summary", "Content", "author@example.com");
851 assert_eq!(article.filename("enterprise"), "enterprise_kb-0001.kb.yaml");
852
853 let article_with_domain = article.with_domain("sales");
854 assert_eq!(
855 article_with_domain.filename("enterprise"),
856 "enterprise_sales_kb-0001.kb.yaml"
857 );
858 }
859
860 #[test]
861 fn test_knowledge_article_markdown_filename() {
862 let article = KnowledgeArticle::new(
863 1,
864 "Data Classification Guide",
865 "Summary",
866 "Content",
867 "author@example.com",
868 );
869 let filename = article.markdown_filename();
870 assert!(filename.starts_with("KB-0001-"));
871 assert!(filename.ends_with(".md"));
872 }
873
874 #[test]
875 fn test_knowledge_article_yaml_roundtrip() {
876 let article = KnowledgeArticle::new(
877 1,
878 "Test Article",
879 "Test summary",
880 "Test content",
881 "author@example.com",
882 )
883 .with_status(KnowledgeStatus::Published)
884 .with_domain("test");
885
886 let yaml = article.to_yaml().unwrap();
887 let parsed = KnowledgeArticle::from_yaml(&yaml).unwrap();
888
889 assert_eq!(article.id, parsed.id);
890 assert_eq!(article.title, parsed.title);
891 assert_eq!(article.status, parsed.status);
892 assert_eq!(article.domain, parsed.domain);
893 }
894
895 #[test]
896 fn test_knowledge_index() {
897 let mut index = KnowledgeIndex::new();
898 assert_eq!(index.get_next_number(), 1);
899
900 let article1 =
901 KnowledgeArticle::new(1, "First", "Summary", "Content", "author@example.com");
902 index.add_article(&article1, "test_kb-0001.kb.yaml".to_string());
903
904 assert_eq!(index.articles.len(), 1);
905 assert_eq!(index.get_next_number(), 2);
906
907 let article2 =
908 KnowledgeArticle::new(2, "Second", "Summary", "Content", "author@example.com");
909 index.add_article(&article2, "test_kb-0002.kb.yaml".to_string());
910
911 assert_eq!(index.articles.len(), 2);
912 assert_eq!(index.get_next_number(), 3);
913 }
914
915 #[test]
916 fn test_related_article() {
917 let related = RelatedArticle::new(
918 Uuid::new_v4(),
919 "KB-0002",
920 "PII Handling",
921 ArticleRelationship::Related,
922 );
923
924 assert_eq!(related.article_number, "KB-0002");
925 assert_eq!(related.relationship, ArticleRelationship::Related);
926 }
927
928 #[test]
929 fn test_knowledge_type_display() {
930 assert_eq!(format!("{}", KnowledgeType::Guide), "Guide");
931 assert_eq!(format!("{}", KnowledgeType::Standard), "Standard");
932 assert_eq!(format!("{}", KnowledgeType::HowTo), "How-To");
933 assert_eq!(format!("{}", KnowledgeType::Concept), "Concept");
934 assert_eq!(format!("{}", KnowledgeType::Runbook), "Runbook");
935 }
936
937 #[test]
938 fn test_knowledge_status_display() {
939 assert_eq!(format!("{}", KnowledgeStatus::Draft), "Draft");
940 assert_eq!(format!("{}", KnowledgeStatus::Review), "Review");
941 assert_eq!(format!("{}", KnowledgeStatus::Published), "Published");
942 assert_eq!(format!("{}", KnowledgeStatus::Archived), "Archived");
943 }
944
945 #[test]
946 fn test_timestamp_number_generation() {
947 use chrono::TimeZone;
948 let dt = Utc.with_ymd_and_hms(2026, 1, 10, 14, 30, 0).unwrap();
949 let number = KnowledgeArticle::generate_timestamp_number(&dt);
950 assert_eq!(number, 2601101430);
951 }
952
953 #[test]
954 fn test_is_timestamp_number() {
955 let sequential_article =
956 KnowledgeArticle::new(1, "Test", "Summary", "Content", "author@example.com");
957 assert!(!sequential_article.is_timestamp_number());
958
959 let timestamp_article = KnowledgeArticle::new(
960 2601101430,
961 "Test",
962 "Summary",
963 "Content",
964 "author@example.com",
965 );
966 assert!(timestamp_article.is_timestamp_number());
967 }
968
969 #[test]
970 fn test_timestamp_article_filename() {
971 let article = KnowledgeArticle::new(
972 2601101430,
973 "Test",
974 "Summary",
975 "Content",
976 "author@example.com",
977 );
978 assert_eq!(
979 article.filename("enterprise"),
980 "enterprise_kb-2601101430.kb.yaml"
981 );
982 }
983
984 #[test]
985 fn test_timestamp_article_markdown_filename() {
986 let article = KnowledgeArticle::new(
987 2601101430,
988 "Test Article",
989 "Summary",
990 "Content",
991 "author@example.com",
992 );
993 let filename = article.markdown_filename();
994 assert!(filename.starts_with("KB-2601101430-"));
995 assert!(filename.ends_with(".md"));
996 }
997
998 #[test]
999 fn test_article_with_multiple_authors() {
1000 let article = KnowledgeArticle::new(1, "Test", "Summary", "Content", "author1@example.com")
1001 .add_author("author2@example.com")
1002 .add_author("author3@example.com");
1003
1004 assert_eq!(article.authors.len(), 3);
1005 }
1006
1007 #[test]
1008 fn test_knowledge_index_with_timestamp_numbering() {
1009 let index = KnowledgeIndex::new_with_timestamp_numbering();
1010 assert!(index.use_timestamp_numbering);
1011
1012 let next = index.get_next_number();
1014 assert!(next >= 1000000000); }
1016}