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 pub kando_dir: PathBuf,
24 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 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 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
73fn 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
89pub 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
103fn 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
117fn has_kando_branch(git_root: &Path) -> Option<String> {
119 use std::process::Command;
120
121 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 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
149pub fn resolve_board(start: &Path) -> Result<BoardContext, StorageError> {
157 use crate::board::sync;
158
159 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 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 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 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 Err(StorageError::NotFound(start.to_path_buf()))
228}
229
230fn 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
264pub 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
295pub 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
300pub 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 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
360pub fn save_board(kando_dir: &Path, board: &Board) -> Result<(), StorageError> {
362 let columns_dir = kando_dir.join("columns");
363
364 for col in &board.columns {
366 validate_slug(&col.slug)?;
367 }
368
369 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 for col in &board.columns {
398 let col_dir = columns_dir.join(&col.slug);
399 fs::create_dir_all(&col_dir)?;
400
401 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 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
431pub 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
445pub 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(()); }
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
469fn 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 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
496fn serialize_card(card: &Card) -> String {
498 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
554fn 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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
584 pub sync_branch: Option<String>,
585 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
587 pub nerd_font: bool,
588 #[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)]
599 pub slug: String,
600 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
618pub struct TrashEntry {
619 pub id: String,
620 pub deleted: String, 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
631fn trash_dir(kando_dir: &Path) -> PathBuf {
633 kando_dir.join("trash")
634}
635
636fn trash_meta_path(kando_dir: &Path) -> PathBuf {
638 trash_dir(kando_dir).join("_meta.toml")
639}
640
641pub 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 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 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
678pub 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 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
703pub fn load_trash(kando_dir: &Path) -> Vec<TrashEntry> {
705 load_trash_meta(kando_dir).entries
706}
707
708pub 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, };
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
739fn 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
748fn 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
757fn 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
788pub 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
838pub 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
854pub 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); Ok(())
862}
863
864fn 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
889pub 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
928pub 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
945pub 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
982pub 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
993pub 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
1003pub 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 let _ = fs::remove_file(&new_path);
1029 return Err(e.into());
1030 }
1031 Ok(())
1032}
1033
1034pub 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 if let Some(entry) = templates.iter().find(|(slug, _)| slug == name_or_slug) {
1040 return Some(entry.clone());
1041 }
1042 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 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 #[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 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 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 assert!(!src.exists());
1183 let trash_file = kando_dir.join("trash").join(format!("{id}.md"));
1184 assert!(trash_file.exists());
1185
1186 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_card(&kando_dir, &id, &col_slug).unwrap();
1208
1209 let restored = kando_dir.join("columns").join(&col_slug).join(format!("{id}.md"));
1211 assert!(restored.exists());
1212
1213 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_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 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 let purged = purge_trash(&kando_dir, 30).unwrap();
1267 assert_eq!(purged, vec![id.clone()]);
1268
1269 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 let purged = purge_trash(&kando_dir, 30).unwrap();
1291 assert!(purged.is_empty());
1292
1293 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 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 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 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 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 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 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 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_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 let dir = tempfile::tempdir().unwrap();
1404 init_board(dir.path(), "Test", None).unwrap();
1405 let kando_dir = dir.path().join(".kando");
1406
1407 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 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 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 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 assert_eq!(load_trash(&kando_dir).len(), 2);
1518
1519 restore_card(&kando_dir, "001", &col_slug).unwrap();
1521
1522 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 #[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 #[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 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 #[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 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 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 let (fm, _) = parse_frontmatter(&text).unwrap();
1746 let loaded: Card = toml::from_str(&fm).unwrap();
1747 assert!(loaded.due.is_none());
1748 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 #[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"); 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 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 #[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 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 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 #[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 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 assert_eq!(
1999 super::json_escape("\t\"a\\b\"\n"),
2000 "\"\\t\\\"a\\\\b\\\"\\n\""
2001 );
2002 }
2003
2004 #[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 let dir = tempfile::tempdir().unwrap();
2080 let nonexistent = dir.path().join("no-such-dir").join(".kando");
2081 append_activity(&nonexistent, "create", "1", "Task", &[]);
2082 }
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 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 #[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 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 #[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 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 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 let result = rename_column_dir(&kando_dir, "backlog", "done");
2185 assert!(result.is_err(), "should fail when new dir already exists");
2186 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 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 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 #[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 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 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 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 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 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 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 save_template(&kando_dir, "alpha", &make_test_template()).unwrap();
2573 save_template(&kando_dir, "beta", &make_test_template()).unwrap();
2574 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 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 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 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 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 #[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 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 let result = rename_template(&kando_dir, "bug", "defect");
2790 assert!(result.is_err());
2791 }
2792
2793 #[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 #[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 #[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 #[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 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}