1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use tokio::task::JoinSet;
6
7use crate::error::{Error, Result};
8use crate::task::{self, Task};
9
10const BEARS_DIR: &str = ".bears";
11const ARCHIVE_SUBDIR: &str = "archive";
12
13pub fn tasks_dir(base: &Path) -> PathBuf {
15 base.join(BEARS_DIR)
16}
17
18pub fn archive_dir(base: &Path) -> PathBuf {
20 tasks_dir(base).join(ARCHIVE_SUBDIR)
21}
22
23pub fn init(base: &Path) -> Result<PathBuf> {
25 let dir = base.join(BEARS_DIR);
26 fs::create_dir_all(&dir)?;
27 fs::create_dir_all(dir.join(ARCHIVE_SUBDIR))?;
28 crate::config::create_default(base)?;
29 Ok(dir)
30}
31
32pub async fn load_all(base: &Path) -> Result<HashMap<String, Task>> {
35 let dir = tasks_dir(base);
36 if !dir.exists() {
37 return Err(Error::NotInitialized);
38 }
39
40 let mut paths = Vec::new();
42 for entry in fs::read_dir(&dir)? {
43 let entry = entry?;
44 let path = entry.path();
45 if path.extension().and_then(|e| e.to_str()) == Some("md") {
46 paths.push(path);
47 }
48 }
49
50 let mut join_set = JoinSet::new();
52 for path in paths {
53 join_set.spawn(async move {
54 let content = tokio::fs::read_to_string(&path).await;
55 (path, content)
56 });
57 }
58
59 let mut results = Vec::new();
63 while let Some(result) = join_set.join_next().await {
64 let (path, content) = result.map_err(|e| std::io::Error::other(e.to_string()))?;
65 results.push((path, content));
66 }
67 results.sort_by(|(a, _), (b, _)| a.cmp(b));
68
69 let mut tasks = HashMap::new();
70 for (path, content) in results {
71 let content = match content {
72 Ok(c) => c,
73 Err(e) => {
74 eprintln!("warning: skipping {}: {e}", path.display());
75 continue;
76 }
77 };
78 match task::parse_task(&content) {
79 Ok(t) => {
80 if tasks.contains_key(&t.id) {
81 eprintln!("warning: duplicate task ID {} in {}", t.id, path.display());
82 continue;
83 }
84 tasks.insert(t.id.clone(), t);
85 }
86 Err(e) => {
87 let e = match e {
92 Error::InvalidFrontmatter { reason, .. } => Error::InvalidFrontmatter {
93 path: path.clone(),
94 reason,
95 },
96 other => other,
97 };
98 eprintln!("warning: skipping {}: {e}", path.display());
99 }
100 }
101 }
102
103 Ok(tasks)
104}
105
106pub async fn load_archived(base: &Path) -> Result<HashMap<String, Task>> {
110 let dir = archive_dir(base);
111 if !dir.exists() {
112 return Ok(HashMap::new());
113 }
114
115 let mut paths = Vec::new();
116 for entry in fs::read_dir(&dir)? {
117 let entry = entry?;
118 let path = entry.path();
119 if path.extension().and_then(|e| e.to_str()) == Some("md") {
120 paths.push(path);
121 }
122 }
123
124 let mut join_set = JoinSet::new();
125 for path in paths {
126 join_set.spawn(async move {
127 let content = tokio::fs::read_to_string(&path).await;
128 (path, content)
129 });
130 }
131
132 let mut results = Vec::new();
134 while let Some(result) = join_set.join_next().await {
135 let (path, content) = result.map_err(|e| std::io::Error::other(e.to_string()))?;
136 results.push((path, content));
137 }
138 results.sort_by(|(a, _), (b, _)| a.cmp(b));
139
140 let mut tasks = HashMap::new();
141 for (path, content) in results {
142 let content = match content {
143 Ok(c) => c,
144 Err(e) => {
145 eprintln!("warning: skipping archived {}: {e}", path.display());
146 continue;
147 }
148 };
149 match task::parse_task(&content) {
150 Ok(t) => {
151 if tasks.contains_key(&t.id) {
152 eprintln!(
153 "warning: duplicate archived task ID {} in {}",
154 t.id,
155 path.display()
156 );
157 continue;
158 }
159 tasks.insert(t.id.clone(), t);
160 }
161 Err(e) => {
162 let e = match e {
163 Error::InvalidFrontmatter { reason, .. } => Error::InvalidFrontmatter {
164 path: path.clone(),
165 reason,
166 },
167 other => other,
168 };
169 eprintln!("warning: skipping archived {}: {e}", path.display());
170 }
171 }
172 }
173
174 Ok(tasks)
175}
176
177pub fn find_archived_path(base: &Path, id: &str) -> Result<PathBuf> {
180 let dir = archive_dir(base);
181 if !dir.exists() {
182 return Err(Error::TaskNotFound(id.into()));
183 }
184 let prefix = format!("{id}-");
185
186 let mut matches: Vec<PathBuf> = Vec::new();
187 for entry in fs::read_dir(&dir)? {
188 let entry = entry?;
189 let name = entry.file_name();
190 let name = name.to_string_lossy();
191 if name.starts_with(&prefix) && name.ends_with(".md") {
192 matches.push(entry.path());
193 }
194 }
195
196 matches.sort();
197 matches
198 .into_iter()
199 .next()
200 .ok_or_else(|| Error::TaskNotFound(id.into()))
201}
202
203pub fn move_to_archive(base: &Path, id: &str) -> Result<()> {
205 let adir = archive_dir(base);
206 fs::create_dir_all(&adir)?;
207 let src = find_task_path(base, id)?;
208 let filename = src
209 .file_name()
210 .ok_or_else(|| Error::TaskNotFound(id.into()))?;
211 let dst = adir.join(filename);
212 fs::rename(src, dst)?;
213 Ok(())
214}
215
216pub fn move_from_archive(base: &Path, id: &str) -> Result<()> {
218 let src = find_archived_path(base, id)?;
219 let filename = src
220 .file_name()
221 .ok_or_else(|| Error::TaskNotFound(id.into()))?;
222 let dst = tasks_dir(base).join(filename);
223 fs::rename(src, dst)?;
224 Ok(())
225}
226
227pub fn archived_id_set(base: &Path) -> HashSet<String> {
230 let dir = archive_dir(base);
231 if !dir.exists() {
232 return HashSet::new();
233 }
234 let Ok(entries) = fs::read_dir(&dir) else {
235 return HashSet::new();
236 };
237 entries
238 .flatten()
239 .filter_map(|e| {
240 let name = e.file_name();
241 let name = name.to_string_lossy();
242 if !name.ends_with(".md") {
243 return None;
244 }
245 name.split('-').next().map(|id| id.to_string())
247 })
248 .collect()
249}
250
251pub fn find_task_path(base: &Path, id: &str) -> Result<PathBuf> {
256 let dir = tasks_dir(base);
257 let prefix = format!("{id}-");
258
259 let mut matches: Vec<PathBuf> = Vec::new();
260 for entry in fs::read_dir(&dir)? {
261 let entry = entry?;
262 let name = entry.file_name();
263 let name = name.to_string_lossy();
264 if name.starts_with(&prefix) && name.ends_with(".md") {
265 matches.push(entry.path());
266 }
267 }
268
269 matches.sort();
270 matches
271 .into_iter()
272 .next()
273 .ok_or_else(|| Error::TaskNotFound(id.into()))
274}
275
276pub fn load_one(base: &Path, id: &str) -> Result<Task> {
279 let path = find_task_path(base, id)?;
280 let content = fs::read_to_string(&path)?;
281 task::parse_task(&content).map_err(|e| match e {
282 Error::InvalidFrontmatter { reason, .. } => Error::InvalidFrontmatter {
283 path: path.clone(),
284 reason,
285 },
286 other => Error::InvalidFrontmatter {
287 path: path.clone(),
288 reason: other.to_string(),
289 },
290 })
291}
292
293pub fn save(base: &Path, t: &Task) -> Result<()> {
295 let dir = tasks_dir(base);
296 let new_path = dir.join(task::filename(t));
297 let old_path = find_task_path(base, &t.id).ok();
298
299 let tmp_path = dir.join(format!(".{}.tmp", task::filename(t)));
301 fs::write(&tmp_path, task::render_task(t))?;
302 fs::rename(&tmp_path, &new_path)?;
303
304 if let Some(old_path) = old_path
306 && old_path != new_path
307 {
308 fs::remove_file(&old_path)?;
309 }
310 Ok(())
311}
312
313pub fn resolve_prefix(tasks: &HashMap<String, Task>, prefix: &str) -> Result<String> {
317 if tasks.contains_key(prefix) {
319 return Ok(prefix.to_string());
320 }
321
322 let matches: Vec<&String> = tasks.keys().filter(|id| id.starts_with(prefix)).collect();
323
324 match matches.len() {
325 0 => Err(Error::TaskNotFound(prefix.into())),
326 1 => Ok(matches[0].clone()),
327 _ => {
328 let mut sorted = matches.iter().map(|s| s.as_str()).collect::<Vec<_>>();
329 sorted.sort();
330 Err(Error::AmbiguousPrefix {
331 prefix: prefix.into(),
332 matches: sorted.join(", "),
333 })
334 }
335 }
336}
337
338pub fn delete(base: &Path, id: &str) -> Result<()> {
340 let path = find_task_path(base, id)?;
341 fs::remove_file(&path)?;
342 Ok(())
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use crate::task::{Priority, TaskType};
349 use tempfile::TempDir;
350
351 #[test]
352 fn test_init_creates_dir() {
353 let tmp = TempDir::new().unwrap();
354 let dir = init(tmp.path()).unwrap();
355 assert!(dir.exists());
356 }
357
358 #[tokio::test]
359 async fn test_load_all_empty() {
360 let tmp = TempDir::new().unwrap();
361 init(tmp.path()).unwrap();
362 let tasks = load_all(tmp.path()).await.unwrap();
363 assert!(tasks.is_empty());
364 }
365
366 #[test]
367 fn test_save_and_load() {
368 let tmp = TempDir::new().unwrap();
369 init(tmp.path()).unwrap();
370
371 let mut t = Task::new("ab12".into(), "Test task".into(), Priority::P1);
372 t.tags = vec!["backend".into()];
373 t.body = "Some body text.\n".into();
374
375 save(tmp.path(), &t).unwrap();
376
377 let loaded = load_one(tmp.path(), "ab12").unwrap();
378 assert_eq!(loaded.id, "ab12");
379 assert_eq!(loaded.title, "Test task");
380 assert_eq!(loaded.tags, vec!["backend"]);
381 assert_eq!(loaded.body, "Some body text.\n");
382 }
383
384 #[test]
385 fn test_save_renames_on_title_change() {
386 let tmp = TempDir::new().unwrap();
387 init(tmp.path()).unwrap();
388
389 let t = Task::new("cd34".into(), "Original title".into(), Priority::P2);
390 save(tmp.path(), &t).unwrap();
391
392 let old_path = find_task_path(tmp.path(), "cd34").unwrap();
393 assert!(old_path.ends_with("cd34-original-title.md"));
394
395 let mut t2 = load_one(tmp.path(), "cd34").unwrap();
396 t2.title = "New title".into();
397 save(tmp.path(), &t2).unwrap();
398
399 let new_path = find_task_path(tmp.path(), "cd34").unwrap();
400 assert!(new_path.ends_with("cd34-new-title.md"));
401 assert!(!old_path.exists());
402 }
403
404 #[tokio::test]
405 async fn test_load_all_skips_non_md() {
406 let tmp = TempDir::new().unwrap();
407 init(tmp.path()).unwrap();
408 fs::write(tmp.path().join(BEARS_DIR).join("notes.txt"), "ignored").unwrap();
410 let tasks = load_all(tmp.path()).await.unwrap();
411 assert!(tasks.is_empty());
412 }
413
414 #[tokio::test]
418 async fn test_load_all_skips_invalid_frontmatter() {
419 let tmp = TempDir::new().unwrap();
420 init(tmp.path()).unwrap();
421
422 let t = Task::new("ok01".into(), "Good task".into(), Priority::P2);
424 save(tmp.path(), &t).unwrap();
425
426 let bad_path = tmp.path().join(BEARS_DIR).join("bad-no-delimiters.md");
428 fs::write(&bad_path, "just some text, no frontmatter").unwrap();
429
430 let tasks = load_all(tmp.path()).await.unwrap();
432 assert_eq!(tasks.len(), 1, "bad file should be skipped");
433 assert!(tasks.contains_key("ok01"));
434
435 }
439
440 #[tokio::test]
441 async fn test_not_initialized() {
442 let tmp = TempDir::new().unwrap();
443 assert!(matches!(
444 load_all(tmp.path()).await,
445 Err(Error::NotInitialized)
446 ));
447 }
448
449 #[test]
450 fn test_task_not_found() {
451 let tmp = TempDir::new().unwrap();
452 init(tmp.path()).unwrap();
453 assert!(matches!(
454 load_one(tmp.path(), "zzzz"),
455 Err(Error::TaskNotFound(_))
456 ));
457 }
458
459 #[test]
460 fn test_load_one_missing_delimiter() {
461 let tmp = TempDir::new().unwrap();
462 init(tmp.path()).unwrap();
463 fs::write(
465 tmp.path().join(BEARS_DIR).join("bad1-no-delimiters.md"),
466 "just some text, no frontmatter",
467 )
468 .unwrap();
469 let err = load_one(tmp.path(), "bad1").unwrap_err();
470 match &err {
471 Error::InvalidFrontmatter { path, reason } => {
472 assert!(path.ends_with("bad1-no-delimiters.md"));
473 assert!(reason.contains("missing opening --- delimiter"), "{reason}");
474 }
475 other => panic!("expected InvalidFrontmatter, got {other:?}"),
476 }
477 }
478
479 #[test]
480 fn test_load_one_bad_yaml() {
481 let tmp = TempDir::new().unwrap();
482 init(tmp.path()).unwrap();
483 fs::write(
485 tmp.path().join(BEARS_DIR).join("bad2-bad-yaml.md"),
486 "---\n: :\nbogus yaml\n---\n",
487 )
488 .unwrap();
489 let err = load_one(tmp.path(), "bad2").unwrap_err();
490 match &err {
491 Error::InvalidFrontmatter { path, reason } => {
492 assert!(path.ends_with("bad2-bad-yaml.md"));
493 assert!(!reason.contains("failed to parse frontmatter"), "{reason}");
495 assert!(!reason.is_empty());
496 }
497 other => panic!("expected InvalidFrontmatter, got {other:?}"),
498 }
499 }
500
501 #[test]
502 fn test_save_leaves_no_temp_files() {
503 let tmp = TempDir::new().unwrap();
504 init(tmp.path()).unwrap();
505
506 let t = Task::new("at01".into(), "Atomic".into(), Priority::P2);
507 save(tmp.path(), &t).unwrap();
508
509 let leftovers: Vec<_> = fs::read_dir(tmp.path().join(BEARS_DIR))
510 .unwrap()
511 .filter_map(|e| e.ok())
512 .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("tmp"))
513 .collect();
514 assert!(leftovers.is_empty());
515 }
516
517 #[test]
518 fn test_delete() {
519 let tmp = TempDir::new().unwrap();
520 init(tmp.path()).unwrap();
521
522 let t = Task::new("ef56".into(), "Delete me".into(), Priority::P3);
523 save(tmp.path(), &t).unwrap();
524 assert!(find_task_path(tmp.path(), "ef56").is_ok());
525
526 delete(tmp.path(), "ef56").unwrap();
527 assert!(find_task_path(tmp.path(), "ef56").is_err());
528 }
529
530 #[test]
531 fn test_resolve_prefix_exact_match() {
532 let mut tasks = HashMap::new();
533 tasks.insert(
534 "ab12".into(),
535 Task::new("ab12".into(), "T1".into(), Priority::P2),
536 );
537 assert_eq!(resolve_prefix(&tasks, "ab12").unwrap(), "ab12");
538 }
539
540 #[test]
541 fn test_resolve_prefix_unique() {
542 let mut tasks = HashMap::new();
543 tasks.insert(
544 "ab12".into(),
545 Task::new("ab12".into(), "T1".into(), Priority::P2),
546 );
547 tasks.insert(
548 "cd34".into(),
549 Task::new("cd34".into(), "T2".into(), Priority::P2),
550 );
551 assert_eq!(resolve_prefix(&tasks, "ab").unwrap(), "ab12");
552 }
553
554 #[test]
555 fn test_resolve_prefix_ambiguous() {
556 let mut tasks = HashMap::new();
557 tasks.insert(
558 "ab12".into(),
559 Task::new("ab12".into(), "T1".into(), Priority::P2),
560 );
561 tasks.insert(
562 "ab34".into(),
563 Task::new("ab34".into(), "T2".into(), Priority::P2),
564 );
565 let err = resolve_prefix(&tasks, "ab").unwrap_err();
566 assert!(matches!(err, Error::AmbiguousPrefix { .. }));
567 }
568
569 #[test]
570 fn test_resolve_prefix_no_match() {
571 let mut tasks = HashMap::new();
572 tasks.insert(
573 "ab12".into(),
574 Task::new("ab12".into(), "T1".into(), Priority::P2),
575 );
576 let err = resolve_prefix(&tasks, "zz").unwrap_err();
577 assert!(matches!(err, Error::TaskNotFound(_)));
578 }
579
580 #[test]
581 fn test_save_and_load_epic() {
582 let tmp = TempDir::new().unwrap();
583 init(tmp.path()).unwrap();
584
585 let mut t = Task::new("ep01".into(), "My epic".into(), Priority::P1);
586 t.task_type = TaskType::Epic;
587 t.body = "Epic description.\n".into();
588
589 save(tmp.path(), &t).unwrap();
590
591 let loaded = load_one(tmp.path(), "ep01").unwrap();
592 assert_eq!(loaded.task_type, TaskType::Epic);
593 assert_eq!(loaded.title, "My epic");
594 assert_eq!(loaded.body, "Epic description.\n");
595
596 let path = find_task_path(tmp.path(), "ep01").unwrap();
598 let content = fs::read_to_string(&path).unwrap();
599 assert!(content.contains("type: epic"));
600 }
601
602 #[test]
603 fn test_save_and_load_task_no_type_field() {
604 let tmp = TempDir::new().unwrap();
605 init(tmp.path()).unwrap();
606
607 let t = Task::new("tk01".into(), "Regular task".into(), Priority::P2);
608 save(tmp.path(), &t).unwrap();
609
610 let path = find_task_path(tmp.path(), "tk01").unwrap();
612 let content = fs::read_to_string(&path).unwrap();
613 assert!(!content.contains("type:"));
614
615 let loaded = load_one(tmp.path(), "tk01").unwrap();
617 assert_eq!(loaded.task_type, TaskType::Task);
618 }
619
620 #[tokio::test]
621 async fn test_load_all_mixed_types() {
622 let tmp = TempDir::new().unwrap();
623 init(tmp.path()).unwrap();
624
625 let t1 = Task::new("tk02".into(), "A task".into(), Priority::P2);
626 save(tmp.path(), &t1).unwrap();
627
628 let mut t2 = Task::new("ep02".into(), "An epic".into(), Priority::P1);
629 t2.task_type = TaskType::Epic;
630 save(tmp.path(), &t2).unwrap();
631
632 let tasks = load_all(tmp.path()).await.unwrap();
633 assert_eq!(tasks.len(), 2);
634 assert_eq!(tasks["tk02"].task_type, TaskType::Task);
635 assert_eq!(tasks["ep02"].task_type, TaskType::Epic);
636 }
637
638 #[tokio::test]
643 async fn test_load_all_duplicate_id_deterministic() {
644 let tmp = TempDir::new().unwrap();
645 init(tmp.path()).unwrap();
646
647 let dir = tmp.path().join(BEARS_DIR);
648
649 let make_content = |title: &str| {
652 format!(
653 "---\nid: dup1\ntitle: {title}\nstatus: open\npriority: P2\ncreated: 2026-01-01T00:00:00Z\nupdated: 2026-01-01T00:00:00Z\n---\n"
654 )
655 };
656
657 let first_file = dir.join("dup1-aaa-slug.md"); let second_file = dir.join("dup1-zzz-slug.md"); fs::write(&first_file, make_content("First file")).unwrap();
661 fs::write(&second_file, make_content("Second file")).unwrap();
662
663 let tasks = load_all(tmp.path()).await.unwrap();
665 assert_eq!(
666 tasks.len(),
667 1,
668 "duplicate should be deduplicated to one entry"
669 );
670 assert_eq!(
671 tasks["dup1"].title, "First file",
672 "lex-first file must win in load_all"
673 );
674
675 let path = find_task_path(tmp.path(), "dup1").unwrap();
677 assert!(
678 path.ends_with("dup1-aaa-slug.md"),
679 "find_task_path must return lex-first file, got: {}",
680 path.display()
681 );
682 }
683
684 #[test]
687 fn test_init_creates_archive_dir() {
688 let tmp = TempDir::new().unwrap();
689 init(tmp.path()).unwrap();
690 assert!(archive_dir(tmp.path()).exists());
691 }
692
693 #[tokio::test]
694 async fn test_load_all_ignores_archive_subdir() {
695 let tmp = TempDir::new().unwrap();
696 init(tmp.path()).unwrap();
697
698 let active = Task::new("ac01".into(), "Active task".into(), Priority::P2);
700 save(tmp.path(), &active).unwrap();
701
702 let archived = Task::new("ar01".into(), "Archived task".into(), Priority::P3);
704 let archived_content = crate::task::render_task(&archived);
705 fs::write(
706 archive_dir(tmp.path()).join(crate::task::filename(&archived)),
707 archived_content,
708 )
709 .unwrap();
710
711 let tasks = load_all(tmp.path()).await.unwrap();
713 assert_eq!(tasks.len(), 1);
714 assert!(tasks.contains_key("ac01"));
715 assert!(!tasks.contains_key("ar01"));
716 }
717
718 #[test]
719 fn test_move_to_archive_and_back() {
720 let tmp = TempDir::new().unwrap();
721 init(tmp.path()).unwrap();
722
723 let t = Task::new("mv01".into(), "Move me".into(), Priority::P1);
724 save(tmp.path(), &t).unwrap();
725
726 assert!(find_task_path(tmp.path(), "mv01").is_ok());
728 assert!(find_archived_path(tmp.path(), "mv01").is_err());
729
730 move_to_archive(tmp.path(), "mv01").unwrap();
732 assert!(find_task_path(tmp.path(), "mv01").is_err());
733 assert!(find_archived_path(tmp.path(), "mv01").is_ok());
734
735 move_from_archive(tmp.path(), "mv01").unwrap();
737 assert!(find_task_path(tmp.path(), "mv01").is_ok());
738 assert!(find_archived_path(tmp.path(), "mv01").is_err());
739 }
740
741 #[tokio::test]
742 async fn test_load_archived() {
743 let tmp = TempDir::new().unwrap();
744 init(tmp.path()).unwrap();
745
746 let t1 = Task::new("ar01".into(), "Archive this".into(), Priority::P2);
748 save(tmp.path(), &t1).unwrap();
749 let t2 = Task::new("ac01".into(), "Keep active".into(), Priority::P1);
750 save(tmp.path(), &t2).unwrap();
751
752 move_to_archive(tmp.path(), "ar01").unwrap();
753
754 let archived = load_archived(tmp.path()).await.unwrap();
755 assert_eq!(archived.len(), 1);
756 assert!(archived.contains_key("ar01"));
757
758 let active = load_all(tmp.path()).await.unwrap();
759 assert_eq!(active.len(), 1);
760 assert!(active.contains_key("ac01"));
761 }
762
763 #[tokio::test]
764 async fn test_load_archived_empty_when_no_archive_dir() {
765 let tmp = TempDir::new().unwrap();
766 fs::create_dir_all(tasks_dir(tmp.path())).unwrap();
769 let archived = load_archived(tmp.path()).await.unwrap();
770 assert!(archived.is_empty());
771 }
772}