1use std::fmt;
10use std::str::FromStr;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use serde::{Deserialize, Deserializer, Serialize, Serializer};
14
15use crate::error::{Result, TixError};
16use crate::slug::slugify;
17
18#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
23pub struct Status(pub String);
24
25impl Status {
26 pub fn new(name: impl Into<String>) -> Self {
28 Status(name.into())
29 }
30
31 pub fn is(&self, name: &str) -> bool {
33 self.0 == name
34 }
35
36 pub fn display(&self) -> &str {
38 &self.0
39 }
40}
41
42impl Default for Status {
43 fn default() -> Self {
44 Status("todo".to_string())
45 }
46}
47
48impl fmt::Display for Status {
49 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50 f.write_str(&self.0)
51 }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
62pub enum Priority {
63 Low,
64 #[default]
65 Medium,
66 High,
67 Other(String),
69}
70
71impl Priority {
72 fn as_str(&self) -> &str {
73 match self {
74 Priority::Low => "low",
75 Priority::Medium => "medium",
76 Priority::High => "high",
77 Priority::Other(s) => s,
78 }
79 }
80}
81
82impl FromStr for Priority {
83 type Err = std::convert::Infallible;
84
85 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
86 Ok(match s.to_ascii_lowercase().as_str() {
87 "low" => Priority::Low,
88 "medium" => Priority::Medium,
89 "high" => Priority::High,
90 _ => Priority::Other(s.to_string()),
91 })
92 }
93}
94
95impl fmt::Display for Priority {
96 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97 f.write_str(self.as_str())
98 }
99}
100
101impl Serialize for Priority {
102 fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
103 s.serialize_str(self.as_str())
104 }
105}
106
107impl<'de> Deserialize<'de> for Priority {
108 fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
109 let raw = String::deserialize(d)?;
110 match raw.parse() {
111 Ok(parsed) => Ok(parsed),
112 Err(_) => Ok(Priority::Other(raw)),
113 }
114 }
115}
116
117pub type Labels = Vec<String>;
120
121fn de_assignee<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<Option<String>, D::Error> {
142 let opt = Option::<String>::deserialize(d)?;
143 Ok(opt.map(|v| v.strip_prefix('@').unwrap_or(&v).to_string()))
144}
145
146fn se_assignee<S: Serializer>(opt: &Option<String>, s: S) -> std::result::Result<S::Ok, S::Error> {
149 match opt {
150 None => s.serialize_none(),
151 Some(value) => {
152 let is_identity = value.contains('<') && value.contains('>');
153 let full = if is_identity || value.starts_with('@') {
154 value.clone()
155 } else {
156 format!("@{value}")
157 };
158 s.serialize_str(&full)
159 }
160 }
161}
162
163pub fn today() -> String {
169 let secs = SystemTime::now()
170 .duration_since(UNIX_EPOCH)
171 .map(|d| d.as_secs())
172 .unwrap_or(0);
173 let days = (secs / 86_400) as i64;
174 let (y, m, d) = civil_from_days(days);
175 format!("{y:04}-{m:02}-{d:02}")
176}
177
178fn civil_from_days(z: i64) -> (i64, u32, u32) {
181 let z = z + 719_468;
182 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
183 let doe = z - era * 146_097; let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
186 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; (if m <= 2 { y + 1 } else { y }, m, d)
191}
192
193fn parse_comment_head(after: &str) -> (String, String) {
200 let (author, date) = match after.len() {
201 len if len >= 12 && after.as_bytes()[len - 1] == b')' => {
202 let close = len - 1;
203 let open = after[..close].rfind(" (");
204 match open {
205 Some(open) => {
206 let maybe_date = &after[open + 2..close];
207 if maybe_date.len() == 10
208 && maybe_date.as_bytes()[4] == b'-'
209 && maybe_date.as_bytes()[7] == b'-'
210 {
211 (&after[..open], maybe_date)
212 } else {
213 (after, "")
214 }
215 }
216 None => (after, ""),
217 }
218 }
219 _ => (after, ""),
220 };
221 (author.trim_end().to_string(), date.to_string())
222}
223
224#[derive(Debug, Clone, PartialEq, Eq)]
231pub struct Comment {
232 pub author: String,
233 pub date: String,
234 pub text: String,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246pub struct Issue {
247 pub number: u32,
249 #[serde(default)]
251 pub title: String,
252 #[serde(default)]
254 pub status: Status,
255 #[serde(default)]
257 pub labels: Labels,
258 #[serde(default)]
260 pub priority: Priority,
261 #[serde(
265 default,
266 skip_serializing_if = "Option::is_none",
267 deserialize_with = "de_assignee",
268 serialize_with = "se_assignee"
269 )]
270 pub assignee: Option<String>,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub estimate: Option<String>,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub commit: Option<String>,
277 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub gitlab_id: Option<String>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub linear_id: Option<String>,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub mission: Option<String>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub task: Option<String>,
289 #[serde(default = "today")]
291 pub created: String,
292 #[serde(default = "today")]
294 pub updated: String,
295 #[serde(skip)]
297 pub filename: String,
298 #[serde(skip)]
300 pub body: String,
301}
302
303impl Issue {
304 pub fn parse(contents: &str) -> Result<Self> {
310 let rest = contents
311 .strip_prefix("---\n")
312 .ok_or_else(|| TixError::Parse("file must start with a `---` delimiter".into()))?;
313 let mut current = rest;
314 let mut fm_len = 0usize;
315 loop {
316 let line_end = current.find('\n').map(|i| i + 1).unwrap_or(current.len());
317 let content = current[..line_end]
318 .strip_suffix('\n')
319 .unwrap_or(¤t[..line_end]);
320 if content == "---" {
321 break;
322 }
323 fm_len += line_end;
324 current = ¤t[line_end..];
325 if current.is_empty() {
326 return Err(TixError::Parse(
327 "frontmatter is not terminated by a closing `---`".into(),
328 ));
329 }
330 }
331 let frontmatter = &rest[..fm_len];
332 let body_raw = &rest[fm_len..];
333 let mut body = body_raw.strip_prefix("---\n").unwrap_or(body_raw);
334 if let Some(after) = body.strip_prefix('\n') {
335 body = after;
336 }
337 let body = body.to_string();
338 let mut issue: Issue =
339 serde_yaml::from_str(frontmatter).map_err(|e| TixError::Parse(e.to_string()))?;
340 issue.body = body;
341 Ok(issue)
342 }
343
344 pub fn frontmatter(&self) -> String {
347 let fm =
348 serde_yaml::to_string(self).expect("Issue frontmatter serialization is infallible");
349 fm.trim_end_matches('\n').to_string() + "\n"
350 }
351
352 pub fn to_file_contents(&self) -> String {
355 format!("---\n{}---\n\n{}", self.frontmatter(), self.body)
356 }
357
358 pub fn filename_stem(&self) -> String {
360 format!("{}-{}", self.created, slugify(&self.title))
361 }
362
363 pub fn bump_updated(&mut self) {
365 self.updated = today();
366 }
367
368 fn comments_boundary(body: &str) -> Option<usize> {
375 let mut in_fence = false;
376 let mut offset = 0usize;
377 for raw in body.split_inclusive('\n') {
378 let line = raw.strip_suffix('\n').unwrap_or(raw);
379 if line.starts_with("```") || line.starts_with("~~~") {
380 in_fence = !in_fence;
381 } else if !in_fence && line == "## Comments" {
382 return Some(offset);
383 }
384 offset += raw.len();
385 }
386 None
387 }
388
389 pub fn description(&self) -> &str {
393 match Self::comments_boundary(&self.body) {
394 Some(idx) => self.body[..idx].trim_end(),
395 None => self.body.trim_end(),
396 }
397 }
398
399 pub fn comments(&self) -> Vec<Comment> {
403 let Some(boundary) = Self::comments_boundary(&self.body) else {
404 return Vec::new();
405 };
406 let section = &self.body[boundary..];
407 let mut comments = Vec::new();
408 let mut offset = 0usize;
409 for raw in section.split_inclusive('\n') {
410 let line = raw.strip_suffix('\n').unwrap_or(raw);
411 if let Some(after) = line.strip_prefix("### ") {
412 let (author, date) = parse_comment_head(after);
413 let start = offset + raw.len();
414 let mut text = String::new();
415 for next in section[start..].split_inclusive('\n') {
416 if next.strip_suffix('\n').unwrap_or(next).starts_with("### ") {
417 break;
418 }
419 text.push_str(next);
420 }
421 comments.push(Comment {
422 author,
423 date,
424 text: text.trim_end_matches('\n').to_string(),
425 });
426 }
427 offset += raw.len();
428 }
429 comments
430 }
431
432 pub fn add_comment(&mut self, author: &str, text: &str) {
439 if text.trim().is_empty() {
440 return;
441 }
442 if Self::comments_boundary(&self.body).is_none() {
443 if !self.body.trim().is_empty() {
444 if !self.body.ends_with('\n') {
445 self.body.push('\n');
446 }
447 self.body.push('\n');
448 }
449 self.body.push_str("## Comments\n");
450 }
451 if !self.body.is_empty() && !self.body.ends_with('\n') {
452 self.body.push('\n');
453 }
454 if !self.body.ends_with("\n\n") {
455 self.body.push('\n');
456 }
457 self.body
458 .push_str(&format!("### {author} ({})\n\n{text}\n", today()));
459 self.bump_updated();
460 }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466
467 #[test]
468 fn status_helpers() {
469 let s = Status::new("in progress");
470 assert!(s.is("in progress"));
471 assert!(!s.is("in_progress"));
472 assert_eq!(s.display(), "in progress");
473 assert_eq!(Status::default(), Status::new("todo"));
474 }
475
476 #[test]
477 fn status_survives_unknown_column_name() {
478 let parsed: Status = serde_yaml::from_str("\"renamed-column\"").unwrap();
479 assert_eq!(parsed.display(), "renamed-column");
480 }
481
482 #[test]
483 fn priority_roundtrips_lowercase_and_parses_case_insensitively() {
484 assert_eq!("low".parse::<Priority>().unwrap(), Priority::Low);
485 assert_eq!("HIGH".parse::<Priority>().unwrap(), Priority::High);
486 assert_eq!(Priority::default(), Priority::Medium);
487 assert_eq!(Priority::Low.to_string(), "low");
488 }
489
490 #[test]
491 fn unknown_priority_is_preserved_not_normalized() {
492 let urgent: Priority = serde_yaml::from_str("\"urgent\"").unwrap();
493 assert_eq!(urgent.to_string(), "urgent");
494 let yaml = serde_yaml::to_string(&urgent).unwrap();
495 assert!(yaml.contains("urgent"));
496 }
497
498 #[test]
499 fn issue_roundtrip_preserves_every_field_including_number_and_body() {
500 let mut issue = Issue {
501 number: 7,
502 title: "Add documentation for the feature".into(),
503 status: Status::new("todo"),
504 labels: vec!["docs".into(), "onboarding".into()],
505 priority: Priority::High,
506 assignee: Some("alice".into()),
507 estimate: Some("2".into()),
508 commit: Some("abc123".into()),
509 gitlab_id: Some("gl-9".into()),
510 linear_id: Some("LIN-9".into()),
511 mission: None,
512 task: None,
513 created: "2026-05-10".into(),
514 updated: "2026-05-10".into(),
515 filename: String::new(),
516 body: "Describe the issue here.\n\n".into(),
517 };
518 let contents = issue.to_file_contents();
519 let parsed = Issue::parse(&contents).unwrap();
520 assert_eq!(parsed, issue);
521 issue.body.clear();
522 assert_ne!(parsed, issue);
523 }
524
525 #[test]
526 fn missing_dates_default_to_today() {
527 let yaml = "number: 2\ntitle: Hi\nstatus: todo\n";
528 let issue: Issue = serde_yaml::from_str(yaml).unwrap();
529 assert_eq!(issue.created, today());
530 assert_eq!(issue.updated, today());
531 }
532
533 #[test]
534 fn body_is_preserved_verbatim() {
535 let issue = Issue {
536 number: 1,
537 title: "T".into(),
538 status: Status::default(),
539 labels: vec![],
540 priority: Priority::default(),
541 assignee: None,
542 estimate: None,
543 commit: None,
544 gitlab_id: None,
545 linear_id: None,
546 mission: None,
547 task: None,
548 created: "2026-01-02".into(),
549 updated: "2026-01-02".into(),
550 filename: String::new(),
551 body: "line one\n\nline two\n```\ncode\n```".into(),
552 };
553 let contents = issue.to_file_contents();
554 let parsed = Issue::parse(&contents).unwrap();
555 assert_eq!(parsed.body, issue.body);
556 }
557
558 #[test]
559 fn assignee_at_prefix_is_stripped_on_read_and_restored_on_write() {
560 let yaml = "number: 3\ntitle: T\nstatus: todo\nassignee: \"@alice\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n";
561 let issue: Issue = serde_yaml::from_str(yaml).unwrap();
562 assert_eq!(issue.assignee.as_deref(), Some("alice"));
563 let rerendered = issue.frontmatter();
564 assert!(
565 rerendered.contains("assignee: \"@alice\"")
566 || rerendered.contains("assignee: '@alice'")
567 );
568 }
569
570 #[test]
571 fn full_identity_assignee_round_trips_verbatim() {
572 let issue = Issue {
573 number: 5,
574 title: "T".into(),
575 status: Status::default(),
576 labels: vec![],
577 priority: Priority::default(),
578 assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
579 estimate: None,
580 commit: None,
581 gitlab_id: None,
582 linear_id: None,
583 mission: None,
584 task: None,
585 created: "2026-01-01".into(),
586 updated: "2026-01-01".into(),
587 filename: String::new(),
588 body: String::new(),
589 };
590 let fm = issue.frontmatter();
591 assert!(
592 fm.contains("Herbert Maschke <thyseus@pm.me>"),
593 "identity must be stored verbatim, got:\n{fm}"
594 );
595 assert!(
596 !fm.contains("@Herbert Maschke"),
597 "identity must not be @-prefixed, got:\n{fm}"
598 );
599 let parsed = Issue::parse(&issue.to_file_contents()).unwrap();
600 assert_eq!(parsed, issue);
601 }
602
603 #[test]
604 fn bare_handle_assignee_writes_legacy_at_prefix_and_reads_back_bare() {
605 let issue = Issue {
606 number: 6,
607 title: "T".into(),
608 status: Status::default(),
609 labels: vec![],
610 priority: Priority::default(),
611 assignee: Some("alice".into()),
612 estimate: None,
613 commit: None,
614 gitlab_id: None,
615 linear_id: None,
616 mission: None,
617 task: None,
618 created: "2026-01-01".into(),
619 updated: "2026-01-01".into(),
620 filename: String::new(),
621 body: String::new(),
622 };
623 let fm = issue.frontmatter();
624 assert!(
625 fm.contains("assignee: \"@alice\"") || fm.contains("assignee: '@alice'"),
626 "legacy @-prefixed bytes expected, got:\n{fm}"
627 );
628 let parsed = Issue::parse(&issue.to_file_contents()).unwrap();
629 assert_eq!(parsed.assignee.as_deref(), Some("alice"));
630 }
631
632 #[test]
633 fn assignee_read_accepts_both_bare_and_at_prefixed_handles() {
634 let legacy = "number: 7\ntitle: T\nstatus: todo\nassignee: \"@alice\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n";
635 let bare = "number: 7\ntitle: T\nstatus: todo\nassignee: \"alice\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n";
636 let from_legacy: Issue = serde_yaml::from_str(legacy).unwrap();
637 let from_bare: Issue = serde_yaml::from_str(bare).unwrap();
638 assert_eq!(from_legacy.assignee.as_deref(), Some("alice"));
639 assert_eq!(from_bare.assignee.as_deref(), Some("alice"));
640 }
641
642 #[test]
643 fn angle_bracket_identity_without_name_written_verbatim() {
644 let issue = Issue {
645 number: 8,
646 title: "T".into(),
647 status: Status::default(),
648 labels: vec![],
649 priority: Priority::default(),
650 assignee: Some("<thyseus@pm.me>".into()),
651 estimate: None,
652 commit: None,
653 gitlab_id: None,
654 linear_id: None,
655 mission: None,
656 task: None,
657 created: "2026-01-01".into(),
658 updated: "2026-01-01".into(),
659 filename: String::new(),
660 body: String::new(),
661 };
662 let fm = issue.frontmatter();
663 assert!(
664 fm.contains("<thyseus@pm.me>"),
665 "identity-like value must be verbatim, got:\n{fm}"
666 );
667 assert!(
668 !fm.contains("@<thyseus"),
669 "identity-like value must not be @-prefixed, got:\n{fm}"
670 );
671 }
672
673 #[test]
674 fn all_none_frontmatter_only_contains_required_keys() {
675 let issue = Issue {
676 number: 4,
677 title: "T".into(),
678 status: Status::default(),
679 labels: vec![],
680 priority: Priority::default(),
681 assignee: None,
682 estimate: None,
683 commit: None,
684 gitlab_id: None,
685 linear_id: None,
686 mission: None,
687 task: None,
688 created: "2026-01-01".into(),
689 updated: "2026-01-01".into(),
690 filename: String::new(),
691 body: String::new(),
692 };
693 let fm = issue.frontmatter();
694 assert!(fm.contains("number:"));
695 assert!(fm.contains("title:"));
696 assert!(fm.contains("status:"));
697 assert!(fm.contains("labels:"));
698 assert!(fm.contains("priority:"));
699 assert!(fm.contains("created:"));
700 assert!(fm.contains("updated:"));
701 for key in ["assignee", "estimate", "commit", "gitlab_id", "linear_id"] {
702 assert!(!fm.contains(key), "frontmatter must omit {key}, got:\n{fm}");
703 }
704 }
705
706 #[test]
707 fn filename_stem_matches_contract_format() {
708 let issue = Issue {
709 number: 1,
710 title: "Fix !Login crash?".into(),
711 status: Status::default(),
712 labels: vec![],
713 priority: Priority::default(),
714 assignee: None,
715 estimate: None,
716 commit: None,
717 gitlab_id: None,
718 linear_id: None,
719 mission: None,
720 task: None,
721 created: "2026-05-10".into(),
722 updated: "2026-05-10".into(),
723 filename: String::new(),
724 body: String::new(),
725 };
726 assert_eq!(issue.filename_stem(), "2026-05-10-fix-login-crash");
727 }
728
729 #[test]
730 fn bump_updated_stamps_today() {
731 let mut issue = Issue {
732 number: 1,
733 title: "T".into(),
734 status: Status::default(),
735 labels: vec![],
736 priority: Priority::default(),
737 assignee: None,
738 estimate: None,
739 commit: None,
740 gitlab_id: None,
741 linear_id: None,
742 mission: None,
743 task: None,
744 created: "2020-01-01".into(),
745 updated: "2020-01-01".into(),
746 filename: String::new(),
747 body: String::new(),
748 };
749 issue.bump_updated();
750 assert_eq!(issue.updated, today());
751 assert_ne!(issue.updated, "2020-01-01");
752 }
753
754 #[test]
755 fn parse_rejects_malformed_frontmatter() {
756 assert!(Issue::parse("no delimiter\n").is_err());
757 assert!(Issue::parse("---\na: 1\n").is_err()); assert!(Issue::parse("---\nbroken: [").is_err()); }
760
761 #[test]
762 fn today_is_an_iso_calendar_date() {
763 let t = today();
764 assert_eq!(t.len(), 10);
765 let parts: Vec<&str> = t.split('-').collect();
766 assert_eq!(parts.len(), 3);
767 assert_eq!(parts[0].len(), 4);
768 assert_eq!(parts[1].len(), 2);
769 assert_eq!(parts[2].len(), 2);
770 }
771
772 #[test]
773 fn civil_from_days_epoch_is_1970_01_01() {
774 assert_eq!(civil_from_days(0), (1970, 1, 1));
775 }
776
777 fn issue(body: &str) -> Issue {
782 Issue {
783 number: 1,
784 title: "T".into(),
785 status: Status::default(),
786 labels: vec![],
787 priority: Priority::default(),
788 assignee: None,
789 estimate: None,
790 commit: None,
791 gitlab_id: None,
792 linear_id: None,
793 mission: None,
794 task: None,
795 created: "2026-01-01".into(),
796 updated: "2026-01-01".into(),
797 filename: String::new(),
798 body: body.to_string(),
799 }
800 }
801
802 #[test]
805 fn comments_empty_when_no_section() {
806 let i = issue("Some description text.\n");
807 assert_eq!(i.description(), "Some description text.");
808 assert!(i.comments().is_empty());
809 }
810
811 #[test]
812 fn comments_parsed_from_section() {
813 let i = issue(
814 "Hello\n\n## Comments\n\n\
815 ### @alice (2026-09-13)\n\n\
816 First comment.\n\n\
817 ### bob (2026-09-14)\n\n\
818 Second comment.\n",
819 );
820 assert_eq!(i.description(), "Hello");
821 let cs = i.comments();
822 assert_eq!(cs.len(), 2);
823 assert_eq!(cs[0].author, "@alice");
824 assert_eq!(cs[0].date, "2026-09-13");
825 assert_eq!(cs[1].author, "bob");
826 assert_eq!(cs[1].date, "2026-09-14");
827 }
828
829 #[test]
830 fn code_fence_boundary_not_treated_as_boundary() {
831 let body = "Description\n\n\
832 ```\n\
833 ## Comments\n\
834 ```\n\n\
835 ## Comments\n\n\
836 ### eve (2026-10-01)\n\n\
837 real comment\n";
838 let i = issue(body);
839 let cs = i.comments();
840 assert_eq!(cs.len(), 1);
841 assert_eq!(cs[0].author, "eve");
842 assert_eq!(cs[0].date, "2026-10-01");
843 assert_eq!(cs[0].text, "\nreal comment");
844 assert!(
845 i.description().contains("## Comments"),
846 "code-blocked heading must be in description"
847 );
848 }
849
850 #[test]
851 fn blank_lines_preserved_in_comment_text() {
852 let i = issue(
853 "## Comments\n\n\
854 ### @alice (2026-09-13)\n\n\
855 line one\n\nline two\n\n\n\
856 line three\n",
857 );
858 let cs = i.comments();
859 assert_eq!(cs.len(), 1);
860 let text = &cs[0].text;
861 assert!(
862 text.contains("\nline two"),
863 "internal blank lines must be preserved: got {text:?}"
864 );
865 assert!(
866 text.contains("line three"),
867 "must contain all lines: got {text:?}"
868 );
869 }
870
871 #[test]
872 fn comments_empty_when_section_has_no_heads() {
873 let i = issue("## Comments\n\n\n");
874 assert!(i.comments().is_empty());
875 }
876
877 #[test]
880 fn add_first_comment_to_empty_body() {
881 let mut i = issue("");
882 i.add_comment("@alice", "Hello world");
883 assert_eq!(i.description(), "");
884 let cs = i.comments();
885 assert_eq!(cs.len(), 1);
886 assert_eq!(cs[0].author, "@alice");
887 assert_eq!(cs[0].text, "\nHello world");
888 assert!(i.body.contains("## Comments"));
889 }
890
891 #[test]
892 fn second_comment_appended_after_first() {
893 let mut i = issue("");
894 i.add_comment("@alice", "first");
895 i.add_comment("bob", "second");
896 let cs = i.comments();
897 assert_eq!(cs.len(), 2);
898 let pos_a = i.body.find("### @alice");
899 let pos_b = i.body.find("### bob");
900 assert!(pos_a < pos_b, "first comment must precede second");
901 }
902
903 #[test]
904 fn whitespace_only_comment_discarded() {
905 let mut i = issue("Hello\n");
906 let before = i.body.clone();
907 i.add_comment("@alice", " \n ");
908 assert_eq!(i.body, before, "whitespace-only text must not change body");
909 }
910
911 #[test]
912 fn description_unchanged_after_add_comment() {
913 let mut i = issue("Description text.\n");
914 i.add_comment("@alice", "a comment");
915 assert_eq!(i.description(), "Description text.");
916 }
917
918 #[test]
919 fn literal_comments_heading_in_code_block_not_touched() {
920 let mut i = issue("```\n## Comments\n```\n");
921 i.add_comment("@alice", "new");
922 assert!(
923 i.body.contains("```\n## Comments\n```"),
924 "code-block heading must be preserved: {0:?}",
925 i.body
926 );
927 assert!(i.body.contains("### @alice"), "must contain new comment");
928 }
929
930 #[test]
933 fn roundtrip_preserves_comment_data() {
934 let mut i = issue(
935 "## Comments\n\n\
936 ### @alice (2026-09-13)\n\n\
937 Original comment.\n",
938 );
939 i.add_comment("bob", "Second comment");
940 let file = i.to_file_contents();
941 let parsed = Issue::parse(&file).unwrap();
942 assert_eq!(parsed.description(), i.description());
943 let cs = parsed.comments();
944 assert_eq!(cs.len(), 2);
945 assert_eq!(cs[0].author, "@alice");
946 assert_eq!(cs[1].author, "bob");
947 assert_eq!(cs[1].text, "\nSecond comment");
948 }
949
950 #[test]
951 fn three_comments_in_file_order() {
952 let mut i = issue("");
953 i.add_comment("a", "one");
954 i.add_comment("b", "two");
955 i.add_comment("c", "three");
956 let cs = i.comments();
957 assert_eq!(
958 cs.iter().map(|c| c.author.as_str()).collect::<Vec<_>>(),
959 ["a", "b", "c"]
960 );
961 assert_eq!(
962 i.body,
963 format!(
964 "## Comments\n\n### a ({0})\n\none\n\n### b ({0})\n\ntwo\n\n### c ({0})\n\nthree\n",
965 today()
966 )
967 );
968 }
969
970 #[test]
971 fn preserves_hand_authored_comment_markdown() {
972 let body = "## Comments\n\n\
973 ### @alice (2026-09-13)\n\n\
974 **bold** and _italic_\n\n\
975 - item 1\n- item 2\n\n";
976 let i = issue(body);
977 let cs = i.comments();
978 assert_eq!(cs.len(), 1);
979 assert!(cs[0].text.contains("**bold**"));
980 assert!(cs[0].text.contains("- item 1"));
981 }
982
983 #[test]
984 fn description_code_block_heading_not_boundary() {
985 let mut i = issue("Intro text here\n\n```\n## Comments\n```\n");
986 let before = i.body.clone();
987 i.add_comment("eve", "real");
988 assert_eq!(i.description(), "Intro text here\n\n```\n## Comments\n```");
989 let cs = i.comments();
990 assert_eq!(cs.len(), 1);
991 assert_eq!(cs[0].author, "eve");
992 assert!(
993 i.body.starts_with(&before),
994 "code-block heading must be preserved"
995 );
996 }
997}