Skip to main content

bears/
store.rs

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
13/// Returns the `.bears/` directory path relative to the given base.
14pub fn tasks_dir(base: &Path) -> PathBuf {
15    base.join(BEARS_DIR)
16}
17
18/// Returns the `.bears/archive/` directory path relative to the given base.
19pub fn archive_dir(base: &Path) -> PathBuf {
20    tasks_dir(base).join(ARCHIVE_SUBDIR)
21}
22
23/// Initialize a new `.bears/` directory (and `.bears/archive/`) and `.bears.yml` config.
24pub 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
32/// Load all tasks from the `.bears/` directory.
33/// Reads files in parallel using tokio. Warns and skips files with invalid frontmatter.
34pub 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    // Collect .md file paths (directory listing is fast, no need to parallelize)
41    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    // Read all files in parallel
51    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    // Collect all results first, then sort by path so the in-memory winner
60    // for a duplicate ID is the lexicographically-first filename — matching
61    // the deterministic rule used by find_task_path.
62    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                // Patch real path into InvalidFrontmatter so the message
88                // reads "invalid frontmatter in <path>: <reason>" instead of
89                // the awkward "invalid frontmatter in : <reason>" that
90                // parse_task produces (it always sets path = "").
91                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
106/// Load all archived tasks from the `.bears/archive/` directory.
107/// Reads files in parallel using tokio. Warns and skips files with invalid frontmatter.
108/// Returns an empty map (not an error) if the archive dir does not exist yet.
109pub 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    // Collect and sort by path for the same determinism as load_all.
133    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
177/// Find the file path for an archived task by its exact ID.
178/// Uses the same lexicographic-first rule as find_task_path.
179pub 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
203/// Move a task file from `.bears/` to `.bears/archive/`.
204pub 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
216/// Move a task file from `.bears/archive/` back to `.bears/`.
217pub 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
227/// Return archived task IDs by scanning filenames only (no YAML parsing).
228/// Used synchronously to extend the collision set during task creation.
229pub 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            // filename format: {id}-{slug}.md — extract the id prefix
246            name.split('-').next().map(|id| id.to_string())
247        })
248        .collect()
249}
250
251/// Find the file path for a task by its ID prefix.
252/// When multiple files share the same ID prefix (duplicate-ID situation),
253/// returns the lexicographically first filename so the on-disk winner is
254/// deterministic and matches the winner chosen by load_all.
255pub 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
276/// Load a single task by exact ID — a fresh read from disk, bypassing any
277/// snapshot (used for file-level operations and the assignee fence).
278pub 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
293/// Save a task to disk. Deletes the old file if the slug has changed.
294pub 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    // Write atomically (temp file + rename) so a crash cannot truncate a task.
300    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    // Remove the old file only after the new one is safely in place
305    if let Some(old_path) = old_path
306        && old_path != new_path
307    {
308        fs::remove_file(&old_path)?;
309    }
310    Ok(())
311}
312
313/// Resolve a task ID or unique prefix to a full task ID.
314/// Returns the exact match if found, or the unique prefix match.
315/// Errors if zero or multiple tasks match.
316pub fn resolve_prefix(tasks: &HashMap<String, Task>, prefix: &str) -> Result<String> {
317    // Exact match first
318    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
338/// Delete a task file by ID.
339pub 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        // write a non-.md file to confirm it's skipped
409        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    /// load_all must skip files with invalid frontmatter and continue loading
415    /// the rest. The test also verifies the warning message includes the real
416    /// file path (not an empty string as parse_task would produce).
417    #[tokio::test]
418    async fn test_load_all_skips_invalid_frontmatter() {
419        let tmp = TempDir::new().unwrap();
420        init(tmp.path()).unwrap();
421
422        // A valid task
423        let t = Task::new("ok01".into(), "Good task".into(), Priority::P2);
424        save(tmp.path(), &t).unwrap();
425
426        // A file with no frontmatter delimiters (parse_task will set path = "")
427        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        // load_all should skip the bad file and return only the good task
431        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        // load_all re-injects the real path, so find_task_path must not return
436        // the bad file as a task (it's not a valid task file with id prefix).
437        // What we really care about is that loading succeeded without panic/error.
438    }
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        // Write a file with no frontmatter delimiters
464        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        // Write a file with delimiters but invalid YAML
484        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                // Should contain the serde_yml error details, not just "failed to parse"
494                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        // Verify the file on disk contains "type: epic"
597        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        // Verify the file on disk does NOT contain "type:"
611        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        // Load it back and verify default
616        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    /// Two files that share the same task ID (a corrupt state that can arise
639    /// e.g. from a botched manual edit) must be resolved deterministically:
640    /// load_all keeps the task from the lexicographically-first filename, and
641    /// find_task_path returns that same file — so memory and disk agree.
642    #[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        // Build a minimal valid frontmatter block for a shared ID "dup1".
650        // "aaa" slug sorts before "zzz", so "dup1-aaa-slug.md" must win.
651        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"); // lex-first
658        let second_file = dir.join("dup1-zzz-slug.md"); // lex-second
659
660        fs::write(&first_file, make_content("First file")).unwrap();
661        fs::write(&second_file, make_content("Second file")).unwrap();
662
663        // load_all: only one entry for "dup1", and it comes from the lex-first file
664        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        // find_task_path must return the same lex-first file
676        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    // ── Archive storage layer tests ──────────────────────────────────
685
686    #[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        // Save an active task
699        let active = Task::new("ac01".into(), "Active task".into(), Priority::P2);
700        save(tmp.path(), &active).unwrap();
701
702        // Write a task file directly into the archive subdir
703        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        // load_all must only return the active task, not the archived one
712        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        // File exists in active dir
727        assert!(find_task_path(tmp.path(), "mv01").is_ok());
728        assert!(find_archived_path(tmp.path(), "mv01").is_err());
729
730        // Move to archive
731        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 back from archive
736        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        // Save two tasks and archive one
747        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        // Don't call init — no .bears/ or archive/ exists
767        // Manually create just .bears/ without archive subdir
768        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}