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 #[serde(
370 default,
371 skip_serializing_if = "Vec::is_empty",
372 alias = "linked_sketches"
373 )]
374 pub linked_sketches: Vec<Uuid>,
375
376 #[serde(default, skip_serializing_if = "Vec::is_empty")]
379 pub tags: Vec<Tag>,
380 #[serde(skip_serializing_if = "Option::is_none")]
382 pub notes: Option<String>,
383
384 #[serde(alias = "created_at")]
386 pub created_at: DateTime<Utc>,
387 #[serde(alias = "updated_at")]
389 pub updated_at: DateTime<Utc>,
390}
391
392impl KnowledgeArticle {
393 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 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 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 pub fn generate_id(number: u64) -> Uuid {
457 let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(); let name = format!("knowledge:{}", number);
460 Uuid::new_v5(&namespace, name.as_bytes())
461 }
462
463 pub fn is_timestamp_number(&self) -> bool {
465 self.number >= 1000000000 && self.number <= 9999999999
466 }
467
468 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 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 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 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 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 pub fn with_status(mut self, status: KnowledgeStatus) -> Self {
508 self.status = status;
509 self.updated_at = Utc::now();
510 self
511 }
512
513 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn markdown_filename(&self) -> String {
661 let slug = slugify(&self.title);
662 format!("{}-{}.md", self.formatted_number(), slug)
663 }
664
665 pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
667 serde_yaml::from_str(yaml_content)
668 }
669
670 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
672 serde_yaml::to_string(self)
673 }
674}
675
676#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
678#[serde(rename_all = "camelCase")]
679pub struct KnowledgeIndexEntry {
680 pub number: u64,
682 pub id: Uuid,
684 pub title: String,
686 #[serde(alias = "article_type")]
688 pub article_type: KnowledgeType,
689 pub status: KnowledgeStatus,
691 #[serde(skip_serializing_if = "Option::is_none")]
693 pub domain: Option<String>,
694 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(), }
709 }
710}
711
712#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
714#[serde(rename_all = "camelCase")]
715pub struct KnowledgeIndex {
716 #[serde(alias = "schema_version")]
718 pub schema_version: String,
719 #[serde(skip_serializing_if = "Option::is_none", alias = "last_updated")]
721 pub last_updated: Option<DateTime<Utc>>,
722 #[serde(default)]
724 pub articles: Vec<KnowledgeIndexEntry>,
725 #[serde(alias = "next_number")]
727 pub next_number: u64,
728 #[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 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 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 pub fn add_article(&mut self, article: &KnowledgeArticle, filename: String) {
764 let mut entry = KnowledgeIndexEntry::from(article);
765 entry.file = filename;
766
767 self.articles.retain(|a| a.number != article.number);
769 self.articles.push(entry);
770
771 self.articles.sort_by(|a, b| a.number.cmp(&b.number));
773
774 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 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 pub fn find_by_number(&self, number: u64) -> Option<&KnowledgeIndexEntry> {
795 self.articles.iter().find(|a| a.number == number)
796 }
797
798 pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
800 serde_yaml::from_str(yaml_content)
801 }
802
803 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
805 serde_yaml::to_string(self)
806 }
807}
808
809fn 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
820fn 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) .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 assert_eq!(id1, id2);
886 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 let next = index.get_next_number();
1056 assert!(next >= 1000000000); }
1058}