1use chrono::{DateTime, Utc};
26use serde::{Deserialize, Serialize};
27use std::collections::HashSet;
28use thiserror::Error;
29use uuid::Uuid;
30
31#[derive(Error, Debug)]
33pub enum CornellNoteError {
34 #[error("JSON parsing error: {0}")]
35 JsonError(#[from] serde_json::Error),
36
37 #[error("Validation error: {0}")]
38 ValidationError(String),
39
40 #[error("IO error: {0}")]
41 IoError(#[from] std::io::Error),
42
43 #[error("Invalid version format: {0}")]
44 InvalidVersion(String),
45
46 #[error("Invalid UUID: {0}")]
47 InvalidUuid(String),
48
49 #[error("Invalid datetime: {0}")]
50 InvalidDatetime(String),
51}
52
53pub type Result<T> = std::result::Result<T, CornellNoteError>;
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57#[serde(rename_all = "lowercase")]
58pub enum CueType {
59 Text,
60 Question,
61 Keyword,
62 Custom,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67#[serde(rename_all = "lowercase")]
68pub enum NoteType {
69 Text,
70 List,
71 Code,
72 Image,
73 Link,
74 Custom,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79#[serde(rename_all = "lowercase")]
80pub enum ContentFormat {
81 Markdown,
82 Html,
83 Plain,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
88pub struct Attachment {
89 #[serde(rename = "type")]
90 pub attachment_type: String,
91 pub url: String,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 pub metadata: Option<serde_json::Value>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
98pub struct StructuredContent {
99 pub format: ContentFormat,
100 pub data: String,
101 #[serde(skip_serializing_if = "Option::is_none")]
102 pub attachments: Option<Vec<Attachment>>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
107#[serde(untagged)]
108pub enum Content {
109 Simple(String),
110 Structured(StructuredContent),
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
115pub struct Metadata {
116 pub id: Uuid,
117 pub title: String,
118 pub created: DateTime<Utc>,
119 pub modified: DateTime<Utc>,
120 #[serde(skip_serializing_if = "Option::is_none")]
121 pub subject: Option<String>,
122 #[serde(skip_serializing_if = "Option::is_none")]
123 pub topic: Option<String>,
124 #[serde(skip_serializing_if = "Option::is_none")]
125 pub author: Option<String>,
126 #[serde(skip_serializing_if = "Option::is_none")]
127 pub tags: Option<Vec<String>>,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
132pub struct Cue {
133 pub id: Uuid,
134 pub content: Content,
135 #[serde(rename = "type")]
136 pub cue_type: CueType,
137 pub position: u32,
138 pub created: DateTime<Utc>,
139 pub modified: DateTime<Utc>,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
144pub struct Note {
145 pub id: Uuid,
146 pub content: Content,
147 #[serde(rename = "type")]
148 pub note_type: NoteType,
149 pub position: u32,
150 pub created: DateTime<Utc>,
151 pub modified: DateTime<Utc>,
152 #[serde(skip_serializing_if = "Option::is_none", rename = "cueLinks")]
153 pub cue_links: Option<Vec<Uuid>>,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
158pub struct Section {
159 pub id: Uuid,
160 pub cues: Vec<Cue>,
161 pub notes: Vec<Note>,
162 pub position: u32,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
167pub struct Summary {
168 pub content: Content,
169 pub created: DateTime<Utc>,
170 pub modified: DateTime<Utc>,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
175pub struct CornellNote {
176 pub version: String,
177 pub metadata: Metadata,
178 pub sections: Vec<Section>,
179 pub summary: Summary,
180}
181
182impl Metadata {
183 pub fn validate(&self) -> Result<()> {
185 if self.title.is_empty() {
186 return Err(CornellNoteError::ValidationError(
187 "Title cannot be empty".to_string(),
188 ));
189 }
190
191 if self.modified < self.created {
192 return Err(CornellNoteError::ValidationError(
193 "Modified date cannot be before created date".to_string(),
194 ));
195 }
196
197 Ok(())
198 }
199}
200
201impl CornellNote {
202 pub fn new(title: String) -> Self {
204 let now = Utc::now();
205 CornellNote {
206 version: "1.0".to_string(),
207 metadata: Metadata {
208 id: Uuid::new_v4(),
209 title,
210 created: now,
211 modified: now,
212 subject: None,
213 topic: None,
214 author: None,
215 tags: None,
216 },
217 sections: vec![],
218 summary: Summary {
219 content: Content::Simple(String::new()),
220 created: now,
221 modified: now,
222 },
223 }
224 }
225
226 pub fn validate(&self) -> Result<()> {
228 let version_regex = regex::Regex::new(r"^[0-9]+\.[0-9]+$").unwrap();
230 if !version_regex.is_match(&self.version) {
231 return Err(CornellNoteError::InvalidVersion(format!(
232 "Version '{}' does not match required format (e.g., '1.0')",
233 self.version
234 )));
235 }
236
237 self.metadata.validate()?;
239
240 if self.sections.is_empty() {
242 return Err(CornellNoteError::ValidationError(
243 "Document must contain at least one section".to_string(),
244 ));
245 }
246
247 let mut section_ids = HashSet::new();
249 let mut section_positions = HashSet::new();
250
251 for section in &self.sections {
252 if !section_ids.insert(section.id) {
254 return Err(CornellNoteError::ValidationError(format!(
255 "Duplicate section ID: {}",
256 section.id
257 )));
258 }
259
260 if !section_positions.insert(section.position) {
262 return Err(CornellNoteError::ValidationError(format!(
263 "Duplicate section position: {}",
264 section.position
265 )));
266 }
267
268 section.validate()?;
269 }
270
271 self.summary.validate()?;
273
274 Ok(())
275 }
276
277 pub fn add_section(&mut self, section: Section) {
279 self.sections.push(section);
280 self.metadata.modified = Utc::now();
281 }
282
283 pub fn update_summary(&mut self, content: Content) {
285 self.summary.content = content;
286 self.summary.modified = Utc::now();
287 self.metadata.modified = Utc::now();
288 }
289}
290
291impl Section {
292 pub fn new(position: u32) -> Self {
294 Section {
295 id: Uuid::new_v4(),
296 cues: vec![],
297 notes: vec![],
298 position,
299 }
300 }
301
302 pub fn validate(&self) -> Result<()> {
304 let mut cue_ids = HashSet::new();
306 let mut cue_positions = HashSet::new();
307
308 for cue in &self.cues {
309 if !cue_ids.insert(cue.id) {
310 return Err(CornellNoteError::ValidationError(format!(
311 "Duplicate cue ID in section: {}",
312 cue.id
313 )));
314 }
315
316 if !cue_positions.insert(cue.position) {
317 return Err(CornellNoteError::ValidationError(format!(
318 "Duplicate cue position in section: {}",
319 cue.position
320 )));
321 }
322
323 cue.validate()?;
324 }
325
326 let mut note_ids = HashSet::new();
327 let mut note_positions = HashSet::new();
328
329 for note in &self.notes {
330 if !note_ids.insert(note.id) {
331 return Err(CornellNoteError::ValidationError(format!(
332 "Duplicate note ID in section: {}",
333 note.id
334 )));
335 }
336
337 if !note_positions.insert(note.position) {
338 return Err(CornellNoteError::ValidationError(format!(
339 "Duplicate note position in section: {}",
340 note.position
341 )));
342 }
343
344 note.validate()?;
345
346 if let Some(ref links) = note.cue_links {
348 for link in links {
349 if !cue_ids.contains(link) {
350 return Err(CornellNoteError::ValidationError(format!(
351 "Note {} references non-existent cue: {}",
352 note.id, link
353 )));
354 }
355 }
356 }
357 }
358
359 Ok(())
360 }
361
362 pub fn add_cue(&mut self, cue: Cue) {
364 self.cues.push(cue);
365 }
366
367 pub fn add_note(&mut self, note: Note) {
369 self.notes.push(note);
370 }
371}
372
373impl Cue {
374 pub fn new(content: Content, cue_type: CueType, position: u32) -> Self {
376 let now = Utc::now();
377 Cue {
378 id: Uuid::new_v4(),
379 content,
380 cue_type,
381 position,
382 created: now,
383 modified: now,
384 }
385 }
386
387 pub fn validate(&self) -> Result<()> {
389 if self.modified < self.created {
390 return Err(CornellNoteError::ValidationError(format!(
391 "Cue {} has modified date before created date",
392 self.id
393 )));
394 }
395 Ok(())
396 }
397}
398
399impl Note {
400 pub fn new(content: Content, note_type: NoteType, position: u32) -> Self {
402 let now = Utc::now();
403 Note {
404 id: Uuid::new_v4(),
405 content,
406 note_type,
407 position,
408 created: now,
409 modified: now,
410 cue_links: None,
411 }
412 }
413
414 pub fn new_with_links(
416 content: Content,
417 note_type: NoteType,
418 position: u32,
419 cue_links: Vec<Uuid>,
420 ) -> Self {
421 let now = Utc::now();
422 Note {
423 id: Uuid::new_v4(),
424 content,
425 note_type,
426 position,
427 created: now,
428 modified: now,
429 cue_links: Some(cue_links),
430 }
431 }
432
433 pub fn validate(&self) -> Result<()> {
435 if self.modified < self.created {
436 return Err(CornellNoteError::ValidationError(format!(
437 "Note {} has modified date before created date",
438 self.id
439 )));
440 }
441 Ok(())
442 }
443}
444
445impl Summary {
446 pub fn new(content: Content) -> Self {
448 let now = Utc::now();
449 Summary {
450 content,
451 created: now,
452 modified: now,
453 }
454 }
455
456 pub fn validate(&self) -> Result<()> {
458 if self.modified < self.created {
459 return Err(CornellNoteError::ValidationError(
460 "Summary has modified date before created date".to_string(),
461 ));
462 }
463 Ok(())
464 }
465}
466
467pub fn from_json(json: &str) -> Result<CornellNote> {
469 let note: CornellNote = serde_json::from_str(json)?;
470 note.validate()?;
471 Ok(note)
472}
473
474pub fn from_json_unchecked(json: &str) -> Result<CornellNote> {
476 Ok(serde_json::from_str(json)?)
477}
478
479pub fn to_json(note: &CornellNote) -> Result<String> {
481 Ok(serde_json::to_string(note)?)
482}
483
484pub fn to_json_pretty(note: &CornellNote) -> Result<String> {
486 Ok(serde_json::to_string_pretty(note)?)
487}
488
489pub fn read_from_file(path: &str) -> Result<CornellNote> {
491 let contents = std::fs::read_to_string(path)?;
492 from_json(&contents)
493}
494
495pub fn write_to_file(note: &CornellNote, path: &str) -> Result<()> {
497 let json = to_json_pretty(note)?;
498 std::fs::write(path, json)?;
499 Ok(())
500}
501
502fn content_to_string(content: &Content) -> String {
504 match content {
505 Content::Simple(s) => s.clone(),
506 Content::Structured(structured) => structured.data.clone(),
507 }
508}
509
510pub fn to_markdown(note: &CornellNote) -> String {
512 let mut output = String::new();
513
514 output.push_str(&format!("# {}\n\n", note.metadata.title));
516
517 if let Some(ref subject) = note.metadata.subject {
519 output.push_str(&format!("**Subject:** {}\n\n", subject));
520 }
521 if let Some(ref topic) = note.metadata.topic {
522 output.push_str(&format!("**Topic:** {}\n\n", topic));
523 }
524 if let Some(ref author) = note.metadata.author {
525 output.push_str(&format!("**Author:** {}\n\n", author));
526 }
527 if let Some(ref tags) = note.metadata.tags {
528 if !tags.is_empty() {
529 output.push_str(&format!("**Tags:** {}\n\n", tags.join(", ")));
530 }
531 }
532
533 let mut sorted_sections = note.sections.clone();
535 sorted_sections.sort_by_key(|s| s.position);
536
537 for section in sorted_sections {
538 output.push_str(&format!("## Section {}\n\n", section.position + 1));
539
540 if !section.cues.is_empty() {
542 output.push_str("### Cues\n\n");
543 let mut sorted_cues = section.cues.clone();
544 sorted_cues.sort_by_key(|c| c.position);
545
546 for cue in sorted_cues {
547 let content = content_to_string(&cue.content);
548 output.push_str(&format!("- {}\n", content));
549 }
550 output.push('\n');
551 }
552
553 if !section.notes.is_empty() {
555 output.push_str("### Notes\n\n");
556 let mut sorted_notes = section.notes.clone();
557 sorted_notes.sort_by_key(|n| n.position);
558
559 for note_entry in sorted_notes {
560 let content = content_to_string(¬e_entry.content);
561 output.push_str(&content);
562 output.push_str("\n\n");
563 }
564 }
565 }
566
567 output.push_str("## Summary\n\n");
569 let summary_content = content_to_string(¬e.summary.content);
570 output.push_str(&summary_content);
571 output.push('\n');
572
573 output
574}
575
576pub fn write_to_markdown_file(note: &CornellNote, path: &str) -> Result<()> {
578 let markdown = to_markdown(note);
579 std::fs::write(path, markdown)?;
580 Ok(())
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586
587 #[test]
588 fn test_create_new_note() {
589 let note = CornellNote::new("Test Note".to_string());
590 assert_eq!(note.version, "1.0");
591 assert_eq!(note.metadata.title, "Test Note");
592 assert!(note.sections.is_empty());
593 }
594
595 #[test]
596 fn test_validation_requires_sections() {
597 let note = CornellNote::new("Test".to_string());
598 assert!(note.validate().is_err());
599 }
600
601 #[test]
602 fn test_version_validation() {
603 let mut note = CornellNote::new("Test".to_string());
604 note.version = "invalid".to_string();
605 let result = note.validate();
606 assert!(result.is_err());
607 match result {
608 Err(CornellNoteError::InvalidVersion(_)) => (),
609 _ => panic!("Expected InvalidVersion error"),
610 }
611 }
612
613 #[test]
614 fn test_empty_title_validation() {
615 let mut note = CornellNote::new("".to_string());
616 let mut section = Section::new(0);
617 section.add_cue(Cue::new(
618 Content::Simple("Test cue".to_string()),
619 CueType::Text,
620 0,
621 ));
622 note.add_section(section);
623 assert!(note.validate().is_err());
624 }
625
626 #[test]
627 fn test_valid_note_with_section() {
628 let mut note = CornellNote::new("Valid Note".to_string());
629 let mut section = Section::new(0);
630
631 let cue = Cue::new(
632 Content::Simple("What is Rust?".to_string()),
633 CueType::Question,
634 0,
635 );
636 section.add_cue(cue);
637
638 let note_entry = Note::new(
639 Content::Simple("A systems programming language".to_string()),
640 NoteType::Text,
641 0,
642 );
643 section.add_note(note_entry);
644
645 note.add_section(section);
646 assert!(note.validate().is_ok());
647 }
648
649 #[test]
650 fn test_cue_links_validation() {
651 let mut note = CornellNote::new("Test".to_string());
652 let mut section = Section::new(0);
653
654 let cue = Cue::new(Content::Simple("Cue".to_string()), CueType::Text, 0);
655 let cue_id = cue.id;
656 section.add_cue(cue);
657
658 let note_entry = Note::new_with_links(
660 Content::Simple("Note".to_string()),
661 NoteType::Text,
662 0,
663 vec![cue_id],
664 );
665 section.add_note(note_entry);
666
667 note.add_section(section);
668 assert!(note.validate().is_ok());
669 }
670
671 #[test]
672 fn test_invalid_cue_links() {
673 let mut note = CornellNote::new("Test".to_string());
674 let mut section = Section::new(0);
675
676 let note_entry = Note::new_with_links(
678 Content::Simple("Note".to_string()),
679 NoteType::Text,
680 0,
681 vec![Uuid::new_v4()],
682 );
683 section.add_note(note_entry);
684
685 note.add_section(section);
686 assert!(note.validate().is_err());
687 }
688
689 #[test]
690 fn test_structured_content() {
691 let structured = StructuredContent {
692 format: ContentFormat::Markdown,
693 data: "# Header\n\nContent".to_string(),
694 attachments: None,
695 };
696
697 let content = Content::Structured(structured);
698 let cue = Cue::new(content, CueType::Text, 0);
699 assert!(cue.validate().is_ok());
700 }
701
702 #[test]
703 fn test_json_roundtrip() {
704 let mut note = CornellNote::new("Roundtrip Test".to_string());
705 let mut section = Section::new(0);
706
707 section.add_cue(Cue::new(
708 Content::Simple("Question?".to_string()),
709 CueType::Question,
710 0,
711 ));
712 section.add_note(Note::new(
713 Content::Simple("Answer".to_string()),
714 NoteType::Text,
715 0,
716 ));
717
718 note.add_section(section);
719
720 let json = to_json_pretty(¬e).unwrap();
721 let parsed = from_json(&json).unwrap();
722
723 assert_eq!(note, parsed);
724 }
725
726 #[test]
727 fn test_duplicate_section_ids() {
728 let mut note = CornellNote::new("Test".to_string());
729 let section1 = Section::new(0);
730 let mut section2 = Section::new(1);
731 section2.id = section1.id; note.add_section(section1);
734 note.add_section(section2);
735
736 assert!(note.validate().is_err());
737 }
738
739 #[test]
740 fn test_duplicate_cue_positions() {
741 let mut note = CornellNote::new("Test".to_string());
742 let mut section = Section::new(0);
743
744 section.add_cue(Cue::new(
745 Content::Simple("Cue 1".to_string()),
746 CueType::Text,
747 0,
748 ));
749 section.add_cue(Cue::new(
750 Content::Simple("Cue 2".to_string()),
751 CueType::Text,
752 0, ));
754
755 note.add_section(section);
756 assert!(note.validate().is_err());
757 }
758
759 #[test]
760 fn test_markdown_export() {
761 let mut note = CornellNote::new("Introduction to Rust".to_string());
762 note.metadata.subject = Some("Computer Science".to_string());
763 note.metadata.topic = Some("Programming Languages".to_string());
764 note.metadata.author = Some("Student".to_string());
765 note.metadata.tags = Some(vec!["rust".to_string(), "programming".to_string()]);
766
767 let mut section = Section::new(0);
768
769 section.add_cue(Cue::new(
770 Content::Simple("What is Rust?".to_string()),
771 CueType::Question,
772 0,
773 ));
774 section.add_cue(Cue::new(
775 Content::Simple("Memory Safety".to_string()),
776 CueType::Keyword,
777 1,
778 ));
779
780 section.add_note(Note::new(
781 Content::Simple("A systems programming language".to_string()),
782 NoteType::Text,
783 0,
784 ));
785 section.add_note(Note::new(
786 Content::Structured(StructuredContent {
787 format: ContentFormat::Markdown,
788 data: "Rust provides memory safety without GC".to_string(),
789 attachments: None,
790 }),
791 NoteType::Text,
792 1,
793 ));
794
795 note.add_section(section);
796 note.update_summary(Content::Simple(
797 "Rust is a safe systems language".to_string(),
798 ));
799
800 let markdown = to_markdown(¬e);
801
802 assert!(markdown.contains("# Introduction to Rust"));
803 assert!(markdown.contains("**Subject:** Computer Science"));
804 assert!(markdown.contains("**Topic:** Programming Languages"));
805 assert!(markdown.contains("**Author:** Student"));
806 assert!(markdown.contains("**Tags:** rust, programming"));
807 assert!(markdown.contains("## Section 1"));
808 assert!(markdown.contains("### Cues"));
809 assert!(markdown.contains("- What is Rust?"));
810 assert!(markdown.contains("- Memory Safety"));
811 assert!(markdown.contains("### Notes"));
812 assert!(markdown.contains("A systems programming language"));
813 assert!(markdown.contains("Rust provides memory safety without GC"));
814 assert!(markdown.contains("## Summary"));
815 assert!(markdown.contains("Rust is a safe systems language"));
816 }
817
818 #[test]
819 fn test_markdown_file_write() {
820 let mut note = CornellNote::new("Test Note".to_string());
821 let mut section = Section::new(0);
822
823 section.add_cue(Cue::new(
824 Content::Simple("Cue".to_string()),
825 CueType::Text,
826 0,
827 ));
828 section.add_note(Note::new(
829 Content::Simple("Note".to_string()),
830 NoteType::Text,
831 0,
832 ));
833
834 note.add_section(section);
835 note.update_summary(Content::Simple("Summary".to_string()));
836
837 let temp_dir = std::env::temp_dir();
839 let temp_file = temp_dir.join("test_note.md");
840 write_to_markdown_file(¬e, temp_file.to_str().unwrap()).unwrap();
841
842 let contents = std::fs::read_to_string(&temp_file).unwrap();
843 assert!(contents.contains("# Test Note"));
844 assert!(contents.contains("## Section 1"));
845 assert!(contents.contains("### Cues"));
846 assert!(contents.contains("- Cue"));
847 assert!(contents.contains("### Notes"));
848 assert!(contents.contains("Note"));
849 assert!(contents.contains("## Summary"));
850 assert!(contents.contains("Summary"));
851
852 std::fs::remove_file(temp_file).ok();
853 }
854}