Skip to main content

bn/
discovery.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{bail, Context, Result};
4
5/// Walk up from `start` looking for a `.beans/` directory.
6/// Returns the path to the `.beans/` directory if found.
7/// Errors if no `.beans/` directory exists in any ancestor.
8pub fn find_beans_dir(start: &Path) -> Result<PathBuf> {
9    let mut current = start.to_path_buf();
10    loop {
11        let candidate = current.join(".beans");
12        if candidate.is_dir() {
13            return Ok(candidate);
14        }
15        if !current.pop() {
16            bail!("No .beans/ directory found. Run `bn init` first.");
17        }
18    }
19}
20
21/// Find a bean file by ID, supporting both new and legacy naming conventions.
22///
23/// Searches for bean files in this order:
24/// 1. New format: `{id}-{slug}.md` (e.g., "1-my-task.md", "11.1-refactor-parser.md")
25/// 2. Legacy format: `{id}.yaml` (e.g., "1.yaml", "11.1.yaml")
26///
27/// Returns the full path if found.
28///
29/// # Examples
30/// - `find_bean_file(beans_dir, "1")` → `.beans/1-my-task.md` or `.beans/1.yaml`
31/// - `find_bean_file(beans_dir, "11.1")` → `.beans/11.1-refactor-parser.md` or `.beans/11.1.yaml`
32///
33/// # Arguments
34/// * `beans_dir` - Path to the `.beans/` directory
35/// * `id` - The bean ID to find (e.g., "1", "11.1", "3.2.1")
36///
37/// # Errors
38/// * If the ID is invalid (empty, contains path traversal, etc.)
39/// * If no bean file is found for the given ID
40/// * If glob pattern matching fails
41pub fn find_bean_file(beans_dir: &Path, id: &str) -> Result<PathBuf> {
42    // Validate ID to prevent path traversal attacks
43    crate::util::validate_bean_id(id)?;
44
45    // First, try the new naming convention: {id}-{slug}.md
46    let md_pattern = format!("{}/*{}-*.md", beans_dir.display(), id);
47    for entry in glob::glob(&md_pattern).context("glob pattern failed")? {
48        let path = entry?;
49        if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
50            // Check if filename matches {id}-*.md pattern exactly
51            if filename.starts_with(&format!("{}-", id)) && filename.ends_with(".md") {
52                return Ok(path);
53            }
54        }
55    }
56
57    // Fallback to legacy naming convention: {id}.yaml
58    let yaml_path = beans_dir.join(format!("{}.yaml", id));
59    if yaml_path.exists() {
60        return Ok(yaml_path);
61    }
62
63    Err(anyhow::anyhow!("Bean {} not found", id))
64}
65
66/// Compute the archive path for a bean given its ID, slug, and date.
67///
68/// Returns the path: `.beans/archive/YYYY/MM/<id>-<slug>.md`
69///
70/// # Arguments
71/// * `beans_dir` - Path to the `.beans/` directory
72/// * `id` - The bean ID (e.g., "1", "11.1", "3.2.1")
73/// * `slug` - The bean slug (derived from title)
74/// * `ext` - The file extension (e.g., "md", "yaml")
75/// * `date` - The date to use for year/month subdirectories
76///
77/// # Returns
78/// A PathBuf representing `.beans/archive/YYYY/MM/<id>-<slug>.<ext>`
79///
80/// # Examples
81/// ```ignore
82/// let path = archive_path_for_bean(
83///     Path::new(".beans"),
84///     "12",
85///     "bean-archive-system",
86///     "md",
87///     chrono::NaiveDate::from_ymd_opt(2026, 1, 31).unwrap()
88/// );
89/// // Returns: .beans/archive/2026/01/12-bean-archive-system.md
90/// ```
91pub fn archive_path_for_bean(
92    beans_dir: &Path,
93    id: &str,
94    slug: &str,
95    ext: &str,
96    date: chrono::NaiveDate,
97) -> PathBuf {
98    let year = date.format("%Y").to_string();
99    let month = date.format("%m").to_string();
100    let filename = format!("{}-{}.{}", id, slug, ext);
101    beans_dir
102        .join("archive")
103        .join(&year)
104        .join(&month)
105        .join(filename)
106}
107
108/// Find an archived bean by ID within the `.beans/archive/` directory tree.
109///
110/// Searches recursively through `.beans/archive/**/` for a bean file matching the given ID.
111/// Returns the full path to the first matching bean file.
112///
113/// # Arguments
114/// * `beans_dir` - Path to the `.beans/` directory
115/// * `id` - The bean ID to search for
116///
117/// # Returns
118/// `Ok(PathBuf)` with the path to the archived bean file if found
119/// `Err` if the bean is not found in the archive
120///
121/// # Examples
122/// ```ignore
123/// let path = find_archived_bean(Path::new(".beans"), "12")?;
124/// // Returns: .beans/archive/2026/01/12-bean-archive-system.md
125/// ```
126pub fn find_archived_bean(beans_dir: &Path, id: &str) -> Result<PathBuf> {
127    // Validate ID to prevent path traversal attacks
128    crate::util::validate_bean_id(id)?;
129
130    let archive_dir = beans_dir.join("archive");
131
132    // If archive directory doesn't exist, bean is not archived
133    if !archive_dir.is_dir() {
134        bail!(
135            "Archived bean {} not found (archive directory does not exist)",
136            id
137        );
138    }
139
140    // Recursively search through year subdirectories
141    for year_entry in std::fs::read_dir(&archive_dir).context("Failed to read archive directory")? {
142        let year_path = year_entry?.path();
143        if !year_path.is_dir() {
144            continue;
145        }
146
147        // Search through month subdirectories
148        for month_entry in std::fs::read_dir(&year_path).context("Failed to read year directory")? {
149            let month_path = month_entry?.path();
150            if !month_path.is_dir() {
151                continue;
152            }
153
154            // Search through bean files in month directory
155            for bean_entry in
156                std::fs::read_dir(&month_path).context("Failed to read month directory")?
157            {
158                let bean_path = bean_entry?.path();
159                if !bean_path.is_file() {
160                    continue;
161                }
162
163                // Check if filename matches the pattern {id}-*.md
164                if let Some(filename) = bean_path.file_name().and_then(|n| n.to_str()) {
165                    if filename.starts_with(&format!("{}-", id)) && filename.ends_with(".md") {
166                        return Ok(bean_path);
167                    }
168                }
169            }
170        }
171    }
172
173    Err(anyhow::anyhow!("Archived bean {} not found", id))
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use std::fs;
180
181    #[test]
182    fn finds_beans_in_current_dir() {
183        let dir = tempfile::tempdir().unwrap();
184        fs::create_dir(dir.path().join(".beans")).unwrap();
185
186        let result = find_beans_dir(dir.path()).unwrap();
187        assert_eq!(result, dir.path().join(".beans"));
188    }
189
190    #[test]
191    fn finds_beans_in_parent_dir() {
192        let dir = tempfile::tempdir().unwrap();
193        fs::create_dir(dir.path().join(".beans")).unwrap();
194        let child = dir.path().join("src");
195        fs::create_dir(&child).unwrap();
196
197        let result = find_beans_dir(&child).unwrap();
198        assert_eq!(result, dir.path().join(".beans"));
199    }
200
201    #[test]
202    fn finds_beans_in_grandparent_dir() {
203        let dir = tempfile::tempdir().unwrap();
204        fs::create_dir(dir.path().join(".beans")).unwrap();
205        let child = dir.path().join("src").join("deep");
206        fs::create_dir_all(&child).unwrap();
207
208        let result = find_beans_dir(&child).unwrap();
209        assert_eq!(result, dir.path().join(".beans"));
210    }
211
212    #[test]
213    fn returns_error_when_no_beans_exists() {
214        let dir = tempfile::tempdir().unwrap();
215        let child = dir.path().join("some").join("nested").join("dir");
216        fs::create_dir_all(&child).unwrap();
217
218        let result = find_beans_dir(&child);
219        assert!(result.is_err());
220        let err_msg = result.unwrap_err().to_string();
221        assert!(
222            err_msg.contains("No .beans/ directory found"),
223            "Error message was: {}",
224            err_msg
225        );
226    }
227
228    #[test]
229    fn prefers_closest_beans_dir() {
230        let dir = tempfile::tempdir().unwrap();
231        // Parent has .beans
232        fs::create_dir(dir.path().join(".beans")).unwrap();
233        // Child also has .beans
234        let child = dir.path().join("subproject");
235        fs::create_dir(&child).unwrap();
236        fs::create_dir(child.join(".beans")).unwrap();
237
238        let result = find_beans_dir(&child).unwrap();
239        assert_eq!(result, child.join(".beans"));
240    }
241
242    // =====================================================================
243    // Tests for find_bean_file()
244    // =====================================================================
245
246    #[test]
247    fn find_bean_file_simple_id() {
248        let dir = tempfile::tempdir().unwrap();
249        let beans_dir = dir.path().join(".beans");
250        fs::create_dir(&beans_dir).unwrap();
251
252        // Create a bean file with slug
253        fs::write(beans_dir.join("1-my-task.md"), "test content").unwrap();
254
255        let result = find_bean_file(&beans_dir, "1").unwrap();
256        assert_eq!(result, beans_dir.join("1-my-task.md"));
257    }
258
259    #[test]
260    fn find_bean_file_hierarchical_id() {
261        let dir = tempfile::tempdir().unwrap();
262        let beans_dir = dir.path().join(".beans");
263        fs::create_dir(&beans_dir).unwrap();
264
265        // Create a bean file with hierarchical ID
266        fs::write(beans_dir.join("11.1-refactor-parser.md"), "test content").unwrap();
267
268        let result = find_bean_file(&beans_dir, "11.1").unwrap();
269        assert_eq!(result, beans_dir.join("11.1-refactor-parser.md"));
270    }
271
272    #[test]
273    fn find_bean_file_three_level_id() {
274        let dir = tempfile::tempdir().unwrap();
275        let beans_dir = dir.path().join(".beans");
276        fs::create_dir(&beans_dir).unwrap();
277
278        // Create a bean file with three-level ID
279        fs::write(beans_dir.join("3.2.1-deep-task.md"), "test content").unwrap();
280
281        let result = find_bean_file(&beans_dir, "3.2.1").unwrap();
282        assert_eq!(result, beans_dir.join("3.2.1-deep-task.md"));
283    }
284
285    #[test]
286    fn find_bean_file_returns_first_match() {
287        let dir = tempfile::tempdir().unwrap();
288        let beans_dir = dir.path().join(".beans");
289        fs::create_dir(&beans_dir).unwrap();
290
291        // Create multiple files that start with the same ID prefix
292        // (shouldn't happen in practice, but test the behavior)
293        fs::write(beans_dir.join("2-alpha.md"), "first").unwrap();
294        fs::write(beans_dir.join("2-beta.md"), "second").unwrap();
295
296        let result = find_bean_file(&beans_dir, "2").unwrap();
297        // Should return one of the files (glob order is implementation-dependent)
298        assert!(result.ends_with("2-alpha.md") || result.ends_with("2-beta.md"));
299        assert!(result
300            .file_name()
301            .unwrap()
302            .to_str()
303            .unwrap()
304            .ends_with(".md"));
305    }
306
307    #[test]
308    fn find_bean_file_not_found() {
309        let dir = tempfile::tempdir().unwrap();
310        let beans_dir = dir.path().join(".beans");
311        fs::create_dir(&beans_dir).unwrap();
312
313        // Try to find a bean that doesn't exist
314        let result = find_bean_file(&beans_dir, "999");
315        assert!(result.is_err());
316        let err_msg = result.unwrap_err().to_string();
317        assert!(err_msg.contains("Bean 999 not found"));
318    }
319
320    #[test]
321    fn find_bean_file_validates_id() {
322        let dir = tempfile::tempdir().unwrap();
323        let beans_dir = dir.path().join(".beans");
324        fs::create_dir(&beans_dir).unwrap();
325
326        // Try to find with invalid ID (path traversal attempt)
327        let result = find_bean_file(&beans_dir, "../../../etc/passwd");
328        assert!(result.is_err());
329        let err_msg = result.unwrap_err().to_string();
330        assert!(err_msg.contains("Invalid bean ID") || err_msg.contains("path traversal"));
331    }
332
333    #[test]
334    fn find_bean_file_validates_empty_id() {
335        let dir = tempfile::tempdir().unwrap();
336        let beans_dir = dir.path().join(".beans");
337        fs::create_dir(&beans_dir).unwrap();
338
339        // Try to find with empty ID
340        let result = find_bean_file(&beans_dir, "");
341        assert!(result.is_err());
342        let err_msg = result.unwrap_err().to_string();
343        assert!(err_msg.contains("cannot be empty") || err_msg.contains("invalid"));
344    }
345
346    #[test]
347    fn find_bean_file_with_long_slug() {
348        let dir = tempfile::tempdir().unwrap();
349        let beans_dir = dir.path().join(".beans");
350        fs::create_dir(&beans_dir).unwrap();
351
352        // Create a bean file with a long slug
353        let long_slug = "implement-comprehensive-feature-with-full-test-coverage";
354        let filename = format!("5-{}.md", long_slug);
355        fs::write(beans_dir.join(&filename), "test content").unwrap();
356
357        let result = find_bean_file(&beans_dir, "5").unwrap();
358        assert!(result.to_str().unwrap().contains(long_slug));
359    }
360
361    #[test]
362    fn find_bean_file_supports_legacy_yaml_files() {
363        let dir = tempfile::tempdir().unwrap();
364        let beans_dir = dir.path().join(".beans");
365        fs::create_dir(&beans_dir).unwrap();
366
367        // Create a .yaml file (legacy format - should be found as fallback)
368        fs::write(beans_dir.join("7.yaml"), "old format").unwrap();
369
370        // Should find the legacy .yaml file
371        let result = find_bean_file(&beans_dir, "7");
372        assert!(result.is_ok());
373        assert!(result.unwrap().ends_with("7.yaml"));
374    }
375
376    #[test]
377    fn find_bean_file_prefers_md_over_yaml() {
378        let dir = tempfile::tempdir().unwrap();
379        let beans_dir = dir.path().join(".beans");
380        fs::create_dir(&beans_dir).unwrap();
381
382        // Create both formats - .md should be preferred
383        fs::write(beans_dir.join("7-my-task.md"), "new format").unwrap();
384        fs::write(beans_dir.join("7.yaml"), "old format").unwrap();
385
386        let result = find_bean_file(&beans_dir, "7");
387        assert!(result.is_ok());
388        assert!(result.unwrap().ends_with("7-my-task.md"));
389    }
390
391    #[test]
392    fn find_bean_file_ignores_files_without_proper_prefix() {
393        let dir = tempfile::tempdir().unwrap();
394        let beans_dir = dir.path().join(".beans");
395        fs::create_dir(&beans_dir).unwrap();
396
397        // Create a file that doesn't match the pattern
398        fs::write(beans_dir.join("7-something-else.md"), "wrong file").unwrap();
399
400        // Try to find "8" (which exists as "7-something-else.md")
401        let result = find_bean_file(&beans_dir, "8");
402        assert!(result.is_err());
403    }
404
405    #[test]
406    fn find_bean_file_handles_numeric_id_prefix_matching() {
407        let dir = tempfile::tempdir().unwrap();
408        let beans_dir = dir.path().join(".beans");
409        fs::create_dir(&beans_dir).unwrap();
410
411        // Create files: "2-task.md" and "20-task.md"
412        fs::write(beans_dir.join("2-task.md"), "bean 2").unwrap();
413        fs::write(beans_dir.join("20-task.md"), "bean 20").unwrap();
414
415        // Looking for "2" should only match "2-task.md", not "20-task.md"
416        let result = find_bean_file(&beans_dir, "2").unwrap();
417        assert_eq!(result, beans_dir.join("2-task.md"));
418    }
419
420    #[test]
421    fn find_bean_file_with_special_chars_in_slug() {
422        let dir = tempfile::tempdir().unwrap();
423        let beans_dir = dir.path().join(".beans");
424        fs::create_dir(&beans_dir).unwrap();
425
426        // Create a bean file with hyphens and numbers in slug
427        fs::write(beans_dir.join("6-v2-refactor-api.md"), "test").unwrap();
428
429        let result = find_bean_file(&beans_dir, "6").unwrap();
430        assert_eq!(result, beans_dir.join("6-v2-refactor-api.md"));
431    }
432
433    #[test]
434    fn find_bean_file_rejects_special_chars_in_id() {
435        let dir = tempfile::tempdir().unwrap();
436        let beans_dir = dir.path().join(".beans");
437        fs::create_dir(&beans_dir).unwrap();
438
439        // Try IDs with special characters that should be rejected
440        assert!(find_bean_file(&beans_dir, "task@home").is_err());
441        assert!(find_bean_file(&beans_dir, "task#1").is_err());
442        assert!(find_bean_file(&beans_dir, "task$money").is_err());
443    }
444
445    // =====================================================================
446    // Tests for archive_path_for_bean()
447    // =====================================================================
448
449    #[test]
450    fn archive_path_for_bean_basic() {
451        let dir = tempfile::tempdir().unwrap();
452        let beans_dir = dir.path().join(".beans");
453
454        let date = chrono::NaiveDate::from_ymd_opt(2026, 1, 31).unwrap();
455        let path = archive_path_for_bean(&beans_dir, "12", "bean-archive-system", "md", date);
456
457        // Verify path structure
458        assert_eq!(
459            path,
460            beans_dir.join("archive/2026/01/12-bean-archive-system.md")
461        );
462    }
463
464    #[test]
465    fn archive_path_for_bean_hierarchical_id() {
466        let dir = tempfile::tempdir().unwrap();
467        let beans_dir = dir.path().join(".beans");
468
469        let date = chrono::NaiveDate::from_ymd_opt(2025, 12, 15).unwrap();
470        let path = archive_path_for_bean(&beans_dir, "11.1", "refactor-parser", "md", date);
471
472        assert_eq!(
473            path,
474            beans_dir.join("archive/2025/12/11.1-refactor-parser.md")
475        );
476    }
477
478    #[test]
479    fn archive_path_for_bean_single_digit_month() {
480        let dir = tempfile::tempdir().unwrap();
481        let beans_dir = dir.path().join(".beans");
482
483        let date = chrono::NaiveDate::from_ymd_opt(2026, 3, 5).unwrap();
484        let path = archive_path_for_bean(&beans_dir, "5", "task", "md", date);
485
486        // Month should be zero-padded (03, not 3)
487        assert_eq!(path, beans_dir.join("archive/2026/03/5-task.md"));
488    }
489
490    #[test]
491    fn archive_path_for_bean_three_level_id() {
492        let dir = tempfile::tempdir().unwrap();
493        let beans_dir = dir.path().join(".beans");
494
495        let date = chrono::NaiveDate::from_ymd_opt(2024, 8, 20).unwrap();
496        let path = archive_path_for_bean(&beans_dir, "3.2.1", "deep-task", "md", date);
497
498        assert_eq!(path, beans_dir.join("archive/2024/08/3.2.1-deep-task.md"));
499    }
500
501    #[test]
502    fn archive_path_for_bean_long_slug() {
503        let dir = tempfile::tempdir().unwrap();
504        let beans_dir = dir.path().join(".beans");
505
506        let date = chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
507        let long_slug = "implement-comprehensive-feature-with-full-test-coverage";
508        let path = archive_path_for_bean(&beans_dir, "42", long_slug, "md", date);
509
510        assert!(path.to_str().unwrap().contains(long_slug));
511        assert_eq!(
512            path,
513            beans_dir.join(
514                "archive/2026/01/42-implement-comprehensive-feature-with-full-test-coverage.md"
515            )
516        );
517    }
518
519    #[test]
520    fn archive_path_for_bean_yaml_extension() {
521        let dir = tempfile::tempdir().unwrap();
522        let beans_dir = dir.path().join(".beans");
523
524        let date = chrono::NaiveDate::from_ymd_opt(2026, 1, 31).unwrap();
525        let path = archive_path_for_bean(&beans_dir, "5", "yaml-task", "yaml", date);
526
527        assert_eq!(path, beans_dir.join("archive/2026/01/5-yaml-task.yaml"));
528    }
529
530    // =====================================================================
531    // Tests for find_archived_bean()
532    // =====================================================================
533
534    #[test]
535    fn find_archived_bean_simple_id() {
536        let dir = tempfile::tempdir().unwrap();
537        let beans_dir = dir.path().join(".beans");
538        let archive_dir = beans_dir.join("archive/2026/01");
539        fs::create_dir_all(&archive_dir).unwrap();
540
541        // Create an archived bean file
542        fs::write(archive_dir.join("12-bean-archive.md"), "archived content").unwrap();
543
544        let result = find_archived_bean(&beans_dir, "12").unwrap();
545        assert_eq!(result, archive_dir.join("12-bean-archive.md"));
546    }
547
548    #[test]
549    fn find_archived_bean_hierarchical_id() {
550        let dir = tempfile::tempdir().unwrap();
551        let beans_dir = dir.path().join(".beans");
552        let archive_dir = beans_dir.join("archive/2025/12");
553        fs::create_dir_all(&archive_dir).unwrap();
554
555        // Create an archived bean file
556        fs::write(
557            archive_dir.join("11.1-refactor-parser.md"),
558            "archived content",
559        )
560        .unwrap();
561
562        let result = find_archived_bean(&beans_dir, "11.1").unwrap();
563        assert_eq!(result, archive_dir.join("11.1-refactor-parser.md"));
564    }
565
566    #[test]
567    fn find_archived_bean_multiple_years() {
568        let dir = tempfile::tempdir().unwrap();
569        let beans_dir = dir.path().join(".beans");
570
571        // Create archive structure with multiple years
572        fs::create_dir_all(beans_dir.join("archive/2024/06")).unwrap();
573        fs::create_dir_all(beans_dir.join("archive/2025/12")).unwrap();
574        fs::create_dir_all(beans_dir.join("archive/2026/01")).unwrap();
575
576        // Create bean in 2024
577        fs::write(
578            beans_dir.join("archive/2024/06/5-old-task.md"),
579            "old content",
580        )
581        .unwrap();
582
583        // Create bean in 2026
584        fs::write(
585            beans_dir.join("archive/2026/01/12-new-task.md"),
586            "new content",
587        )
588        .unwrap();
589
590        // Should find the bean regardless of year
591        let result = find_archived_bean(&beans_dir, "5").unwrap();
592        assert!(result.to_str().unwrap().contains("2024/06"));
593
594        let result = find_archived_bean(&beans_dir, "12").unwrap();
595        assert!(result.to_str().unwrap().contains("2026/01"));
596    }
597
598    #[test]
599    fn find_archived_bean_multiple_months() {
600        let dir = tempfile::tempdir().unwrap();
601        let beans_dir = dir.path().join(".beans");
602
603        // Create archive structure with multiple months in same year
604        fs::create_dir_all(beans_dir.join("archive/2026/01")).unwrap();
605        fs::create_dir_all(beans_dir.join("archive/2026/02")).unwrap();
606        fs::create_dir_all(beans_dir.join("archive/2026/03")).unwrap();
607
608        // Create beans in different months
609        fs::write(
610            beans_dir.join("archive/2026/01/10-january-task.md"),
611            "january",
612        )
613        .unwrap();
614
615        fs::write(beans_dir.join("archive/2026/03/10-march-task.md"), "march").unwrap();
616
617        // Both should be found (returns first match)
618        let result = find_archived_bean(&beans_dir, "10").unwrap();
619        assert!(result.to_str().unwrap().contains("2026"));
620        assert!(result
621            .file_name()
622            .unwrap()
623            .to_str()
624            .unwrap()
625            .starts_with("10-"));
626    }
627
628    #[test]
629    fn find_archived_bean_not_found() {
630        let dir = tempfile::tempdir().unwrap();
631        let beans_dir = dir.path().join(".beans");
632        let archive_dir = beans_dir.join("archive/2026/01");
633        fs::create_dir_all(&archive_dir).unwrap();
634
635        // Create a different bean
636        fs::write(archive_dir.join("12-some-task.md"), "content").unwrap();
637
638        // Try to find a bean that doesn't exist
639        let result = find_archived_bean(&beans_dir, "999");
640        assert!(result.is_err());
641        assert!(result
642            .unwrap_err()
643            .to_string()
644            .contains("Archived bean 999 not found"));
645    }
646
647    #[test]
648    fn find_archived_bean_no_archive_dir() {
649        let dir = tempfile::tempdir().unwrap();
650        let beans_dir = dir.path().join(".beans");
651        fs::create_dir(&beans_dir).unwrap();
652
653        // Archive directory doesn't exist
654        let result = find_archived_bean(&beans_dir, "12");
655        assert!(result.is_err());
656        let err_msg = result.unwrap_err().to_string();
657        assert!(err_msg.contains("Archived bean 12 not found"));
658    }
659
660    #[test]
661    fn find_archived_bean_validates_id() {
662        let dir = tempfile::tempdir().unwrap();
663        let beans_dir = dir.path().join(".beans");
664        fs::create_dir(&beans_dir).unwrap();
665
666        // Try with invalid IDs (path traversal)
667        let result = find_archived_bean(&beans_dir, "../../../etc/passwd");
668        assert!(result.is_err());
669        assert!(result.unwrap_err().to_string().contains("Invalid bean ID"));
670
671        let result = find_archived_bean(&beans_dir, "");
672        assert!(result.is_err());
673        assert!(result.unwrap_err().to_string().contains("cannot be empty"));
674    }
675
676    #[test]
677    fn find_archived_bean_three_level_id() {
678        let dir = tempfile::tempdir().unwrap();
679        let beans_dir = dir.path().join(".beans");
680        let archive_dir = beans_dir.join("archive/2024/08");
681        fs::create_dir_all(&archive_dir).unwrap();
682
683        // Create an archived bean with three-level ID
684        fs::write(archive_dir.join("3.2.1-deep-task.md"), "archived content").unwrap();
685
686        let result = find_archived_bean(&beans_dir, "3.2.1").unwrap();
687        assert_eq!(result, archive_dir.join("3.2.1-deep-task.md"));
688    }
689
690    #[test]
691    fn find_archived_bean_ignores_non_matching_ids() {
692        let dir = tempfile::tempdir().unwrap();
693        let beans_dir = dir.path().join(".beans");
694        let archive_dir = beans_dir.join("archive/2026/01");
695        fs::create_dir_all(&archive_dir).unwrap();
696
697        // Create beans with similar IDs
698        fs::write(archive_dir.join("1-first-task.md"), "bean 1").unwrap();
699        fs::write(archive_dir.join("10-tenth-task.md"), "bean 10").unwrap();
700        fs::write(archive_dir.join("100-hundredth-task.md"), "bean 100").unwrap();
701
702        // Looking for "1" should only match "1-first-task.md", not "10-" or "100-"
703        let result = find_archived_bean(&beans_dir, "1").unwrap();
704        assert_eq!(result, archive_dir.join("1-first-task.md"));
705
706        // Looking for "10" should only match "10-tenth-task.md"
707        let result = find_archived_bean(&beans_dir, "10").unwrap();
708        assert_eq!(result, archive_dir.join("10-tenth-task.md"));
709    }
710
711    #[test]
712    fn find_archived_bean_with_long_slug() {
713        let dir = tempfile::tempdir().unwrap();
714        let beans_dir = dir.path().join(".beans");
715        let archive_dir = beans_dir.join("archive/2026/01");
716        fs::create_dir_all(&archive_dir).unwrap();
717
718        let long_slug = "implement-comprehensive-feature-with-full-test-coverage";
719        let filename = format!("42-{}.md", long_slug);
720        fs::write(archive_dir.join(&filename), "archived").unwrap();
721
722        let result = find_archived_bean(&beans_dir, "42").unwrap();
723        assert!(result.to_str().unwrap().contains(long_slug));
724    }
725}