1use std::{
2 collections::BTreeSet,
3 fs::File,
4 io::{self, BufRead, BufReader, BufWriter, Write},
5 path::Path,
6};
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use uuid::Uuid;
11
12use crate::{
13 domain::{
14 requirement::{Content, Metadata, Parent as DomainParent},
15 Hrid, HridError,
16 },
17 Requirement,
18};
19
20#[derive(Debug, Clone)]
22pub struct MarkdownRequirement {
23 frontmatter: FrontMatter,
24 hrid: Hrid,
25 title: String,
26 body: String,
27}
28
29impl MarkdownRequirement {
30 fn write<W: Write>(&self, writer: &mut W) -> io::Result<()> {
31 let frontmatter = serde_yaml::to_string(&self.frontmatter).expect("this must never fail");
32
33 let heading = format!("# {} {}", self.hrid, self.title);
35
36 let result = if self.body.is_empty() {
38 format!("---\n{frontmatter}---\n{heading}\n")
39 } else {
40 format!("---\n{frontmatter}---\n{heading}\n\n{}\n", self.body)
41 };
42
43 writer.write_all(result.as_bytes())
44 }
45
46 pub(crate) fn read<R: BufRead>(reader: &mut R) -> Result<Self, LoadError> {
47 let mut lines = reader.lines();
48
49 let first_line = lines
51 .next()
52 .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "Empty input"))?
53 .map_err(LoadError::from)?;
54
55 if first_line.trim() != "---" {
56 return Err(io::Error::new(
57 io::ErrorKind::InvalidData,
58 "Expected frontmatter starting with '---'",
59 )
60 .into());
61 }
62
63 let frontmatter = lines
65 .by_ref()
66 .map_while(|line| match line {
67 Ok(content) if content.trim() == "---" => None,
68 Ok(content) => Some(Ok(content)),
69 Err(e) => Some(Err(e)),
70 })
71 .collect::<Result<Vec<_>, _>>()?
72 .join("\n");
73
74 let content = lines.collect::<Result<Vec<_>, _>>()?.join("\n");
76
77 let front: FrontMatter = serde_yaml::from_str(&frontmatter)?;
78
79 let (hrid, title, body) = parse_content(&content)?;
81
82 Ok(Self {
83 frontmatter: front,
84 hrid,
85 title,
86 body,
87 })
88 }
89
90 pub fn save(&self, root: &Path, config: &crate::domain::Config) -> io::Result<()> {
103 use crate::storage::construct_path_from_hrid;
104
105 let file_path = construct_path_from_hrid(
106 root,
107 &self.hrid,
108 config.subfolders_are_namespaces,
109 config.digits(),
110 );
111
112 self.save_to_path(&file_path)
113 }
114
115 pub fn save_to_path(&self, file_path: &Path) -> io::Result<()> {
123 if let Some(parent) = file_path.parent() {
125 std::fs::create_dir_all(parent)?;
126 }
127
128 let file = File::create(file_path)?;
129 let mut writer = BufWriter::new(file);
130 self.write(&mut writer)
131 }
132
133 pub fn load(
143 root: &Path,
144 hrid: &Hrid,
145 config: &crate::domain::Config,
146 ) -> Result<Self, LoadError> {
147 use crate::storage::construct_path_from_hrid;
148
149 let file_path = construct_path_from_hrid(
150 root,
151 hrid,
152 config.subfolders_are_namespaces,
153 config.digits(),
154 );
155
156 let file = File::open(&file_path).map_err(|io_error| match io_error.kind() {
157 io::ErrorKind::NotFound => LoadError::NotFound,
158 _ => LoadError::Io(io_error),
159 })?;
160
161 let mut reader = BufReader::new(file);
162 Self::read(&mut reader)
163 }
164}
165
166pub(crate) fn trim_empty_lines(s: &str) -> String {
173 let lines: Vec<&str> = s.lines().collect();
174
175 let first_non_empty = lines.iter().position(|line| !line.trim().is_empty());
177 let last_non_empty = lines.iter().rposition(|line| !line.trim().is_empty());
178
179 match (first_non_empty, last_non_empty) {
180 (Some(start), Some(end)) => lines[start..=end].join("\n"),
181 _ => String::new(),
182 }
183}
184
185fn parse_content(content: &str) -> Result<(Hrid, String, String), LoadError> {
195 let (heading_line_idx, line) = content
197 .lines()
198 .enumerate()
199 .find(|(_, line)| line.trim().starts_with('#'))
200 .ok_or_else(|| {
201 io::Error::new(
202 io::ErrorKind::InvalidData,
203 "No heading found in content - HRID must be in the first heading",
204 )
205 })?;
206
207 let trimmed = line.trim();
208 let after_hashes = trimmed.trim_start_matches('#').trim();
210
211 let first_token = after_hashes
213 .split_whitespace()
214 .next()
215 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "No HRID found in title"))?;
216
217 let hrid = first_token.parse::<Hrid>().map_err(LoadError::from)?;
219
220 let title = after_hashes
222 .strip_prefix(first_token)
223 .unwrap_or("")
224 .trim()
225 .to_string();
226
227 let body_content: String = content
230 .lines()
231 .skip(heading_line_idx + 1)
232 .collect::<Vec<_>>()
233 .join("\n");
234 let body = trim_empty_lines(&body_content);
235
236 Ok((hrid, title, body))
237}
238
239#[derive(Debug, thiserror::Error)]
241#[error("failed to read from markdown")]
242pub enum LoadError {
243 NotFound,
245 Io(#[from] io::Error),
247 Yaml(#[from] serde_yaml::Error),
249 Hrid(#[from] HridError),
251}
252
253#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
254#[serde(from = "FrontMatterVersion")]
255#[serde(into = "FrontMatterVersion")]
256struct FrontMatter {
257 uuid: Uuid,
258 created: DateTime<Utc>,
259 tags: BTreeSet<String>,
260 parents: Vec<Parent>,
261}
262
263#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
265pub struct Parent {
266 uuid: Uuid,
267 fingerprint: String,
268 #[serde(
269 serialize_with = "hrid_as_string",
270 deserialize_with = "hrid_from_string"
271 )]
272 hrid: Hrid,
273}
274
275pub fn hrid_as_string<S>(hrid: &Hrid, serializer: S) -> Result<S::Ok, S::Error>
281where
282 S: serde::Serializer,
283{
284 serializer.serialize_str(&hrid.to_string())
285}
286
287pub fn hrid_from_string<'de, D>(deserializer: D) -> Result<Hrid, D::Error>
293where
294 D: serde::Deserializer<'de>,
295{
296 let s = String::deserialize(deserializer)?;
297 Hrid::try_from(s.as_str()).map_err(serde::de::Error::custom)
298}
299
300#[derive(Debug, Serialize, Deserialize)]
301#[serde(tag = "_version")]
302enum FrontMatterVersion {
303 #[serde(rename = "1")]
304 V1 {
305 uuid: Uuid,
306 created: DateTime<Utc>,
307 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
308 tags: BTreeSet<String>,
309 #[serde(default, skip_serializing_if = "Vec::is_empty")]
310 parents: Vec<Parent>,
311 },
312}
313
314impl From<FrontMatterVersion> for FrontMatter {
315 fn from(version: FrontMatterVersion) -> Self {
316 match version {
317 FrontMatterVersion::V1 {
318 uuid,
319 created,
320 tags,
321 parents,
322 } => Self {
323 uuid,
324 created,
325 tags,
326 parents,
327 },
328 }
329 }
330}
331
332impl From<FrontMatter> for FrontMatterVersion {
333 fn from(front_matter: FrontMatter) -> Self {
334 let FrontMatter {
335 uuid,
336 created,
337 tags,
338 parents,
339 } = front_matter;
340 Self::V1 {
341 uuid,
342 created,
343 tags,
344 parents,
345 }
346 }
347}
348
349impl From<Requirement> for MarkdownRequirement {
350 fn from(req: Requirement) -> Self {
351 let Requirement {
352 content: Content { title, body, tags },
353 metadata:
354 Metadata {
355 uuid,
356 hrid,
357 created,
358 parents,
359 },
360 } = req;
361
362 let frontmatter = FrontMatter {
363 uuid,
364 created,
365 tags,
366 parents: parents
367 .into_iter()
368 .map(|(uuid, DomainParent { hrid, fingerprint })| Parent {
369 uuid,
370 fingerprint,
371 hrid,
372 })
373 .collect(),
374 };
375
376 Self {
377 frontmatter,
378 hrid,
379 title,
380 body,
381 }
382 }
383}
384
385impl TryFrom<MarkdownRequirement> for Requirement {
386 type Error = HridError;
387
388 fn try_from(req: MarkdownRequirement) -> Result<Self, Self::Error> {
389 let MarkdownRequirement {
390 hrid,
391 frontmatter:
392 FrontMatter {
393 uuid,
394 created,
395 tags,
396 parents,
397 },
398 title,
399 body,
400 } = req;
401
402 let parent_map = parents
403 .into_iter()
404 .map(|parent| {
405 let Parent {
406 uuid,
407 fingerprint,
408 hrid: parent_hrid,
409 } = parent;
410 Ok((
411 uuid,
412 DomainParent {
413 hrid: parent_hrid,
414 fingerprint,
415 },
416 ))
417 })
418 .collect::<Result<_, Self::Error>>()?;
419
420 Ok(Self {
421 content: Content { title, body, tags },
422 metadata: Metadata {
423 uuid,
424 hrid,
425 created,
426 parents: parent_map,
427 },
428 })
429 }
430}
431
432#[cfg(test)]
433mod tests {
434 use std::{io::Cursor, num::NonZeroUsize};
435
436 use chrono::TimeZone;
437 use tempfile::TempDir;
438
439 use super::{Parent, *};
440 use crate::domain::hrid::KindString;
441
442 fn req_hrid() -> Hrid {
443 Hrid::new(
444 KindString::new("REQ".to_string()).unwrap(),
445 NonZeroUsize::new(1).unwrap(),
446 )
447 }
448
449 fn create_test_frontmatter() -> FrontMatter {
450 let uuid = Uuid::parse_str("12b3f5c5-b1a8-4aa8-a882-20ff1c2aab53").unwrap();
451 let created = Utc.with_ymd_and_hms(2025, 7, 14, 7, 15, 0).unwrap();
452 let tags = BTreeSet::from(["tag1".to_string(), "tag2".to_string()]);
453 let parents = vec![Parent {
454 uuid: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
455 fingerprint: "fingerprint1".to_string(),
456 hrid: "REQ-PARENT-001".parse().unwrap(),
457 }];
458 FrontMatter {
459 uuid,
460 created,
461 tags,
462 parents,
463 }
464 }
465
466 #[test]
467 fn markdown_round_trip() {
468 let input = r"---
469_version: '1'
470uuid: 12b3f5c5-b1a8-4aa8-a882-20ff1c2aab53
471created: 2025-07-14T07:15:00Z
472tags:
473- tag1
474- tag2
475parents:
476- uuid: 550e8400-e29b-41d4-a716-446655440000
477 fingerprint: fingerprint1
478 hrid: REQ-PARENT-001
479---
480# REQ-001 The Title
481
482This is a paragraph.
483";
484
485 let mut reader = Cursor::new(input);
486 let requirement = MarkdownRequirement::read(&mut reader).unwrap();
487
488 assert_eq!(requirement.hrid, req_hrid());
489
490 let mut bytes: Vec<u8> = vec![];
491 requirement.write(&mut bytes).unwrap();
492
493 let actual = String::from_utf8(bytes).unwrap();
494 assert_eq!(input, &actual);
495 }
496
497 #[test]
498 fn markdown_minimal_content() {
499 let hrid = req_hrid();
500 let content = r"---
501_version: '1'
502uuid: 12b3f5c5-b1a8-4aa8-a882-20ff1c2aab53
503created: 2025-07-14T07:15:00Z
504---
505# REQ-001 Just content
506";
507
508 let mut reader = Cursor::new(content);
509 let requirement = MarkdownRequirement::read(&mut reader).unwrap();
510
511 assert_eq!(requirement.hrid, hrid);
512 assert_eq!(requirement.title, "Just content");
513 assert_eq!(requirement.body, "");
514 assert!(requirement.frontmatter.tags.is_empty());
515 assert!(requirement.frontmatter.parents.is_empty());
516 }
517
518 #[test]
519 fn hrid_only_title() {
520 let content = r"---
521_version: '1'
522uuid: 12b3f5c5-b1a8-4aa8-a882-20ff1c2aab53
523created: 2025-07-14T07:15:00Z
524---
525# REQ-001
526";
527
528 let mut reader = Cursor::new(content);
529 let requirement = MarkdownRequirement::read(&mut reader).unwrap();
530
531 assert_eq!(requirement.hrid, req_hrid());
532 assert_eq!(requirement.title, "");
533 assert_eq!(requirement.body, "");
534 }
535
536 #[test]
537 fn multiline_content() {
538 let content = r"---
539_version: '1'
540uuid: 12b3f5c5-b1a8-4aa8-a882-20ff1c2aab53
541created: 2025-07-14T07:15:00Z
542---
543# REQ-001 Title
544
545Line 2
546
547Line 4
548";
549
550 let mut reader = Cursor::new(content);
551 let requirement = MarkdownRequirement::read(&mut reader).unwrap();
552
553 assert_eq!(requirement.hrid, req_hrid());
554 assert_eq!(requirement.title, "Title");
555 assert_eq!(requirement.body, "Line 2\n\nLine 4");
556 }
557
558 #[test]
559 fn invalid_frontmatter_start() {
560 let content = "invalid frontmatter";
561
562 let mut reader = Cursor::new(content);
563 let result = MarkdownRequirement::read(&mut reader);
564
565 assert!(result.is_err());
566 }
567
568 #[test]
569 fn missing_frontmatter_end() {
570 let content = r"---
571uuid: 12b3f5c5-b1a8-4aa8-a882-20ff1c2aab53
572created: 2025-07-14T07:15:00Z
573This should be content but there's no closing ---";
574
575 let mut reader = Cursor::new(content);
576 let result = MarkdownRequirement::read(&mut reader);
577
578 assert!(result.is_err());
579 }
580
581 #[test]
582 fn invalid_yaml() {
583 let content = r"---
584invalid: yaml: structure:
585created: not-a-date
586---
587# REQ-001 Content";
588
589 let mut reader = Cursor::new(content);
590 let result = MarkdownRequirement::read(&mut reader);
591
592 assert!(matches!(result, Err(LoadError::Yaml(_))));
593 }
594
595 #[test]
596 fn empty_input() {
597 let content = "";
598
599 let mut reader = Cursor::new(content);
600 let result = MarkdownRequirement::read(&mut reader);
601
602 assert!(result.is_err());
603 }
604
605 #[test]
606 fn write_success() {
607 let frontmatter = create_test_frontmatter();
608 let requirement = MarkdownRequirement {
609 frontmatter,
610 hrid: req_hrid(),
611 title: "Test content".to_string(),
612 body: String::new(),
613 };
614
615 let mut buffer = Vec::new();
616 let result = requirement.write(&mut buffer);
617
618 assert!(result.is_ok());
619 let output = String::from_utf8(buffer).unwrap();
620 assert!(output.contains("---"));
621 assert!(output.contains("# REQ-001 Test content"));
622 let lines: Vec<&str> = output.lines().collect();
625 let frontmatter_end = lines
626 .iter()
627 .skip(1)
628 .position(|l| l.trim() == "---")
629 .unwrap()
630 + 1;
631 let frontmatter_lines = &lines[1..frontmatter_end];
632 let has_top_level_hrid = frontmatter_lines
633 .iter()
634 .any(|line| line.starts_with("hrid:") && !line.contains(" "));
635 assert!(
636 !has_top_level_hrid,
637 "Frontmatter should not have top-level hrid field"
638 );
639 }
640
641 #[test]
642 fn save_and_load() {
643 let temp_dir = TempDir::new().unwrap();
644 let frontmatter = create_test_frontmatter();
645 let hrid = req_hrid();
646 let title = "Saved content".to_string();
647 let body = "Some body text".to_string();
648
649 let requirement = MarkdownRequirement {
650 frontmatter: frontmatter.clone(),
651 hrid: hrid.clone(),
652 title: title.clone(),
653 body: body.clone(),
654 };
655
656 let config = crate::domain::Config::default();
658 let save_result = requirement.save(temp_dir.path(), &config);
659 assert!(save_result.is_ok());
660
661 let loaded_requirement =
663 MarkdownRequirement::load(temp_dir.path(), &hrid, &config).unwrap();
664 assert_eq!(loaded_requirement.hrid, hrid);
665 assert_eq!(loaded_requirement.title, title);
666 assert_eq!(loaded_requirement.body, body);
667 assert_eq!(loaded_requirement.frontmatter, frontmatter);
668 }
669
670 #[test]
671 fn load_nonexistent_file() {
672 let temp_dir = TempDir::new().unwrap();
673 let config = crate::domain::Config::default();
674 let result = MarkdownRequirement::load(temp_dir.path(), &req_hrid(), &config);
675 assert!(matches!(result, Err(LoadError::NotFound)));
676 }
677
678 #[test]
679 fn frontmatter_version_conversion() {
680 let uuid = Uuid::parse_str("12b3f5c5-b1a8-4aa8-a882-20ff1c2aab53").unwrap();
681 let created = Utc.with_ymd_and_hms(2025, 7, 14, 7, 15, 0).unwrap();
682 let tags = BTreeSet::from(["tag1".to_owned()]);
683 let parents = vec![Parent {
684 uuid: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
685 fingerprint: "fp1".to_string(),
686 hrid: req_hrid(),
687 }];
688
689 let frontmatter = FrontMatter {
690 uuid,
691 created,
692 tags,
693 parents,
694 };
695 let version: FrontMatterVersion = frontmatter.clone().into();
696 let back_to_frontmatter: FrontMatter = version.into();
697
698 assert_eq!(frontmatter, back_to_frontmatter);
699 }
700
701 #[test]
702 fn parent_creation() {
703 let uuid = Uuid::parse_str("12b3f5c5-b1a8-4aa8-a882-20ff1c2aab53").unwrap();
704 let fingerprint = "test-fingerprint".to_string();
705 let hrid = req_hrid();
706
707 let parent = Parent {
708 uuid,
709 fingerprint: fingerprint.clone(),
710 hrid: hrid.clone(),
711 };
712
713 assert_eq!(parent.uuid, uuid);
714 assert_eq!(parent.fingerprint, fingerprint);
715 assert_eq!(parent.hrid, hrid);
716 }
717
718 #[test]
719 fn content_with_triple_dashes() {
720 let content = r"---
721_version: '1'
722uuid: 12b3f5c5-b1a8-4aa8-a882-20ff1c2aab53
723created: 2025-07-14T07:15:00Z
724---
725# REQ-001 Content
726
727This content has --- in it
728And more --- here
729";
730
731 let mut reader = Cursor::new(content);
732 let requirement = MarkdownRequirement::read(&mut reader).unwrap();
733
734 assert_eq!(requirement.hrid, req_hrid());
735 assert_eq!(requirement.title, "Content");
736 assert_eq!(
737 requirement.body,
738 "This content has --- in it\nAnd more --- here"
739 );
740 }
741
742 #[test]
743 fn frontmatter_with_special_characters() {
744 let content = r#"---
745_version: '1'
746uuid: 12b3f5c5-b1a8-4aa8-a882-20ff1c2aab53
747created: 2025-07-14T07:15:00Z
748tags:
749- "tag with spaces"
750- "tag-with-dashes"
751- "tag_with_underscores"
752---
753# REQ-001 Content here
754"#;
755
756 let mut reader = Cursor::new(content);
757 let requirement = MarkdownRequirement::read(&mut reader).unwrap();
758
759 assert_eq!(requirement.hrid, req_hrid());
760 assert!(requirement.frontmatter.tags.contains("tag with spaces"));
761 assert!(requirement.frontmatter.tags.contains("tag-with-dashes"));
762 assert!(requirement
763 .frontmatter
764 .tags
765 .contains("tag_with_underscores"));
766 }
767
768 #[test]
769 fn missing_hrid_in_title() {
770 let content = r"---
771_version: '1'
772uuid: 12b3f5c5-b1a8-4aa8-a882-20ff1c2aab53
773created: 2025-07-14T07:15:00Z
774---
775# Just a title without HRID
776";
777
778 let mut reader = Cursor::new(content);
779 let result = MarkdownRequirement::read(&mut reader);
780
781 assert!(matches!(result, Err(LoadError::Hrid(_))));
782 }
783
784 #[test]
785 fn no_heading_in_content() {
786 let content = r"---
787_version: '1'
788uuid: 12b3f5c5-b1a8-4aa8-a882-20ff1c2aab53
789created: 2025-07-14T07:15:00Z
790---
791Just plain text without a heading
792";
793
794 let mut reader = Cursor::new(content);
795 let result = MarkdownRequirement::read(&mut reader);
796
797 assert!(matches!(result, Err(LoadError::Io(_))));
798 }
799
800 #[test]
801 fn trim_empty_lines_removes_only_empty_lines() {
802 assert_eq!(trim_empty_lines(""), "");
803 assert_eq!(trim_empty_lines("\n\n"), "");
804 assert_eq!(trim_empty_lines("content"), "content");
805 assert_eq!(trim_empty_lines("\n\ncontent\n\n"), "content");
806 }
807
808 #[test]
809 fn trim_empty_lines_preserves_leading_indentation() {
810 assert_eq!(trim_empty_lines(" indented"), " indented");
811 assert_eq!(trim_empty_lines("\n indented\n"), " indented");
812 assert_eq!(
813 trim_empty_lines(" code block\n more code"),
814 " code block\n more code"
815 );
816 }
817
818 #[test]
819 fn trim_empty_lines_preserves_internal_empty_lines() {
820 assert_eq!(trim_empty_lines("line1\n\nline2"), "line1\n\nline2");
821 assert_eq!(trim_empty_lines("\nline1\n\nline2\n"), "line1\n\nline2");
822 }
823
824 #[test]
825 fn trim_empty_lines_handles_markdown_structures() {
826 let code = " fn main() {\n println!(\"hello\");\n }";
828 assert_eq!(trim_empty_lines(code), code);
829
830 let list = "- Item 1\n - Sub item\n- Item 2";
832 assert_eq!(trim_empty_lines(list), list);
833
834 let quote = "> This is a quote\n> with multiple lines";
836 assert_eq!(trim_empty_lines(quote), quote);
837 }
838
839 #[test]
840 fn trim_empty_lines_with_trailing_whitespace_lines() {
841 assert_eq!(trim_empty_lines(" \n\ncontent\n \n"), "content");
843 }
844
845 #[test]
846 fn parse_content_preserves_body_indentation() {
847 let content = "# REQ-001 Title\n\n code block\n more code";
848 let (hrid, title, body) = parse_content(content).unwrap();
849
850 assert_eq!(hrid.to_string(), "REQ-001");
851 assert_eq!(title, "Title");
852 assert_eq!(body, " code block\n more code");
853 }
854
855 #[test]
856 fn parse_content_trims_empty_lines_around_body() {
857 let content = "# REQ-001 Title\n\n\n\ncontent\n\n\n";
858 let (_hrid, _title, body) = parse_content(content).unwrap();
859
860 assert_eq!(body, "content");
861 }
862
863 #[test]
864 fn round_trip_preserves_indentation() {
865 let temp_dir = TempDir::new().unwrap();
866 let frontmatter = create_test_frontmatter();
867 let hrid = req_hrid();
868 let title = "Code Example".to_string();
869 let body = "Here's a code block:\n\n fn main() {\n println!(\"hello\");\n \
870 }\n\nAnd a list:\n\n- Item 1\n - Sub item\n- Item 2"
871 .to_string();
872
873 let requirement = MarkdownRequirement {
874 frontmatter,
875 hrid: hrid.clone(),
876 title,
877 body: body.clone(),
878 };
879
880 let config = crate::domain::Config::default();
882 requirement.save(temp_dir.path(), &config).unwrap();
883 let loaded = MarkdownRequirement::load(temp_dir.path(), &hrid, &config).unwrap();
884
885 assert_eq!(loaded.body, body);
887 }
888}