1pub mod age;
2pub mod hooks;
3pub mod metrics;
4pub mod storage;
5pub mod sync;
6
7use chrono::{DateTime, NaiveDate, Utc};
8use fuzzy_matcher::skim::SkimMatcherV2;
9use fuzzy_matcher::FuzzyMatcher;
10use serde::{Deserialize, Deserializer, Serialize};
11use unicode_normalization::UnicodeNormalization;
12
13fn deserialize_blocked<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<String>, D::Error> {
16 #[derive(Deserialize)]
17 #[serde(untagged)]
18 enum BoolOrString {
19 Bool(#[allow(dead_code)] bool),
20 Str(#[allow(dead_code)] String),
21 }
22 match Option::<BoolOrString>::deserialize(deserializer)? {
23 None => Ok(None),
24 Some(BoolOrString::Bool(true)) => Ok(Some(String::new())),
25 Some(BoolOrString::Bool(false)) => Ok(None),
26 Some(BoolOrString::Str(s)) => Ok(Some(s)),
27 }
28}
29
30#[derive(Debug, Clone)]
32pub struct Board {
33 pub name: String,
34 pub next_card_id: u32,
35 pub policies: Policies,
36 pub sync_branch: Option<String>,
37 pub nerd_font: bool,
38 pub created_at: Option<DateTime<Utc>>,
40 pub columns: Vec<Column>,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct Policies {
46 #[serde(default = "default_auto_close_days")]
48 pub auto_close_days: u32,
49 #[serde(default = "default_auto_close_target")]
51 pub auto_close_target: String,
52 #[serde(rename = "stale_days", alias = "bubble_up_days", default = "default_stale_days")]
54 pub stale_days: u32,
55 #[serde(default = "default_trash_purge_days")]
57 pub trash_purge_days: u32,
58 #[serde(default)]
60 pub archive_after_days: u32,
61}
62
63fn default_auto_close_days() -> u32 {
64 30
65}
66fn default_auto_close_target() -> String {
67 "archive".to_string()
68}
69fn default_stale_days() -> u32 {
70 7
71}
72fn default_trash_purge_days() -> u32 {
73 30
74}
75
76impl Default for Policies {
77 fn default() -> Self {
78 Self {
79 auto_close_days: default_auto_close_days(),
80 auto_close_target: default_auto_close_target(),
81 stale_days: default_stale_days(),
82 trash_purge_days: default_trash_purge_days(),
83 archive_after_days: 0,
84 }
85 }
86}
87
88#[derive(Debug, Clone)]
90pub struct Column {
91 pub slug: String,
92 pub name: String,
93 pub order: u32,
94 pub wip_limit: Option<u32>,
95 pub hidden: bool,
96 pub cards: Vec<Card>,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
101#[serde(rename_all = "lowercase")]
102pub enum Priority {
103 Low,
104 #[default]
105 Normal,
106 High,
107 Urgent,
108}
109
110impl Priority {
111 pub const ALL: [Priority; 4] = [Self::Low, Self::Normal, Self::High, Self::Urgent];
112
113 pub fn sort_key(self) -> u8 {
115 match self {
116 Self::Urgent => 0,
117 Self::High => 1,
118 Self::Normal => 2,
119 Self::Low => 3,
120 }
121 }
122
123 pub fn as_str(&self) -> &'static str {
124 match self {
125 Self::Low => "low",
126 Self::Normal => "normal",
127 Self::High => "high",
128 Self::Urgent => "urgent",
129 }
130 }
131
132}
133
134impl std::str::FromStr for Priority {
135 type Err = String;
136
137 fn from_str(s: &str) -> Result<Self, Self::Err> {
138 match s.to_lowercase().as_str() {
139 "low" => Ok(Self::Low),
140 "normal" => Ok(Self::Normal),
141 "high" => Ok(Self::High),
142 "urgent" => Ok(Self::Urgent),
143 other => Err(format!("unknown priority '{other}': use low, normal, high, urgent")),
144 }
145 }
146}
147
148impl std::fmt::Display for Priority {
149 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150 f.write_str(self.as_str())
151 }
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct Card {
157 pub id: String,
158 pub title: String,
159 pub created: DateTime<Utc>,
160 pub updated: DateTime<Utc>,
161 #[serde(default)]
162 pub priority: Priority,
163 #[serde(default)]
164 pub tags: Vec<String>,
165 #[serde(default)]
166 pub assignees: Vec<String>,
167 #[serde(default, deserialize_with = "deserialize_blocked", skip_serializing_if = "Option::is_none")]
168 pub blocked: Option<String>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub due: Option<NaiveDate>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub started: Option<DateTime<Utc>>,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub completed: Option<DateTime<Utc>>,
177 #[serde(skip)]
179 pub body: String,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct Template {
186 #[serde(default)]
187 pub priority: Priority,
188 #[serde(default)]
189 pub tags: Vec<String>,
190 #[serde(default)]
191 pub assignees: Vec<String>,
192 #[serde(default, deserialize_with = "deserialize_blocked", skip_serializing_if = "Option::is_none")]
193 pub blocked: Option<String>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub due_offset_days: Option<u32>,
196 #[serde(skip)]
197 pub body: String,
198}
199
200pub fn generate_template_slug(name: &str, existing_slugs: &[String]) -> String {
202 let base = slug_from_name(name);
203 let base = if base == "col" && !name.chars().any(|c| c.is_alphanumeric()) {
204 "template".to_string()
205 } else {
206 base
207 };
208 if !existing_slugs.iter().any(|s| s == &base) {
209 return base;
210 }
211 for n in 2u32.. {
212 let candidate = format!("{base}-{n}");
213 if !existing_slugs.iter().any(|s| s == &candidate) {
214 return candidate;
215 }
216 }
217 unreachable!("generate_template_slug: exhausted candidates for base={base:?}")
218}
219
220impl Card {
221 pub fn new(id: String, title: String) -> Self {
222 let now = Utc::now();
223 Self {
224 id,
225 title,
226 created: now,
227 updated: now,
228 priority: Priority::default(),
229 tags: Vec::new(),
230 assignees: Vec::new(),
231 blocked: None,
232 due: None,
233 started: None,
234 completed: None,
235 body: "<!-- add body content here -->".into(),
236 }
237 }
238
239 pub fn is_blocked(&self) -> bool {
240 self.blocked.is_some()
241 }
242
243 pub fn is_overdue(&self, today: NaiveDate) -> bool {
247 self.due.is_some_and(|d| d < today)
248 }
249
250 pub fn touch(&mut self) {
252 self.updated = Utc::now();
253 }
254}
255
256impl Board {
257 pub fn next_card_id(&mut self) -> String {
259 let n = self.next_card_id;
260 self.next_card_id += 1;
261 n.to_string()
262 }
263
264 pub fn find_card(&self, card_id: &str) -> Option<(usize, usize)> {
266 for (col_idx, col) in self.columns.iter().enumerate() {
267 for (card_idx, card) in col.cards.iter().enumerate() {
268 if card.id == card_id {
269 return Some((col_idx, card_idx));
270 }
271 }
272 }
273 None
274 }
275
276 pub fn move_card(&mut self, from_col: usize, card_idx: usize, to_col: usize) {
278 if from_col >= self.columns.len() || to_col >= self.columns.len() {
279 return;
280 }
281 if card_idx >= self.columns[from_col].cards.len() {
282 return;
283 }
284 let mut card = self.columns[from_col].cards.remove(card_idx);
285 card.touch();
286 if card.started.is_none() && to_col > 0 {
289 card.started = Some(Utc::now());
290 }
291 if self.columns[to_col].slug == "done" {
293 if card.completed.is_none() {
294 card.completed = Some(Utc::now());
295 }
296 } else {
297 card.completed = None;
298 }
299 self.columns[to_col].cards.push(card);
300 }
301
302 pub fn all_tags(&self) -> Vec<(String, usize)> {
304 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
305 for col in &self.columns {
306 for card in &col.cards {
307 for tag in &card.tags {
308 *counts.entry(tag.as_str()).or_insert(0) += 1;
309 }
310 }
311 }
312 let mut tags: Vec<_> = counts
313 .into_iter()
314 .map(|(tag, count)| (tag.to_string(), count))
315 .collect();
316 tags.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
317 tags
318 }
319
320 pub fn all_assignees(&self) -> Vec<(String, usize)> {
322 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
323 for col in &self.columns {
324 for card in &col.cards {
325 for assignee in &card.assignees {
326 *counts.entry(assignee.as_str()).or_insert(0) += 1;
327 }
328 }
329 }
330 let mut assignees: Vec<_> = counts
331 .into_iter()
332 .map(|(name, count)| (name.to_string(), count))
333 .collect();
334 assignees.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
335 assignees
336 }
337}
338
339#[allow(clippy::too_many_arguments)]
349pub fn card_is_visible(
350 card: &Card,
351 active_filter: Option<&str>,
352 tag_filters: &[String],
353 assignee_filters: &[String],
354 staleness_filters: &[String],
355 overdue_filter: bool,
356 policies: &Policies,
357 now: DateTime<Utc>,
358 matcher: &SkimMatcherV2,
359) -> bool {
360 if let Some(filter) = active_filter {
361 card_matches_filter(card, filter, matcher)
362 } else {
363 let tag_ok =
364 tag_filters.is_empty() || card.tags.iter().any(|t| tag_filters.contains(t));
365 let assignee_ok = assignee_filters.is_empty()
366 || card.assignees.iter().any(|a| assignee_filters.contains(a));
367 let staleness_ok = staleness_filters.is_empty() || {
368 let label = age::card_staleness_label(card, policies, now);
369 staleness_filters.iter().any(|f| f.as_str() == label)
370 };
371 let overdue_ok = !overdue_filter || card.is_overdue(now.date_naive());
372 tag_ok && assignee_ok && staleness_ok && overdue_ok
373 }
374}
375
376pub fn card_matches_filter(card: &Card, filter: &str, matcher: &SkimMatcherV2) -> bool {
386 let terms: Vec<&str> = filter.split_whitespace().collect();
387 if terms.is_empty() {
388 return true;
389 }
390
391 for term in &terms {
392 let (negated, pattern) = if let Some(rest) = term.strip_prefix('!') {
393 (true, rest)
394 } else {
395 (false, *term)
396 };
397
398 let bare = pattern.trim_start_matches('@');
400 if bare.is_empty() {
401 continue;
402 }
403
404 let matches_any_field = matcher.fuzzy_match(&card.title, bare).is_some()
405 || card
406 .tags
407 .iter()
408 .any(|t| matcher.fuzzy_match(t, bare).is_some())
409 || card
410 .assignees
411 .iter()
412 .any(|a| matcher.fuzzy_match(a, bare).is_some());
413
414 if negated && matches_any_field {
415 return false;
416 }
417 if !negated && !matches_any_field {
418 return false;
419 }
420 }
421
422 true
423}
424
425pub fn slug_from_name(name: &str) -> String {
433 let base: String = name
434 .nfc()
435 .collect::<String>()
436 .to_lowercase()
437 .nfc()
438 .map(|c| if c.is_alphanumeric() { c } else { '-' })
439 .collect();
440 let base = base
443 .split('-')
444 .filter(|s| !s.is_empty())
445 .collect::<Vec<_>>()
446 .join("-");
447 if base.is_empty() {
448 "col".to_string()
449 } else {
450 base
451 }
452}
453
454pub fn slug_for_rename(name: &str) -> Result<String, &'static str> {
460 let slug = slug_from_name(name);
461 if slug == "archive" {
462 Err("'archive' is a reserved slug")
463 } else {
464 Ok(slug)
465 }
466}
467
468pub fn generate_slug(name: &str, existing: &[Column]) -> String {
474 let base = slug_from_name(name);
475 let base = if base == "archive" { "archive-col".to_string() } else { base };
477 if !existing.iter().any(|c| c.slug == base) {
479 return base;
480 }
481 for n in 2u32.. {
482 let candidate = format!("{base}-{n}");
483 if !existing.iter().any(|c| c.slug == candidate) {
484 return candidate;
485 }
486 }
487 unreachable!("generate_slug: exhausted candidates for base={base:?}")
488}
489
490pub fn slug_to_name(slug: &str) -> String {
502 slug.split('-')
503 .map(|word| {
504 let mut chars = word.chars();
505 match chars.next() {
506 None => String::new(),
507 Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
508 }
509 })
510 .collect::<Vec<_>>()
511 .join(" ")
512}
513
514pub fn normalize_column_orders(columns: &mut [Column]) {
517 columns.sort_by_key(|c| c.order);
518 for (i, col) in columns.iter_mut().enumerate() {
519 col.order = i as u32;
520 }
521}
522
523impl Column {
524 pub fn sort_cards(&mut self) {
526 self.cards
527 .sort_by(|a, b| {
528 a.priority
529 .sort_key()
530 .cmp(&b.priority.sort_key())
531 .then(b.updated.cmp(&a.updated))
532 });
533 }
534
535 pub fn is_over_wip_limit(&self) -> bool {
537 self.wip_limit
538 .is_some_and(|limit| self.cards.len() as u32 >= limit)
539 }
540
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546
547 fn test_board_with_done() -> Board {
548 Board {
549 name: "Test".into(),
550 next_card_id: 10,
551 policies: Policies::default(),
552 sync_branch: None,
553
554 nerd_font: false,
555 created_at: None,
556 columns: vec![
557 Column {
558 slug: "backlog".into(),
559 name: "Backlog".into(),
560 order: 0,
561 wip_limit: None,
562 hidden: false,
563 cards: vec![Card::new("1".into(), "Card A".into())],
564 },
565 Column {
566 slug: "done".into(),
567 name: "Done".into(),
568 order: 1,
569 wip_limit: None,
570 hidden: false,
571 cards: Vec::new(),
572 },
573 ],
574 }
575 }
576
577 #[test]
578 fn move_to_done_sets_completed() {
579 let mut board = test_board_with_done();
580 assert!(board.columns[0].cards[0].completed.is_none());
581
582 board.move_card(0, 0, 1);
583 let card = &board.columns[1].cards[0];
584 assert!(card.completed.is_some(), "completed should be set when moving to done");
585 }
586
587 #[test]
588 fn move_away_from_done_clears_completed() {
589 let mut board = test_board_with_done();
590 board.move_card(0, 0, 1);
592 assert!(board.columns[1].cards[0].completed.is_some());
593
594 board.move_card(1, 0, 0);
596 let card = &board.columns[0].cards[0];
597 assert!(card.completed.is_none(), "completed should be cleared when moving away from done");
598 }
599
600 #[test]
601 fn move_to_done_preserves_existing_completed() {
602 let mut board = test_board_with_done();
603 let original_time = Utc::now() - chrono::TimeDelta::days(5);
605 board.columns[0].cards[0].completed = Some(original_time);
606
607 board.move_card(0, 0, 1);
608 let card = &board.columns[1].cards[0];
609 assert_eq!(
610 card.completed, Some(original_time),
611 "moving to done when already completed should keep original timestamp"
612 );
613 }
614
615 #[test]
616 fn move_between_non_done_columns_leaves_completed_none() {
617 let mut board = Board {
618 name: "Test".into(),
619 next_card_id: 10,
620 policies: Policies::default(),
621 sync_branch: None,
622
623 nerd_font: false,
624 created_at: None,
625 columns: vec![
626 Column {
627 slug: "backlog".into(),
628 name: "Backlog".into(),
629 order: 0,
630 wip_limit: None,
631 hidden: false,
632 cards: vec![Card::new("1".into(), "Card A".into())],
633 },
634 Column {
635 slug: "in-progress".into(),
636 name: "In Progress".into(),
637 order: 1,
638 wip_limit: None,
639 hidden: false,
640 cards: Vec::new(),
641 },
642 Column {
643 slug: "done".into(),
644 name: "Done".into(),
645 order: 2,
646 wip_limit: None,
647 hidden: false,
648 cards: Vec::new(),
649 },
650 ],
651 };
652
653 board.move_card(0, 0, 1);
655 let card = &board.columns[1].cards[0];
656 assert!(
657 card.completed.is_none(),
658 "moving between non-done columns should not set completed"
659 );
660 }
661
662 fn three_column_board() -> Board {
665 Board {
666 name: "Test".into(),
667 next_card_id: 10,
668 policies: Policies::default(),
669 sync_branch: None,
670
671 nerd_font: false,
672 created_at: None,
673 columns: vec![
674 Column {
675 slug: "backlog".into(),
676 name: "Backlog".into(),
677 order: 0,
678 wip_limit: None,
679 hidden: false,
680 cards: vec![Card::new("1".into(), "Card A".into())],
681 },
682 Column {
683 slug: "in-progress".into(),
684 name: "In Progress".into(),
685 order: 1,
686 wip_limit: None,
687 hidden: false,
688 cards: Vec::new(),
689 },
690 Column {
691 slug: "done".into(),
692 name: "Done".into(),
693 order: 2,
694 wip_limit: None,
695 hidden: false,
696 cards: Vec::new(),
697 },
698 ],
699 }
700 }
701
702 #[test]
703 fn move_past_backlog_sets_started() {
704 let mut board = three_column_board();
705 assert!(board.columns[0].cards[0].started.is_none());
706
707 board.move_card(0, 0, 1); let card = &board.columns[1].cards[0];
709 assert!(card.started.is_some(), "started should be set when moving past backlog");
710 }
711
712 #[test]
713 fn move_within_active_preserves_started() {
714 let mut board = three_column_board();
715 board.move_card(0, 0, 1); let original_started = board.columns[1].cards[0].started;
717
718 board.columns.insert(2, Column {
720 slug: "review".into(),
721 name: "Review".into(),
722 order: 2,
723 wip_limit: None,
724 hidden: false,
725 cards: Vec::new(),
726 });
727 board.move_card(1, 0, 2); let card = &board.columns[2].cards[0];
729 assert_eq!(card.started, original_started, "started should be preserved when moving within active columns");
730 }
731
732 #[test]
733 fn move_back_to_backlog_preserves_started() {
734 let mut board = three_column_board();
735 board.move_card(0, 0, 1); assert!(board.columns[1].cards[0].started.is_some());
737
738 board.move_card(1, 0, 0); let card = &board.columns[0].cards[0];
740 assert!(card.started.is_some(), "started should NOT be cleared when moving back to backlog");
741 }
742
743 #[test]
744 fn move_to_done_sets_started_if_none() {
745 let mut board = test_board_with_done();
746 assert!(board.columns[0].cards[0].started.is_none());
747
748 board.move_card(0, 0, 1); let card = &board.columns[1].cards[0];
750 assert!(card.started.is_some(), "started should be set when moving from backlog to done");
751 assert!(card.completed.is_some(), "completed should also be set");
752 }
753
754 #[test]
755 fn card_in_backlog_has_no_started() {
756 let card = Card::new("1".into(), "Test".into());
757 assert!(card.started.is_none());
758 }
759
760 fn card_with_meta(title: &str, tags: &[&str], assignees: &[&str]) -> Card {
763 let mut c = Card::new("1".into(), title.into());
764 c.tags = tags.iter().map(|t| t.to_string()).collect();
765 c.assignees = assignees.iter().map(|a| a.to_string()).collect();
766 c
767 }
768
769 #[test]
770 fn filter_fuzzy_matches_title() {
771 let matcher = SkimMatcherV2::default();
772 let card = card_with_meta("Implement login", &["auth"], &["alice"]);
773 assert!(card_matches_filter(&card, "login", &matcher));
774 }
775
776 #[test]
777 fn filter_no_match_returns_false() {
778 let matcher = SkimMatcherV2::default();
779 let card = card_with_meta("Implement login", &["auth"], &["alice"]);
780 assert!(!card_matches_filter(&card, "logout", &matcher));
781 }
782
783 #[test]
784 fn filter_negation_excludes_match() {
785 let matcher = SkimMatcherV2::default();
786 let card = card_with_meta("Implement login", &["auth"], &["alice"]);
787 assert!(!card_matches_filter(&card, "!login", &matcher));
788 }
789
790 #[test]
791 fn filter_negation_includes_non_match() {
792 let matcher = SkimMatcherV2::default();
793 let card = card_with_meta("Implement login", &["auth"], &["alice"]);
794 assert!(card_matches_filter(&card, "!logout", &matcher));
795 }
796
797 #[test]
798 fn filter_at_prefix_matches_tag() {
799 let matcher = SkimMatcherV2::default();
800 let card = card_with_meta("Implement login", &["auth"], &["alice"]);
801 assert!(card_matches_filter(&card, "@auth", &matcher));
802 }
803
804 #[test]
805 fn filter_at_prefix_missing_tag_returns_false() {
806 let matcher = SkimMatcherV2::default();
807 let card = card_with_meta("Implement login", &["auth"], &["alice"]);
808 assert!(!card_matches_filter(&card, "@missing", &matcher));
809 }
810
811 #[test]
812 fn filter_multi_term_and_both_match() {
813 let matcher = SkimMatcherV2::default();
814 let card = card_with_meta("Implement login", &["auth", "frontend"], &["alice"]);
815 assert!(card_matches_filter(&card, "login auth", &matcher));
816 }
817
818 #[test]
819 fn filter_multi_term_and_one_misses() {
820 let matcher = SkimMatcherV2::default();
821 let card = card_with_meta("Implement login", &["auth"], &["alice"]);
822 assert!(!card_matches_filter(&card, "login missing", &matcher));
823 }
824
825 #[test]
826 fn filter_matches_assignee() {
827 let matcher = SkimMatcherV2::default();
828 let card = card_with_meta("Implement login", &[], &["alice"]);
829 assert!(card_matches_filter(&card, "alice", &matcher));
830 }
831
832 #[test]
833 fn filter_empty_returns_true() {
834 let matcher = SkimMatcherV2::default();
835 let card = card_with_meta("Anything", &[], &[]);
836 assert!(card_matches_filter(&card, "", &matcher));
837 }
838
839 #[test]
840 fn card_is_visible_no_filters_shows_all() {
841 let matcher = SkimMatcherV2::default();
842 let policies = Policies::default();
843 let now = Utc::now();
844 let card = card_with_meta("Test", &[], &[]);
845 assert!(card_is_visible(&card, None, &[], &[], &[], false, &policies, now, &matcher));
846 }
847
848 #[test]
849 fn card_is_visible_text_filter_overrides_picker() {
850 let matcher = SkimMatcherV2::default();
851 let policies = Policies::default();
852 let now = Utc::now();
853 let card = card_with_meta("Login feature", &["bug"], &[]);
854 assert!(card_is_visible(
856 &card,
857 Some("login"),
858 &["nonexistent".to_string()],
859 &[],
860 &[],
861 false,
862 &policies,
863 now,
864 &matcher,
865 ));
866 }
867
868 #[test]
869 fn card_is_visible_tag_filter_must_match() {
870 let matcher = SkimMatcherV2::default();
871 let policies = Policies::default();
872 let now = Utc::now();
873 let card = card_with_meta("Test", &["bug"], &[]);
874 assert!(card_is_visible(&card, None, &["bug".to_string()], &[], &[], false, &policies, now, &matcher));
875 assert!(!card_is_visible(&card, None, &["feature".to_string()], &[], &[], false, &policies, now, &matcher));
876 }
877
878 #[test]
879 fn card_is_visible_assignee_filter_must_match() {
880 let matcher = SkimMatcherV2::default();
881 let policies = Policies::default();
882 let now = Utc::now();
883 let card = card_with_meta("Test", &[], &["alice"]);
884 assert!(card_is_visible(&card, None, &[], &["alice".to_string()], &[], false, &policies, now, &matcher));
885 assert!(!card_is_visible(&card, None, &[], &["bob".to_string()], &[], false, &policies, now, &matcher));
886 }
887
888 #[test]
889 fn card_is_visible_both_tag_and_assignee_must_match() {
890 let matcher = SkimMatcherV2::default();
891 let policies = Policies::default();
892 let now = Utc::now();
893 let card = card_with_meta("Test", &["bug"], &["alice"]);
894 assert!(card_is_visible(&card, None, &["bug".to_string()], &["alice".to_string()], &[], false, &policies, now, &matcher));
896 assert!(!card_is_visible(&card, None, &["bug".to_string()], &["bob".to_string()], &[], false, &policies, now, &matcher));
898 assert!(!card_is_visible(&card, None, &["feature".to_string()], &["alice".to_string()], &[], false, &policies, now, &matcher));
900 }
901
902 #[test]
903 fn card_is_visible_staleness_filter_matches() {
904 use chrono::TimeZone;
905 let matcher = SkimMatcherV2::default();
906 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
907 let policies = Policies { stale_days: 7, ..Default::default() };
908 let mut card = card_with_meta("Test", &[], &[]);
910 card.created = Utc.with_ymd_and_hms(2025, 6, 14, 12, 0, 0).unwrap();
911 card.updated = now;
912 assert!(card_is_visible(&card, None, &[], &[], &["normal".to_string()], false, &policies, now, &matcher));
913 }
914
915 #[test]
916 fn card_is_visible_staleness_filter_no_match() {
917 use chrono::TimeZone;
918 let matcher = SkimMatcherV2::default();
919 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
920 let policies = Policies { stale_days: 7, ..Default::default() };
921 let mut card = card_with_meta("Test", &[], &[]);
923 card.created = Utc.with_ymd_and_hms(2025, 6, 14, 12, 0, 0).unwrap();
924 card.updated = now;
925 assert!(!card_is_visible(&card, None, &[], &[], &["stale".to_string()], false, &policies, now, &matcher));
926 }
927
928 #[test]
929 fn card_is_visible_multiple_staleness_filters() {
930 use chrono::TimeZone;
931 let matcher = SkimMatcherV2::default();
932 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
933 let policies = Policies { stale_days: 7, ..Default::default() };
934 let mut stale_card = card_with_meta("Stale", &[], &[]);
936 stale_card.created = Utc.with_ymd_and_hms(2025, 5, 1, 12, 0, 0).unwrap();
937 stale_card.updated = Utc.with_ymd_and_hms(2025, 6, 8, 12, 0, 0).unwrap();
938 let filters = vec!["stale".to_string(), "very stale".to_string()];
939 assert!(card_is_visible(&stale_card, None, &[], &[], &filters, false, &policies, now, &matcher));
940 let mut normal_card = card_with_meta("Normal", &[], &[]);
942 normal_card.created = Utc.with_ymd_and_hms(2025, 6, 14, 12, 0, 0).unwrap();
943 normal_card.updated = now;
944 assert!(!card_is_visible(&normal_card, None, &[], &[], &filters, false, &policies, now, &matcher));
945 }
946
947 #[test]
948 fn card_is_visible_staleness_combined_with_tag_filter() {
949 use chrono::TimeZone;
950 let matcher = SkimMatcherV2::default();
951 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
952 let policies = Policies { stale_days: 7, ..Default::default() };
953 let mut card = card_with_meta("Test", &["bug"], &[]);
955 card.created = Utc.with_ymd_and_hms(2025, 6, 14, 12, 0, 0).unwrap();
956 card.updated = now;
957 assert!(card_is_visible(&card, None, &["bug".to_string()], &[], &["normal".to_string()], false, &policies, now, &matcher));
959 assert!(!card_is_visible(&card, None, &["bug".to_string()], &[], &["stale".to_string()], false, &policies, now, &matcher));
961 }
962
963 #[test]
964 fn card_is_visible_text_filter_overrides_staleness() {
965 use chrono::TimeZone;
966 let matcher = SkimMatcherV2::default();
967 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
968 let policies = Policies { stale_days: 7, ..Default::default() };
969 let mut card = card_with_meta("Login feature", &[], &[]);
970 card.created = Utc.with_ymd_and_hms(2025, 6, 14, 12, 0, 0).unwrap();
971 card.updated = now;
972 assert!(card_is_visible(&card, Some("login"), &[], &[], &["stale".to_string()], false, &policies, now, &matcher));
974 }
975
976 #[test]
979 fn sort_cards_by_priority() {
980 let now = Utc::now();
981 let mut col = Column {
982 slug: "test".into(),
983 name: "Test".into(),
984 order: 0,
985 wip_limit: None,
986 hidden: false,
987 cards: vec![],
988 };
989 let mut c1 = Card::new("1".into(), "Normal".into());
990 c1.priority = Priority::Normal;
991 c1.updated = now;
992 let mut c2 = Card::new("2".into(), "Urgent".into());
993 c2.priority = Priority::Urgent;
994 c2.updated = now;
995 let mut c3 = Card::new("3".into(), "Low".into());
996 c3.priority = Priority::Low;
997 c3.updated = now;
998 let mut c4 = Card::new("4".into(), "High".into());
999 c4.priority = Priority::High;
1000 c4.updated = now;
1001 col.cards = vec![c1, c2, c3, c4];
1002
1003 col.sort_cards();
1004
1005 let priorities: Vec<Priority> = col.cards.iter().map(|c| c.priority).collect();
1006 assert_eq!(priorities, vec![Priority::Urgent, Priority::High, Priority::Normal, Priority::Low]);
1007 }
1008
1009 #[test]
1012 fn priority_from_str_valid() {
1013 assert_eq!("low".parse::<Priority>().unwrap(), Priority::Low);
1014 assert_eq!("normal".parse::<Priority>().unwrap(), Priority::Normal);
1015 assert_eq!("high".parse::<Priority>().unwrap(), Priority::High);
1016 assert_eq!("urgent".parse::<Priority>().unwrap(), Priority::Urgent);
1017 }
1018
1019 #[test]
1020 fn priority_from_str_case_insensitive() {
1021 assert_eq!("HIGH".parse::<Priority>().unwrap(), Priority::High);
1022 assert_eq!("Urgent".parse::<Priority>().unwrap(), Priority::Urgent);
1023 }
1024
1025 #[test]
1026 fn priority_from_str_unknown_returns_err() {
1027 assert!("unknown".parse::<Priority>().is_err());
1028 assert!("".parse::<Priority>().is_err());
1029 }
1030
1031 #[test]
1032 fn priority_sort_key_ordering() {
1033 assert!(Priority::Urgent.sort_key() < Priority::High.sort_key());
1034 assert!(Priority::High.sort_key() < Priority::Normal.sort_key());
1035 assert!(Priority::Normal.sort_key() < Priority::Low.sort_key());
1036 }
1037
1038 #[test]
1041 fn is_over_wip_limit_no_limit() {
1042 let col = Column {
1043 slug: "test".into(),
1044 name: "Test".into(),
1045 order: 0,
1046 wip_limit: None,
1047 hidden: false,
1048 cards: vec![Card::new("1".into(), "A".into())],
1049 };
1050 assert!(!col.is_over_wip_limit());
1051 }
1052
1053 #[test]
1054 fn is_over_wip_limit_under() {
1055 let col = Column {
1056 slug: "test".into(),
1057 name: "Test".into(),
1058 order: 0,
1059 wip_limit: Some(3),
1060 hidden: false,
1061 cards: vec![Card::new("1".into(), "A".into())],
1062 };
1063 assert!(!col.is_over_wip_limit());
1064 }
1065
1066 #[test]
1067 fn is_over_wip_limit_at_limit() {
1068 let col = Column {
1069 slug: "test".into(),
1070 name: "Test".into(),
1071 order: 0,
1072 wip_limit: Some(2),
1073 hidden: false,
1074 cards: vec![Card::new("1".into(), "A".into()), Card::new("2".into(), "B".into())],
1075 };
1076 assert!(col.is_over_wip_limit());
1077 }
1078
1079 #[test]
1082 fn find_card_returns_correct_indices() {
1083 let board = three_column_board();
1084 assert_eq!(board.find_card("1"), Some((0, 0)));
1085 }
1086
1087 #[test]
1088 fn find_card_not_found_returns_none() {
1089 let board = three_column_board();
1090 assert_eq!(board.find_card("999"), None);
1091 }
1092
1093 #[test]
1096 fn next_card_id_increments() {
1097 let mut board = test_board_with_done();
1098 let id1 = board.next_card_id();
1099 let id2 = board.next_card_id();
1100 assert_ne!(id1, id2);
1101 assert_eq!(id1.parse::<u32>().unwrap() + 1, id2.parse::<u32>().unwrap());
1102 }
1103
1104 #[test]
1107 fn all_tags_collects_and_sorts_by_count() {
1108 let mut board = test_board_with_done();
1109 board.columns[0].cards[0].tags = vec!["bug".into(), "ui".into()];
1110 let mut card2 = Card::new("2".into(), "B".into());
1111 card2.tags = vec!["bug".into()];
1112 board.columns[0].cards.push(card2);
1113
1114 let tags = board.all_tags();
1115 assert_eq!(tags[0].0, "bug");
1116 assert_eq!(tags[0].1, 2);
1117 assert_eq!(tags[1].0, "ui");
1118 assert_eq!(tags[1].1, 1);
1119 }
1120
1121 #[test]
1122 fn all_assignees_collects_and_sorts_by_count() {
1123 let mut board = test_board_with_done();
1124 board.columns[0].cards[0].assignees = vec!["alice".into(), "bob".into()];
1125 let mut card2 = Card::new("2".into(), "B".into());
1126 card2.assignees = vec!["alice".into()];
1127 board.columns[0].cards.push(card2);
1128
1129 let assignees = board.all_assignees();
1130 assert_eq!(assignees[0].0, "alice");
1131 assert_eq!(assignees[0].1, 2);
1132 assert_eq!(assignees[1].0, "bob");
1133 assert_eq!(assignees[1].1, 1);
1134 }
1135
1136 #[test]
1139 fn card_new_defaults() {
1140 let c = Card::new("001".into(), "Test".into());
1141 assert_eq!(c.id, "001");
1142 assert_eq!(c.title, "Test");
1143 assert_eq!(c.priority, Priority::Normal);
1144 assert!(c.tags.is_empty());
1145 assert!(c.assignees.is_empty());
1146 assert!(!c.is_blocked());
1147 assert!(c.started.is_none());
1148 assert!(c.completed.is_none());
1149 assert_eq!(c.body, "<!-- add body content here -->");
1150 }
1151
1152 #[test]
1153 fn card_touch_updates_timestamp() {
1154 let mut c = Card::new("001".into(), "Test".into());
1155 let before = c.updated;
1156 std::thread::sleep(std::time::Duration::from_millis(2));
1157 c.touch();
1158 assert!(c.updated >= before);
1159 }
1160
1161 #[test]
1164 fn move_card_same_column_noop() {
1165 let mut board = test_board_with_done();
1166 let count_before = board.columns[0].cards.len();
1167 board.move_card(0, 0, 0);
1168 assert_eq!(board.columns[0].cards.len(), count_before);
1169 }
1170
1171 #[test]
1172 fn move_card_invalid_index_does_not_panic() {
1173 let mut board = test_board_with_done();
1174 board.move_card(0, 999, 1);
1176 board.move_card(999, 0, 0);
1178 board.move_card(0, 0, 999);
1179 }
1180
1181 #[test]
1184 fn sort_cards_blocked_first() {
1185 let mut col = Column {
1186 slug: "test".into(),
1187 name: "Test".into(),
1188 order: 0,
1189 wip_limit: None,
1190 hidden: false,
1191 cards: vec![],
1192 };
1193 let mut normal = Card::new("1".into(), "Normal".into());
1194 normal.priority = Priority::Normal;
1195 let mut blocked = Card::new("2".into(), "Blocked".into());
1196 blocked.priority = Priority::Normal;
1197 blocked.blocked = Some(String::new());
1198 col.cards = vec![normal, blocked];
1199 col.sort_cards();
1200 assert_eq!(col.cards.len(), 2);
1204 }
1205
1206 #[test]
1207 fn sort_cards_priority_then_updated() {
1208 use chrono::TimeZone;
1209 let mut col = Column {
1210 slug: "test".into(),
1211 name: "Test".into(),
1212 order: 0,
1213 wip_limit: None,
1214 hidden: false,
1215 cards: vec![],
1216 };
1217 let mut old = Card::new("1".into(), "Old".into());
1218 old.priority = Priority::Normal;
1219 old.updated = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
1220 let mut recent = Card::new("2".into(), "Recent".into());
1221 recent.priority = Priority::Normal;
1222 recent.updated = Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap();
1223 col.cards = vec![old, recent];
1224 col.sort_cards();
1225 assert_eq!(col.cards[0].id, "2");
1227 assert_eq!(col.cards[1].id, "1");
1228 }
1229
1230 #[test]
1233 fn priority_display() {
1234 assert_eq!(format!("{}", Priority::Urgent), "urgent");
1235 assert_eq!(format!("{}", Priority::High), "high");
1236 assert_eq!(format!("{}", Priority::Normal), "normal");
1237 assert_eq!(format!("{}", Priority::Low), "low");
1238 }
1239
1240 #[test]
1241 fn priority_as_str_all_variants() {
1242 assert_eq!(Priority::Urgent.as_str(), "urgent");
1243 assert_eq!(Priority::High.as_str(), "high");
1244 assert_eq!(Priority::Normal.as_str(), "normal");
1245 assert_eq!(Priority::Low.as_str(), "low");
1246 }
1247
1248 #[test]
1251 fn slug_to_name_single_word() {
1252 assert_eq!(slug_to_name("backlog"), "Backlog");
1253 }
1254
1255 #[test]
1256 fn slug_to_name_multi_word() {
1257 assert_eq!(slug_to_name("in-progress"), "In Progress");
1258 }
1259
1260 #[test]
1261 fn slug_to_name_three_words() {
1262 assert_eq!(slug_to_name("ready-for-review"), "Ready For Review");
1263 }
1264
1265 #[test]
1266 fn slug_to_name_empty_string() {
1267 assert_eq!(slug_to_name(""), "");
1268 }
1269
1270 #[test]
1271 fn slug_to_name_with_digits() {
1272 assert_eq!(slug_to_name("col-2"), "Col 2");
1273 }
1274
1275 #[test]
1276 fn slug_to_name_uppercase_passthrough() {
1277 assert_eq!(slug_to_name("in-PROGRESS"), "In PROGRESS");
1279 }
1280
1281 #[test]
1282 fn slug_to_name_single_hyphen_does_not_panic() {
1283 let result = slug_to_name("-");
1285 assert_eq!(result, " "); }
1287
1288 #[test]
1291 fn slug_from_name_single_word() {
1292 assert_eq!(slug_from_name("Backlog"), "backlog");
1293 }
1294
1295 #[test]
1296 fn slug_from_name_multi_word() {
1297 assert_eq!(slug_from_name("In Progress"), "in-progress");
1298 }
1299
1300 #[test]
1301 fn slug_from_name_empty_string_falls_back_to_col() {
1302 assert_eq!(slug_from_name(""), "col");
1303 }
1304
1305 #[test]
1306 fn slug_from_name_only_special_chars_falls_back_to_col() {
1307 assert_eq!(slug_from_name("!!! ???"), "col");
1308 }
1309
1310 #[test]
1311 fn slug_from_name_leading_trailing_spaces_trimmed() {
1312 assert_eq!(slug_from_name(" hello "), "hello");
1313 }
1314
1315 #[test]
1316 fn slug_from_name_consecutive_spaces_collapse_to_one_hyphen() {
1317 assert_eq!(slug_from_name("hello world"), "hello-world");
1318 }
1319
1320 #[test]
1321 fn slug_from_name_leading_special_chars_stripped() {
1322 assert_eq!(slug_from_name("---hello"), "hello");
1323 }
1324
1325 #[test]
1326 fn slug_from_name_unicode_preserved() {
1327 assert_eq!(slug_from_name("日本語"), "日本語");
1328 }
1329
1330 #[test]
1331 fn slug_from_name_mixed_ascii_unicode_preserves_all() {
1332 assert_eq!(slug_from_name("hello 世界"), "hello-世界");
1333 }
1334
1335 #[test]
1336 fn slug_from_name_non_alphanumeric_unicode_falls_back() {
1337 assert_eq!(slug_from_name("→ ← ↑"), "col");
1339 }
1340
1341 #[test]
1342 fn slug_from_name_nordic_characters_preserved() {
1343 assert_eq!(slug_from_name("Kamelåså"), "kamelåså");
1344 }
1345
1346 #[test]
1347 fn slug_from_name_nfc_normalization_consistency() {
1348 let nfc = "Kamelåså"; let nfd = "Kamela\u{030A}sa\u{030A}"; assert_eq!(slug_from_name(nfc), slug_from_name(nfd));
1352 }
1353
1354 #[test]
1355 fn slug_from_name_unicode_symbols_replaced() {
1356 assert_eq!(slug_from_name("hello·world"), "hello-world");
1357 assert_eq!(slug_from_name("test™"), "test");
1358 }
1359
1360 #[test]
1361 fn slug_from_name_numbers_in_name() {
1362 assert_eq!(slug_from_name("Col 2"), "col-2");
1363 }
1364
1365 #[test]
1366 fn slug_from_name_does_not_guard_archive_reservation() {
1367 assert_eq!(slug_from_name("Archive"), "archive");
1371 }
1372
1373 fn slug_col(slug: &str) -> Column {
1376 Column { slug: slug.into(), name: slug.into(), order: 0, wip_limit: None, hidden: false, cards: vec![] }
1377 }
1378
1379 #[test]
1380 fn generate_slug_basic_name() {
1381 assert_eq!(generate_slug("My Feature", &[]), "my-feature");
1382 }
1383
1384 #[test]
1385 fn generate_slug_spaces_become_hyphens() {
1386 assert_eq!(generate_slug("Hello World!!!", &[]), "hello-world");
1387 }
1388
1389 #[test]
1390 fn generate_slug_reserved_archive_avoided() {
1391 let slug = generate_slug("Archive", &[]);
1392 assert_ne!(slug, "archive", "should not produce the reserved slug 'archive'");
1393 assert!(slug.starts_with("archive"), "slug should still be archive-derived: {slug}");
1396 }
1397
1398 #[test]
1399 fn generate_slug_uniqueness_appends_suffix() {
1400 let existing = vec![slug_col("backlog")];
1401 assert_eq!(generate_slug("Backlog", &existing), "backlog-2");
1402 }
1403
1404 #[test]
1405 fn generate_slug_uniqueness_skips_taken_suffixes() {
1406 let existing = vec![slug_col("backlog"), slug_col("backlog-2")];
1407 assert_eq!(generate_slug("Backlog", &existing), "backlog-3");
1408 }
1409
1410 #[test]
1411 fn generate_slug_all_non_alphanum_falls_back_to_col() {
1412 assert_eq!(generate_slug("!!! ???", &[]), "col");
1413 }
1414
1415 #[test]
1416 fn generate_slug_col_uniqueness_when_col_taken() {
1417 let existing = vec![slug_col("col")];
1418 assert_eq!(generate_slug("!!! ???", &existing), "col-2");
1419 }
1420
1421 #[test]
1422 fn generate_slug_unicode_name_preserves_characters() {
1423 let slug = generate_slug("日本語", &[]);
1424 assert_eq!(slug, "日本語");
1425 }
1426
1427 #[test]
1428 fn generate_slug_leading_special_chars_stripped() {
1429 let slug = generate_slug("---hello", &[]);
1431 assert!(slug.starts_with(|c: char| c.is_alphanumeric()));
1432 }
1433
1434 fn make_col(slug: &str, order: u32) -> Column {
1437 Column { slug: slug.into(), name: slug.into(), order, wip_limit: None, hidden: false, cards: vec![] }
1438 }
1439
1440 #[test]
1441 fn normalize_column_orders_dense_already() {
1442 let mut cols = vec![make_col("a", 0), make_col("b", 1), make_col("c", 2)];
1443 normalize_column_orders(&mut cols);
1444 assert_eq!(cols.iter().map(|c| c.order).collect::<Vec<_>>(), [0, 1, 2]);
1445 }
1446
1447 #[test]
1448 fn normalize_column_orders_sparse_orders() {
1449 let mut cols = vec![make_col("a", 0), make_col("b", 10), make_col("c", 20)];
1450 normalize_column_orders(&mut cols);
1451 assert_eq!(cols[0].slug, "a");
1453 assert_eq!(cols[1].slug, "b");
1454 assert_eq!(cols[2].slug, "c");
1455 assert_eq!(cols.iter().map(|c| c.order).collect::<Vec<_>>(), [0, 1, 2]);
1456 }
1457
1458 #[test]
1459 fn normalize_column_orders_reorders_by_order_field() {
1460 let mut cols = vec![make_col("c", 2), make_col("a", 0), make_col("b", 1)];
1462 normalize_column_orders(&mut cols);
1463 assert_eq!(cols[0].slug, "a");
1464 assert_eq!(cols[1].slug, "b");
1465 assert_eq!(cols[2].slug, "c");
1466 }
1467
1468 #[test]
1469 fn normalize_column_orders_single_column() {
1470 let mut cols = vec![make_col("solo", 42)];
1471 normalize_column_orders(&mut cols);
1472 assert_eq!(cols[0].order, 0);
1473 }
1474
1475 #[test]
1476 fn normalize_column_orders_empty_vec_does_not_panic() {
1477 let mut cols: Vec<Column> = vec![];
1478 normalize_column_orders(&mut cols); }
1480
1481 #[test]
1484 fn is_overdue_none_returns_false() {
1485 let card = Card::new("1".into(), "Test".into());
1486 assert!(!card.is_overdue(chrono::NaiveDate::from_ymd_opt(2025, 6, 15).unwrap()));
1487 }
1488
1489 #[test]
1490 fn is_overdue_yesterday_returns_true() {
1491 let today = chrono::NaiveDate::from_ymd_opt(2025, 6, 15).unwrap();
1492 let mut card = Card::new("1".into(), "Test".into());
1493 card.due = Some(today - chrono::Days::new(1));
1494 assert!(card.is_overdue(today));
1495 }
1496
1497 #[test]
1498 fn is_overdue_today_returns_false() {
1499 let today = chrono::NaiveDate::from_ymd_opt(2025, 6, 15).unwrap();
1500 let mut card = Card::new("1".into(), "Test".into());
1501 card.due = Some(today);
1502 assert!(!card.is_overdue(today));
1503 }
1504
1505 #[test]
1506 fn is_overdue_tomorrow_returns_false() {
1507 let today = chrono::NaiveDate::from_ymd_opt(2025, 6, 15).unwrap();
1508 let mut card = Card::new("1".into(), "Test".into());
1509 card.due = Some(today + chrono::Days::new(1));
1510 assert!(!card.is_overdue(today));
1511 }
1512
1513 #[test]
1514 fn is_overdue_far_past_returns_true() {
1515 let today = chrono::NaiveDate::from_ymd_opt(2025, 6, 15).unwrap();
1516 let mut card = Card::new("1".into(), "Test".into());
1517 card.due = Some(chrono::NaiveDate::from_ymd_opt(2024, 6, 15).unwrap());
1518 assert!(card.is_overdue(today));
1519 }
1520
1521 #[test]
1522 fn card_new_due_is_none() {
1523 let card = Card::new("1".into(), "Test".into());
1524 assert!(card.due.is_none());
1525 }
1526
1527 fn fixed_now() -> DateTime<Utc> {
1530 use chrono::TimeZone;
1531 Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap()
1532 }
1533
1534 #[test]
1535 fn card_is_visible_overdue_filter_shows_overdue_card() {
1536 let matcher = SkimMatcherV2::default();
1537 let now = fixed_now();
1538 let policies = Policies::default();
1539 let mut card = Card::new("1".into(), "Test".into());
1540 card.due = Some(now.date_naive() - chrono::Days::new(1));
1541 assert!(card_is_visible(&card, None, &[], &[], &[], true, &policies, now, &matcher));
1542 }
1543
1544 #[test]
1545 fn card_is_visible_overdue_filter_hides_non_overdue_card() {
1546 let matcher = SkimMatcherV2::default();
1547 let now = fixed_now();
1548 let policies = Policies::default();
1549 let mut card = Card::new("1".into(), "Test".into());
1550 card.due = Some(now.date_naive() + chrono::Days::new(1));
1551 assert!(!card_is_visible(&card, None, &[], &[], &[], true, &policies, now, &matcher));
1552 }
1553
1554 #[test]
1555 fn card_is_visible_overdue_filter_hides_card_with_no_due() {
1556 let matcher = SkimMatcherV2::default();
1557 let now = fixed_now();
1558 let policies = Policies::default();
1559 let card = Card::new("1".into(), "Test".into());
1560 assert!(!card_is_visible(&card, None, &[], &[], &[], true, &policies, now, &matcher));
1561 }
1562
1563 #[test]
1564 fn card_is_visible_overdue_filter_off_shows_all() {
1565 let matcher = SkimMatcherV2::default();
1566 let now = fixed_now();
1567 let policies = Policies::default();
1568 let card = Card::new("1".into(), "Test".into());
1569 assert!(card_is_visible(&card, None, &[], &[], &[], false, &policies, now, &matcher));
1570 }
1571
1572 #[test]
1573 fn card_is_visible_overdue_filter_card_due_today_is_hidden() {
1574 let matcher = SkimMatcherV2::default();
1575 let now = fixed_now();
1576 let policies = Policies::default();
1577 let mut card = Card::new("1".into(), "Test".into());
1578 card.due = Some(now.date_naive());
1579 assert!(!card_is_visible(&card, None, &[], &[], &[], true, &policies, now, &matcher));
1580 }
1581
1582 #[test]
1583 fn card_is_visible_overdue_combined_with_tag_filter() {
1584 let matcher = SkimMatcherV2::default();
1585 let now = fixed_now();
1586 let policies = Policies::default();
1587 let mut card = Card::new("1".into(), "Test".into());
1588 card.due = Some(now.date_naive() - chrono::Days::new(1));
1589 card.tags = vec!["bug".into()];
1590 assert!(card_is_visible(&card, None, &["bug".to_string()], &[], &[], true, &policies, now, &matcher));
1592 assert!(!card_is_visible(&card, None, &["feature".to_string()], &[], &[], true, &policies, now, &matcher));
1594 }
1595
1596 #[test]
1597 fn card_is_visible_text_filter_overrides_overdue_filter() {
1598 let matcher = SkimMatcherV2::default();
1599 let now = fixed_now();
1600 let policies = Policies::default();
1601 let mut card = Card::new("1".into(), "Test card".into());
1602 card.due = Some(now.date_naive() + chrono::Days::new(1)); assert!(card_is_visible(&card, Some("test"), &[], &[], &[], true, &policies, now, &matcher));
1605 }
1606
1607 #[test]
1610 fn generate_template_slug_basic_name() {
1611 assert_eq!(generate_template_slug("Bug Report", &[]), "bug-report");
1612 }
1613
1614 #[test]
1615 fn generate_template_slug_dedup_appends_suffix() {
1616 let existing = vec!["bug-report".to_string()];
1617 assert_eq!(generate_template_slug("Bug Report", &existing), "bug-report-2");
1618 }
1619
1620 #[test]
1621 fn generate_template_slug_dedup_skips_taken_suffixes() {
1622 let existing = vec!["bug-report".to_string(), "bug-report-2".to_string()];
1623 assert_eq!(generate_template_slug("Bug Report", &existing), "bug-report-3");
1624 }
1625
1626 #[test]
1627 fn generate_template_slug_empty_name_returns_template() {
1628 assert_eq!(generate_template_slug("", &[]), "template");
1629 }
1630
1631 #[test]
1632 fn generate_template_slug_special_chars_only_returns_template() {
1633 assert_eq!(generate_template_slug("!!! ???", &[]), "template");
1634 }
1635
1636 #[test]
1637 fn generate_template_slug_short_alpha_name() {
1638 assert_eq!(generate_template_slug("Col", &[]), "col");
1641 }
1642
1643 #[test]
1644 fn generate_template_slug_numeric_only() {
1645 assert_eq!(generate_template_slug("123", &[]), "123");
1646 }
1647
1648 #[test]
1649 fn generate_template_slug_unicode_preserves_characters() {
1650 assert_eq!(generate_template_slug("日本語", &[]), "日本語");
1651 }
1652
1653 #[test]
1654 fn generate_template_slug_template_already_exists() {
1655 let existing = vec!["template".to_string()];
1656 assert_eq!(generate_template_slug("", &existing), "template-2");
1657 }
1658
1659 #[test]
1662 fn deserialize_blocked_true_yields_some_empty() {
1663 let toml_str = r#"
1664id = "1"
1665title = "T"
1666created = "2025-01-01T00:00:00Z"
1667updated = "2025-01-01T00:00:00Z"
1668blocked = true
1669"#;
1670 let card: Card = toml::from_str(toml_str).unwrap();
1671 assert_eq!(card.blocked, Some(String::new()));
1672 }
1673
1674 #[test]
1675 fn deserialize_blocked_false_yields_none() {
1676 let toml_str = r#"
1677id = "1"
1678title = "T"
1679created = "2025-01-01T00:00:00Z"
1680updated = "2025-01-01T00:00:00Z"
1681blocked = false
1682"#;
1683 let card: Card = toml::from_str(toml_str).unwrap();
1684 assert!(card.blocked.is_none());
1685 }
1686
1687 #[test]
1688 fn deserialize_blocked_absent_yields_none() {
1689 let toml_str = r#"
1690id = "1"
1691title = "T"
1692created = "2025-01-01T00:00:00Z"
1693updated = "2025-01-01T00:00:00Z"
1694"#;
1695 let card: Card = toml::from_str(toml_str).unwrap();
1696 assert!(card.blocked.is_none());
1697 }
1698
1699 #[test]
1700 fn deserialize_blocked_string_yields_some_reason() {
1701 let toml_str = r#"
1702id = "1"
1703title = "T"
1704created = "2025-01-01T00:00:00Z"
1705updated = "2025-01-01T00:00:00Z"
1706blocked = "waiting on API"
1707"#;
1708 let card: Card = toml::from_str(toml_str).unwrap();
1709 assert_eq!(card.blocked, Some("waiting on API".to_string()));
1710 }
1711
1712 #[test]
1713 fn deserialize_blocked_empty_string_yields_some_empty() {
1714 let toml_str = r#"
1715id = "1"
1716title = "T"
1717created = "2025-01-01T00:00:00Z"
1718updated = "2025-01-01T00:00:00Z"
1719blocked = ""
1720"#;
1721 let card: Card = toml::from_str(toml_str).unwrap();
1722 assert_eq!(card.blocked, Some(String::new()));
1723 }
1724
1725 #[test]
1726 fn is_blocked_none_returns_false() {
1727 let card = Card::new("1".into(), "T".into());
1728 assert!(!card.is_blocked());
1729 }
1730
1731 #[test]
1732 fn is_blocked_some_empty_returns_true() {
1733 let mut card = Card::new("1".into(), "T".into());
1734 card.blocked = Some(String::new());
1735 assert!(card.is_blocked());
1736 }
1737
1738 #[test]
1739 fn is_blocked_some_reason_returns_true() {
1740 let mut card = Card::new("1".into(), "T".into());
1741 card.blocked = Some("reason".into());
1742 assert!(card.is_blocked());
1743 }
1744}