1use std::path::{Path, PathBuf};
2
3use anyhow::{bail, Context, Result};
4
5pub 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
21pub fn find_bean_file(beans_dir: &Path, id: &str) -> Result<PathBuf> {
42 crate::util::validate_bean_id(id)?;
44
45 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 if filename.starts_with(&format!("{}-", id)) && filename.ends_with(".md") {
52 return Ok(path);
53 }
54 }
55 }
56
57 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
66pub 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
108pub fn find_archived_bean(beans_dir: &Path, id: &str) -> Result<PathBuf> {
127 crate::util::validate_bean_id(id)?;
129
130 let archive_dir = beans_dir.join("archive");
131
132 if !archive_dir.is_dir() {
134 bail!(
135 "Archived bean {} not found (archive directory does not exist)",
136 id
137 );
138 }
139
140 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 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 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 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 fs::create_dir(dir.path().join(".beans")).unwrap();
233 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 #[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 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 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 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 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 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 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 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 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 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 fs::write(beans_dir.join("7.yaml"), "old format").unwrap();
369
370 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 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 fs::write(beans_dir.join("7-something-else.md"), "wrong file").unwrap();
399
400 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 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 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 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 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 #[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 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 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 #[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 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 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 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 fs::write(
578 beans_dir.join("archive/2024/06/5-old-task.md"),
579 "old content",
580 )
581 .unwrap();
582
583 fs::write(
585 beans_dir.join("archive/2026/01/12-new-task.md"),
586 "new content",
587 )
588 .unwrap();
589
590 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 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 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 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 fs::write(archive_dir.join("12-some-task.md"), "content").unwrap();
637
638 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 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 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 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 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 let result = find_archived_bean(&beans_dir, "1").unwrap();
704 assert_eq!(result, archive_dir.join("1-first-task.md"));
705
706 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}