requiem/storage/
markdown.rs

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/// A requirement serialized in markdown format with YAML frontmatter.
21#[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        // Construct the heading with HRID and title
34        let heading = format!("# {} {}", self.hrid, self.title);
35
36        // Combine frontmatter, heading, and body
37        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        // Ensure frontmatter starts correctly
50        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        // Collect lines until next '---'
64        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        // The rest of the lines are Markdown content
75        let content = lines.collect::<Result<Vec<_>, _>>()?.join("\n");
76
77        let front: FrontMatter = serde_yaml::from_str(&frontmatter)?;
78
79        // Extract HRID, title, and body from content
80        let (hrid, title, body) = parse_content(&content)?;
81
82        Ok(Self {
83            frontmatter: front,
84            hrid,
85            title,
86            body,
87        })
88    }
89
90    /// Writes the requirement to a file path constructed using the given
91    /// config.
92    ///
93    /// The path construction respects the `subfolders_are_namespaces` setting:
94    /// - If `false`: file is saved as `root/FULL-HRID.md`
95    /// - If `true`: file is saved as `root/namespace/folders/KIND-ID.md`
96    ///
97    /// Parent directories are created automatically if they don't exist.
98    ///
99    /// # Errors
100    ///
101    /// Returns an error if the file cannot be created or written to.
102    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    /// Writes the requirement to a specific file path.
116    ///
117    /// Parent directories are created automatically if they don't exist.
118    ///
119    /// # Errors
120    ///
121    /// Returns an error if the file cannot be created or written to.
122    pub fn save_to_path(&self, file_path: &Path) -> io::Result<()> {
123        // Create parent directories if needed
124        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    /// Reads a requirement using the given configuration.
134    ///
135    /// The path construction respects the `subfolders_are_namespaces` setting:
136    /// - If `false`: loads from `root/FULL-HRID.md`
137    /// - If `true`: loads from `root/namespace/folders/KIND-ID.md`
138    ///
139    /// # Errors
140    ///
141    /// Returns an error if the file cannot be read or parsed.
142    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
166/// Trim empty lines from the start and end of a string, preserving indentation.
167///
168/// Unlike `.trim()`, this function only removes completely empty lines from
169/// the beginning and end, keeping any leading/trailing whitespace on non-empty
170/// lines. This is crucial for preserving markdown structures like code blocks,
171/// lists, and blockquotes which rely on indentation.
172pub(crate) fn trim_empty_lines(s: &str) -> String {
173    let lines: Vec<&str> = s.lines().collect();
174
175    // Find first and last non-empty lines
176    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
185/// Parses markdown content into HRID, title, and body.
186///
187/// The HRID must be the first token in the first heading (after the `#`
188/// markers), followed by the title. The body is everything after the first
189/// heading.
190///
191/// # Errors
192///
193/// Returns an error if no heading is found or if the HRID cannot be parsed.
194fn parse_content(content: &str) -> Result<(Hrid, String, String), LoadError> {
195    // Find the first non-empty line that starts with '#'
196    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    // Remove leading '#' characters and whitespace
209    let after_hashes = trimmed.trim_start_matches('#').trim();
210
211    // Extract the first token (should be the HRID)
212    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    // Parse the HRID
218    let hrid = first_token.parse::<Hrid>().map_err(LoadError::from)?;
219
220    // The rest after the HRID is the title
221    let title = after_hashes
222        .strip_prefix(first_token)
223        .unwrap_or("")
224        .trim()
225        .to_string();
226
227    // The body is everything after the heading line
228    // Preserve leading indentation but trim empty lines from start/end
229    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/// Errors that can occur when loading a requirement from markdown.
240#[derive(Debug, thiserror::Error)]
241#[error("failed to read from markdown")]
242pub enum LoadError {
243    /// The requirement file was not found.
244    NotFound,
245    /// An I/O error occurred.
246    Io(#[from] io::Error),
247    /// The YAML frontmatter could not be parsed.
248    Yaml(#[from] serde_yaml::Error),
249    /// The HRID could not be parsed.
250    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/// A parent requirement reference in the serialized format.
264#[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
275/// Serialize an HRID as a string.
276///
277/// # Errors
278///
279/// Returns an error if serialization fails.
280pub 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
287/// Deserialize an HRID from a string.
288///
289/// # Errors
290///
291/// Returns an error if the string cannot be parsed as a valid HRID.
292pub 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        // The frontmatter should not have an hrid field at the top level
623        // (though parent entries still contain hrid fields)
624        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        // Test save
657        let config = crate::domain::Config::default();
658        let save_result = requirement.save(temp_dir.path(), &config);
659        assert!(save_result.is_ok());
660
661        // Test load
662        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        // Code block
827        let code = "    fn main() {\n        println!(\"hello\");\n    }";
828        assert_eq!(trim_empty_lines(code), code);
829
830        // List with indentation
831        let list = "- Item 1\n  - Sub item\n- Item 2";
832        assert_eq!(trim_empty_lines(list), list);
833
834        // Blockquote
835        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        // Lines with only spaces should be treated as empty
842        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        // Save and reload
881        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        // Verify indentation is preserved
886        assert_eq!(loaded.body, body);
887    }
888}