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 SketchStatus {
39 #[default]
41 Draft,
42 Review,
44 Published,
46 Archived,
48}
49
50impl std::fmt::Display for SketchStatus {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 match self {
53 SketchStatus::Draft => write!(f, "Draft"),
54 SketchStatus::Review => write!(f, "Review"),
55 SketchStatus::Published => write!(f, "Published"),
56 SketchStatus::Archived => write!(f, "Archived"),
57 }
58 }
59}
60
61#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
63#[serde(rename_all = "lowercase")]
64pub enum SketchType {
65 #[default]
67 Architecture,
68 DataFlow,
70 EntityRelationship,
72 Sequence,
74 Flowchart,
76 Wireframe,
78 Concept,
80 Infrastructure,
82 Other,
84}
85
86impl std::fmt::Display for SketchType {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 match self {
89 SketchType::Architecture => write!(f, "Architecture"),
90 SketchType::DataFlow => write!(f, "Data Flow"),
91 SketchType::EntityRelationship => write!(f, "Entity Relationship"),
92 SketchType::Sequence => write!(f, "Sequence"),
93 SketchType::Flowchart => write!(f, "Flowchart"),
94 SketchType::Wireframe => write!(f, "Wireframe"),
95 SketchType::Concept => write!(f, "Concept"),
96 SketchType::Infrastructure => write!(f, "Infrastructure"),
97 SketchType::Other => write!(f, "Other"),
98 }
99 }
100}
101
102fn deserialize_sketch_number<'de, D>(deserializer: D) -> Result<u64, D::Error>
106where
107 D: serde::Deserializer<'de>,
108{
109 use serde::de::{self, Visitor};
110
111 struct NumberVisitor;
112
113 impl<'de> Visitor<'de> for NumberVisitor {
114 type Value = u64;
115
116 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
117 formatter.write_str("a number or a string like 'SKETCH-0001'")
118 }
119
120 fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
121 where
122 E: de::Error,
123 {
124 Ok(value)
125 }
126
127 fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
128 where
129 E: de::Error,
130 {
131 if value >= 0 {
132 Ok(value as u64)
133 } else {
134 Err(E::custom("negative numbers are not allowed"))
135 }
136 }
137
138 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
139 where
140 E: de::Error,
141 {
142 let num_str = value
144 .to_uppercase()
145 .strip_prefix("SKETCH-")
146 .map(|s| s.to_string())
147 .unwrap_or_else(|| value.to_string());
148
149 num_str
150 .parse::<u64>()
151 .map_err(|_| E::custom(format!("invalid sketch number format: {}", value)))
152 }
153 }
154
155 deserializer.deserialize_any(NumberVisitor)
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
163#[serde(rename_all = "camelCase")]
164pub struct Sketch {
165 pub id: Uuid,
167 #[serde(deserialize_with = "deserialize_sketch_number")]
170 pub number: u64,
171 pub title: String,
173 #[serde(alias = "sketch_type")]
175 pub sketch_type: SketchType,
176 pub status: SketchStatus,
178 #[serde(skip_serializing_if = "Option::is_none")]
180 pub domain: Option<String>,
181 #[serde(skip_serializing_if = "Option::is_none", alias = "domain_id")]
183 pub domain_id: Option<Uuid>,
184 #[serde(skip_serializing_if = "Option::is_none", alias = "workspace_id")]
186 pub workspace_id: Option<Uuid>,
187
188 #[serde(skip_serializing_if = "Option::is_none")]
191 pub description: Option<String>,
192 #[serde(alias = "excalidraw_data")]
194 pub excalidraw_data: String,
195 #[serde(skip_serializing_if = "Option::is_none", alias = "thumbnail_path")]
197 pub thumbnail_path: Option<String>,
198
199 #[serde(default, skip_serializing_if = "Vec::is_empty")]
202 pub authors: Vec<String>,
203
204 #[serde(
207 default,
208 skip_serializing_if = "Vec::is_empty",
209 alias = "linked_assets"
210 )]
211 pub linked_assets: Vec<AssetLink>,
212 #[serde(
214 default,
215 skip_serializing_if = "Vec::is_empty",
216 alias = "linked_decisions"
217 )]
218 pub linked_decisions: Vec<Uuid>,
219 #[serde(
221 default,
222 skip_serializing_if = "Vec::is_empty",
223 alias = "linked_knowledge"
224 )]
225 pub linked_knowledge: Vec<Uuid>,
226 #[serde(
228 default,
229 skip_serializing_if = "Vec::is_empty",
230 alias = "related_sketches"
231 )]
232 pub related_sketches: Vec<Uuid>,
233
234 #[serde(default, skip_serializing_if = "Vec::is_empty")]
237 pub tags: Vec<Tag>,
238 #[serde(skip_serializing_if = "Option::is_none")]
240 pub notes: Option<String>,
241
242 #[serde(alias = "created_at")]
244 pub created_at: DateTime<Utc>,
245 #[serde(alias = "updated_at")]
247 pub updated_at: DateTime<Utc>,
248}
249
250impl Sketch {
251 pub fn new(number: u64, title: impl Into<String>, excalidraw_data: impl Into<String>) -> Self {
253 let now = Utc::now();
254 Self {
255 id: Self::generate_id(number),
256 number,
257 title: title.into(),
258 sketch_type: SketchType::Architecture,
259 status: SketchStatus::Draft,
260 domain: None,
261 domain_id: None,
262 workspace_id: None,
263 description: None,
264 excalidraw_data: excalidraw_data.into(),
265 thumbnail_path: None,
266 authors: Vec::new(),
267 linked_assets: Vec::new(),
268 linked_decisions: Vec::new(),
269 linked_knowledge: Vec::new(),
270 related_sketches: Vec::new(),
271 tags: Vec::new(),
272 notes: None,
273 created_at: now,
274 updated_at: now,
275 }
276 }
277
278 pub fn new_with_timestamp(
281 title: impl Into<String>,
282 excalidraw_data: impl Into<String>,
283 ) -> Self {
284 let now = Utc::now();
285 let number = Self::generate_timestamp_number(&now);
286 Self::new(number, title, excalidraw_data)
287 }
288
289 pub fn generate_timestamp_number(dt: &DateTime<Utc>) -> u64 {
291 let formatted = dt.format("%y%m%d%H%M").to_string();
292 formatted.parse().unwrap_or(0)
293 }
294
295 pub fn generate_id(number: u64) -> Uuid {
297 let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(); let name = format!("sketch:{}", number);
300 Uuid::new_v5(&namespace, name.as_bytes())
301 }
302
303 pub fn is_timestamp_number(&self) -> bool {
305 self.number >= 1000000000 && self.number <= 9999999999
306 }
307
308 pub fn formatted_number(&self) -> String {
311 if self.is_timestamp_number() {
312 format!("SKETCH-{}", self.number)
313 } else {
314 format!("SKETCH-{:04}", self.number)
315 }
316 }
317
318 pub fn filename(&self, workspace_name: &str) -> String {
320 let number_str = if self.is_timestamp_number() {
321 format!("{}", self.number)
322 } else {
323 format!("{:04}", self.number)
324 };
325
326 match &self.domain {
327 Some(domain) => format!(
328 "{}_{}_sketch-{}.sketch.yaml",
329 sanitize_name(workspace_name),
330 sanitize_name(domain),
331 number_str
332 ),
333 None => format!(
334 "{}_sketch-{}.sketch.yaml",
335 sanitize_name(workspace_name),
336 number_str
337 ),
338 }
339 }
340
341 pub fn thumbnail_filename(&self) -> String {
343 let number_str = if self.is_timestamp_number() {
344 format!("{}", self.number)
345 } else {
346 format!("{:04}", self.number)
347 };
348 format!("thumbnails/sketch-{}.png", number_str)
349 }
350
351 pub fn with_type(mut self, sketch_type: SketchType) -> Self {
353 self.sketch_type = sketch_type;
354 self.updated_at = Utc::now();
355 self
356 }
357
358 pub fn with_status(mut self, status: SketchStatus) -> Self {
360 self.status = status;
361 self.updated_at = Utc::now();
362 self
363 }
364
365 pub fn with_domain(mut self, domain: impl Into<String>) -> Self {
367 self.domain = Some(domain.into());
368 self.updated_at = Utc::now();
369 self
370 }
371
372 pub fn with_domain_id(mut self, domain_id: Uuid) -> Self {
374 self.domain_id = Some(domain_id);
375 self.updated_at = Utc::now();
376 self
377 }
378
379 pub fn with_workspace_id(mut self, workspace_id: Uuid) -> Self {
381 self.workspace_id = Some(workspace_id);
382 self.updated_at = Utc::now();
383 self
384 }
385
386 pub fn with_description(mut self, description: impl Into<String>) -> Self {
388 self.description = Some(description.into());
389 self.updated_at = Utc::now();
390 self
391 }
392
393 pub fn with_thumbnail(mut self, thumbnail_path: impl Into<String>) -> Self {
395 self.thumbnail_path = Some(thumbnail_path.into());
396 self.updated_at = Utc::now();
397 self
398 }
399
400 pub fn add_author(mut self, author: impl Into<String>) -> Self {
402 self.authors.push(author.into());
403 self.updated_at = Utc::now();
404 self
405 }
406
407 pub fn add_asset_link(mut self, link: AssetLink) -> Self {
409 self.linked_assets.push(link);
410 self.updated_at = Utc::now();
411 self
412 }
413
414 pub fn link_decision(mut self, decision_id: Uuid) -> Self {
416 if !self.linked_decisions.contains(&decision_id) {
417 self.linked_decisions.push(decision_id);
418 self.updated_at = Utc::now();
419 }
420 self
421 }
422
423 pub fn link_knowledge(mut self, knowledge_id: Uuid) -> Self {
425 if !self.linked_knowledge.contains(&knowledge_id) {
426 self.linked_knowledge.push(knowledge_id);
427 self.updated_at = Utc::now();
428 }
429 self
430 }
431
432 pub fn add_related_sketch(mut self, sketch_id: Uuid) -> Self {
434 if !self.related_sketches.contains(&sketch_id) {
435 self.related_sketches.push(sketch_id);
436 self.updated_at = Utc::now();
437 }
438 self
439 }
440
441 pub fn add_tag(mut self, tag: Tag) -> Self {
443 self.tags.push(tag);
444 self.updated_at = Utc::now();
445 self
446 }
447
448 pub fn with_notes(mut self, notes: impl Into<String>) -> Self {
450 self.notes = Some(notes.into());
451 self.updated_at = Utc::now();
452 self
453 }
454
455 pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
457 serde_yaml::from_str(yaml_content)
458 }
459
460 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
462 serde_yaml::to_string(self)
463 }
464}
465
466#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
468#[serde(rename_all = "camelCase")]
469pub struct SketchIndexEntry {
470 pub number: u64,
472 pub id: Uuid,
474 pub title: String,
476 #[serde(alias = "sketch_type")]
478 pub sketch_type: SketchType,
479 pub status: SketchStatus,
481 #[serde(skip_serializing_if = "Option::is_none")]
483 pub domain: Option<String>,
484 pub file: String,
486 #[serde(skip_serializing_if = "Option::is_none", alias = "thumbnail_path")]
488 pub thumbnail_path: Option<String>,
489}
490
491impl From<&Sketch> for SketchIndexEntry {
492 fn from(sketch: &Sketch) -> Self {
493 Self {
494 number: sketch.number,
495 id: sketch.id,
496 title: sketch.title.clone(),
497 sketch_type: sketch.sketch_type.clone(),
498 status: sketch.status.clone(),
499 domain: sketch.domain.clone(),
500 file: String::new(), thumbnail_path: sketch.thumbnail_path.clone(),
502 }
503 }
504}
505
506#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
508#[serde(rename_all = "camelCase")]
509pub struct SketchIndex {
510 #[serde(alias = "schema_version")]
512 pub schema_version: String,
513 #[serde(skip_serializing_if = "Option::is_none", alias = "last_updated")]
515 pub last_updated: Option<DateTime<Utc>>,
516 #[serde(default)]
518 pub sketches: Vec<SketchIndexEntry>,
519 #[serde(alias = "next_number")]
521 pub next_number: u64,
522 #[serde(default, alias = "use_timestamp_numbering")]
524 pub use_timestamp_numbering: bool,
525}
526
527impl Default for SketchIndex {
528 fn default() -> Self {
529 Self::new()
530 }
531}
532
533impl SketchIndex {
534 pub fn new() -> Self {
536 Self {
537 schema_version: "1.0".to_string(),
538 last_updated: Some(Utc::now()),
539 sketches: Vec::new(),
540 next_number: 1,
541 use_timestamp_numbering: false,
542 }
543 }
544
545 pub fn new_with_timestamp_numbering() -> Self {
547 Self {
548 schema_version: "1.0".to_string(),
549 last_updated: Some(Utc::now()),
550 sketches: Vec::new(),
551 next_number: 1,
552 use_timestamp_numbering: true,
553 }
554 }
555
556 pub fn add_sketch(&mut self, sketch: &Sketch, filename: String) {
558 let mut entry = SketchIndexEntry::from(sketch);
559 entry.file = filename;
560
561 self.sketches.retain(|s| s.number != sketch.number);
563 self.sketches.push(entry);
564
565 self.sketches.sort_by(|a, b| a.number.cmp(&b.number));
567
568 if !self.use_timestamp_numbering && sketch.number >= self.next_number {
570 self.next_number = sketch.number + 1;
571 }
572
573 self.last_updated = Some(Utc::now());
574 }
575
576 pub fn get_next_number(&self) -> u64 {
580 if self.use_timestamp_numbering {
581 Sketch::generate_timestamp_number(&Utc::now())
582 } else {
583 self.next_number
584 }
585 }
586
587 pub fn find_by_number(&self, number: u64) -> Option<&SketchIndexEntry> {
589 self.sketches.iter().find(|s| s.number == number)
590 }
591
592 pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
594 serde_yaml::from_str(yaml_content)
595 }
596
597 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
599 serde_yaml::to_string(self)
600 }
601}
602
603fn sanitize_name(name: &str) -> String {
605 name.chars()
606 .map(|c| match c {
607 ' ' | '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
608 _ => c,
609 })
610 .collect::<String>()
611 .to_lowercase()
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617
618 #[test]
619 fn test_sketch_new() {
620 let sketch = Sketch::new(1, "Architecture Diagram", "{}");
621
622 assert_eq!(sketch.number, 1);
623 assert_eq!(sketch.formatted_number(), "SKETCH-0001");
624 assert_eq!(sketch.title, "Architecture Diagram");
625 assert_eq!(sketch.status, SketchStatus::Draft);
626 assert_eq!(sketch.sketch_type, SketchType::Architecture);
627 }
628
629 #[test]
630 fn test_sketch_builder_pattern() {
631 let sketch = Sketch::new(1, "Test", "{}")
632 .with_type(SketchType::DataFlow)
633 .with_status(SketchStatus::Published)
634 .with_domain("sales")
635 .with_description("Test description")
636 .add_author("architect@example.com");
637
638 assert_eq!(sketch.sketch_type, SketchType::DataFlow);
639 assert_eq!(sketch.status, SketchStatus::Published);
640 assert_eq!(sketch.domain, Some("sales".to_string()));
641 assert_eq!(sketch.description, Some("Test description".to_string()));
642 assert_eq!(sketch.authors.len(), 1);
643 }
644
645 #[test]
646 fn test_sketch_id_generation() {
647 let id1 = Sketch::generate_id(1);
648 let id2 = Sketch::generate_id(1);
649 let id3 = Sketch::generate_id(2);
650
651 assert_eq!(id1, id2);
653 assert_ne!(id1, id3);
655 }
656
657 #[test]
658 fn test_sketch_filename() {
659 let sketch = Sketch::new(1, "Test", "{}");
660 assert_eq!(
661 sketch.filename("enterprise"),
662 "enterprise_sketch-0001.sketch.yaml"
663 );
664
665 let sketch_with_domain = sketch.with_domain("sales");
666 assert_eq!(
667 sketch_with_domain.filename("enterprise"),
668 "enterprise_sales_sketch-0001.sketch.yaml"
669 );
670 }
671
672 #[test]
673 fn test_sketch_thumbnail_filename() {
674 let sketch = Sketch::new(1, "Test", "{}");
675 assert_eq!(sketch.thumbnail_filename(), "thumbnails/sketch-0001.png");
676
677 let timestamp_sketch = Sketch::new(2601101430, "Test", "{}");
678 assert_eq!(
679 timestamp_sketch.thumbnail_filename(),
680 "thumbnails/sketch-2601101430.png"
681 );
682 }
683
684 #[test]
685 fn test_sketch_yaml_roundtrip() {
686 let sketch = Sketch::new(1, "Test Sketch", r#"{"elements":[]}"#)
687 .with_status(SketchStatus::Published)
688 .with_domain("test");
689
690 let yaml = sketch.to_yaml().unwrap();
691 let parsed = Sketch::from_yaml(&yaml).unwrap();
692
693 assert_eq!(sketch.id, parsed.id);
694 assert_eq!(sketch.title, parsed.title);
695 assert_eq!(sketch.status, parsed.status);
696 assert_eq!(sketch.domain, parsed.domain);
697 }
698
699 #[test]
700 fn test_sketch_index() {
701 let mut index = SketchIndex::new();
702 assert_eq!(index.get_next_number(), 1);
703
704 let sketch1 = Sketch::new(1, "First", "{}");
705 index.add_sketch(&sketch1, "test_sketch-0001.sketch.yaml".to_string());
706
707 assert_eq!(index.sketches.len(), 1);
708 assert_eq!(index.get_next_number(), 2);
709
710 let sketch2 = Sketch::new(2, "Second", "{}");
711 index.add_sketch(&sketch2, "test_sketch-0002.sketch.yaml".to_string());
712
713 assert_eq!(index.sketches.len(), 2);
714 assert_eq!(index.get_next_number(), 3);
715 }
716
717 #[test]
718 fn test_sketch_type_display() {
719 assert_eq!(format!("{}", SketchType::Architecture), "Architecture");
720 assert_eq!(format!("{}", SketchType::DataFlow), "Data Flow");
721 assert_eq!(
722 format!("{}", SketchType::EntityRelationship),
723 "Entity Relationship"
724 );
725 assert_eq!(format!("{}", SketchType::Concept), "Concept");
726 }
727
728 #[test]
729 fn test_sketch_status_display() {
730 assert_eq!(format!("{}", SketchStatus::Draft), "Draft");
731 assert_eq!(format!("{}", SketchStatus::Review), "Review");
732 assert_eq!(format!("{}", SketchStatus::Published), "Published");
733 assert_eq!(format!("{}", SketchStatus::Archived), "Archived");
734 }
735
736 #[test]
737 fn test_timestamp_number_generation() {
738 use chrono::TimeZone;
739 let dt = Utc.with_ymd_and_hms(2026, 1, 10, 14, 30, 0).unwrap();
740 let number = Sketch::generate_timestamp_number(&dt);
741 assert_eq!(number, 2601101430);
742 }
743
744 #[test]
745 fn test_is_timestamp_number() {
746 let sequential_sketch = Sketch::new(1, "Test", "{}");
747 assert!(!sequential_sketch.is_timestamp_number());
748
749 let timestamp_sketch = Sketch::new(2601101430, "Test", "{}");
750 assert!(timestamp_sketch.is_timestamp_number());
751 }
752
753 #[test]
754 fn test_timestamp_sketch_filename() {
755 let sketch = Sketch::new(2601101430, "Test", "{}");
756 assert_eq!(
757 sketch.filename("enterprise"),
758 "enterprise_sketch-2601101430.sketch.yaml"
759 );
760 }
761
762 #[test]
763 fn test_sketch_index_with_timestamp_numbering() {
764 let index = SketchIndex::new_with_timestamp_numbering();
765 assert!(index.use_timestamp_numbering);
766
767 let next = index.get_next_number();
769 assert!(next >= 1000000000); }
771
772 #[test]
773 fn test_sketch_linking() {
774 let decision_id = Uuid::new_v4();
775 let knowledge_id = Uuid::new_v4();
776 let sketch_id = Uuid::new_v4();
777
778 let sketch = Sketch::new(1, "Test", "{}")
779 .link_decision(decision_id)
780 .link_knowledge(knowledge_id)
781 .add_related_sketch(sketch_id);
782
783 assert_eq!(sketch.linked_decisions.len(), 1);
784 assert_eq!(sketch.linked_knowledge.len(), 1);
785 assert_eq!(sketch.related_sketches.len(), 1);
786 }
787}