Skip to main content

kando_core/board/
mod.rs

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
13/// Deserialize `blocked` from either a TOML bool or a string.
14/// `true` → `Some("")`, `false` / absent → `None`, `"reason"` → `Some("reason")`.
15fn 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/// The top-level board containing all columns and cards.
31#[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    /// When the board was created. None for legacy boards.
39    pub created_at: Option<DateTime<Utc>>,
40    pub columns: Vec<Column>,
41}
42
43/// Board-level policies for hygiene automation.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct Policies {
46    /// Cards untouched for this many days get auto-closed. 0 = disabled.
47    #[serde(default = "default_auto_close_days")]
48    pub auto_close_days: u32,
49    /// Which column auto-closed cards move to.
50    #[serde(default = "default_auto_close_target")]
51    pub auto_close_target: String,
52    /// Cards untouched for this many days get a visual warning.
53    #[serde(rename = "stale_days", alias = "bubble_up_days", default = "default_stale_days")]
54    pub stale_days: u32,
55    /// Trash entries older than this many days are permanently purged. 0 = never.
56    #[serde(default = "default_trash_purge_days")]
57    pub trash_purge_days: u32,
58    /// Cards in the `done` column for this many days are auto-archived. 0 = disabled.
59    #[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/// A single kanban column (not "list").
89#[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/// Priority levels for cards.
100#[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    /// Sort key: lower = higher priority (sorts first).
114    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/// A single kanban card (not "task" or "item").
155#[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    /// When the card first moved past backlog (commitment point). `None` for cards still in backlog.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub started: Option<DateTime<Utc>>,
174    /// When the card was moved to the "done" column. `None` if not yet completed.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub completed: Option<DateTime<Utc>>,
177    /// The markdown body (not serialized into frontmatter).
178    #[serde(skip)]
179    pub body: String,
180}
181
182/// A card template for creating new cards with preset fields.
183/// Display name is derived from the slug via `slug_to_name()` — no stored name field.
184#[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
200/// Generate a filesystem-safe slug for a template name, unique among `existing_slugs`.
201pub 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    /// Whether this card is past its due date.
244    ///
245    /// A card due *today* is NOT overdue — only `due < today`.
246    pub fn is_overdue(&self, today: NaiveDate) -> bool {
247        self.due.is_some_and(|d| d < today)
248    }
249
250    /// Touch the card, updating its `updated` timestamp.
251    pub fn touch(&mut self) {
252        self.updated = Utc::now();
253    }
254}
255
256impl Board {
257    /// Generate the next card ID and increment the counter.
258    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    /// Find which column a card is in and its index.
265    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    /// Move a card from one column to another.
277    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        // Set started when card first moves past backlog (column 0).
287        // Commitment is a one-time event: never cleared once set.
288        if card.started.is_none() && to_col > 0 {
289            card.started = Some(Utc::now());
290        }
291        // Set or clear completed based on target column
292        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    /// Collect all unique tags across the board with counts.
303    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    /// Collect all unique assignees across all cards, with counts.
321    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// ---------------------------------------------------------------------------
340// Card filtering
341// ---------------------------------------------------------------------------
342
343/// Check whether a card is visible under the current set of filters.
344///
345/// `active_filter` is the text/fuzzy search (from `/`).
346/// `tag_filters` / `assignee_filters` / `staleness_filters` are the picker-based filters.
347/// Text search takes precedence over picker filters.
348#[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
376/// Check whether a card matches a multi-term fuzzy filter.
377///
378/// The filter string is split on whitespace into individual terms.
379/// Each term must fuzzy-match at least one card field (title, any tag,
380/// or any assignee) — OR across fields, AND across terms.
381///
382/// Special prefixes:
383/// - `@` is stripped so `@alice` matches the assignee `alice`
384/// - `!` negates: the card must NOT match that term in any field
385pub 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        // Strip leading @ for assignee-friendly matching
399        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
425/// Derive a raw slug from a display name without uniqueness deduplication.
426///
427/// Maps non-alphanumeric chars to `-`, collapses runs, trims leading/trailing
428/// hyphens, and falls back to `"col"` if the result would be empty.
429///
430/// **Does not** guard the `"archive"` reserved slug — callers that need that
431/// guarantee should use [`slug_for_rename`] or [`generate_slug`].
432pub 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    // After split/filter/join the result is guaranteed to start with an
441    // alphanumeric char (or be empty), so only the empty check is needed.
442    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
454/// Derive the slug to use when **renaming** an existing column.
455///
456/// Like [`slug_from_name`] but also enforces the `"archive"` reservation,
457/// returning `Err` when the derived slug would be reserved. Uniqueness and
458/// same-slug detection remain the caller's responsibility.
459pub 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
468/// Generate a filesystem-safe slug from a display name, guaranteed unique
469/// among `existing` columns. The reserved slug `"archive"` is never returned.
470///
471/// Algorithm: lowercase → map non-alphanumeric to `-` → collapse/trim hyphens
472/// → fall back to `"col"` if empty → append `-2`, `-3`, … for uniqueness.
473pub fn generate_slug(name: &str, existing: &[Column]) -> String {
474    let base = slug_from_name(name);
475    // "archive" is a reserved slug — never generate it directly.
476    let base = if base == "archive" { "archive-col".to_string() } else { base };
477    // Ensure uniqueness.
478    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
490/// Convert a slug back to a display name: split on `-`, capitalise each word.
491///
492/// # Invariants
493///
494/// Assumes the slug was produced by [`generate_slug`] or [`slug_from_name`]
495/// (no leading/trailing hyphens, lowercase alphanumeric + hyphens).
496/// Degenerate input such as `"-"` produces degenerate output (`" "`) without
497/// panicking, but callers should rely on [`validate_slug`] to prevent such
498/// input from reaching this function.
499///
500/// Examples: `"backlog"` → `"Backlog"`, `"in-progress"` → `"In Progress"`.
501pub 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
514/// Normalize column orders to dense 0, 1, 2, … sorted by current order.
515/// Call this after every add / remove / reorder operation, before save_board.
516pub 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    /// Sort cards by priority (urgent first) then by updated timestamp (most recent first).
525    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    /// Whether this column is at or over its WIP limit.
536    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        // First move to done
591        board.move_card(0, 0, 1);
592        assert!(board.columns[1].cards[0].completed.is_some());
593
594        // Move back to backlog
595        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        // Manually set completed to a specific time
604        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        // Move backlog → in-progress (neither is "done")
654        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    // ── Started (commitment point) tests ──
663
664    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); // backlog → in-progress
708        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); // backlog → in-progress
716        let original_started = board.columns[1].cards[0].started;
717
718        // Add a review column and move there
719        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); // in-progress → review
728        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); // backlog → in-progress
736        assert!(board.columns[1].cards[0].started.is_some());
737
738        board.move_card(1, 0, 0); // in-progress → backlog
739        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); // backlog → done
749        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    // ── Filter tests ──
761
762    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        // Text filter matches, even though tag filter wouldn't match
855        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        // Both match
895        assert!(card_is_visible(&card, None, &["bug".to_string()], &["alice".to_string()], &[], false, &policies, now, &matcher));
896        // Tag matches, assignee doesn't
897        assert!(!card_is_visible(&card, None, &["bug".to_string()], &["bob".to_string()], &[], false, &policies, now, &matcher));
898        // Assignee matches, tag doesn't
899        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        // Card created yesterday, updated recently → "normal"
909        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        // Card is "normal" but filter only accepts "stale"
922        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        // Stale card (updated 7 days ago)
935        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        // Normal card should not match
941        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        // Card is "normal" and has tag "bug"
954        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        // Both tag and staleness match → visible
958        assert!(card_is_visible(&card, None, &["bug".to_string()], &[], &["normal".to_string()], false, &policies, now, &matcher));
959        // Tag matches but staleness doesn't → hidden
960        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        // Text filter matches, staleness filter would NOT match — text takes precedence
973        assert!(card_is_visible(&card, Some("login"), &[], &[], &["stale".to_string()], false, &policies, now, &matcher));
974    }
975
976    // ── Sort tests ──
977
978    #[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    // ── Priority tests ──
1010
1011    #[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    // ── WIP limit tests ──
1039
1040    #[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    // ── find_card tests ──
1080
1081    #[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    // ── next_card_id tests ──
1094
1095    #[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    // ── all_tags / all_assignees tests ──
1105
1106    #[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    // ── Card::new defaults ──
1137
1138    #[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    // ── move_card edge cases ──
1162
1163    #[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        // Out-of-bounds card index
1175        board.move_card(0, 999, 1);
1176        // Out-of-bounds column index
1177        board.move_card(999, 0, 0);
1178        board.move_card(0, 0, 999);
1179    }
1180
1181    // ── sort_cards specifics ──
1182
1183    #[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        // Both Normal priority, but sort is by priority then updated — blocked doesn't affect sort
1201        // The sort is: priority.sort_key then updated desc
1202        // Since both are Normal, the one with a later updated time sorts first
1203        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        // Most recently updated sorts first
1226        assert_eq!(col.cards[0].id, "2");
1227        assert_eq!(col.cards[1].id, "1");
1228    }
1229
1230    // ── Priority display ──
1231
1232    #[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    // ── slug_to_name ──
1249
1250    #[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        // Only the first char of each segment is uppercased; rest is unchanged.
1278        assert_eq!(slug_to_name("in-PROGRESS"), "In PROGRESS");
1279    }
1280
1281    #[test]
1282    fn slug_to_name_single_hyphen_does_not_panic() {
1283        // "-" splits into two empty segments → no panic.
1284        let result = slug_to_name("-");
1285        assert_eq!(result, " "); // two empty words joined by space
1286    }
1287
1288    // ── slug_from_name ──
1289
1290    #[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        // Purely symbolic/punctuation Unicode falls back to "col"
1338        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        // Composed (NFC) and decomposed (NFD) representations of "å" must produce the same slug
1349        let nfc = "Kamelåså"; // å as U+00E5
1350        let nfd = "Kamela\u{030A}sa\u{030A}"; // a + combining ring above
1351        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        // slug_from_name does NOT remap the reserved "archive" slug —
1368        // that guard lives in cmd_col_rename. This is the documented distinction
1369        // between slug_from_name and generate_slug.
1370        assert_eq!(slug_from_name("Archive"), "archive");
1371    }
1372
1373    // ── generate_slug ──
1374
1375    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        // The implementation redirects "archive" to "archive-col", but the key
1394        // contract is just that the reserved slug is avoided.
1395        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        // Name starts with special chars that map to hyphens; leading hyphens removed.
1430        let slug = generate_slug("---hello", &[]);
1431        assert!(slug.starts_with(|c: char| c.is_alphanumeric()));
1432    }
1433
1434    // ── normalize_column_orders ──
1435
1436    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        // Should sort by original order then assign 0,1,2.
1452        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        // Vec is in wrong order relative to the order field.
1461        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); // must not panic
1479    }
1480
1481    // ── is_overdue tests ──
1482
1483    #[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    // ── card_is_visible overdue filter tests ──
1528
1529    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        // Both match
1591        assert!(card_is_visible(&card, None, &["bug".to_string()], &[], &[], true, &policies, now, &matcher));
1592        // Tag doesn't match
1593        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)); // not overdue
1603        // Text search takes precedence over overdue filter
1604        assert!(card_is_visible(&card, Some("test"), &[], &[], &[], true, &policies, now, &matcher));
1605    }
1606
1607    // ── generate_template_slug ──
1608
1609    #[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        // "Col" contains alphanumeric chars so slug_from_name produces "col",
1639        // which is a regular slug — not the empty/unicode fallback path.
1640        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    // ── deserialize_blocked tests ──
1660
1661    #[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}