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)]
292 pub links: Vec<String>,
293 #[serde(default)]
296 pub files: Vec<String>,
297 #[serde(default = "today")]
299 pub created: String,
300 #[serde(default = "today")]
302 pub updated: String,
303 #[serde(skip)]
305 pub filename: String,
306 #[serde(skip)]
308 pub body: String,
309}
310
311impl Issue {
312 pub fn parse(contents: &str) -> Result<Self> {
318 let rest = contents
319 .strip_prefix("---\n")
320 .ok_or_else(|| TixError::Parse("file must start with a `---` delimiter".into()))?;
321 let mut current = rest;
322 let mut fm_len = 0usize;
323 loop {
324 let line_end = current.find('\n').map(|i| i + 1).unwrap_or(current.len());
325 let content = current[..line_end]
326 .strip_suffix('\n')
327 .unwrap_or(¤t[..line_end]);
328 if content == "---" {
329 break;
330 }
331 fm_len += line_end;
332 current = ¤t[line_end..];
333 if current.is_empty() {
334 return Err(TixError::Parse(
335 "frontmatter is not terminated by a closing `---`".into(),
336 ));
337 }
338 }
339 let frontmatter = &rest[..fm_len];
340 let body_raw = &rest[fm_len..];
341 let mut body = body_raw.strip_prefix("---\n").unwrap_or(body_raw);
342 if let Some(after) = body.strip_prefix('\n') {
343 body = after;
344 }
345 let body = body.to_string();
346 let mut issue: Issue =
347 serde_yaml::from_str(frontmatter).map_err(|e| TixError::Parse(e.to_string()))?;
348 issue.body = body;
349 Ok(issue)
350 }
351
352 pub fn frontmatter(&self) -> String {
355 let fm =
356 serde_yaml::to_string(self).expect("Issue frontmatter serialization is infallible");
357 fm.trim_end_matches('\n').to_string() + "\n"
358 }
359
360 pub fn to_file_contents(&self) -> String {
363 format!("---\n{}---\n\n{}", self.frontmatter(), self.body)
364 }
365
366 pub fn filename_stem(&self) -> String {
368 format!("{}-{}", self.created, slugify(&self.title))
369 }
370
371 pub fn bump_updated(&mut self) {
373 self.updated = today();
374 }
375
376 fn comments_boundary(body: &str) -> Option<usize> {
383 let mut in_fence = false;
384 let mut offset = 0usize;
385 for raw in body.split_inclusive('\n') {
386 let line = raw.strip_suffix('\n').unwrap_or(raw);
387 if line.starts_with("```") || line.starts_with("~~~") {
388 in_fence = !in_fence;
389 } else if !in_fence && line == "## Comments" {
390 return Some(offset);
391 }
392 offset += raw.len();
393 }
394 None
395 }
396
397 pub fn description(&self) -> &str {
401 match Self::comments_boundary(&self.body) {
402 Some(idx) => self.body[..idx].trim_end(),
403 None => self.body.trim_end(),
404 }
405 }
406
407 pub fn comments(&self) -> Vec<Comment> {
411 let Some(boundary) = Self::comments_boundary(&self.body) else {
412 return Vec::new();
413 };
414 let section = &self.body[boundary..];
415 let mut comments = Vec::new();
416 let mut offset = 0usize;
417 for raw in section.split_inclusive('\n') {
418 let line = raw.strip_suffix('\n').unwrap_or(raw);
419 if let Some(after) = line.strip_prefix("### ") {
420 let (author, date) = parse_comment_head(after);
421 let start = offset + raw.len();
422 let mut text = String::new();
423 for next in section[start..].split_inclusive('\n') {
424 if next.strip_suffix('\n').unwrap_or(next).starts_with("### ") {
425 break;
426 }
427 text.push_str(next);
428 }
429 comments.push(Comment {
430 author,
431 date,
432 text: text.trim_end_matches('\n').to_string(),
433 });
434 }
435 offset += raw.len();
436 }
437 comments
438 }
439
440 pub fn add_comment(&mut self, author: &str, text: &str) {
447 if text.trim().is_empty() {
448 return;
449 }
450 if Self::comments_boundary(&self.body).is_none() {
451 if !self.body.trim().is_empty() {
452 if !self.body.ends_with('\n') {
453 self.body.push('\n');
454 }
455 self.body.push('\n');
456 }
457 self.body.push_str("## Comments\n");
458 }
459 if !self.body.is_empty() && !self.body.ends_with('\n') {
460 self.body.push('\n');
461 }
462 if !self.body.ends_with("\n\n") {
463 self.body.push('\n');
464 }
465 self.body
466 .push_str(&format!("### {author} ({})\n\n{text}\n", today()));
467 self.bump_updated();
468 }
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474
475 #[test]
476 fn status_helpers() {
477 let s = Status::new("in progress");
478 assert!(s.is("in progress"));
479 assert!(!s.is("in_progress"));
480 assert_eq!(s.display(), "in progress");
481 assert_eq!(Status::default(), Status::new("todo"));
482 }
483
484 #[test]
485 fn status_survives_unknown_column_name() {
486 let parsed: Status = serde_yaml::from_str("\"renamed-column\"").unwrap();
487 assert_eq!(parsed.display(), "renamed-column");
488 }
489
490 #[test]
491 fn priority_roundtrips_lowercase_and_parses_case_insensitively() {
492 assert_eq!("low".parse::<Priority>().unwrap(), Priority::Low);
493 assert_eq!("HIGH".parse::<Priority>().unwrap(), Priority::High);
494 assert_eq!(Priority::default(), Priority::Medium);
495 assert_eq!(Priority::Low.to_string(), "low");
496 }
497
498 #[test]
499 fn unknown_priority_is_preserved_not_normalized() {
500 let urgent: Priority = serde_yaml::from_str("\"urgent\"").unwrap();
501 assert_eq!(urgent.to_string(), "urgent");
502 let yaml = serde_yaml::to_string(&urgent).unwrap();
503 assert!(yaml.contains("urgent"));
504 }
505
506 #[test]
507 fn issue_roundtrip_preserves_every_field_including_number_and_body() {
508 let mut issue = Issue {
509 number: 7,
510 title: "Add documentation for the feature".into(),
511 status: Status::new("todo"),
512 labels: vec!["docs".into(), "onboarding".into()],
513 priority: Priority::High,
514 assignee: Some("alice".into()),
515 estimate: Some("2".into()),
516 commit: Some("abc123".into()),
517 gitlab_id: Some("gl-9".into()),
518 linear_id: Some("LIN-9".into()),
519 mission: None,
520 task: None,
521 links: vec![],
522 files: vec![],
523 created: "2026-05-10".into(),
524 updated: "2026-05-10".into(),
525 filename: String::new(),
526 body: "Describe the issue here.\n\n".into(),
527 };
528 let contents = issue.to_file_contents();
529 let parsed = Issue::parse(&contents).unwrap();
530 assert_eq!(parsed, issue);
531 issue.body.clear();
532 assert_ne!(parsed, issue);
533 }
534
535 #[test]
536 fn links_round_trip_preserves_entries_verbatim() {
537 let issue = Issue {
538 number: 9,
539 title: "T".into(),
540 status: Status::default(),
541 labels: vec![],
542 priority: Priority::default(),
543 assignee: None,
544 estimate: None,
545 commit: None,
546 gitlab_id: None,
547 linear_id: None,
548 mission: None,
549 task: None,
550 links: vec!["12".into(), "47".into()],
551 files: vec!["docs/x.md".into(), "specs/y/spec.md".into()],
552 created: "2026-01-01".into(),
553 updated: "2026-01-01".into(),
554 filename: String::new(),
555 body: "body".into(),
556 };
557 let contents = issue.to_file_contents();
558 let parsed = Issue::parse(&contents).unwrap();
559 assert_eq!(parsed, issue);
560 assert_eq!(parsed.links, ["12", "47"]);
561 assert_eq!(parsed.files, ["docs/x.md", "specs/y/spec.md"]);
562 }
563
564 #[test]
565 fn empty_links_and_files_serialize_as_inline_empty_lists() {
566 let issue = Issue {
567 number: 4,
568 title: "T".into(),
569 status: Status::default(),
570 labels: vec![],
571 priority: Priority::default(),
572 assignee: None,
573 estimate: None,
574 commit: None,
575 gitlab_id: None,
576 linear_id: None,
577 mission: None,
578 task: None,
579 links: vec![],
580 files: vec![],
581 created: "2026-01-01".into(),
582 updated: "2026-01-01".into(),
583 filename: String::new(),
584 body: String::new(),
585 };
586 let fm = issue.frontmatter();
587 assert!(fm.contains("links: []"), "got:\n{fm}");
588 assert!(fm.contains("files: []"), "got:\n{fm}");
589 }
590
591 #[test]
592 fn non_numeric_link_entries_parse_without_error() {
593 let yaml = "\
594number: 1
595title: T
596status: todo
597links:
598- '12'
599- unknown-token
600- 47
601created: '2026-01-01'
602updated: '2026-01-01'
603";
604 let issue: Issue = serde_yaml::from_str(yaml).unwrap();
605 assert_eq!(issue.links, ["12", "unknown-token", "47"]);
606 let parsed = Issue::parse(&format!("---\n{yaml}---\n\nbody\n")).unwrap();
607 assert_eq!(parsed.links.len(), 3);
608 assert_eq!(parsed.links[1], "unknown-token");
609 }
610
611 #[test]
612 fn missing_dates_default_to_today() {
613 let yaml = "number: 2\ntitle: Hi\nstatus: todo\n";
614 let issue: Issue = serde_yaml::from_str(yaml).unwrap();
615 assert_eq!(issue.created, today());
616 assert_eq!(issue.updated, today());
617 }
618
619 #[test]
620 fn body_is_preserved_verbatim() {
621 let issue = Issue {
622 number: 1,
623 title: "T".into(),
624 status: Status::default(),
625 labels: vec![],
626 priority: Priority::default(),
627 assignee: None,
628 estimate: None,
629 commit: None,
630 gitlab_id: None,
631 linear_id: None,
632 mission: None,
633 task: None,
634 links: vec![],
635 files: vec![],
636 created: "2026-01-02".into(),
637 updated: "2026-01-02".into(),
638 filename: String::new(),
639 body: "line one\n\nline two\n```\ncode\n```".into(),
640 };
641 let contents = issue.to_file_contents();
642 let parsed = Issue::parse(&contents).unwrap();
643 assert_eq!(parsed.body, issue.body);
644 }
645
646 #[test]
647 fn assignee_at_prefix_is_stripped_on_read_and_restored_on_write() {
648 let yaml = "number: 3\ntitle: T\nstatus: todo\nassignee: \"@alice\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n";
649 let issue: Issue = serde_yaml::from_str(yaml).unwrap();
650 assert_eq!(issue.assignee.as_deref(), Some("alice"));
651 let rerendered = issue.frontmatter();
652 assert!(
653 rerendered.contains("assignee: \"@alice\"")
654 || rerendered.contains("assignee: '@alice'")
655 );
656 }
657
658 #[test]
659 fn full_identity_assignee_round_trips_verbatim() {
660 let issue = Issue {
661 number: 5,
662 title: "T".into(),
663 status: Status::default(),
664 labels: vec![],
665 priority: Priority::default(),
666 assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
667 estimate: None,
668 commit: None,
669 gitlab_id: None,
670 linear_id: None,
671 mission: None,
672 task: None,
673 links: vec![],
674 files: vec![],
675 created: "2026-01-01".into(),
676 updated: "2026-01-01".into(),
677 filename: String::new(),
678 body: String::new(),
679 };
680 let fm = issue.frontmatter();
681 assert!(
682 fm.contains("Herbert Maschke <thyseus@pm.me>"),
683 "identity must be stored verbatim, got:\n{fm}"
684 );
685 assert!(
686 !fm.contains("@Herbert Maschke"),
687 "identity must not be @-prefixed, got:\n{fm}"
688 );
689 let parsed = Issue::parse(&issue.to_file_contents()).unwrap();
690 assert_eq!(parsed, issue);
691 }
692
693 #[test]
694 fn bare_handle_assignee_writes_legacy_at_prefix_and_reads_back_bare() {
695 let issue = Issue {
696 number: 6,
697 title: "T".into(),
698 status: Status::default(),
699 labels: vec![],
700 priority: Priority::default(),
701 assignee: Some("alice".into()),
702 estimate: None,
703 commit: None,
704 gitlab_id: None,
705 linear_id: None,
706 mission: None,
707 task: None,
708 links: vec![],
709 files: vec![],
710 created: "2026-01-01".into(),
711 updated: "2026-01-01".into(),
712 filename: String::new(),
713 body: String::new(),
714 };
715 let fm = issue.frontmatter();
716 assert!(
717 fm.contains("assignee: \"@alice\"") || fm.contains("assignee: '@alice'"),
718 "legacy @-prefixed bytes expected, got:\n{fm}"
719 );
720 let parsed = Issue::parse(&issue.to_file_contents()).unwrap();
721 assert_eq!(parsed.assignee.as_deref(), Some("alice"));
722 }
723
724 #[test]
725 fn assignee_read_accepts_both_bare_and_at_prefixed_handles() {
726 let legacy = "number: 7\ntitle: T\nstatus: todo\nassignee: \"@alice\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n";
727 let bare = "number: 7\ntitle: T\nstatus: todo\nassignee: \"alice\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n";
728 let from_legacy: Issue = serde_yaml::from_str(legacy).unwrap();
729 let from_bare: Issue = serde_yaml::from_str(bare).unwrap();
730 assert_eq!(from_legacy.assignee.as_deref(), Some("alice"));
731 assert_eq!(from_bare.assignee.as_deref(), Some("alice"));
732 }
733
734 #[test]
735 fn angle_bracket_identity_without_name_written_verbatim() {
736 let issue = Issue {
737 number: 8,
738 title: "T".into(),
739 status: Status::default(),
740 labels: vec![],
741 priority: Priority::default(),
742 assignee: Some("<thyseus@pm.me>".into()),
743 estimate: None,
744 commit: None,
745 gitlab_id: None,
746 linear_id: None,
747 mission: None,
748 task: None,
749 links: vec![],
750 files: vec![],
751 created: "2026-01-01".into(),
752 updated: "2026-01-01".into(),
753 filename: String::new(),
754 body: String::new(),
755 };
756 let fm = issue.frontmatter();
757 assert!(
758 fm.contains("<thyseus@pm.me>"),
759 "identity-like value must be verbatim, got:\n{fm}"
760 );
761 assert!(
762 !fm.contains("@<thyseus"),
763 "identity-like value must not be @-prefixed, got:\n{fm}"
764 );
765 }
766
767 #[test]
768 fn all_none_frontmatter_only_contains_required_keys() {
769 let issue = Issue {
770 number: 4,
771 title: "T".into(),
772 status: Status::default(),
773 labels: vec![],
774 priority: Priority::default(),
775 assignee: None,
776 estimate: None,
777 commit: None,
778 gitlab_id: None,
779 linear_id: None,
780 mission: None,
781 task: None,
782 links: vec![],
783 files: vec![],
784 created: "2026-01-01".into(),
785 updated: "2026-01-01".into(),
786 filename: String::new(),
787 body: String::new(),
788 };
789 let fm = issue.frontmatter();
790 assert!(fm.contains("number:"));
791 assert!(fm.contains("title:"));
792 assert!(fm.contains("status:"));
793 assert!(fm.contains("labels:"));
794 assert!(fm.contains("priority:"));
795 assert!(fm.contains("created:"));
796 assert!(fm.contains("updated:"));
797 for key in ["assignee", "estimate", "commit", "gitlab_id", "linear_id"] {
798 assert!(!fm.contains(key), "frontmatter must omit {key}, got:\n{fm}");
799 }
800 }
801
802 #[test]
803 fn filename_stem_matches_contract_format() {
804 let issue = Issue {
805 number: 1,
806 title: "Fix !Login crash?".into(),
807 status: Status::default(),
808 labels: vec![],
809 priority: Priority::default(),
810 assignee: None,
811 estimate: None,
812 commit: None,
813 gitlab_id: None,
814 linear_id: None,
815 mission: None,
816 task: None,
817 links: vec![],
818 files: vec![],
819 created: "2026-05-10".into(),
820 updated: "2026-05-10".into(),
821 filename: String::new(),
822 body: String::new(),
823 };
824 assert_eq!(issue.filename_stem(), "2026-05-10-fix-login-crash");
825 }
826
827 #[test]
828 fn bump_updated_stamps_today() {
829 let mut issue = Issue {
830 number: 1,
831 title: "T".into(),
832 status: Status::default(),
833 labels: vec![],
834 priority: Priority::default(),
835 assignee: None,
836 estimate: None,
837 commit: None,
838 gitlab_id: None,
839 linear_id: None,
840 mission: None,
841 task: None,
842 links: vec![],
843 files: vec![],
844 created: "2020-01-01".into(),
845 updated: "2020-01-01".into(),
846 filename: String::new(),
847 body: String::new(),
848 };
849 issue.bump_updated();
850 assert_eq!(issue.updated, today());
851 assert_ne!(issue.updated, "2020-01-01");
852 }
853
854 #[test]
855 fn parse_rejects_malformed_frontmatter() {
856 assert!(Issue::parse("no delimiter\n").is_err());
857 assert!(Issue::parse("---\na: 1\n").is_err()); assert!(Issue::parse("---\nbroken: [").is_err()); }
860
861 #[test]
862 fn today_is_an_iso_calendar_date() {
863 let t = today();
864 assert_eq!(t.len(), 10);
865 let parts: Vec<&str> = t.split('-').collect();
866 assert_eq!(parts.len(), 3);
867 assert_eq!(parts[0].len(), 4);
868 assert_eq!(parts[1].len(), 2);
869 assert_eq!(parts[2].len(), 2);
870 }
871
872 #[test]
873 fn civil_from_days_epoch_is_1970_01_01() {
874 assert_eq!(civil_from_days(0), (1970, 1, 1));
875 }
876
877 fn issue(body: &str) -> Issue {
882 Issue {
883 number: 1,
884 title: "T".into(),
885 status: Status::default(),
886 labels: vec![],
887 priority: Priority::default(),
888 assignee: None,
889 estimate: None,
890 commit: None,
891 gitlab_id: None,
892 linear_id: None,
893 mission: None,
894 task: None,
895 links: vec![],
896 files: vec![],
897 created: "2026-01-01".into(),
898 updated: "2026-01-01".into(),
899 filename: String::new(),
900 body: body.to_string(),
901 }
902 }
903
904 #[test]
907 fn comments_empty_when_no_section() {
908 let i = issue("Some description text.\n");
909 assert_eq!(i.description(), "Some description text.");
910 assert!(i.comments().is_empty());
911 }
912
913 #[test]
914 fn comments_parsed_from_section() {
915 let i = issue(
916 "Hello\n\n## Comments\n\n\
917 ### @alice (2026-09-13)\n\n\
918 First comment.\n\n\
919 ### bob (2026-09-14)\n\n\
920 Second comment.\n",
921 );
922 assert_eq!(i.description(), "Hello");
923 let cs = i.comments();
924 assert_eq!(cs.len(), 2);
925 assert_eq!(cs[0].author, "@alice");
926 assert_eq!(cs[0].date, "2026-09-13");
927 assert_eq!(cs[1].author, "bob");
928 assert_eq!(cs[1].date, "2026-09-14");
929 }
930
931 #[test]
932 fn code_fence_boundary_not_treated_as_boundary() {
933 let body = "Description\n\n\
934 ```\n\
935 ## Comments\n\
936 ```\n\n\
937 ## Comments\n\n\
938 ### eve (2026-10-01)\n\n\
939 real comment\n";
940 let i = issue(body);
941 let cs = i.comments();
942 assert_eq!(cs.len(), 1);
943 assert_eq!(cs[0].author, "eve");
944 assert_eq!(cs[0].date, "2026-10-01");
945 assert_eq!(cs[0].text, "\nreal comment");
946 assert!(
947 i.description().contains("## Comments"),
948 "code-blocked heading must be in description"
949 );
950 }
951
952 #[test]
953 fn blank_lines_preserved_in_comment_text() {
954 let i = issue(
955 "## Comments\n\n\
956 ### @alice (2026-09-13)\n\n\
957 line one\n\nline two\n\n\n\
958 line three\n",
959 );
960 let cs = i.comments();
961 assert_eq!(cs.len(), 1);
962 let text = &cs[0].text;
963 assert!(
964 text.contains("\nline two"),
965 "internal blank lines must be preserved: got {text:?}"
966 );
967 assert!(
968 text.contains("line three"),
969 "must contain all lines: got {text:?}"
970 );
971 }
972
973 #[test]
974 fn comments_empty_when_section_has_no_heads() {
975 let i = issue("## Comments\n\n\n");
976 assert!(i.comments().is_empty());
977 }
978
979 #[test]
982 fn add_first_comment_to_empty_body() {
983 let mut i = issue("");
984 i.add_comment("@alice", "Hello world");
985 assert_eq!(i.description(), "");
986 let cs = i.comments();
987 assert_eq!(cs.len(), 1);
988 assert_eq!(cs[0].author, "@alice");
989 assert_eq!(cs[0].text, "\nHello world");
990 assert!(i.body.contains("## Comments"));
991 }
992
993 #[test]
994 fn second_comment_appended_after_first() {
995 let mut i = issue("");
996 i.add_comment("@alice", "first");
997 i.add_comment("bob", "second");
998 let cs = i.comments();
999 assert_eq!(cs.len(), 2);
1000 let pos_a = i.body.find("### @alice");
1001 let pos_b = i.body.find("### bob");
1002 assert!(pos_a < pos_b, "first comment must precede second");
1003 }
1004
1005 #[test]
1006 fn whitespace_only_comment_discarded() {
1007 let mut i = issue("Hello\n");
1008 let before = i.body.clone();
1009 i.add_comment("@alice", " \n ");
1010 assert_eq!(i.body, before, "whitespace-only text must not change body");
1011 }
1012
1013 #[test]
1014 fn description_unchanged_after_add_comment() {
1015 let mut i = issue("Description text.\n");
1016 i.add_comment("@alice", "a comment");
1017 assert_eq!(i.description(), "Description text.");
1018 }
1019
1020 #[test]
1021 fn literal_comments_heading_in_code_block_not_touched() {
1022 let mut i = issue("```\n## Comments\n```\n");
1023 i.add_comment("@alice", "new");
1024 assert!(
1025 i.body.contains("```\n## Comments\n```"),
1026 "code-block heading must be preserved: {0:?}",
1027 i.body
1028 );
1029 assert!(i.body.contains("### @alice"), "must contain new comment");
1030 }
1031
1032 #[test]
1035 fn roundtrip_preserves_comment_data() {
1036 let mut i = issue(
1037 "## Comments\n\n\
1038 ### @alice (2026-09-13)\n\n\
1039 Original comment.\n",
1040 );
1041 i.add_comment("bob", "Second comment");
1042 let file = i.to_file_contents();
1043 let parsed = Issue::parse(&file).unwrap();
1044 assert_eq!(parsed.description(), i.description());
1045 let cs = parsed.comments();
1046 assert_eq!(cs.len(), 2);
1047 assert_eq!(cs[0].author, "@alice");
1048 assert_eq!(cs[1].author, "bob");
1049 assert_eq!(cs[1].text, "\nSecond comment");
1050 }
1051
1052 #[test]
1053 fn three_comments_in_file_order() {
1054 let mut i = issue("");
1055 i.add_comment("a", "one");
1056 i.add_comment("b", "two");
1057 i.add_comment("c", "three");
1058 let cs = i.comments();
1059 assert_eq!(
1060 cs.iter().map(|c| c.author.as_str()).collect::<Vec<_>>(),
1061 ["a", "b", "c"]
1062 );
1063 assert_eq!(
1064 i.body,
1065 format!(
1066 "## Comments\n\n### a ({0})\n\none\n\n### b ({0})\n\ntwo\n\n### c ({0})\n\nthree\n",
1067 today()
1068 )
1069 );
1070 }
1071
1072 #[test]
1073 fn preserves_hand_authored_comment_markdown() {
1074 let body = "## Comments\n\n\
1075 ### @alice (2026-09-13)\n\n\
1076 **bold** and _italic_\n\n\
1077 - item 1\n- item 2\n\n";
1078 let i = issue(body);
1079 let cs = i.comments();
1080 assert_eq!(cs.len(), 1);
1081 assert!(cs[0].text.contains("**bold**"));
1082 assert!(cs[0].text.contains("- item 1"));
1083 }
1084
1085 #[test]
1086 fn description_code_block_heading_not_boundary() {
1087 let mut i = issue("Intro text here\n\n```\n## Comments\n```\n");
1088 let before = i.body.clone();
1089 i.add_comment("eve", "real");
1090 assert_eq!(i.description(), "Intro text here\n\n```\n## Comments\n```");
1091 let cs = i.comments();
1092 assert_eq!(cs.len(), 1);
1093 assert_eq!(cs[0].author, "eve");
1094 assert!(
1095 i.body.starts_with(&before),
1096 "code-block heading must be preserved"
1097 );
1098 }
1099}