Skip to main content

kando_core/board/
storage.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use super::{slug_to_name, Board, Card, Column, Policies, Template};
8use crate::config::{BoardConfig, KandoToml};
9
10#[derive(Debug, Clone)]
11pub enum BoardMode {
12    Local,
13    GitSync {
14        #[allow(dead_code)]
15        shadow_root: PathBuf,
16        branch: String,
17    },
18}
19
20#[derive(Debug, Clone)]
21pub struct BoardContext {
22    /// Path to `.kando/` (local dir or shadow dir).
23    pub kando_dir: PathBuf,
24    /// Project root (parent of `.kando/` or `.kando.toml`).
25    pub project_root: PathBuf,
26    pub mode: BoardMode,
27}
28
29impl BoardContext {
30    pub fn is_git_sync(&self) -> bool {
31        matches!(self.mode, BoardMode::GitSync { .. })
32    }
33
34    /// Push changes to remote, dispatching based on board mode.
35    pub fn sync_push(&self, ss: &mut crate::board::sync::SyncState, msg: &str) {
36        match &self.mode {
37            BoardMode::GitSync { .. } => {
38                crate::board::sync::commit_and_push_shadow(ss, msg);
39            }
40            BoardMode::Local => {
41                crate::board::sync::commit_and_push(ss, &self.kando_dir, msg);
42            }
43        }
44    }
45
46    /// Pull changes from remote, dispatching based on board mode.
47    pub fn sync_pull(&self, ss: &mut crate::board::sync::SyncState) -> crate::board::sync::SyncStatus {
48        match &self.mode {
49            BoardMode::GitSync { .. } => crate::board::sync::pull_shadow(ss),
50            BoardMode::Local => crate::board::sync::pull(ss, &self.kando_dir),
51        }
52    }
53}
54
55#[derive(Debug, thiserror::Error)]
56pub enum StorageError {
57    #[error("io error: {0}")]
58    Io(#[from] std::io::Error),
59    #[error("toml serialization error: {0}")]
60    TomlSer(#[from] toml::ser::Error),
61    #[error("toml deserialization error: {0}")]
62    TomlDe(#[from] toml::de::Error),
63    #[error(".kando directory not found (walk up from {0})")]
64    NotFound(PathBuf),
65    #[error("broken git-sync: .kando.toml found at {toml_path} but {reason}")]
66    BrokenGitSync { toml_path: PathBuf, reason: String },
67    #[error("invalid card file {path}: {reason}")]
68    InvalidCard { path: PathBuf, reason: String },
69    #[error("invalid slug: {0:?} (must be lowercase alphanumeric and hyphens)")]
70    InvalidSlug(String),
71}
72
73/// Validate that a slug is safe for use as a directory name.
74/// Must start with an alphanumeric character, then only lowercase alphanumeric and hyphens.
75/// Supports Unicode letters (e.g. å, ö, ñ) — not just ASCII.
76fn validate_slug(slug: &str) -> Result<(), StorageError> {
77    let first = match slug.chars().next() {
78        None => return Err(StorageError::InvalidSlug(slug.to_string())),
79        Some(c) => c,
80    };
81    if !first.is_alphanumeric()
82        || slug.chars().any(|c| (!c.is_alphanumeric() && c != '-') || c.is_uppercase())
83    {
84        return Err(StorageError::InvalidSlug(slug.to_string()));
85    }
86    Ok(())
87}
88
89/// Find the .kando directory by walking up from `start`.
90pub fn find_kando_dir(start: &Path) -> Result<PathBuf, StorageError> {
91    let mut dir = start.to_path_buf();
92    loop {
93        let candidate = dir.join(".kando");
94        if candidate.is_dir() {
95            return Ok(candidate);
96        }
97        if !dir.pop() {
98            return Err(StorageError::NotFound(start.to_path_buf()));
99        }
100    }
101}
102
103/// Find the `.kando.toml` marker by walking up from `start`.
104fn find_kando_toml(start: &Path) -> Option<PathBuf> {
105    let mut dir = start.to_path_buf();
106    loop {
107        let candidate = dir.join(".kando.toml");
108        if candidate.is_file() {
109            return Some(candidate);
110        }
111        if !dir.pop() {
112            return None;
113        }
114    }
115}
116
117/// Check if the current directory's git repo has a `kando` branch (local or remote).
118fn has_kando_branch(git_root: &Path) -> Option<String> {
119    use std::process::Command;
120
121    // Check local branches
122    let output = Command::new("git")
123        .args(["branch", "--list", "kando"])
124        .current_dir(git_root)
125        .output()
126        .ok()?;
127    let stdout = String::from_utf8_lossy(&output.stdout);
128    if stdout.lines().any(|l| {
129        let trimmed = l.trim();
130        trimmed == "kando" || trimmed == "* kando"
131    }) {
132        return Some("kando".to_string());
133    }
134
135    // Check remote branches
136    let output = Command::new("git")
137        .args(["branch", "-r", "--list", "origin/kando"])
138        .current_dir(git_root)
139        .output()
140        .ok()?;
141    let stdout = String::from_utf8_lossy(&output.stdout);
142    if stdout.lines().any(|l| l.trim() == "origin/kando") {
143        return Some("kando".to_string());
144    }
145
146    None
147}
148
149/// Resolve the board context by searching for `.kando.toml` (git-synced) or `.kando/` (local).
150///
151/// Resolution order:
152/// 1. Walk up looking for `.kando.toml` → GitSync mode (shadow-primary)
153/// 2. Walk up looking for `.kando/` directory → Local mode
154/// 3. Check for `kando` branch in git repo → auto-bootstrap GitSync
155/// 4. Error: no board found
156pub fn resolve_board(start: &Path) -> Result<BoardContext, StorageError> {
157    use crate::board::sync;
158
159    // 1. Look for .kando.toml (git-synced board)
160    if let Some(toml_path) = find_kando_toml(start) {
161        let project_root = toml_path.parent().unwrap().to_path_buf();
162        let toml_str = fs::read_to_string(&toml_path)?;
163        let kando_toml: KandoToml = toml::from_str(&toml_str)?;
164
165        let git_root = sync::find_git_root(&project_root)
166            .ok_or_else(|| StorageError::BrokenGitSync {
167                toml_path: toml_path.clone(),
168                reason: "no git repository found".to_string(),
169            })?;
170        let remote_url = sync::get_remote_url(&git_root).map_err(|_| {
171            StorageError::BrokenGitSync {
172                toml_path: toml_path.clone(),
173                reason: "no git remote configured".to_string(),
174            }
175        })?;
176        let shadow_root = sync::shadow_dir_for(&remote_url);
177        let kando_dir = shadow_root.join(".kando");
178
179        return Ok(BoardContext {
180            kando_dir,
181            project_root,
182            mode: BoardMode::GitSync {
183                shadow_root,
184                branch: kando_toml.branch,
185            },
186        });
187    }
188
189    // 2. Look for .kando/ directory (local board)
190    if let Ok(kando_dir) = find_kando_dir(start) {
191        let project_root = kando_dir.parent().unwrap().to_path_buf();
192        return Ok(BoardContext {
193            kando_dir,
194            project_root,
195            mode: BoardMode::Local,
196        });
197    }
198
199    // 3. Check for kando branch in git repo (auto-bootstrap)
200    if let Some(git_root) = sync::find_git_root(start) {
201        if let Some(branch) = has_kando_branch(&git_root) {
202            if let Ok(remote_url) = sync::get_remote_url(&git_root) {
203                let shadow_root = sync::shadow_dir_for(&remote_url);
204                let kando_dir = shadow_root.join(".kando");
205                let project_root = git_root.clone();
206
207                // Write .kando.toml for future discovery
208                let kando_toml = KandoToml { branch: branch.clone() };
209                let toml_str = toml::to_string_pretty(&kando_toml).unwrap_or_else(|_| {
210                    format!("branch = \"{branch}\"\n")
211                });
212                let _ = fs::write(git_root.join(".kando.toml"), toml_str);
213
214                return Ok(BoardContext {
215                    kando_dir,
216                    project_root,
217                    mode: BoardMode::GitSync {
218                        shadow_root,
219                        branch,
220                    },
221                });
222            }
223        }
224    }
225
226    // 4. No board found
227    Err(StorageError::NotFound(start.to_path_buf()))
228}
229
230/// Default column definitions for a new board.
231fn default_columns() -> Vec<ColumnConfig> {
232    vec![
233        ColumnConfig {
234            slug: "backlog".into(),
235            name: "Backlog".into(),
236            order: 0,
237            wip_limit: None,
238            hidden: None,
239        },
240        ColumnConfig {
241            slug: "in-progress".into(),
242            name: "In Progress".into(),
243            order: 1,
244            wip_limit: Some(3),
245            hidden: None,
246        },
247        ColumnConfig {
248            slug: "done".into(),
249            name: "Done".into(),
250            order: 2,
251            wip_limit: None,
252            hidden: None,
253        },
254        ColumnConfig {
255            slug: "archive".into(),
256            name: "Archive".into(),
257            order: 3,
258            wip_limit: None,
259            hidden: Some(true),
260        },
261    ]
262}
263
264/// Initialize a new .kando directory at a specific target path with default config and columns.
265pub fn init_board_at(kando_dir: &Path, name: &str, sync_branch: Option<&str>) -> Result<PathBuf, StorageError> {
266    fs::create_dir_all(kando_dir)?;
267
268    let columns_dir = kando_dir.join("columns");
269    let cols = default_columns();
270
271    for col in &cols {
272        let col_dir = columns_dir.join(&col.slug);
273        fs::create_dir_all(&col_dir)?;
274    }
275
276    let config = BoardConfig {
277        board: BoardSection {
278            name: name.to_string(),
279            next_card_id: 1,
280            policies: Policies::default(),
281            sync_branch: sync_branch.map(|s| s.to_string()),
282            nerd_font: false,
283            created_at: Some(Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()),
284        },
285        columns: cols,
286    };
287    let config_str = toml::to_string_pretty(&config)?;
288    fs::write(kando_dir.join("config.toml"), config_str)?;
289
290    save_local_config(kando_dir, &crate::config::LocalConfig::default())?;
291
292    Ok(kando_dir.to_path_buf())
293}
294
295/// Initialize a new .kando directory with default config and columns.
296pub fn init_board(root: &Path, name: &str, sync_branch: Option<&str>) -> Result<PathBuf, StorageError> {
297    init_board_at(&root.join(".kando"), name, sync_branch)
298}
299
300/// Load the full board from a .kando directory.
301pub fn load_board(kando_dir: &Path) -> Result<Board, StorageError> {
302    let config_path = kando_dir.join("config.toml");
303    let config_str = fs::read_to_string(&config_path)?;
304    let config: BoardConfig = toml::from_str(&config_str)?;
305
306    let columns_dir = kando_dir.join("columns");
307    let mut columns = Vec::new();
308
309    for col_config in &config.columns {
310        validate_slug(&col_config.slug)?;
311        let col_dir = columns_dir.join(&col_config.slug);
312        // Load cards
313        let mut cards = Vec::new();
314        if col_dir.exists() {
315            for entry in fs::read_dir(&col_dir)? {
316                let entry = entry?;
317                let path = entry.path();
318                if path.extension().and_then(|e| e.to_str()) == Some("md") {
319                    match load_card(&path) {
320                        Ok(card) => cards.push(card),
321                        Err(e) => {
322                            eprintln!("Warning: skipping invalid card {}: {e}", path.display());
323                        }
324                    }
325                }
326            }
327        }
328
329        let mut col = Column {
330            slug: col_config.slug.clone(),
331            name: slug_to_name(&col_config.slug),
332            order: col_config.order,
333            wip_limit: col_config.wip_limit,
334            hidden: col_config.hidden.unwrap_or(false),
335            cards,
336        };
337        col.sort_cards();
338        columns.push(col);
339    }
340
341    columns.sort_by_key(|c| c.order);
342
343    let created_at = config
344        .board
345        .created_at
346        .as_deref()
347        .and_then(|s| s.parse::<DateTime<Utc>>().ok());
348
349    Ok(Board {
350        name: config.board.name,
351        next_card_id: config.board.next_card_id,
352        policies: config.board.policies,
353        sync_branch: config.board.sync_branch,
354        nerd_font: config.board.nerd_font,
355        created_at,
356        columns,
357    })
358}
359
360/// Save the full board back to the .kando directory.
361pub fn save_board(kando_dir: &Path, board: &Board) -> Result<(), StorageError> {
362    let columns_dir = kando_dir.join("columns");
363
364    // Validate all slugs before doing any work or allocating.
365    for col in &board.columns {
366        validate_slug(&col.slug)?;
367    }
368
369    // Build config
370    let column_configs: Vec<ColumnConfig> = board
371        .columns
372        .iter()
373        .map(|col| ColumnConfig {
374            slug: col.slug.clone(),
375            name: col.name.clone(),
376            order: col.order,
377            wip_limit: col.wip_limit,
378            hidden: if col.hidden { Some(true) } else { None },
379        })
380        .collect();
381
382    let config = BoardConfig {
383        board: BoardSection {
384            name: board.name.clone(),
385            next_card_id: board.next_card_id,
386            policies: board.policies.clone(),
387            sync_branch: board.sync_branch.clone(),
388            nerd_font: board.nerd_font,
389            created_at: board.created_at.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()),
390        },
391        columns: column_configs,
392    };
393    let config_str = toml::to_string_pretty(&config)?;
394    fs::write(kando_dir.join("config.toml"), config_str)?;
395
396    // Save each column
397    for col in &board.columns {
398        let col_dir = columns_dir.join(&col.slug);
399        fs::create_dir_all(&col_dir)?;
400
401        // Remove card files that no longer exist in this column
402        if col_dir.exists() {
403            let current_ids: std::collections::HashSet<String> =
404                col.cards.iter().map(|c| format!("{}.md", c.id)).collect();
405            for entry in fs::read_dir(&col_dir)? {
406                let entry = entry?;
407                let name = entry.file_name().to_string_lossy().to_string();
408                if name.ends_with(".md") && !current_ids.contains(&name) {
409                    fs::remove_file(entry.path())?;
410                }
411            }
412        }
413
414        // Write card files (skip unchanged cards)
415        for card in &col.cards {
416            let card_path = col_dir.join(format!("{}.md", card.id));
417            let content = serialize_card(card);
418            let needs_write = match fs::read_to_string(&card_path) {
419                Ok(existing) => existing.replace("\r\n", "\n") != content,
420                Err(_) => true,
421            };
422            if needs_write {
423                fs::write(&card_path, content)?;
424            }
425        }
426    }
427
428    Ok(())
429}
430
431/// Remove a column's on-disk directory (.kando/columns/{slug}/).
432///
433/// Callers must call `save_board` **before** this function so the column config
434/// is removed from config.toml and stale card files are written out first.
435/// The slug is validated before any I/O is attempted.
436pub fn remove_column_dir(kando_dir: &Path, slug: &str) -> Result<(), StorageError> {
437    validate_slug(slug)?;
438    let col_dir = kando_dir.join("columns").join(slug);
439    if col_dir.exists() {
440        fs::remove_dir_all(&col_dir)?;
441    }
442    Ok(())
443}
444
445/// Rename a column's on-disk directory from `old_slug` to `new_slug`.
446///
447/// Both slugs are validated before any I/O is attempted.
448/// If the old directory does not exist the function is a no-op (idempotent).
449pub fn rename_column_dir(kando_dir: &Path, old_slug: &str, new_slug: &str) -> Result<(), StorageError> {
450    validate_slug(old_slug)?;
451    validate_slug(new_slug)?;
452    if old_slug == new_slug {
453        return Ok(()); // same path — no-op
454    }
455    let old_dir = kando_dir.join("columns").join(old_slug);
456    let new_dir = kando_dir.join("columns").join(new_slug);
457    if new_dir.exists() {
458        return Err(StorageError::Io(std::io::Error::new(
459            std::io::ErrorKind::AlreadyExists,
460            format!("column directory '{}' already exists", new_slug),
461        )));
462    }
463    if old_dir.exists() {
464        fs::rename(&old_dir, &new_dir)?;
465    }
466    Ok(())
467}
468
469/// Parse a card .md file with TOML frontmatter.
470fn load_card(path: &Path) -> Result<Card, StorageError> {
471    let content = fs::read_to_string(path)?;
472    let (frontmatter, body) = parse_frontmatter(&content).ok_or_else(|| {
473        StorageError::InvalidCard {
474            path: path.to_path_buf(),
475            reason: "missing or invalid TOML frontmatter".into(),
476        }
477    })?;
478
479    let mut card: Card = toml::from_str(&frontmatter).map_err(|e| StorageError::InvalidCard {
480        path: path.to_path_buf(),
481        reason: format!("invalid TOML: {e}"),
482    })?;
483    // Validate card ID is safe for use as a filename
484    if card.id.is_empty()
485        || !card.id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
486    {
487        return Err(StorageError::InvalidCard {
488            path: path.to_path_buf(),
489            reason: format!("unsafe card id: {:?}", card.id),
490        });
491    }
492    card.body = body;
493    Ok(card)
494}
495
496/// Serialize a card to the frontmatter + markdown body format.
497fn serialize_card(card: &Card) -> String {
498    // Build frontmatter manually for clean formatting
499    let mut fm = String::new();
500    fm.push_str(&format!("id = {:?}\n", card.id));
501    fm.push_str(&format!("title = {:?}\n", card.title));
502    fm.push_str(&format!(
503        "created = \"{}\"\n",
504        card.created.format("%Y-%m-%dT%H:%M:%SZ")
505    ));
506    fm.push_str(&format!(
507        "updated = \"{}\"\n",
508        card.updated.format("%Y-%m-%dT%H:%M:%SZ")
509    ));
510    fm.push_str(&format!("priority = {:?}\n", card.priority.as_str()));
511    if !card.tags.is_empty() {
512        let tags: Vec<String> = card.tags.iter().map(|t| format!("{t:?}")).collect();
513        fm.push_str(&format!("tags = [{}]\n", tags.join(", ")));
514    } else {
515        fm.push_str("tags = []\n");
516    }
517    if !card.assignees.is_empty() {
518        let assignees: Vec<String> = card.assignees.iter().map(|a| format!("{a:?}")).collect();
519        fm.push_str(&format!("assignees = [{}]\n", assignees.join(", ")));
520    }
521    if let Some(reason) = &card.blocked {
522        fm.push_str(&format!("blocked = {:?}\n", reason));
523    }
524    if let Some(due) = card.due {
525        fm.push_str(&format!("due = \"{}\"\n", due.format("%Y-%m-%d")));
526    }
527    if let Some(started) = card.started {
528        fm.push_str(&format!(
529            "started = \"{}\"\n",
530            started.format("%Y-%m-%dT%H:%M:%SZ")
531        ));
532    }
533    if let Some(completed) = card.completed {
534        fm.push_str(&format!(
535            "completed = \"{}\"\n",
536            completed.format("%Y-%m-%dT%H:%M:%SZ")
537        ));
538    }
539
540    let mut out = String::new();
541    out.push_str("---\n");
542    out.push_str(&fm);
543    out.push_str("---\n");
544    if !card.body.is_empty() {
545        out.push('\n');
546        out.push_str(&card.body);
547        if !card.body.ends_with('\n') {
548            out.push('\n');
549        }
550    }
551    out
552}
553
554/// Parse `---` delimited TOML frontmatter from a string.
555/// Returns (frontmatter, body).
556///
557/// Normalizes `\r\n` to `\n` so files edited on Windows parse correctly.
558fn parse_frontmatter(content: &str) -> Option<(String, String)> {
559    let content = content.replace("\r\n", "\n");
560    let content = content.trim_start();
561    if !content.starts_with("---") {
562        return None;
563    }
564    let after_first = &content[3..];
565    let after_first = after_first.strip_prefix('\n').unwrap_or(after_first);
566    let end = after_first.find("\n---")?;
567    let frontmatter = after_first[..end].to_string();
568    let rest = &after_first[end + 4..];
569    let body = rest.strip_prefix('\n').unwrap_or(rest).to_string();
570    let body = body.trim().to_string();
571    Some((frontmatter, body))
572}
573
574// ── Serialization helpers for config/meta files ──
575
576#[derive(Debug, Serialize, Deserialize)]
577pub struct BoardSection {
578    pub name: String,
579    pub next_card_id: u32,
580    #[serde(default)]
581    pub policies: Policies,
582    /// Git branch to sync with. None = no sync.
583    #[serde(default, skip_serializing_if = "Option::is_none")]
584    pub sync_branch: Option<String>,
585    /// Use Nerd Font glyphs instead of ASCII icons.
586    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
587    pub nerd_font: bool,
588    /// When the board was created (ISO-8601 string).
589    #[serde(default, skip_serializing_if = "Option::is_none")]
590    pub created_at: Option<String>,
591}
592
593#[derive(Debug, Clone, Serialize, Deserialize)]
594pub struct ColumnConfig {
595    // `#[serde(default)]` allows forward-compatible parsing; the resulting
596    // empty string is caught immediately by `validate_slug` in `load_board`,
597    // so no silent bad state persists.
598    #[serde(default)]
599    pub slug: String,
600    /// Legacy field: column names are always derived from the slug via
601    /// [`slug_to_name`] at load time. Kept only for backwards-compatible
602    /// deserialization of existing `config.toml` files; never written back
603    /// to disk (`skip_serializing`).
604    #[serde(default, skip_serializing)]
605    #[allow(dead_code)]
606    pub name: String,
607    pub order: u32,
608    #[serde(skip_serializing_if = "Option::is_none")]
609    pub wip_limit: Option<u32>,
610    #[serde(skip_serializing_if = "Option::is_none")]
611    pub hidden: Option<bool>,
612}
613
614// ── Trash (soft-delete) ──
615
616/// A single entry in `.kando/trash/_meta.toml`.
617#[derive(Debug, Clone, Serialize, Deserialize)]
618pub struct TrashEntry {
619    pub id: String,
620    pub deleted: String, // ISO-8601 string, quoted for serde/toml compat
621    pub from_column: String,
622    pub title: String,
623}
624
625#[derive(Debug, Default, Serialize, Deserialize)]
626struct TrashMeta {
627    #[serde(default)]
628    entries: Vec<TrashEntry>,
629}
630
631/// Path to the trash directory.
632fn trash_dir(kando_dir: &Path) -> PathBuf {
633    kando_dir.join("trash")
634}
635
636/// Path to the trash metadata file.
637fn trash_meta_path(kando_dir: &Path) -> PathBuf {
638    trash_dir(kando_dir).join("_meta.toml")
639}
640
641/// Move a card file to `trash/` and record metadata.
642/// Returns the `TrashEntry` (for undo tracking).
643pub fn trash_card(
644    kando_dir: &Path,
645    col_slug: &str,
646    card_id: &str,
647    card_title: &str,
648) -> Result<TrashEntry, StorageError> {
649    let src = kando_dir
650        .join("columns")
651        .join(col_slug)
652        .join(format!("{card_id}.md"));
653    let dst_dir = trash_dir(kando_dir);
654    fs::create_dir_all(&dst_dir)?;
655    let dst = dst_dir.join(format!("{card_id}.md"));
656
657    // Move file (rename if same filesystem, else copy+delete)
658    if fs::rename(&src, &dst).is_err() {
659        fs::copy(&src, &dst)?;
660        fs::remove_file(&src)?;
661    }
662
663    let entry = TrashEntry {
664        id: card_id.to_string(),
665        deleted: Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
666        from_column: col_slug.to_string(),
667        title: card_title.to_string(),
668    };
669
670    // Append entry to _meta.toml
671    let mut meta = load_trash_meta(kando_dir);
672    meta.entries.push(entry.clone());
673    save_trash_meta(kando_dir, &meta)?;
674
675    Ok(entry)
676}
677
678/// Restore a card from `trash/` back to a column directory.
679/// Removes the entry from `_meta.toml`.
680pub fn restore_card(
681    kando_dir: &Path,
682    card_id: &str,
683    target_col_slug: &str,
684) -> Result<(), StorageError> {
685    let src = trash_dir(kando_dir).join(format!("{card_id}.md"));
686    let dst_dir = kando_dir.join("columns").join(target_col_slug);
687    fs::create_dir_all(&dst_dir)?;
688    let dst = dst_dir.join(format!("{card_id}.md"));
689
690    if fs::rename(&src, &dst).is_err() {
691        fs::copy(&src, &dst)?;
692        fs::remove_file(&src)?;
693    }
694
695    // Remove from _meta.toml
696    let mut meta = load_trash_meta(kando_dir);
697    meta.entries.retain(|e| e.id != card_id);
698    save_trash_meta(kando_dir, &meta)?;
699
700    Ok(())
701}
702
703/// Load all trash entries from `_meta.toml`.
704pub fn load_trash(kando_dir: &Path) -> Vec<TrashEntry> {
705    load_trash_meta(kando_dir).entries
706}
707
708/// Purge trash entries older than `max_age_days`. Returns IDs of purged cards.
709pub fn purge_trash(kando_dir: &Path, max_age_days: u32) -> Result<Vec<String>, StorageError> {
710    if max_age_days == 0 {
711        return Ok(Vec::new());
712    }
713
714    let mut meta = load_trash_meta(kando_dir);
715    let now = Utc::now();
716    let mut purged = Vec::new();
717    let tdir = trash_dir(kando_dir);
718
719    meta.entries.retain(|entry| {
720        let keep = match entry.deleted.parse::<DateTime<Utc>>() {
721            Ok(deleted) => (now - deleted).num_days() < i64::from(max_age_days),
722            Err(_) => true, // keep entries with unparseable dates
723        };
724        if !keep {
725            let card_file = tdir.join(format!("{}.md", entry.id));
726            let _ = fs::remove_file(card_file);
727            purged.push(entry.id.clone());
728        }
729        keep
730    });
731
732    if !purged.is_empty() {
733        save_trash_meta(kando_dir, &meta)?;
734    }
735
736    Ok(purged)
737}
738
739/// Internal: load trash metadata, returning empty on missing/corrupt file.
740fn load_trash_meta(kando_dir: &Path) -> TrashMeta {
741    let path = trash_meta_path(kando_dir);
742    match fs::read_to_string(&path) {
743        Ok(content) => toml::from_str(&content).unwrap_or_default(),
744        Err(_) => TrashMeta::default(),
745    }
746}
747
748/// Internal: write trash metadata to disk.
749fn save_trash_meta(kando_dir: &Path, meta: &TrashMeta) -> Result<(), StorageError> {
750    let dir = trash_dir(kando_dir);
751    fs::create_dir_all(&dir)?;
752    let content = toml::to_string_pretty(meta)?;
753    fs::write(trash_meta_path(kando_dir), content)?;
754    Ok(())
755}
756
757// ---------------------------------------------------------------------------
758// Activity log (.kando/activity.log — committed, append-only JSONL)
759// ---------------------------------------------------------------------------
760
761/// Escape a string as a JSON-encoded string value (including surrounding quotes).
762///
763/// ASCII control characters (U+0000–U+001F) are written as `\uXXXX`.
764/// All other characters, including non-ASCII Unicode, are written as raw UTF-8,
765/// which is valid JSON per RFC 8259 §8.1.  Surrogate-pair encoding for code
766/// points above U+FFFF is intentionally not performed; standard JSON parsers
767/// handle raw UTF-8 correctly.
768fn json_escape(s: &str) -> String {
769    let mut out = String::with_capacity(s.len() + 2);
770    out.push('"');
771    for c in s.chars() {
772        match c {
773            '"'  => out.push_str("\\\""),
774            '\\' => out.push_str("\\\\"),
775            '\n' => out.push_str("\\n"),
776            '\r' => out.push_str("\\r"),
777            '\t' => out.push_str("\\t"),
778            c if (c as u32) < 0x20 => {
779                out.push_str(&format!("\\u{:04x}", c as u32));
780            }
781            c => out.push(c),
782        }
783    }
784    out.push('"');
785    out
786}
787
788/// Append a single JSONL event to `.kando/activity.log`.
789///
790/// The common fields are `ts`, `action`, `id`, and `title`.  `extras` is a
791/// slice of `(key, value)` pairs that are appended after them.  The write is
792/// best-effort: any I/O error is silently discarded so a log failure never
793/// interrupts normal board operations.
794pub fn append_activity(
795    kando_dir: &Path,
796    action: &str,
797    card_id: &str,
798    card_title: &str,
799    extras: &[(&str, &str)],
800) {
801    let _ = try_append_activity(kando_dir, action, card_id, card_title, extras);
802    super::hooks::fire_hook(kando_dir, action, card_id, card_title, extras);
803}
804
805fn try_append_activity(
806    kando_dir: &Path,
807    action: &str,
808    card_id: &str,
809    card_title: &str,
810    extras: &[(&str, &str)],
811) -> std::io::Result<()> {
812    use std::io::Write;
813    let ts = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
814    let mut line = format!(
815        "{{\"ts\":{},\"action\":{},\"id\":{},\"title\":{}",
816        json_escape(&ts),
817        json_escape(action),
818        json_escape(card_id),
819        json_escape(card_title),
820    );
821    for (k, v) in extras {
822        line.push(',');
823        line.push_str(&json_escape(k));
824        line.push(':');
825        line.push_str(&json_escape(v));
826    }
827    line.push('}');
828
829    let path = kando_dir.join("activity.log");
830    let mut file = std::fs::OpenOptions::new()
831        .append(true)
832        .create(true)
833        .open(&path)?;
834    writeln!(file, "{line}")?;
835    Ok(())
836}
837
838// ---------------------------------------------------------------------------
839// Local config (.kando/local.toml — gitignored, per-user preferences)
840// ---------------------------------------------------------------------------
841
842/// Load per-user local preferences from `.kando/local.toml`.
843/// Returns `Ok(default)` if the file is absent; surfaces a `StorageError`
844/// if the file exists but cannot be parsed, so callers can warn the user.
845pub fn load_local_config(kando_dir: &Path) -> Result<crate::config::LocalConfig, StorageError> {
846    let path = kando_dir.join("local.toml");
847    if !path.exists() {
848        return Ok(crate::config::LocalConfig::default());
849    }
850    let content = fs::read_to_string(&path)?;
851    Ok(toml::from_str(&content)?)
852}
853
854/// Persist per-user local preferences to `.kando/local.toml`.
855/// A best-effort attempt is made to add `local.toml` to `.kando/.gitignore`;
856/// gitignore failures are intentionally ignored so they never block the save.
857pub fn save_local_config(kando_dir: &Path, config: &crate::config::LocalConfig) -> Result<(), StorageError> {
858    let content = toml::to_string_pretty(config)?;
859    fs::write(kando_dir.join("local.toml"), content)?;
860    let _ = ensure_local_gitignore(kando_dir); // best-effort
861    Ok(())
862}
863
864/// Ensure `.kando/.gitignore` contains a `local.toml` entry.
865/// Opens the file once with read+write access to avoid a TOCTOU window.
866fn ensure_local_gitignore(kando_dir: &Path) -> Result<(), StorageError> {
867    use std::io::{Read, Seek, Write};
868    let path = kando_dir.join(".gitignore");
869    let entry = "local.toml";
870    let mut file = std::fs::OpenOptions::new()
871        .read(true)
872        .write(true)
873        .create(true)
874        .truncate(false)
875        .open(&path)?;
876    let mut content = String::new();
877    file.read_to_string(&mut content)?;
878    if content.lines().any(|l| l.trim() == entry) {
879        return Ok(());
880    }
881    file.seek(std::io::SeekFrom::End(0))?;
882    if !content.is_empty() && !content.ends_with('\n') {
883        file.write_all(b"\n")?;
884    }
885    writeln!(file, "{entry}")?;
886    Ok(())
887}
888
889// ---------------------------------------------------------------------------
890// Template storage
891// ---------------------------------------------------------------------------
892
893/// Load all templates from `.kando/templates/`, sorted by slug.
894/// Returns `(slug, Template)` pairs. Skips invalid files with a warning.
895pub fn load_templates(kando_dir: &Path) -> Vec<(String, Template)> {
896    let templates_dir = kando_dir.join("templates");
897    if !templates_dir.is_dir() {
898        return Vec::new();
899    }
900    let mut entries: Vec<(String, Template)> = Vec::new();
901    let Ok(read_dir) = fs::read_dir(&templates_dir) else {
902        return Vec::new();
903    };
904    for entry in read_dir {
905        let Ok(entry) = entry else { continue };
906        let path = entry.path();
907        if path.extension().and_then(|e| e.to_str()) != Some("md") {
908            continue;
909        }
910        let slug = path.file_stem()
911            .and_then(|s| s.to_str())
912            .unwrap_or("")
913            .to_string();
914        if slug.is_empty() {
915            continue;
916        }
917        match load_template(&path) {
918            Ok(tmpl) => entries.push((slug, tmpl)),
919            Err(e) => {
920                eprintln!("Warning: skipping invalid template {}: {e}", path.display());
921            }
922        }
923    }
924    entries.sort_by(|a, b| a.0.cmp(&b.0));
925    entries
926}
927
928/// Load a single template from a `.md` file with TOML frontmatter.
929pub fn load_template(path: &Path) -> Result<Template, StorageError> {
930    let content = fs::read_to_string(path)?;
931    let (frontmatter, body) = parse_frontmatter(&content).ok_or_else(|| {
932        StorageError::InvalidCard {
933            path: path.to_path_buf(),
934            reason: "missing or invalid TOML frontmatter".into(),
935        }
936    })?;
937    let mut tmpl: Template = toml::from_str(&frontmatter).map_err(|e| StorageError::InvalidCard {
938        path: path.to_path_buf(),
939        reason: format!("invalid TOML: {e}"),
940    })?;
941    tmpl.body = body;
942    Ok(tmpl)
943}
944
945/// Serialize a template to the frontmatter + markdown body format.
946pub fn serialize_template(tmpl: &Template) -> String {
947    let mut fm = String::new();
948    fm.push_str(&format!("priority = {:?}\n", tmpl.priority.as_str()));
949    if !tmpl.tags.is_empty() {
950        let tags: Vec<String> = tmpl.tags.iter().map(|t| format!("{t:?}")).collect();
951        fm.push_str(&format!("tags = [{}]\n", tags.join(", ")));
952    } else {
953        fm.push_str("tags = []\n");
954    }
955    if !tmpl.assignees.is_empty() {
956        let assignees: Vec<String> = tmpl.assignees.iter().map(|a| format!("{a:?}")).collect();
957        fm.push_str(&format!("assignees = [{}]\n", assignees.join(", ")));
958    } else {
959        fm.push_str("assignees = []\n");
960    }
961    if let Some(reason) = &tmpl.blocked {
962        fm.push_str(&format!("blocked = {:?}\n", reason));
963    }
964    if let Some(offset) = tmpl.due_offset_days {
965        fm.push_str(&format!("due_offset_days = {offset}\n"));
966    }
967
968    let mut out = String::new();
969    out.push_str("---\n");
970    out.push_str(&fm);
971    out.push_str("---\n");
972    if !tmpl.body.is_empty() {
973        out.push('\n');
974        out.push_str(&tmpl.body);
975        if !tmpl.body.ends_with('\n') {
976            out.push('\n');
977        }
978    }
979    out
980}
981
982/// Save a template to `.kando/templates/<slug>.md`. Creates the directory if needed.
983pub fn save_template(kando_dir: &Path, slug: &str, tmpl: &Template) -> Result<PathBuf, StorageError> {
984    validate_slug(slug)?;
985    let templates_dir = kando_dir.join("templates");
986    fs::create_dir_all(&templates_dir)?;
987    let path = templates_dir.join(format!("{slug}.md"));
988    let content = serialize_template(tmpl);
989    fs::write(&path, content)?;
990    Ok(path)
991}
992
993/// Delete a template file.
994pub fn delete_template(kando_dir: &Path, slug: &str) -> Result<(), StorageError> {
995    validate_slug(slug)?;
996    let path = kando_dir.join("templates").join(format!("{slug}.md"));
997    if path.exists() {
998        fs::remove_file(&path)?;
999    }
1000    Ok(())
1001}
1002
1003/// Rename a template from `old_slug` to `new_slug`.
1004///
1005/// Both slugs are validated. If the new slug already exists on disk the
1006/// operation is rejected. The old file is only removed after the new one
1007/// has been written successfully.
1008pub fn rename_template(kando_dir: &Path, old_slug: &str, new_slug: &str) -> Result<(), StorageError> {
1009    validate_slug(old_slug)?;
1010    validate_slug(new_slug)?;
1011    if old_slug == new_slug {
1012        return Ok(());
1013    }
1014    let templates_dir = kando_dir.join("templates");
1015    let old_path = templates_dir.join(format!("{old_slug}.md"));
1016    let new_path = templates_dir.join(format!("{new_slug}.md"));
1017    if new_path.exists() {
1018        return Err(StorageError::Io(std::io::Error::new(
1019            std::io::ErrorKind::AlreadyExists,
1020            format!("template '{new_slug}' already exists"),
1021        )));
1022    }
1023    let tmpl = load_template(&old_path)?;
1024    let content = serialize_template(&tmpl);
1025    fs::write(&new_path, content)?;
1026    if let Err(e) = fs::remove_file(&old_path) {
1027        // Clean up the new file to avoid duplicates.
1028        let _ = fs::remove_file(&new_path);
1029        return Err(e.into());
1030    }
1031    Ok(())
1032}
1033
1034/// Find a template by slug (exact) or derived name (case-insensitive).
1035pub fn find_template(kando_dir: &Path, name_or_slug: &str) -> Option<(String, Template)> {
1036    use crate::board::slug_to_name;
1037    let templates = load_templates(kando_dir);
1038    // Exact slug match first
1039    if let Some(entry) = templates.iter().find(|(slug, _)| slug == name_or_slug) {
1040        return Some(entry.clone());
1041    }
1042    // Case-insensitive derived-name match
1043    let lower = name_or_slug.to_lowercase();
1044    templates.into_iter().find(|(slug, _)| slug_to_name(slug).to_lowercase() == lower)
1045}
1046
1047#[cfg(test)]
1048mod tests {
1049    use super::*;
1050    use crate::board::Priority;
1051    use std::fs;
1052
1053    #[test]
1054    fn test_parse_frontmatter() {
1055        let content = "---\nid = \"001\"\ntitle = \"Test\"\n---\n\nBody text here.\n";
1056        let (fm, body) = parse_frontmatter(content).unwrap();
1057        assert!(fm.contains("id = \"001\""));
1058        assert_eq!(body, "Body text here.");
1059    }
1060
1061    #[test]
1062    fn test_parse_frontmatter_crlf() {
1063        let content = "---\r\nid = \"001\"\r\ntitle = \"Test\"\r\n---\r\n\r\nBody text here.\r\n";
1064        let (fm, body) = parse_frontmatter(content).unwrap();
1065        assert!(fm.contains("id = \"001\""));
1066        assert_eq!(body, "Body text here.");
1067    }
1068
1069    #[test]
1070    fn test_parse_frontmatter_no_body() {
1071        let content = "---\nid = \"001\"\ntitle = \"Test\"\n---\n";
1072        let (fm, body) = parse_frontmatter(content).unwrap();
1073        assert!(fm.contains("id = \"001\""));
1074        assert!(body.is_empty());
1075    }
1076
1077    #[test]
1078    fn test_card_roundtrip() {
1079        let mut card = Card::new("042".into(), "Test roundtrip".into());
1080        card.tags = vec!["bug".into(), "ui".into()];
1081        card.priority = Priority::High;
1082        card.body = "Some description here.".into();
1083
1084        let serialized = serialize_card(&card);
1085        let (fm, body) = parse_frontmatter(&serialized).unwrap();
1086        let deserialized: Card = toml::from_str(&fm).unwrap();
1087
1088        assert_eq!(deserialized.id, "042");
1089        assert_eq!(deserialized.title, "Test roundtrip");
1090        assert_eq!(deserialized.priority, Priority::High);
1091        assert_eq!(deserialized.tags, vec!["bug", "ui"]);
1092        assert_eq!(body, "Some description here.");
1093    }
1094
1095    #[test]
1096    fn test_init_and_load_board() {
1097        let dir = tempfile::tempdir().unwrap();
1098        init_board(dir.path(), "Test Project", None).unwrap();
1099
1100        let kando_dir = dir.path().join(".kando");
1101        assert!(kando_dir.exists());
1102        assert!(kando_dir.join("config.toml").exists());
1103        assert!(kando_dir.join("columns/backlog").is_dir());
1104        assert!(kando_dir.join("columns/in-progress").is_dir());
1105        assert!(kando_dir.join("columns/done").is_dir());
1106        assert!(kando_dir.join("columns/archive").is_dir());
1107
1108        let board = load_board(&kando_dir).unwrap();
1109        assert_eq!(board.name, "Test Project");
1110        assert_eq!(board.columns.len(), 4);
1111        assert_eq!(board.columns[0].slug, "backlog");
1112        assert_eq!(board.columns[1].slug, "in-progress");
1113        assert_eq!(board.columns[1].wip_limit, Some(3));
1114        assert_eq!(board.columns[3].hidden, true);
1115    }
1116
1117    #[test]
1118    fn test_save_and_load_with_cards() {
1119        let dir = tempfile::tempdir().unwrap();
1120        init_board(dir.path(), "Test", None).unwrap();
1121        let kando_dir = dir.path().join(".kando");
1122
1123        let mut board = load_board(&kando_dir).unwrap();
1124        let id = board.next_card_id();
1125        let mut card = Card::new(id, "Test card".into());
1126        card.tags = vec!["test".into()];
1127        board.columns[0].cards.push(card);
1128
1129        save_board(&kando_dir, &board).unwrap();
1130
1131        let reloaded = load_board(&kando_dir).unwrap();
1132        assert_eq!(reloaded.columns[0].cards.len(), 1);
1133        assert_eq!(reloaded.columns[0].cards[0].title, "Test card");
1134        assert_eq!(reloaded.columns[0].cards[0].tags, vec!["test"]);
1135        assert_eq!(
1136            reloaded.columns[0].cards[0].body,
1137            "<!-- add body content here -->"
1138        );
1139        assert_eq!(reloaded.next_card_id, 2);
1140    }
1141
1142    #[test]
1143    fn test_find_kando_dir() {
1144        let dir = tempfile::tempdir().unwrap();
1145        init_board(dir.path(), "Test", None).unwrap();
1146
1147        // Create a nested directory
1148        let nested = dir.path().join("src/deep/nested");
1149        fs::create_dir_all(&nested).unwrap();
1150
1151        let found = find_kando_dir(&nested).unwrap();
1152        assert_eq!(found, dir.path().join(".kando"));
1153    }
1154
1155    // -----------------------------------------------------------------------
1156    // Trash (soft-delete) tests
1157    // -----------------------------------------------------------------------
1158
1159    #[test]
1160    fn test_trash_card_moves_file_and_records_meta() {
1161        let dir = tempfile::tempdir().unwrap();
1162        init_board(dir.path(), "Test", None).unwrap();
1163        let kando_dir = dir.path().join(".kando");
1164
1165        // Create a card on disk
1166        let mut board = load_board(&kando_dir).unwrap();
1167        let id = board.next_card_id();
1168        board.columns[0].cards.push(Card::new(id.clone(), "Trash me".into()));
1169        save_board(&kando_dir, &board).unwrap();
1170
1171        let col_slug = &board.columns[0].slug;
1172        let src = kando_dir.join("columns").join(col_slug).join(format!("{id}.md"));
1173        assert!(src.exists());
1174
1175        // Trash it
1176        let entry = trash_card(&kando_dir, col_slug, &id, "Trash me").unwrap();
1177        assert_eq!(entry.id, id);
1178        assert_eq!(entry.from_column, col_slug.as_str());
1179        assert_eq!(entry.title, "Trash me");
1180
1181        // Source gone, trash file present
1182        assert!(!src.exists());
1183        let trash_file = kando_dir.join("trash").join(format!("{id}.md"));
1184        assert!(trash_file.exists());
1185
1186        // Meta records the entry
1187        let entries = load_trash(&kando_dir);
1188        assert_eq!(entries.len(), 1);
1189        assert_eq!(entries[0].id, id);
1190    }
1191
1192    #[test]
1193    fn test_restore_card_moves_file_back() {
1194        let dir = tempfile::tempdir().unwrap();
1195        init_board(dir.path(), "Test", None).unwrap();
1196        let kando_dir = dir.path().join(".kando");
1197
1198        let mut board = load_board(&kando_dir).unwrap();
1199        let id = board.next_card_id();
1200        board.columns[0].cards.push(Card::new(id.clone(), "Restore me".into()));
1201        save_board(&kando_dir, &board).unwrap();
1202
1203        let col_slug = board.columns[0].slug.clone();
1204        trash_card(&kando_dir, &col_slug, &id, "Restore me").unwrap();
1205
1206        // Restore to same column
1207        restore_card(&kando_dir, &id, &col_slug).unwrap();
1208
1209        // File is back in the column directory
1210        let restored = kando_dir.join("columns").join(&col_slug).join(format!("{id}.md"));
1211        assert!(restored.exists());
1212
1213        // Trash file gone, meta cleared
1214        let trash_file = kando_dir.join("trash").join(format!("{id}.md"));
1215        assert!(!trash_file.exists());
1216        assert!(load_trash(&kando_dir).is_empty());
1217    }
1218
1219    #[test]
1220    fn test_restore_card_to_different_column() {
1221        let dir = tempfile::tempdir().unwrap();
1222        init_board(dir.path(), "Test", None).unwrap();
1223        let kando_dir = dir.path().join(".kando");
1224
1225        let mut board = load_board(&kando_dir).unwrap();
1226        let id = board.next_card_id();
1227        board.columns[0].cards.push(Card::new(id.clone(), "Move me".into()));
1228        save_board(&kando_dir, &board).unwrap();
1229
1230        let from_slug = board.columns[0].slug.clone();
1231        let to_slug = board.columns[1].slug.clone();
1232        trash_card(&kando_dir, &from_slug, &id, "Move me").unwrap();
1233
1234        // Restore to a different column
1235        restore_card(&kando_dir, &id, &to_slug).unwrap();
1236
1237        let restored = kando_dir.join("columns").join(&to_slug).join(format!("{id}.md"));
1238        assert!(restored.exists());
1239        let old_loc = kando_dir.join("columns").join(&from_slug).join(format!("{id}.md"));
1240        assert!(!old_loc.exists());
1241    }
1242
1243    #[test]
1244    fn test_purge_trash_removes_old_entries() {
1245        let dir = tempfile::tempdir().unwrap();
1246        init_board(dir.path(), "Test", None).unwrap();
1247        let kando_dir = dir.path().join(".kando");
1248
1249        let mut board = load_board(&kando_dir).unwrap();
1250        let id = board.next_card_id();
1251        board.columns[0].cards.push(Card::new(id.clone(), "Old card".into()));
1252        save_board(&kando_dir, &board).unwrap();
1253
1254        let col_slug = board.columns[0].slug.clone();
1255        trash_card(&kando_dir, &col_slug, &id, "Old card").unwrap();
1256
1257        // Manually backdate the entry to 60 days ago
1258        let mut meta = load_trash_meta(&kando_dir);
1259        let old_date = (Utc::now() - chrono::TimeDelta::days(60))
1260            .format("%Y-%m-%dT%H:%M:%SZ")
1261            .to_string();
1262        meta.entries[0].deleted = old_date;
1263        save_trash_meta(&kando_dir, &meta).unwrap();
1264
1265        // Purge with 30-day limit
1266        let purged = purge_trash(&kando_dir, 30).unwrap();
1267        assert_eq!(purged, vec![id.clone()]);
1268
1269        // File removed, meta empty
1270        let trash_file = kando_dir.join("trash").join(format!("{id}.md"));
1271        assert!(!trash_file.exists());
1272        assert!(load_trash(&kando_dir).is_empty());
1273    }
1274
1275    #[test]
1276    fn test_purge_trash_keeps_recent_entries() {
1277        let dir = tempfile::tempdir().unwrap();
1278        init_board(dir.path(), "Test", None).unwrap();
1279        let kando_dir = dir.path().join(".kando");
1280
1281        let mut board = load_board(&kando_dir).unwrap();
1282        let id = board.next_card_id();
1283        board.columns[0].cards.push(Card::new(id.clone(), "Recent".into()));
1284        save_board(&kando_dir, &board).unwrap();
1285
1286        let col_slug = board.columns[0].slug.clone();
1287        trash_card(&kando_dir, &col_slug, &id, "Recent").unwrap();
1288
1289        // Purge with 30 days — card was just trashed, should survive
1290        let purged = purge_trash(&kando_dir, 30).unwrap();
1291        assert!(purged.is_empty());
1292
1293        // Entry still present
1294        assert_eq!(load_trash(&kando_dir).len(), 1);
1295    }
1296
1297    #[test]
1298    fn test_purge_trash_zero_days_is_disabled() {
1299        let dir = tempfile::tempdir().unwrap();
1300        init_board(dir.path(), "Test", None).unwrap();
1301        let kando_dir = dir.path().join(".kando");
1302
1303        let mut board = load_board(&kando_dir).unwrap();
1304        let id = board.next_card_id();
1305        board.columns[0].cards.push(Card::new(id.clone(), "Safe".into()));
1306        save_board(&kando_dir, &board).unwrap();
1307
1308        let col_slug = board.columns[0].slug.clone();
1309        trash_card(&kando_dir, &col_slug, &id, "Safe").unwrap();
1310
1311        // Backdate to 100 days ago
1312        let mut meta = load_trash_meta(&kando_dir);
1313        let old_date = (Utc::now() - chrono::TimeDelta::days(100))
1314            .format("%Y-%m-%dT%H:%M:%SZ")
1315            .to_string();
1316        meta.entries[0].deleted = old_date;
1317        save_trash_meta(&kando_dir, &meta).unwrap();
1318
1319        // max_age_days == 0 means never purge
1320        let purged = purge_trash(&kando_dir, 0).unwrap();
1321        assert!(purged.is_empty());
1322        assert_eq!(load_trash(&kando_dir).len(), 1);
1323    }
1324
1325    #[test]
1326    fn test_load_trash_empty_when_no_trash_dir() {
1327        let dir = tempfile::tempdir().unwrap();
1328        init_board(dir.path(), "Test", None).unwrap();
1329        let kando_dir = dir.path().join(".kando");
1330
1331        // No trash directory exists yet
1332        assert!(load_trash(&kando_dir).is_empty());
1333    }
1334
1335    #[test]
1336    fn test_trash_card_missing_source_file() {
1337        let dir = tempfile::tempdir().unwrap();
1338        init_board(dir.path(), "Test", None).unwrap();
1339        let kando_dir = dir.path().join(".kando");
1340
1341        // Try to trash a card that doesn't exist on disk
1342        let result = trash_card(&kando_dir, "backlog", "nonexistent", "Ghost");
1343        assert!(result.is_err());
1344    }
1345
1346    #[test]
1347    fn test_restore_card_missing_trash_file() {
1348        let dir = tempfile::tempdir().unwrap();
1349        init_board(dir.path(), "Test", None).unwrap();
1350        let kando_dir = dir.path().join(".kando");
1351
1352        // Create trash dir and _meta.toml with an entry but no actual file
1353        let trash = kando_dir.join("trash");
1354        fs::create_dir_all(&trash).unwrap();
1355        let meta = TrashMeta {
1356            entries: vec![TrashEntry {
1357                id: "orphan".into(),
1358                deleted: "2025-01-01T00:00:00Z".into(),
1359                from_column: "backlog".into(),
1360                title: "Orphan".into(),
1361            }],
1362        };
1363        save_trash_meta(&kando_dir, &meta).unwrap();
1364
1365        // Restore should fail because the .md file is missing
1366        let result = restore_card(&kando_dir, "orphan", "backlog");
1367        assert!(result.is_err());
1368    }
1369
1370    #[test]
1371    fn test_board_created_at_set_on_init() {
1372        let dir = tempfile::tempdir().unwrap();
1373        init_board(dir.path(), "Test", None).unwrap();
1374        let kando_dir = dir.path().join(".kando");
1375
1376        let board = load_board(&kando_dir).unwrap();
1377        assert!(board.created_at.is_some(), "init_board should set created_at");
1378        // Should be recent (within last 5 seconds)
1379        let age = (Utc::now() - board.created_at.unwrap()).num_seconds();
1380        assert!(age < 5, "created_at should be recent, but was {age}s ago");
1381    }
1382
1383    #[test]
1384    fn test_board_created_at_roundtrip() {
1385        let dir = tempfile::tempdir().unwrap();
1386        init_board(dir.path(), "Test", None).unwrap();
1387        let kando_dir = dir.path().join(".kando");
1388
1389        let board = load_board(&kando_dir).unwrap();
1390        let original_created_at = board.created_at.unwrap();
1391
1392        // Save and reload — created_at should be preserved
1393        save_board(&kando_dir, &board).unwrap();
1394        let reloaded = load_board(&kando_dir).unwrap();
1395        assert!(reloaded.created_at.is_some());
1396        let delta = (reloaded.created_at.unwrap() - original_created_at).num_seconds().abs();
1397        assert!(delta <= 1, "created_at should roundtrip within 1 second, delta was {delta}s");
1398    }
1399
1400    #[test]
1401    fn test_board_created_at_none_for_legacy_config() {
1402        // Simulate a legacy config.toml without created_at
1403        let dir = tempfile::tempdir().unwrap();
1404        init_board(dir.path(), "Test", None).unwrap();
1405        let kando_dir = dir.path().join(".kando");
1406
1407        // Read config, remove created_at, write back
1408        let config_path = kando_dir.join("config.toml");
1409        let config_str = fs::read_to_string(&config_path).unwrap();
1410        let cleaned: String = config_str.lines()
1411            .filter(|line| !line.starts_with("created_at"))
1412            .collect::<Vec<_>>()
1413            .join("\n");
1414        fs::write(&config_path, cleaned).unwrap();
1415
1416        let board = load_board(&kando_dir).unwrap();
1417        assert!(board.created_at.is_none(), "legacy board without created_at should load as None");
1418    }
1419
1420    #[test]
1421    fn test_card_completed_roundtrip() {
1422        let mut card = Card::new("100".into(), "Completed card".into());
1423        let completed_time = Utc::now();
1424        card.completed = Some(completed_time);
1425
1426        let serialized = serialize_card(&card);
1427        assert!(serialized.contains("completed = \""));
1428
1429        let (fm, _body) = parse_frontmatter(&serialized).unwrap();
1430        let deserialized: Card = toml::from_str(&fm).unwrap();
1431        assert!(deserialized.completed.is_some());
1432        // Timestamps lose sub-second precision through serialization
1433        let delta = (deserialized.completed.unwrap() - completed_time).num_seconds().abs();
1434        assert!(delta <= 1, "completed timestamp should roundtrip within 1 second");
1435    }
1436
1437    #[test]
1438    fn test_card_without_completed_deserializes_as_none() {
1439        let card = Card::new("101".into(), "Pending card".into());
1440        let serialized = serialize_card(&card);
1441        assert!(!serialized.contains("completed"));
1442
1443        let (fm, _body) = parse_frontmatter(&serialized).unwrap();
1444        let deserialized: Card = toml::from_str(&fm).unwrap();
1445        assert!(deserialized.completed.is_none());
1446    }
1447
1448    #[test]
1449    fn test_card_started_roundtrip() {
1450        let mut card = Card::new("102".into(), "Started card".into());
1451        let started_time = Utc::now();
1452        card.started = Some(started_time);
1453
1454        let serialized = serialize_card(&card);
1455        assert!(serialized.contains("started = \""));
1456
1457        let (fm, _body) = parse_frontmatter(&serialized).unwrap();
1458        let deserialized: Card = toml::from_str(&fm).unwrap();
1459        assert!(deserialized.started.is_some());
1460        let delta = (deserialized.started.unwrap() - started_time).num_seconds().abs();
1461        assert!(delta <= 1, "started timestamp should roundtrip within 1 second");
1462    }
1463
1464    #[test]
1465    fn test_card_without_started_deserializes_as_none() {
1466        let card = Card::new("103".into(), "New card".into());
1467        let serialized = serialize_card(&card);
1468        assert!(!serialized.contains("started"));
1469
1470        let (fm, _body) = parse_frontmatter(&serialized).unwrap();
1471        let deserialized: Card = toml::from_str(&fm).unwrap();
1472        assert!(deserialized.started.is_none());
1473    }
1474
1475    #[test]
1476    fn test_purge_trash_keeps_entry_with_unparseable_date() {
1477        let dir = tempfile::tempdir().unwrap();
1478        init_board(dir.path(), "Test", None).unwrap();
1479        let kando_dir = dir.path().join(".kando");
1480
1481        // Create a trash entry with a garbage date
1482        let trash = kando_dir.join("trash");
1483        fs::create_dir_all(&trash).unwrap();
1484        fs::write(trash.join("bad.md"), "---\nid = \"bad\"\ntitle = \"Bad date\"\n---\n").unwrap();
1485        let meta = TrashMeta {
1486            entries: vec![TrashEntry {
1487                id: "bad".into(),
1488                deleted: "not-a-date".into(),
1489                from_column: "backlog".into(),
1490                title: "Bad date".into(),
1491            }],
1492        };
1493        save_trash_meta(&kando_dir, &meta).unwrap();
1494
1495        // Purge should keep the entry (unparseable date ⇒ keep, not delete)
1496        let purged = purge_trash(&kando_dir, 1).unwrap();
1497        assert!(purged.is_empty());
1498        assert_eq!(load_trash(&kando_dir).len(), 1);
1499    }
1500
1501    #[test]
1502    fn test_trash_two_cards_restore_one() {
1503        let dir = tempfile::tempdir().unwrap();
1504        init_board(dir.path(), "Test", None).unwrap();
1505        let kando_dir = dir.path().join(".kando");
1506
1507        let mut board = load_board(&kando_dir).unwrap();
1508        board.columns[0].cards.push(Card::new("001".into(), "First".into()));
1509        board.columns[0].cards.push(Card::new("002".into(), "Second".into()));
1510        save_board(&kando_dir, &board).unwrap();
1511
1512        let col_slug = board.columns[0].slug.clone();
1513        trash_card(&kando_dir, &col_slug, "001", "First").unwrap();
1514        trash_card(&kando_dir, &col_slug, "002", "Second").unwrap();
1515
1516        // Both in trash
1517        assert_eq!(load_trash(&kando_dir).len(), 2);
1518
1519        // Restore only the first
1520        restore_card(&kando_dir, "001", &col_slug).unwrap();
1521
1522        // First is back, second still in trash
1523        let entries = load_trash(&kando_dir);
1524        assert_eq!(entries.len(), 1);
1525        assert_eq!(entries[0].id, "002");
1526
1527        let restored = kando_dir.join("columns").join(&col_slug).join("001.md");
1528        assert!(restored.exists());
1529        let still_trashed = kando_dir.join("trash/002.md");
1530        assert!(still_trashed.exists());
1531    }
1532
1533    // ── validate_slug tests ──
1534
1535    #[test]
1536    fn validate_slug_valid_simple() {
1537        assert!(validate_slug("backlog").is_ok());
1538    }
1539
1540    #[test]
1541    fn validate_slug_valid_with_hyphens() {
1542        assert!(validate_slug("in-progress").is_ok());
1543    }
1544
1545    #[test]
1546    fn validate_slug_valid_with_digits() {
1547        assert!(validate_slug("col1").is_ok());
1548        assert!(validate_slug("1col").is_ok());
1549    }
1550
1551    #[test]
1552    fn validate_slug_empty_returns_err() {
1553        assert!(validate_slug("").is_err());
1554    }
1555
1556    #[test]
1557    fn validate_slug_uppercase_returns_err() {
1558        assert!(validate_slug("MyColumn").is_err());
1559    }
1560
1561    #[test]
1562    fn validate_slug_spaces_returns_err() {
1563        assert!(validate_slug("my column").is_err());
1564    }
1565
1566    #[test]
1567    fn validate_slug_special_chars_returns_err() {
1568        assert!(validate_slug("col@name").is_err());
1569    }
1570
1571    #[test]
1572    fn validate_slug_leading_hyphen_returns_err() {
1573        assert!(validate_slug("-col").is_err());
1574    }
1575
1576    #[test]
1577    fn validate_slug_underscore_returns_err() {
1578        assert!(validate_slug("my_col").is_err());
1579    }
1580
1581    #[test]
1582    fn validate_slug_unicode_letters_accepted() {
1583        assert!(validate_slug("kamelåså").is_ok());
1584        assert!(validate_slug("日本語").is_ok());
1585        assert!(validate_slug("hello-世界").is_ok());
1586    }
1587
1588    #[test]
1589    fn validate_slug_unicode_uppercase_returns_err() {
1590        assert!(validate_slug("Kamelåså").is_err());
1591        assert!(validate_slug("ÅNGSTRÖM").is_err());
1592        assert!(validate_slug("naïVé").is_err());
1593    }
1594
1595    // ── parse_frontmatter edge cases ──
1596
1597    #[test]
1598    fn parse_frontmatter_empty_returns_none() {
1599        assert!(parse_frontmatter("").is_none());
1600    }
1601
1602    #[test]
1603    fn parse_frontmatter_no_closing_delimiter() {
1604        assert!(parse_frontmatter("---\nid = \"1\"\ntitle = \"X\"\n").is_none());
1605    }
1606
1607    #[test]
1608    fn parse_frontmatter_only_delimiters_returns_none() {
1609        // "---\n---\n" has no content between delimiters; parse requires "\n---" which
1610        // needs at least one line of frontmatter content.
1611        assert!(parse_frontmatter("---\n---\n").is_none());
1612    }
1613
1614    #[test]
1615    fn parse_frontmatter_minimal_content() {
1616        let result = parse_frontmatter("---\nkey = \"val\"\n---\n");
1617        assert!(result.is_some());
1618        let (fm, body) = result.unwrap();
1619        assert!(fm.contains("key"));
1620        assert!(body.is_empty());
1621    }
1622
1623    #[test]
1624    fn parse_frontmatter_no_opening_delimiter() {
1625        assert!(parse_frontmatter("id = \"1\"\n---\n").is_none());
1626    }
1627
1628    // ── serialize_card edge cases ──
1629
1630    #[test]
1631    fn serialize_card_blocked_roundtrip() {
1632        let mut card = Card::new("001".into(), "Blocked".into());
1633        card.blocked = Some(String::new());
1634        let text = serialize_card(&card);
1635        assert!(text.contains("blocked = "));
1636        // Roundtrip
1637        let (fm, _body) = parse_frontmatter(&text).unwrap();
1638        let loaded: Card = toml::from_str(&fm).unwrap();
1639        assert!(loaded.is_blocked());
1640    }
1641
1642    #[test]
1643    fn serialize_card_blocked_with_reason_roundtrip() {
1644        let mut card = Card::new("001".into(), "Blocked".into());
1645        card.blocked = Some("waiting on API".into());
1646        let text = serialize_card(&card);
1647        assert!(text.contains("blocked = \"waiting on API\""));
1648        let (fm, _body) = parse_frontmatter(&text).unwrap();
1649        let loaded: Card = toml::from_str(&fm).unwrap();
1650        assert_eq!(loaded.blocked, Some("waiting on API".to_string()));
1651    }
1652
1653    #[test]
1654    fn serialize_card_blocked_none_omits_field() {
1655        let card = Card::new("001".into(), "Normal card".into());
1656        let text = serialize_card(&card);
1657        assert!(!text.contains("blocked ="));
1658    }
1659
1660    #[test]
1661    fn serialize_card_with_assignees_roundtrip() {
1662        let mut card = Card::new("001".into(), "Team".into());
1663        card.assignees = vec!["alice".into(), "bob".into()];
1664        let text = serialize_card(&card);
1665        assert!(text.contains("assignees = "));
1666        let (fm, _body) = parse_frontmatter(&text).unwrap();
1667        let loaded: Card = toml::from_str(&fm).unwrap();
1668        assert_eq!(loaded.assignees, vec!["alice", "bob"]);
1669    }
1670
1671    #[test]
1672    fn serialize_card_with_started_completed_roundtrip() {
1673        use chrono::TimeZone;
1674        let mut card = Card::new("001".into(), "Done".into());
1675        card.started = Some(Utc.with_ymd_and_hms(2025, 6, 1, 10, 0, 0).unwrap());
1676        card.completed = Some(Utc.with_ymd_and_hms(2025, 6, 10, 10, 0, 0).unwrap());
1677        let text = serialize_card(&card);
1678        assert!(text.contains("started = "));
1679        assert!(text.contains("completed = "));
1680        let (fm, _body) = parse_frontmatter(&text).unwrap();
1681        let loaded: Card = toml::from_str(&fm).unwrap();
1682        assert!(loaded.started.is_some());
1683        assert!(loaded.completed.is_some());
1684    }
1685
1686    #[test]
1687    fn serialize_card_empty_body_no_trailing_content() {
1688        let mut card = Card::new("001".into(), "No body".into());
1689        card.body = String::new();
1690        let text = serialize_card(&card);
1691        // Should end right after the closing ---
1692        assert!(text.ends_with("---\n"));
1693    }
1694
1695    #[test]
1696    fn serialize_card_body_gets_trailing_newline() {
1697        let mut card = Card::new("001".into(), "Has body".into());
1698        card.body = "Some text".into();
1699        let text = serialize_card(&card);
1700        assert!(text.ends_with("Some text\n"));
1701    }
1702
1703    #[test]
1704    fn serialize_card_all_fields_roundtrip() {
1705        use chrono::TimeZone;
1706        let mut card = Card::new("999".into(), "Full card".into());
1707        card.priority = Priority::Urgent;
1708        card.tags = vec!["bug".into(), "critical".into()];
1709        card.assignees = vec!["alice".into()];
1710        card.blocked = Some(String::new());
1711        card.started = Some(Utc.with_ymd_and_hms(2025, 5, 1, 0, 0, 0).unwrap());
1712        card.completed = Some(Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap());
1713        card.body = "Full description.\n\nMultiple paragraphs.".into();
1714
1715        let text = serialize_card(&card);
1716        let (fm, body) = parse_frontmatter(&text).unwrap();
1717        let loaded: Card = toml::from_str(&fm).unwrap();
1718        assert_eq!(loaded.id, "999");
1719        assert_eq!(loaded.title, "Full card");
1720        assert_eq!(loaded.priority, Priority::Urgent);
1721        assert_eq!(loaded.tags, vec!["bug", "critical"]);
1722        assert_eq!(loaded.assignees, vec!["alice"]);
1723        assert!(loaded.is_blocked());
1724        assert!(loaded.started.is_some());
1725        assert!(loaded.completed.is_some());
1726        assert_eq!(body, card.body);
1727    }
1728
1729    #[test]
1730    fn serialize_card_due_date_roundtrip() {
1731        let mut card = Card::new("1".into(), "Due test".into());
1732        card.due = Some(chrono::NaiveDate::from_ymd_opt(2025, 12, 31).unwrap());
1733
1734        let text = serialize_card(&card);
1735        let (fm, _) = parse_frontmatter(&text).unwrap();
1736        let loaded: Card = toml::from_str(&fm).unwrap();
1737        assert_eq!(loaded.due, Some(chrono::NaiveDate::from_ymd_opt(2025, 12, 31).unwrap()));
1738    }
1739
1740    #[test]
1741    fn serialize_card_no_due_date_omitted() {
1742        let card = Card::new("1".into(), "No due".into());
1743        let text = serialize_card(&card);
1744        // Verify structurally
1745        let (fm, _) = parse_frontmatter(&text).unwrap();
1746        let loaded: Card = toml::from_str(&fm).unwrap();
1747        assert!(loaded.due.is_none());
1748        // Also verify field is literally absent from serialized text
1749        assert!(!text.contains("due ="), "due field should be omitted when None");
1750    }
1751
1752    #[test]
1753    fn serialize_card_due_date_format_is_yyyy_mm_dd() {
1754        let mut card = Card::new("1".into(), "Format test".into());
1755        card.due = Some(chrono::NaiveDate::from_ymd_opt(2025, 1, 15).unwrap());
1756        let text = serialize_card(&card);
1757        assert!(text.contains(r#"due = "2025-01-15""#));
1758    }
1759
1760    // -----------------------------------------------------------------------
1761    // LocalConfig / local.toml
1762    // -----------------------------------------------------------------------
1763
1764    #[test]
1765    fn load_local_config_absent_file_returns_default() {
1766        let dir = tempfile::tempdir().unwrap();
1767        init_board(dir.path(), "Test", None).unwrap();
1768        let kando_dir = dir.path().join(".kando");
1769        let _cfg = load_local_config(&kando_dir).unwrap();
1770    }
1771
1772    #[test]
1773    fn load_local_config_invalid_toml_returns_err() {
1774        let dir = tempfile::tempdir().unwrap();
1775        init_board(dir.path(), "Test", None).unwrap();
1776        let kando_dir = dir.path().join(".kando");
1777        fs::write(kando_dir.join("local.toml"), "bad = !!!\n").unwrap();
1778        assert!(load_local_config(&kando_dir).is_err());
1779    }
1780
1781    #[test]
1782    fn load_local_config_empty_file_uses_serde_default() {
1783        let dir = tempfile::tempdir().unwrap();
1784        init_board(dir.path(), "Test", None).unwrap();
1785        let kando_dir = dir.path().join(".kando");
1786        fs::write(kando_dir.join("local.toml"), "").unwrap();
1787        let _cfg = load_local_config(&kando_dir).unwrap();
1788    }
1789
1790    #[test]
1791    fn load_local_config_legacy_focus_mode_field_is_ignored() {
1792        let dir = tempfile::tempdir().unwrap();
1793        init_board(dir.path(), "Test", None).unwrap();
1794        let kando_dir = dir.path().join(".kando");
1795        fs::write(kando_dir.join("local.toml"), "focus_mode = true\n").unwrap();
1796        let _cfg = load_local_config(&kando_dir).unwrap();
1797    }
1798
1799    #[test]
1800    fn load_local_config_extra_fields_are_ignored() {
1801        let dir = tempfile::tempdir().unwrap();
1802        init_board(dir.path(), "Test", None).unwrap();
1803        let kando_dir = dir.path().join(".kando");
1804        fs::write(kando_dir.join("local.toml"), "unknown_future_key = 42\n").unwrap();
1805        let _cfg = load_local_config(&kando_dir).unwrap();
1806    }
1807
1808    #[test]
1809    fn load_local_config_nonexistent_kando_dir_returns_default() {
1810        let dir = tempfile::tempdir().unwrap();
1811        let kando_dir = dir.path().join(".kando"); // never created
1812        let _cfg = load_local_config(&kando_dir).unwrap();
1813    }
1814
1815    #[test]
1816    fn save_local_config_creates_file() {
1817        let dir = tempfile::tempdir().unwrap();
1818        init_board(dir.path(), "Test", None).unwrap();
1819        let kando_dir = dir.path().join(".kando");
1820        let cfg = crate::config::LocalConfig::default();
1821        save_local_config(&kando_dir, &cfg).unwrap();
1822        assert!(kando_dir.join("local.toml").exists());
1823    }
1824
1825    #[test]
1826    fn save_local_config_roundtrip() {
1827        let dir = tempfile::tempdir().unwrap();
1828        init_board(dir.path(), "Test", None).unwrap();
1829        let kando_dir = dir.path().join(".kando");
1830        let original = crate::config::LocalConfig::default();
1831        save_local_config(&kando_dir, &original).unwrap();
1832        let _loaded = load_local_config(&kando_dir).unwrap();
1833    }
1834
1835    #[test]
1836    fn save_local_config_creates_gitignore_with_entry() {
1837        let dir = tempfile::tempdir().unwrap();
1838        init_board(dir.path(), "Test", None).unwrap();
1839        let kando_dir = dir.path().join(".kando");
1840        save_local_config(&kando_dir, &crate::config::LocalConfig::default()).unwrap();
1841        let gitignore = fs::read_to_string(kando_dir.join(".gitignore")).unwrap();
1842        assert!(gitignore.lines().any(|l| l.trim() == "local.toml"));
1843    }
1844
1845    #[test]
1846    fn save_local_config_gitignore_entry_not_duplicated() {
1847        let dir = tempfile::tempdir().unwrap();
1848        init_board(dir.path(), "Test", None).unwrap();
1849        let kando_dir = dir.path().join(".kando");
1850        let cfg = crate::config::LocalConfig::default();
1851        save_local_config(&kando_dir, &cfg).unwrap();
1852        save_local_config(&kando_dir, &cfg).unwrap();
1853        let gitignore = fs::read_to_string(kando_dir.join(".gitignore")).unwrap();
1854        let count = gitignore.lines().filter(|l| l.trim() == "local.toml").count();
1855        assert_eq!(count, 1);
1856    }
1857
1858    #[test]
1859    fn save_local_config_gitignore_entry_appended_to_existing_content() {
1860        let dir = tempfile::tempdir().unwrap();
1861        init_board(dir.path(), "Test", None).unwrap();
1862        let kando_dir = dir.path().join(".kando");
1863        fs::write(kando_dir.join(".gitignore"), "*.log\n").unwrap();
1864        save_local_config(&kando_dir, &crate::config::LocalConfig::default()).unwrap();
1865        let gitignore = fs::read_to_string(kando_dir.join(".gitignore")).unwrap();
1866        assert_eq!(gitignore, "*.log\nlocal.toml\n");
1867    }
1868
1869    #[test]
1870    fn save_local_config_gitignore_separator_added_when_no_trailing_newline() {
1871        let dir = tempfile::tempdir().unwrap();
1872        init_board(dir.path(), "Test", None).unwrap();
1873        let kando_dir = dir.path().join(".kando");
1874        // Write without trailing newline
1875        fs::write(kando_dir.join(".gitignore"), "*.log").unwrap();
1876        save_local_config(&kando_dir, &crate::config::LocalConfig::default()).unwrap();
1877        let gitignore = fs::read_to_string(kando_dir.join(".gitignore")).unwrap();
1878        assert_eq!(gitignore, "*.log\nlocal.toml\n");
1879    }
1880
1881    // ── init_board local.toml tests ──
1882
1883    #[test]
1884    fn test_init_board_creates_local_toml() {
1885        let dir = tempfile::tempdir().unwrap();
1886        init_board(dir.path(), "Test", None).unwrap();
1887        let kando_dir = dir.path().join(".kando");
1888        assert!(kando_dir.join("local.toml").exists());
1889    }
1890
1891    #[test]
1892    fn test_init_board_local_toml_has_default_help_hint_shown() {
1893        let dir = tempfile::tempdir().unwrap();
1894        init_board(dir.path(), "Test", None).unwrap();
1895        let kando_dir = dir.path().join(".kando");
1896        let cfg = load_local_config(&kando_dir).unwrap();
1897        assert!(!cfg.help_hint_shown);
1898    }
1899
1900    #[test]
1901    fn test_init_board_creates_gitignore_with_local_toml() {
1902        let dir = tempfile::tempdir().unwrap();
1903        init_board(dir.path(), "Test", None).unwrap();
1904        let kando_dir = dir.path().join(".kando");
1905        let gitignore = fs::read_to_string(kando_dir.join(".gitignore")).unwrap();
1906        assert!(gitignore.lines().any(|l| l.trim() == "local.toml"));
1907    }
1908
1909    #[test]
1910    fn test_load_board_ignores_legacy_tutorial_shown_in_config() {
1911        let dir = tempfile::tempdir().unwrap();
1912        init_board(dir.path(), "Test", None).unwrap();
1913        let kando_dir = dir.path().join(".kando");
1914
1915        // Inject legacy tutorial_shown into config.toml's [board] section
1916        let config_path = kando_dir.join("config.toml");
1917        let config_str = fs::read_to_string(&config_path).unwrap();
1918        let patched = config_str.replace(
1919            "[board]",
1920            "[board]\ntutorial_shown = true",
1921        );
1922        fs::write(&config_path, patched).unwrap();
1923
1924        // load_board must not error on the unknown field
1925        let board = load_board(&kando_dir).unwrap();
1926        assert_eq!(board.name, "Test");
1927    }
1928
1929    #[test]
1930    fn save_local_config_help_hint_shown_true_roundtrip() {
1931        let dir = tempfile::tempdir().unwrap();
1932        init_board(dir.path(), "Test", None).unwrap();
1933        let kando_dir = dir.path().join(".kando");
1934        let cfg = crate::config::LocalConfig { help_hint_shown: true };
1935        save_local_config(&kando_dir, &cfg).unwrap();
1936        let loaded = load_local_config(&kando_dir).unwrap();
1937        assert!(loaded.help_hint_shown);
1938    }
1939
1940    #[test]
1941    fn load_local_config_legacy_tutorial_shown_on_disk() {
1942        let dir = tempfile::tempdir().unwrap();
1943        init_board(dir.path(), "Test", None).unwrap();
1944        let kando_dir = dir.path().join(".kando");
1945        fs::write(kando_dir.join("local.toml"), "tutorial_shown = true\n").unwrap();
1946        let cfg = load_local_config(&kando_dir).unwrap();
1947        assert!(cfg.help_hint_shown);
1948    }
1949
1950    // ── json_escape tests ──
1951
1952    #[test]
1953    fn json_escape_empty_string() {
1954        assert_eq!(super::json_escape(""), "\"\"");
1955    }
1956
1957    #[test]
1958    fn json_escape_plain_ascii() {
1959        assert_eq!(super::json_escape("hello"), "\"hello\"");
1960    }
1961
1962    #[test]
1963    fn json_escape_double_quote() {
1964        assert_eq!(super::json_escape("say \"hi\""), "\"say \\\"hi\\\"\"");
1965    }
1966
1967    #[test]
1968    fn json_escape_backslash() {
1969        // One backslash → JSON-escaped as two backslashes, wrapped in quotes
1970        assert_eq!(super::json_escape("\\"), "\"\\\\\"");
1971    }
1972
1973    #[test]
1974    fn json_escape_newline_cr_tab() {
1975        assert_eq!(super::json_escape("a\nb\rc\td"), "\"a\\nb\\rc\\td\"");
1976    }
1977
1978    #[test]
1979    fn json_escape_control_char_x01() {
1980        assert_eq!(super::json_escape("\x01"), "\"\\u0001\"");
1981    }
1982
1983    #[test]
1984    fn json_escape_nul_byte() {
1985        assert_eq!(super::json_escape("\x00"), "\"\\u0000\"");
1986    }
1987
1988    #[test]
1989    fn json_escape_non_ascii_unicode_passthrough() {
1990        assert_eq!(super::json_escape("héllo"), "\"héllo\"");
1991        assert_eq!(super::json_escape("日本語"), "\"日本語\"");
1992        assert_eq!(super::json_escape("🎉"), "\"🎉\"");
1993    }
1994
1995    #[test]
1996    fn json_escape_mixed_special_chars() {
1997        // tab + double-quote + backslash + newline
1998        assert_eq!(
1999            super::json_escape("\t\"a\\b\"\n"),
2000            "\"\\t\\\"a\\\\b\\\"\\n\""
2001        );
2002    }
2003
2004    // ── append_activity / try_append_activity tests ──
2005
2006    #[test]
2007    fn append_activity_creates_log_file() {
2008        let dir = tempfile::tempdir().unwrap();
2009        let kando_dir = dir.path().join(".kando");
2010        fs::create_dir(&kando_dir).unwrap();
2011
2012        append_activity(&kando_dir, "create", "42", "My Task", &[]);
2013
2014        assert!(kando_dir.join("activity.log").exists());
2015    }
2016
2017    #[test]
2018    fn append_activity_line_contains_required_fields() {
2019        let dir = tempfile::tempdir().unwrap();
2020        let kando_dir = dir.path().join(".kando");
2021        fs::create_dir(&kando_dir).unwrap();
2022
2023        append_activity(&kando_dir, "create", "42", "My Task", &[]);
2024
2025        let content = fs::read_to_string(kando_dir.join("activity.log")).unwrap();
2026        let line = content.trim();
2027        assert!(line.starts_with('{'), "line should be a JSON object");
2028        assert!(line.ends_with('}'), "line should be a JSON object");
2029        assert!(line.contains("\"action\":\"create\""), "missing action");
2030        assert!(line.contains("\"id\":\"42\""), "missing id");
2031        assert!(line.contains("\"title\":\"My Task\""), "missing title");
2032        assert!(line.contains("\"ts\":\""), "missing ts");
2033    }
2034
2035    #[test]
2036    fn append_activity_second_call_appends_new_line() {
2037        let dir = tempfile::tempdir().unwrap();
2038        let kando_dir = dir.path().join(".kando");
2039        fs::create_dir(&kando_dir).unwrap();
2040
2041        append_activity(&kando_dir, "create", "1", "First", &[]);
2042        append_activity(&kando_dir, "delete", "2", "Second", &[]);
2043
2044        let content = fs::read_to_string(kando_dir.join("activity.log")).unwrap();
2045        let lines: Vec<&str> = content.lines().collect();
2046        assert_eq!(lines.len(), 2);
2047        assert!(lines[0].contains("\"action\":\"create\""));
2048        assert!(lines[1].contains("\"action\":\"delete\""));
2049    }
2050
2051    #[test]
2052    fn append_activity_extras_appear_in_output() {
2053        let dir = tempfile::tempdir().unwrap();
2054        let kando_dir = dir.path().join(".kando");
2055        fs::create_dir(&kando_dir).unwrap();
2056
2057        append_activity(&kando_dir, "move", "1", "Task", &[("from", "Backlog"), ("to", "Done")]);
2058
2059        let content = fs::read_to_string(kando_dir.join("activity.log")).unwrap();
2060        assert!(content.contains("\"from\":\"Backlog\""));
2061        assert!(content.contains("\"to\":\"Done\""));
2062    }
2063
2064    #[test]
2065    fn append_activity_special_chars_in_title_escaped() {
2066        let dir = tempfile::tempdir().unwrap();
2067        let kando_dir = dir.path().join(".kando");
2068        fs::create_dir(&kando_dir).unwrap();
2069
2070        append_activity(&kando_dir, "create", "1", "Task \"with\" quotes", &[]);
2071
2072        let content = fs::read_to_string(kando_dir.join("activity.log")).unwrap();
2073        assert!(content.contains("\"title\":\"Task \\\"with\\\" quotes\""));
2074    }
2075
2076    #[test]
2077    fn append_activity_io_error_is_silently_swallowed() {
2078        // Non-existent parent directory — should not panic
2079        let dir = tempfile::tempdir().unwrap();
2080        let nonexistent = dir.path().join("no-such-dir").join(".kando");
2081        append_activity(&nonexistent, "create", "1", "Task", &[]);
2082        // If we reach here without panicking the test passes
2083    }
2084
2085    #[test]
2086    fn append_activity_empty_extras_produces_four_keys() {
2087        let dir = tempfile::tempdir().unwrap();
2088        let kando_dir = dir.path().join(".kando");
2089        fs::create_dir(&kando_dir).unwrap();
2090
2091        append_activity(&kando_dir, "action", "id-val", "title-val", &[]);
2092
2093        let content = fs::read_to_string(kando_dir.join("activity.log")).unwrap();
2094        let line = content.trim();
2095        let key_count = ["\"ts\":", "\"action\":", "\"id\":", "\"title\":"]
2096            .iter()
2097            .filter(|&&k| line.contains(k))
2098            .count();
2099        assert_eq!(key_count, 4, "all four required fields (ts, action, id, title) should be present");
2100    }
2101
2102    #[test]
2103    fn append_activity_timestamp_is_iso8601() {
2104        let dir = tempfile::tempdir().unwrap();
2105        let kando_dir = dir.path().join(".kando");
2106        fs::create_dir(&kando_dir).unwrap();
2107
2108        append_activity(&kando_dir, "test", "1", "T", &[]);
2109
2110        let content = fs::read_to_string(kando_dir.join("activity.log")).unwrap();
2111        // Extract the value of "ts":"..."
2112        let prefix = "\"ts\":\"";
2113        let ts_start = content.find(prefix).unwrap() + prefix.len();
2114        let ts_end = ts_start + content[ts_start..].find('"').unwrap();
2115        let ts = &content[ts_start..ts_end];
2116        assert_eq!(ts.len(), 20, "timestamp should be YYYY-MM-DDTHH:MM:SSZ");
2117        assert!(ts.contains('T'), "timestamp should contain T separator");
2118        assert!(ts.ends_with('Z'), "timestamp should end with Z");
2119    }
2120
2121    // ── append_activity integration: one entry per auto-closed card ──
2122
2123    #[test]
2124    fn append_activity_auto_close_entries_per_card() {
2125        let dir = tempfile::tempdir().unwrap();
2126        let kando_dir = dir.path().join(".kando");
2127        fs::create_dir(&kando_dir).unwrap();
2128
2129        // Simulate what handle_auto_close does: one append_activity per closed card
2130        let cards = [("1", "Alpha", "backlog"), ("2", "Beta", "in-progress")];
2131        for (id, title, from) in &cards {
2132            append_activity(&kando_dir, "auto-close", id, title, &[("from", from)]);
2133        }
2134
2135        let content = fs::read_to_string(kando_dir.join("activity.log")).unwrap();
2136        let lines: Vec<&str> = content.lines().collect();
2137        assert_eq!(lines.len(), 2, "one log entry per auto-closed card");
2138        assert!(lines[0].contains("\"action\":\"auto-close\""));
2139        assert!(lines[0].contains("\"id\":\"1\""));
2140        assert!(lines[0].contains("\"from\":\"backlog\""));
2141        assert!(lines[1].contains("\"action\":\"auto-close\""));
2142        assert!(lines[1].contains("\"id\":\"2\""));
2143        assert!(lines[1].contains("\"from\":\"in-progress\""));
2144    }
2145
2146    // ── rename_column_dir ──
2147
2148    #[test]
2149    fn rename_column_dir_happy_path() {
2150        let dir = tempfile::tempdir().unwrap();
2151        init_board(dir.path(), "Test", None).unwrap();
2152        let kando_dir = dir.path().join(".kando");
2153
2154        // Write a card into backlog so we can verify it moves.
2155        let card_path = kando_dir.join("columns/backlog/001.md");
2156        fs::write(&card_path, "---\nid = \"001\"\ntitle = \"Card\"\n---\n").unwrap();
2157
2158        rename_column_dir(&kando_dir, "backlog", "queue").unwrap();
2159
2160        assert!(!kando_dir.join("columns/backlog").exists(), "old dir should be gone");
2161        assert!(kando_dir.join("columns/queue").exists(), "new dir should exist");
2162        assert!(kando_dir.join("columns/queue/001.md").exists(), "card should have moved");
2163    }
2164
2165    #[test]
2166    fn rename_column_dir_old_does_not_exist_is_noop() {
2167        let dir = tempfile::tempdir().unwrap();
2168        init_board(dir.path(), "Test", None).unwrap();
2169        let kando_dir = dir.path().join(".kando");
2170
2171        // "nonexistent-col" directory was never created.
2172        let result = rename_column_dir(&kando_dir, "nonexistent-col", "new-col");
2173        assert!(result.is_ok(), "missing old dir should be a no-op, got: {result:?}");
2174        assert!(!kando_dir.join("columns/new-col").exists());
2175    }
2176
2177    #[test]
2178    fn rename_column_dir_new_already_exists_returns_err() {
2179        let dir = tempfile::tempdir().unwrap();
2180        init_board(dir.path(), "Test", None).unwrap();
2181        let kando_dir = dir.path().join(".kando");
2182
2183        // Both "backlog" and "done" exist after init_board.
2184        let result = rename_column_dir(&kando_dir, "backlog", "done");
2185        assert!(result.is_err(), "should fail when new dir already exists");
2186        // Old dir must be untouched.
2187        assert!(kando_dir.join("columns/backlog").exists());
2188        assert!(kando_dir.join("columns/done").exists());
2189    }
2190
2191    #[test]
2192    fn rename_column_dir_same_slug_is_noop() {
2193        // When old_slug == new_slug the function short-circuits before any I/O.
2194        let dir = tempfile::tempdir().unwrap();
2195        init_board(dir.path(), "Test", None).unwrap();
2196        let kando_dir = dir.path().join(".kando");
2197        let result = rename_column_dir(&kando_dir, "backlog", "backlog");
2198        assert!(result.is_ok(), "same-slug rename should be a no-op, got: {result:?}");
2199        assert!(kando_dir.join("columns/backlog").exists(), "directory must remain intact");
2200    }
2201
2202    #[test]
2203    fn rename_column_dir_invalid_slug_rejected_before_io() {
2204        let dir = tempfile::tempdir().unwrap();
2205        init_board(dir.path(), "Test", None).unwrap();
2206        let kando_dir = dir.path().join(".kando");
2207
2208        // "INVALID!" contains upper-case and a special char — validate_slug should reject it.
2209        let result = rename_column_dir(&kando_dir, "INVALID!", "valid");
2210        assert!(result.is_err(), "invalid old slug should be rejected");
2211
2212        let result2 = rename_column_dir(&kando_dir, "valid", "INVALID!");
2213        assert!(result2.is_err(), "invalid new slug should be rejected");
2214    }
2215
2216    // ── ColumnConfig: name not written to disk ──
2217
2218    #[test]
2219    fn column_config_name_not_written_to_config() {
2220        let dir = tempfile::tempdir().unwrap();
2221        init_board(dir.path(), "Test", None).unwrap();
2222        let kando_dir = dir.path().join(".kando");
2223
2224        let board = load_board(&kando_dir).unwrap();
2225        save_board(&kando_dir, &board).unwrap();
2226
2227        let config_content = fs::read_to_string(kando_dir.join("config.toml")).unwrap();
2228        // The `name` field on ColumnConfig has skip_serializing, so it should
2229        // not appear inside [[columns]] sections. (It *does* appear as
2230        // `name = "Test"` in [board], so we search specifically in column blocks.)
2231        for section in config_content.split("[[columns]]").skip(1) {
2232            assert!(
2233                !section.contains("name ="),
2234                "`name` field should not be written to [[columns]], got:\n{section}"
2235            );
2236        }
2237    }
2238
2239    #[test]
2240    fn init_board_does_not_create_meta_toml() {
2241        let dir = tempfile::tempdir().unwrap();
2242        init_board(dir.path(), "Test", None).unwrap();
2243        let kando_dir = dir.path().join(".kando");
2244
2245        for slug in &["backlog", "in-progress", "done", "archive"] {
2246            let meta_path = kando_dir.join("columns").join(slug).join("_meta.toml");
2247            assert!(!meta_path.exists(), "_meta.toml should not be created in '{slug}'");
2248        }
2249    }
2250
2251    #[test]
2252    fn save_board_does_not_create_meta_toml() {
2253        let dir = tempfile::tempdir().unwrap();
2254        init_board(dir.path(), "Test", None).unwrap();
2255        let kando_dir = dir.path().join(".kando");
2256
2257        let mut board = load_board(&kando_dir).unwrap();
2258        let id = board.next_card_id();
2259        board.columns[0].cards.push(Card::new(id, "Task".into()));
2260        save_board(&kando_dir, &board).unwrap();
2261
2262        for col in &board.columns {
2263            let meta_path = kando_dir.join("columns").join(&col.slug).join("_meta.toml");
2264            assert!(!meta_path.exists(), "_meta.toml should not exist in '{}'", col.slug);
2265        }
2266    }
2267
2268    #[test]
2269    fn load_board_ignores_leftover_meta_toml() {
2270        let dir = tempfile::tempdir().unwrap();
2271        init_board(dir.path(), "Test", None).unwrap();
2272        let kando_dir = dir.path().join(".kando");
2273
2274        // Simulate legacy _meta.toml files left on disk
2275        for slug in &["backlog", "in-progress", "done", "archive"] {
2276            let meta_path = kando_dir.join("columns").join(slug).join("_meta.toml");
2277            fs::write(&meta_path, format!("slug = \"{slug}\"\norder = 0\n")).unwrap();
2278        }
2279
2280        let board = load_board(&kando_dir).unwrap();
2281        assert_eq!(board.columns.len(), 4);
2282        for col in &board.columns {
2283            assert!(col.cards.is_empty(), "{} should have no cards from _meta.toml", col.slug);
2284        }
2285    }
2286
2287    #[test]
2288    fn round_trip_column_metadata_preserved() {
2289        let dir = tempfile::tempdir().unwrap();
2290        init_board(dir.path(), "Test", None).unwrap();
2291        let kando_dir = dir.path().join(".kando");
2292
2293        let mut board = load_board(&kando_dir).unwrap();
2294        board.columns[0].wip_limit = Some(5);
2295        board.columns[1].wip_limit = None;
2296        board.columns[2].hidden = true;
2297        board.columns[3].hidden = false;
2298        save_board(&kando_dir, &board).unwrap();
2299
2300        let reloaded = load_board(&kando_dir).unwrap();
2301        assert_eq!(reloaded.columns[0].wip_limit, Some(5));
2302        assert_eq!(reloaded.columns[1].wip_limit, None);
2303        assert_eq!(reloaded.columns[2].hidden, true);
2304        assert_eq!(reloaded.columns[3].hidden, false);
2305        assert_eq!(reloaded.columns[0].name, "Backlog");
2306        assert_eq!(reloaded.columns[1].name, "In Progress");
2307    }
2308
2309    #[test]
2310    fn load_board_column_names_match_slugs() {
2311        let dir = tempfile::tempdir().unwrap();
2312        init_board(dir.path(), "Test", None).unwrap();
2313        let kando_dir = dir.path().join(".kando");
2314
2315        let board = load_board(&kando_dir).unwrap();
2316        // Verify all four default columns have names derived from their slugs.
2317        let expected = [
2318            ("backlog", "Backlog"),
2319            ("in-progress", "In Progress"),
2320            ("done", "Done"),
2321            ("archive", "Archive"),
2322        ];
2323        for (slug, expected_name) in &expected {
2324            let col = board.columns.iter().find(|c| c.slug == *slug).unwrap();
2325            assert_eq!(&col.name, expected_name, "slug={slug}");
2326        }
2327    }
2328
2329    // ── Template storage tests ──
2330
2331    fn make_test_template() -> Template {
2332        Template {
2333            priority: Priority::Normal,
2334            tags: Vec::new(),
2335            assignees: Vec::new(),
2336            blocked: None,
2337            due_offset_days: None,
2338            body: String::new(),
2339        }
2340    }
2341
2342    #[test]
2343    fn template_serialize_roundtrip() {
2344        let tmpl = Template {
2345            priority: Priority::High,
2346            tags: vec!["bug".into(), "critical".into()],
2347            assignees: vec!["alice".into()],
2348            blocked: Some(String::new()),
2349            due_offset_days: Some(7),
2350            body: "## Steps\n\n1. Do X\n2. Do Y".to_string(),
2351        };
2352        let text = serialize_template(&tmpl);
2353        let dir = tempfile::tempdir().unwrap();
2354        let path = dir.path().join("test.md");
2355        fs::write(&path, &text).unwrap();
2356        let loaded = load_template(&path).unwrap();
2357        assert_eq!(loaded.priority, Priority::High);
2358        assert_eq!(loaded.tags, vec!["bug", "critical"]);
2359        assert_eq!(loaded.assignees, vec!["alice"]);
2360        assert!(loaded.blocked.is_some());
2361        assert_eq!(loaded.due_offset_days, Some(7));
2362        assert_eq!(loaded.body, "## Steps\n\n1. Do X\n2. Do Y");
2363    }
2364
2365    #[test]
2366    fn template_serialize_roundtrip_minimal() {
2367        let tmpl = make_test_template();
2368        let text = serialize_template(&tmpl);
2369        let dir = tempfile::tempdir().unwrap();
2370        let path = dir.path().join("test.md");
2371        fs::write(&path, &text).unwrap();
2372        let loaded = load_template(&path).unwrap();
2373        assert_eq!(loaded.priority, Priority::Normal);
2374        assert!(loaded.tags.is_empty());
2375        assert!(loaded.assignees.is_empty());
2376        assert!(loaded.blocked.is_none());
2377        assert!(loaded.due_offset_days.is_none());
2378        assert!(loaded.body.is_empty());
2379    }
2380
2381    #[test]
2382    fn template_blocked_with_reason_roundtrip() {
2383        let tmpl = Template {
2384            priority: Priority::Normal,
2385            tags: Vec::new(),
2386            assignees: Vec::new(),
2387            blocked: Some("needs design review".into()),
2388            due_offset_days: None,
2389            body: String::new(),
2390        };
2391        let text = serialize_template(&tmpl);
2392        assert!(text.contains("blocked = \"needs design review\""));
2393        let dir = tempfile::tempdir().unwrap();
2394        let path = dir.path().join("test.md");
2395        fs::write(&path, &text).unwrap();
2396        let loaded = load_template(&path).unwrap();
2397        assert_eq!(loaded.blocked, Some("needs design review".to_string()));
2398    }
2399
2400    #[test]
2401    fn template_serialize_empty_body() {
2402        let tmpl = make_test_template();
2403        let text = serialize_template(&tmpl);
2404        assert!(text.ends_with("---\n"));
2405        // Nothing follows the closing delimiter
2406        let after_close = text.rsplit_once("---\n").unwrap().1;
2407        assert!(after_close.is_empty());
2408    }
2409
2410    #[test]
2411    fn save_template_creates_templates_dir() {
2412        let dir = tempfile::tempdir().unwrap();
2413        init_board(dir.path(), "Test", None).unwrap();
2414        let kando_dir = dir.path().join(".kando");
2415        let tmpl = make_test_template();
2416        save_template(&kando_dir, "bug", &tmpl).unwrap();
2417        assert!(kando_dir.join("templates").join("bug.md").exists());
2418    }
2419
2420    #[test]
2421    fn save_and_load_template() {
2422        let dir = tempfile::tempdir().unwrap();
2423        init_board(dir.path(), "Test", None).unwrap();
2424        let kando_dir = dir.path().join(".kando");
2425        let tmpl = Template {
2426            priority: Priority::Low,
2427            tags: vec!["feat".into()],
2428            assignees: vec!["bob".into()],
2429            blocked: None,
2430            due_offset_days: Some(14),
2431            body: "Description here".to_string(),
2432        };
2433        let path = save_template(&kando_dir, "feature", &tmpl).unwrap();
2434        let loaded = load_template(&path).unwrap();
2435        assert_eq!(loaded.priority, Priority::Low);
2436        assert_eq!(loaded.tags, vec!["feat"]);
2437        assert_eq!(loaded.assignees, vec!["bob"]);
2438        assert_eq!(loaded.due_offset_days, Some(14));
2439        assert_eq!(loaded.body, "Description here");
2440    }
2441
2442    #[test]
2443    fn delete_template_removes_file() {
2444        let dir = tempfile::tempdir().unwrap();
2445        init_board(dir.path(), "Test", None).unwrap();
2446        let kando_dir = dir.path().join(".kando");
2447        let tmpl = make_test_template();
2448        save_template(&kando_dir, "bug", &tmpl).unwrap();
2449        assert!(kando_dir.join("templates").join("bug.md").exists());
2450        delete_template(&kando_dir, "bug").unwrap();
2451        assert!(!kando_dir.join("templates").join("bug.md").exists());
2452    }
2453
2454    #[test]
2455    fn delete_template_nonexistent_is_ok() {
2456        let dir = tempfile::tempdir().unwrap();
2457        init_board(dir.path(), "Test", None).unwrap();
2458        let kando_dir = dir.path().join(".kando");
2459        assert!(delete_template(&kando_dir, "nonexistent").is_ok());
2460    }
2461
2462    #[test]
2463    fn delete_template_invalid_slug_returns_error() {
2464        let dir = tempfile::tempdir().unwrap();
2465        init_board(dir.path(), "Test", None).unwrap();
2466        let kando_dir = dir.path().join(".kando");
2467        assert!(delete_template(&kando_dir, "UPPER").is_err());
2468    }
2469
2470    #[test]
2471    fn save_template_invalid_slug_returns_error() {
2472        let dir = tempfile::tempdir().unwrap();
2473        init_board(dir.path(), "Test", None).unwrap();
2474        let kando_dir = dir.path().join(".kando");
2475        let tmpl = make_test_template();
2476        assert!(save_template(&kando_dir, "has spaces", &tmpl).is_err());
2477    }
2478
2479    #[test]
2480    fn load_templates_empty_dir() {
2481        let dir = tempfile::tempdir().unwrap();
2482        init_board(dir.path(), "Test", None).unwrap();
2483        let kando_dir = dir.path().join(".kando");
2484        fs::create_dir_all(kando_dir.join("templates")).unwrap();
2485        let templates = load_templates(&kando_dir);
2486        assert!(templates.is_empty());
2487    }
2488
2489    #[test]
2490    fn load_templates_no_templates_dir() {
2491        let dir = tempfile::tempdir().unwrap();
2492        init_board(dir.path(), "Test", None).unwrap();
2493        let kando_dir = dir.path().join(".kando");
2494        let templates = load_templates(&kando_dir);
2495        assert!(templates.is_empty());
2496    }
2497
2498    #[test]
2499    fn load_templates_sorted_by_slug() {
2500        let dir = tempfile::tempdir().unwrap();
2501        init_board(dir.path(), "Test", None).unwrap();
2502        let kando_dir = dir.path().join(".kando");
2503        save_template(&kando_dir, "zebra", &make_test_template()).unwrap();
2504        save_template(&kando_dir, "alpha", &make_test_template()).unwrap();
2505        save_template(&kando_dir, "middle", &make_test_template()).unwrap();
2506        let templates = load_templates(&kando_dir);
2507        let slugs: Vec<&str> = templates.iter().map(|(s, _)| s.as_str()).collect();
2508        assert_eq!(slugs, vec!["alpha", "middle", "zebra"]);
2509    }
2510
2511    #[test]
2512    fn load_templates_skips_non_md_files() {
2513        let dir = tempfile::tempdir().unwrap();
2514        init_board(dir.path(), "Test", None).unwrap();
2515        let kando_dir = dir.path().join(".kando");
2516        save_template(&kando_dir, "bug", &make_test_template()).unwrap();
2517        fs::write(kando_dir.join("templates").join("notes.txt"), "not a template").unwrap();
2518        let templates = load_templates(&kando_dir);
2519        assert_eq!(templates.len(), 1);
2520        assert_eq!(templates[0].0, "bug");
2521    }
2522
2523    #[test]
2524    fn load_templates_skips_invalid_frontmatter() {
2525        let dir = tempfile::tempdir().unwrap();
2526        init_board(dir.path(), "Test", None).unwrap();
2527        let kando_dir = dir.path().join(".kando");
2528        save_template(&kando_dir, "good", &make_test_template()).unwrap();
2529        fs::write(kando_dir.join("templates").join("bad.md"), "no frontmatter here").unwrap();
2530        let templates = load_templates(&kando_dir);
2531        assert_eq!(templates.len(), 1);
2532        assert_eq!(templates[0].0, "good");
2533    }
2534
2535    #[test]
2536    fn find_template_by_slug() {
2537        let dir = tempfile::tempdir().unwrap();
2538        init_board(dir.path(), "Test", None).unwrap();
2539        let kando_dir = dir.path().join(".kando");
2540        save_template(&kando_dir, "bug-report", &make_test_template()).unwrap();
2541        let result = find_template(&kando_dir, "bug-report");
2542        assert!(result.is_some());
2543        assert_eq!(result.unwrap().0, "bug-report");
2544    }
2545
2546    #[test]
2547    fn find_template_by_derived_name_case_insensitive() {
2548        let dir = tempfile::tempdir().unwrap();
2549        init_board(dir.path(), "Test", None).unwrap();
2550        let kando_dir = dir.path().join(".kando");
2551        save_template(&kando_dir, "bug-report", &make_test_template()).unwrap();
2552        // slug "bug-report" derives to "Bug Report"; case-insensitive lookup
2553        let result = find_template(&kando_dir, "bug report");
2554        assert!(result.is_some());
2555        assert_eq!(result.unwrap().0, "bug-report");
2556    }
2557
2558    #[test]
2559    fn find_template_not_found() {
2560        let dir = tempfile::tempdir().unwrap();
2561        init_board(dir.path(), "Test", None).unwrap();
2562        let kando_dir = dir.path().join(".kando");
2563        assert!(find_template(&kando_dir, "nonexistent").is_none());
2564    }
2565
2566    #[test]
2567    fn find_template_slug_takes_precedence_over_derived_name() {
2568        let dir = tempfile::tempdir().unwrap();
2569        init_board(dir.path(), "Test", None).unwrap();
2570        let kando_dir = dir.path().join(".kando");
2571        // slug "alpha" derives to "Alpha", slug "beta" derives to "Beta"
2572        save_template(&kando_dir, "alpha", &make_test_template()).unwrap();
2573        save_template(&kando_dir, "beta", &make_test_template()).unwrap();
2574        // Searching "alpha" should match slug "alpha" exactly, not derived name
2575        let result = find_template(&kando_dir, "alpha");
2576        assert!(result.is_some());
2577        let (slug, _) = result.unwrap();
2578        assert_eq!(slug, "alpha");
2579    }
2580
2581    #[test]
2582    fn template_with_all_default_fields() {
2583        let dir = tempfile::tempdir().unwrap();
2584        let path = dir.path().join("test.md");
2585        // Only priority specified; everything else falls back to defaults
2586        fs::write(&path, "---\npriority = \"normal\"\n---\n").unwrap();
2587        let loaded = load_template(&path).unwrap();
2588        assert_eq!(loaded.priority, Priority::Normal);
2589        assert!(loaded.tags.is_empty());
2590        assert!(loaded.assignees.is_empty());
2591        assert!(loaded.blocked.is_none());
2592        assert!(loaded.due_offset_days.is_none());
2593        assert!(loaded.body.is_empty());
2594    }
2595
2596    #[test]
2597    fn save_template_slug_with_leading_hyphen_rejected() {
2598        let dir = tempfile::tempdir().unwrap();
2599        init_board(dir.path(), "Test", None).unwrap();
2600        let kando_dir = dir.path().join(".kando");
2601        let tmpl = make_test_template();
2602        assert!(save_template(&kando_dir, "-bug", &tmpl).is_err());
2603    }
2604
2605    #[test]
2606    fn save_template_overwrites_existing() {
2607        let dir = tempfile::tempdir().unwrap();
2608        init_board(dir.path(), "Test", None).unwrap();
2609        let kando_dir = dir.path().join(".kando");
2610        let mut tmpl = make_test_template();
2611        save_template(&kando_dir, "bug", &tmpl).unwrap();
2612        // Overwrite with different priority
2613        tmpl.priority = crate::board::Priority::Urgent;
2614        save_template(&kando_dir, "bug", &tmpl).unwrap();
2615        let (_, loaded) = find_template(&kando_dir, "bug").unwrap();
2616        assert_eq!(loaded.priority, crate::board::Priority::Urgent);
2617    }
2618
2619    #[test]
2620    fn template_body_with_frontmatter_delimiter_roundtrips() {
2621        let dir = tempfile::tempdir().unwrap();
2622        init_board(dir.path(), "Test", None).unwrap();
2623        let kando_dir = dir.path().join(".kando");
2624        let mut tmpl = make_test_template();
2625        tmpl.body = "Above\n---\nBelow".to_string();
2626        save_template(&kando_dir, "tricky", &tmpl).unwrap();
2627        let (_, loaded) = find_template(&kando_dir, "tricky").unwrap();
2628        assert_eq!(loaded.body, "Above\n---\nBelow");
2629    }
2630
2631    #[test]
2632    fn load_template_with_legacy_name_field_in_frontmatter() {
2633        let dir = tempfile::tempdir().unwrap();
2634        let path = dir.path().join("test.md");
2635        // Old-format template file that still has a `name` field — should load fine
2636        fs::write(&path, "---\nname = \"Bug Report\"\npriority = \"high\"\ntags = [\"bug\"]\n---\nBody text\n").unwrap();
2637        let loaded = load_template(&path).unwrap();
2638        assert_eq!(loaded.priority, Priority::High);
2639        assert_eq!(loaded.tags, vec!["bug"]);
2640        assert_eq!(loaded.body, "Body text");
2641    }
2642
2643    #[test]
2644    fn serialize_template_does_not_contain_name_field() {
2645        let tmpl = make_test_template();
2646        let text = serialize_template(&tmpl);
2647        assert!(!text.contains("name ="), "serialized template should not contain a name field");
2648    }
2649
2650    #[test]
2651    fn find_template_partial_name_does_not_match() {
2652        let dir = tempfile::tempdir().unwrap();
2653        init_board(dir.path(), "Test", None).unwrap();
2654        let kando_dir = dir.path().join(".kando");
2655        save_template(&kando_dir, "bug-report", &make_test_template()).unwrap();
2656        // "bug" is neither the slug nor the full derived name "Bug Report"
2657        assert!(find_template(&kando_dir, "bug").is_none());
2658    }
2659
2660    #[test]
2661    fn save_template_file_does_not_contain_name_field() {
2662        let dir = tempfile::tempdir().unwrap();
2663        init_board(dir.path(), "Test", None).unwrap();
2664        let kando_dir = dir.path().join(".kando");
2665        let tmpl = make_test_template();
2666        save_template(&kando_dir, "bug", &tmpl).unwrap();
2667        let content = fs::read_to_string(kando_dir.join("templates").join("bug.md")).unwrap();
2668        assert!(!content.contains("name ="), "template file on disk should not contain a name field");
2669    }
2670
2671    // ── rename_template tests ──
2672
2673    #[test]
2674    fn rename_template_moves_file() {
2675        let dir = tempfile::tempdir().unwrap();
2676        init_board(dir.path(), "Test", None).unwrap();
2677        let kando_dir = dir.path().join(".kando");
2678        save_template(&kando_dir, "bug", &make_test_template()).unwrap();
2679        rename_template(&kando_dir, "bug", "defect").unwrap();
2680        assert!(kando_dir.join("templates").join("defect.md").exists());
2681        assert!(!kando_dir.join("templates").join("bug.md").exists());
2682    }
2683
2684    #[test]
2685    fn rename_template_preserves_content() {
2686        let dir = tempfile::tempdir().unwrap();
2687        init_board(dir.path(), "Test", None).unwrap();
2688        let kando_dir = dir.path().join(".kando");
2689        let mut tmpl = make_test_template();
2690        tmpl.priority = Priority::High;
2691        tmpl.tags = vec!["rust".into(), "tui".into()];
2692        tmpl.assignees = vec!["alice".into()];
2693        tmpl.blocked = Some(String::new());
2694        tmpl.body = "Template body content".into();
2695        tmpl.due_offset_days = Some(7);
2696        save_template(&kando_dir, "bug", &tmpl).unwrap();
2697        rename_template(&kando_dir, "bug", "defect").unwrap();
2698        let loaded = load_template(&kando_dir.join("templates").join("defect.md")).unwrap();
2699        assert_eq!(loaded.priority, Priority::High);
2700        assert_eq!(loaded.tags, vec!["rust", "tui"]);
2701        assert_eq!(loaded.assignees, vec!["alice"]);
2702        assert!(loaded.blocked.is_some());
2703        assert_eq!(loaded.body, "Template body content");
2704        assert_eq!(loaded.due_offset_days, Some(7));
2705    }
2706
2707    #[test]
2708    fn rename_template_same_slug_is_noop() {
2709        let dir = tempfile::tempdir().unwrap();
2710        init_board(dir.path(), "Test", None).unwrap();
2711        let kando_dir = dir.path().join(".kando");
2712        save_template(&kando_dir, "bug", &make_test_template()).unwrap();
2713        rename_template(&kando_dir, "bug", "bug").unwrap();
2714        assert!(kando_dir.join("templates").join("bug.md").exists());
2715    }
2716
2717    #[test]
2718    fn rename_template_target_already_exists_returns_error() {
2719        let dir = tempfile::tempdir().unwrap();
2720        init_board(dir.path(), "Test", None).unwrap();
2721        let kando_dir = dir.path().join(".kando");
2722        save_template(&kando_dir, "bug", &make_test_template()).unwrap();
2723        save_template(&kando_dir, "defect", &make_test_template()).unwrap();
2724        let result = rename_template(&kando_dir, "bug", "defect");
2725        let err_msg = result.unwrap_err().to_string();
2726        assert!(err_msg.contains("already exists"), "expected 'already exists' in: {err_msg}");
2727        // Both original files must survive.
2728        assert!(kando_dir.join("templates").join("bug.md").exists());
2729        assert!(kando_dir.join("templates").join("defect.md").exists());
2730    }
2731
2732    #[test]
2733    fn rename_template_invalid_old_slug_returns_error() {
2734        let dir = tempfile::tempdir().unwrap();
2735        init_board(dir.path(), "Test", None).unwrap();
2736        let kando_dir = dir.path().join(".kando");
2737        assert!(rename_template(&kando_dir, "UPPER", "valid").is_err());
2738    }
2739
2740    #[test]
2741    fn rename_template_invalid_new_slug_returns_error() {
2742        let dir = tempfile::tempdir().unwrap();
2743        init_board(dir.path(), "Test", None).unwrap();
2744        let kando_dir = dir.path().join(".kando");
2745        save_template(&kando_dir, "bug", &make_test_template()).unwrap();
2746        assert!(rename_template(&kando_dir, "bug", "HAS SPACES").is_err());
2747    }
2748
2749    #[test]
2750    fn rename_template_old_slug_not_found_returns_error() {
2751        let dir = tempfile::tempdir().unwrap();
2752        init_board(dir.path(), "Test", None).unwrap();
2753        let kando_dir = dir.path().join(".kando");
2754        let result = rename_template(&kando_dir, "nonexistent", "new-name");
2755        assert!(result.is_err());
2756        assert!(!kando_dir.join("templates").join("new-name.md").exists());
2757    }
2758
2759    #[test]
2760    fn rename_template_multi_word_slug() {
2761        let dir = tempfile::tempdir().unwrap();
2762        init_board(dir.path(), "Test", None).unwrap();
2763        let kando_dir = dir.path().join(".kando");
2764        save_template(&kando_dir, "bug-report", &make_test_template()).unwrap();
2765        rename_template(&kando_dir, "bug-report", "defect-report").unwrap();
2766        assert!(kando_dir.join("templates").join("defect-report.md").exists());
2767        assert!(!kando_dir.join("templates").join("bug-report.md").exists());
2768    }
2769
2770    #[test]
2771    fn rename_template_body_with_frontmatter_delimiter_preserved() {
2772        let dir = tempfile::tempdir().unwrap();
2773        init_board(dir.path(), "Test", None).unwrap();
2774        let kando_dir = dir.path().join(".kando");
2775        let mut tmpl = make_test_template();
2776        tmpl.body = "Above\n---\nBelow".into();
2777        save_template(&kando_dir, "tricky", &tmpl).unwrap();
2778        rename_template(&kando_dir, "tricky", "renamed").unwrap();
2779        let loaded = load_template(&kando_dir.join("templates").join("renamed.md")).unwrap();
2780        assert_eq!(loaded.body, "Above\n---\nBelow");
2781    }
2782
2783    #[test]
2784    fn rename_template_no_templates_dir_returns_error() {
2785        let dir = tempfile::tempdir().unwrap();
2786        init_board(dir.path(), "Test", None).unwrap();
2787        let kando_dir = dir.path().join(".kando");
2788        // templates directory does not exist — rename should fail gracefully.
2789        let result = rename_template(&kando_dir, "bug", "defect");
2790        assert!(result.is_err());
2791    }
2792
2793    // -----------------------------------------------------------------------
2794    // find_kando_toml tests
2795    // -----------------------------------------------------------------------
2796
2797    #[test]
2798    fn find_kando_toml_at_start_dir() {
2799        let dir = tempfile::tempdir().unwrap();
2800        fs::write(dir.path().join(".kando.toml"), "branch = \"kando\"\n").unwrap();
2801        let result = find_kando_toml(dir.path());
2802        assert_eq!(result, Some(dir.path().join(".kando.toml")));
2803    }
2804
2805    #[test]
2806    fn find_kando_toml_walks_up() {
2807        let dir = tempfile::tempdir().unwrap();
2808        fs::write(dir.path().join(".kando.toml"), "branch = \"kando\"\n").unwrap();
2809        let nested = dir.path().join("src/deep/nested");
2810        fs::create_dir_all(&nested).unwrap();
2811        let result = find_kando_toml(&nested);
2812        assert_eq!(result, Some(dir.path().join(".kando.toml")));
2813    }
2814
2815    #[test]
2816    fn find_kando_toml_not_found() {
2817        let dir = tempfile::tempdir().unwrap();
2818        let result = find_kando_toml(dir.path());
2819        assert!(result.is_none());
2820    }
2821
2822    #[test]
2823    fn find_kando_toml_is_directory_not_file() {
2824        let dir = tempfile::tempdir().unwrap();
2825        fs::create_dir(dir.path().join(".kando.toml")).unwrap();
2826        let result = find_kando_toml(dir.path());
2827        assert!(result.is_none());
2828    }
2829
2830    #[test]
2831    fn find_kando_toml_prefers_nearest_ancestor() {
2832        let dir = tempfile::tempdir().unwrap();
2833        fs::write(dir.path().join(".kando.toml"), "branch = \"outer\"\n").unwrap();
2834        let inner = dir.path().join("sub");
2835        fs::create_dir(&inner).unwrap();
2836        fs::write(inner.join(".kando.toml"), "branch = \"inner\"\n").unwrap();
2837        let child = inner.join("deep");
2838        fs::create_dir(&child).unwrap();
2839        let result = find_kando_toml(&child);
2840        assert_eq!(result, Some(inner.join(".kando.toml")));
2841    }
2842
2843    // -----------------------------------------------------------------------
2844    // BoardContext tests
2845    // -----------------------------------------------------------------------
2846
2847    #[test]
2848    fn board_context_is_git_sync_true_for_gitsync() {
2849        let ctx = BoardContext {
2850            kando_dir: PathBuf::from("/tmp/shadow/.kando"),
2851            project_root: PathBuf::from("/tmp/project"),
2852            mode: BoardMode::GitSync {
2853                shadow_root: PathBuf::from("/tmp/shadow"),
2854                branch: "kando".to_string(),
2855            },
2856        };
2857        assert!(ctx.is_git_sync());
2858    }
2859
2860    #[test]
2861    fn board_context_is_git_sync_false_for_local() {
2862        let ctx = BoardContext {
2863            kando_dir: PathBuf::from("/tmp/project/.kando"),
2864            project_root: PathBuf::from("/tmp/project"),
2865            mode: BoardMode::Local,
2866        };
2867        assert!(!ctx.is_git_sync());
2868    }
2869
2870    // -----------------------------------------------------------------------
2871    // init_board_at tests
2872    // -----------------------------------------------------------------------
2873
2874    #[test]
2875    fn init_board_at_creates_kando_dir() {
2876        let dir = tempfile::tempdir().unwrap();
2877        let kando_dir = dir.path().join("custom/.kando");
2878        init_board_at(&kando_dir, "Test", None).unwrap();
2879        assert!(kando_dir.is_dir());
2880    }
2881
2882    #[test]
2883    fn init_board_at_creates_default_columns() {
2884        let dir = tempfile::tempdir().unwrap();
2885        let kando_dir = dir.path().join(".kando");
2886        init_board_at(&kando_dir, "Test", None).unwrap();
2887        let columns = kando_dir.join("columns");
2888        assert!(columns.join("backlog").is_dir());
2889        assert!(columns.join("in-progress").is_dir());
2890        assert!(columns.join("done").is_dir());
2891        assert!(columns.join("archive").is_dir());
2892    }
2893
2894    #[test]
2895    fn init_board_at_creates_config_toml() {
2896        let dir = tempfile::tempdir().unwrap();
2897        let kando_dir = dir.path().join(".kando");
2898        init_board_at(&kando_dir, "MyBoard", None).unwrap();
2899        assert!(kando_dir.join("config.toml").is_file());
2900        let content = fs::read_to_string(kando_dir.join("config.toml")).unwrap();
2901        assert!(content.contains("MyBoard"));
2902    }
2903
2904    #[test]
2905    fn init_board_at_with_sync_branch() {
2906        let dir = tempfile::tempdir().unwrap();
2907        let kando_dir = dir.path().join(".kando");
2908        init_board_at(&kando_dir, "Test", Some("kando")).unwrap();
2909        let content = fs::read_to_string(kando_dir.join("config.toml")).unwrap();
2910        assert!(content.contains("sync_branch = \"kando\""));
2911    }
2912
2913    #[test]
2914    fn init_board_at_without_sync_branch() {
2915        let dir = tempfile::tempdir().unwrap();
2916        let kando_dir = dir.path().join(".kando");
2917        init_board_at(&kando_dir, "Test", None).unwrap();
2918        let content = fs::read_to_string(kando_dir.join("config.toml")).unwrap();
2919        assert!(!content.contains("sync_branch"));
2920    }
2921
2922    #[test]
2923    fn init_board_at_board_is_loadable() {
2924        let dir = tempfile::tempdir().unwrap();
2925        let kando_dir = dir.path().join(".kando");
2926        init_board_at(&kando_dir, "Test", None).unwrap();
2927        let board = load_board(&kando_dir).unwrap();
2928        assert_eq!(board.name, "Test");
2929        assert_eq!(board.columns.len(), 4);
2930    }
2931
2932    #[test]
2933    fn init_board_at_returns_correct_path() {
2934        let dir = tempfile::tempdir().unwrap();
2935        let kando_dir = dir.path().join(".kando");
2936        let result = init_board_at(&kando_dir, "Test", None).unwrap();
2937        assert_eq!(result, kando_dir);
2938    }
2939
2940    #[test]
2941    fn init_board_at_and_init_board_produce_equivalent_boards() {
2942        let dir1 = tempfile::tempdir().unwrap();
2943        let dir2 = tempfile::tempdir().unwrap();
2944
2945        init_board(dir1.path(), "Test", None).unwrap();
2946        init_board_at(&dir2.path().join(".kando"), "Test", None).unwrap();
2947
2948        let board1 = load_board(&dir1.path().join(".kando")).unwrap();
2949        let board2 = load_board(&dir2.path().join(".kando")).unwrap();
2950
2951        assert_eq!(board1.name, board2.name);
2952        assert_eq!(board1.next_card_id, board2.next_card_id);
2953        assert_eq!(board1.nerd_font, board2.nerd_font);
2954        assert_eq!(board1.sync_branch, board2.sync_branch);
2955        assert_eq!(board1.columns.len(), board2.columns.len());
2956        for (c1, c2) in board1.columns.iter().zip(board2.columns.iter()) {
2957            assert_eq!(c1.slug, c2.slug);
2958            assert_eq!(c1.name, c2.name);
2959            assert_eq!(c1.order, c2.order);
2960            assert_eq!(c1.wip_limit, c2.wip_limit);
2961            assert_eq!(c1.hidden, c2.hidden);
2962            assert_eq!(c1.cards.len(), c2.cards.len());
2963        }
2964    }
2965
2966    #[test]
2967    fn init_board_at_double_init_overwrites() {
2968        let dir = tempfile::tempdir().unwrap();
2969        let kando_dir = dir.path().join(".kando");
2970        init_board_at(&kando_dir, "First", None).unwrap();
2971        init_board_at(&kando_dir, "Second", None).unwrap();
2972        let board = load_board(&kando_dir).unwrap();
2973        assert_eq!(board.name, "Second");
2974    }
2975
2976    // -----------------------------------------------------------------------
2977    // resolve_board tests (local mode only, GitSync requires git remote)
2978    // -----------------------------------------------------------------------
2979
2980    #[test]
2981    fn resolve_board_local_mode() {
2982        let dir = tempfile::tempdir().unwrap();
2983        init_board(dir.path(), "Test", None).unwrap();
2984        let ctx = resolve_board(dir.path()).unwrap();
2985        assert!(!ctx.is_git_sync());
2986        assert_eq!(ctx.kando_dir, dir.path().join(".kando"));
2987        assert_eq!(ctx.project_root, dir.path());
2988    }
2989
2990    #[test]
2991    fn resolve_board_local_from_nested_dir() {
2992        let dir = tempfile::tempdir().unwrap();
2993        init_board(dir.path(), "Test", None).unwrap();
2994        let nested = dir.path().join("src/deep/nested");
2995        fs::create_dir_all(&nested).unwrap();
2996        let ctx = resolve_board(&nested).unwrap();
2997        assert_eq!(ctx.kando_dir, dir.path().join(".kando"));
2998    }
2999
3000    #[test]
3001    fn resolve_board_no_board_found() {
3002        let dir = tempfile::tempdir().unwrap();
3003        let result = resolve_board(dir.path());
3004        assert!(result.is_err());
3005        match result.unwrap_err() {
3006            StorageError::NotFound(_) => {}
3007            other => panic!("Expected NotFound, got: {other:?}"),
3008        }
3009    }
3010
3011    #[test]
3012    fn resolve_board_broken_git_sync_no_git_repo() {
3013        let dir = tempfile::tempdir().unwrap();
3014        fs::write(dir.path().join(".kando.toml"), "branch = \"kando\"\n").unwrap();
3015        let result = resolve_board(dir.path());
3016        assert!(result.is_err());
3017        match result.unwrap_err() {
3018            StorageError::BrokenGitSync { toml_path, reason } => {
3019                assert!(reason.contains("no git repository"), "reason: {reason}");
3020                assert_eq!(toml_path, dir.path().join(".kando.toml"));
3021            }
3022            other => panic!("Expected BrokenGitSync, got: {other:?}"),
3023        }
3024    }
3025
3026    #[test]
3027    fn resolve_board_broken_git_sync_no_remote() {
3028        let dir = tempfile::tempdir().unwrap();
3029        // Init a git repo with no remote
3030        std::process::Command::new("git")
3031            .args(["init"])
3032            .current_dir(dir.path())
3033            .output()
3034            .unwrap();
3035        fs::write(dir.path().join(".kando.toml"), "branch = \"kando\"\n").unwrap();
3036        let result = resolve_board(dir.path());
3037        assert!(result.is_err());
3038        match result.unwrap_err() {
3039            StorageError::BrokenGitSync { reason, .. } => {
3040                assert!(reason.contains("no git remote"), "reason: {reason}");
3041            }
3042            other => panic!("Expected BrokenGitSync, got: {other:?}"),
3043        }
3044    }
3045
3046    #[test]
3047    fn resolve_board_malformed_kando_toml_returns_toml_error() {
3048        let dir = tempfile::tempdir().unwrap();
3049        fs::write(dir.path().join(".kando.toml"), "branch = !!!\n").unwrap();
3050        let result = resolve_board(dir.path());
3051        assert!(result.is_err());
3052        match result.unwrap_err() {
3053            StorageError::TomlDe(_) => {}
3054            other => panic!("Expected TomlDe, got: {other:?}"),
3055        }
3056    }
3057
3058    #[test]
3059    fn resolve_board_empty_kando_toml_uses_default_branch_then_fails() {
3060        let dir = tempfile::tempdir().unwrap();
3061        fs::write(dir.path().join(".kando.toml"), "").unwrap();
3062        let result = resolve_board(dir.path());
3063        assert!(result.is_err());
3064        match result.unwrap_err() {
3065            StorageError::BrokenGitSync { reason, .. } => {
3066                assert!(reason.contains("no git repository"), "reason: {reason}");
3067            }
3068            other => panic!("Expected BrokenGitSync, got: {other:?}"),
3069        }
3070    }
3071
3072    #[test]
3073    fn resolve_board_broken_git_sync_from_nested_dir() {
3074        let dir = tempfile::tempdir().unwrap();
3075        fs::write(dir.path().join(".kando.toml"), "branch = \"kando\"\n").unwrap();
3076        let nested = dir.path().join("src/deep/nested");
3077        fs::create_dir_all(&nested).unwrap();
3078        let result = resolve_board(&nested);
3079        assert!(result.is_err());
3080        match result.unwrap_err() {
3081            StorageError::BrokenGitSync { toml_path, reason } => {
3082                assert!(reason.contains("no git repository"), "reason: {reason}");
3083                assert_eq!(toml_path, dir.path().join(".kando.toml"));
3084            }
3085            other => panic!("Expected BrokenGitSync, got: {other:?}"),
3086        }
3087    }
3088
3089    #[test]
3090    fn broken_git_sync_error_display_is_actionable() {
3091        let err = StorageError::BrokenGitSync {
3092            toml_path: PathBuf::from("/project/.kando.toml"),
3093            reason: "no git remote configured".to_string(),
3094        };
3095        let msg = err.to_string();
3096        assert!(msg.contains(".kando.toml"), "should mention toml file: {msg}");
3097        assert!(msg.contains("no git remote"), "should mention reason: {msg}");
3098    }
3099
3100    #[test]
3101    fn resolve_board_prefers_kando_toml_over_kando_dir() {
3102        let dir = tempfile::tempdir().unwrap();
3103        init_board(dir.path(), "Test", None).unwrap();
3104        fs::write(dir.path().join(".kando.toml"), "branch = \"kando\"\n").unwrap();
3105        let result = resolve_board(dir.path());
3106        assert!(result.is_err());
3107        match result.unwrap_err() {
3108            StorageError::BrokenGitSync { .. } => {}
3109            other => panic!("Expected BrokenGitSync, got: {other:?}"),
3110        }
3111    }
3112}