1use std::path::{Path, PathBuf};
2
3use anyhow::{bail, Context, Result};
4
5pub fn find_mana_dir(start: &Path) -> Result<PathBuf> {
9 if let Some(found) = find_mana_dir_in_ancestors(start) {
10 return Ok(found);
11 }
12
13 if let Ok(canonical_start) = start.canonicalize() {
14 if canonical_start != start {
15 if let Some(found) = find_mana_dir_in_ancestors(&canonical_start) {
16 return Ok(found);
17 }
18 }
19 }
20
21 bail!("No .mana/ directory found. Run `mana init` first.");
22}
23
24fn find_mana_dir_in_ancestors(start: &Path) -> Option<PathBuf> {
25 let mut current = start.to_path_buf();
26 loop {
27 let candidate = current.join(".mana");
28 if candidate.is_dir() {
29 return Some(candidate);
30 }
31 if !current.pop() {
32 return None;
33 }
34 }
35}
36
37pub fn find_unit_file(mana_dir: &Path, id: &str) -> Result<PathBuf> {
58 crate::util::validate_unit_id(id)?;
60
61 let md_pattern = format!("{}/*{}-*.md", mana_dir.display(), id);
63 for entry in glob::glob(&md_pattern).context("glob pattern failed")? {
64 let path = entry?;
65 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
66 if filename.starts_with(&format!("{}-", id)) && filename.ends_with(".md") {
68 return Ok(path);
69 }
70 }
71 }
72
73 let yaml_path = mana_dir.join(format!("{}.yaml", id));
75 if yaml_path.exists() {
76 return Ok(yaml_path);
77 }
78
79 Err(anyhow::anyhow!("Unit {} not found", id))
80}
81
82pub fn archive_path_for_unit(
108 mana_dir: &Path,
109 id: &str,
110 slug: &str,
111 ext: &str,
112 date: chrono::NaiveDate,
113) -> PathBuf {
114 let year = date.format("%Y").to_string();
115 let month = date.format("%m").to_string();
116 let filename = format!("{}-{}.{}", id, slug, ext);
117 mana_dir
118 .join("archive")
119 .join(&year)
120 .join(&month)
121 .join(filename)
122}
123
124pub fn find_archived_unit(mana_dir: &Path, id: &str) -> Result<PathBuf> {
143 crate::util::validate_unit_id(id)?;
145
146 let archive_dir = mana_dir.join("archive");
147
148 if !archive_dir.is_dir() {
150 bail!(
151 "Archived unit {} not found (archive directory does not exist)",
152 id
153 );
154 }
155
156 for year_entry in std::fs::read_dir(&archive_dir).context("Failed to read archive directory")? {
158 let year_path = year_entry?.path();
159 if !year_path.is_dir() {
160 continue;
161 }
162
163 for month_entry in std::fs::read_dir(&year_path).context("Failed to read year directory")? {
165 let month_path = month_entry?.path();
166 if !month_path.is_dir() {
167 continue;
168 }
169
170 for unit_entry in
172 std::fs::read_dir(&month_path).context("Failed to read month directory")?
173 {
174 let unit_path = unit_entry?.path();
175 if !unit_path.is_file() {
176 continue;
177 }
178
179 if let Some(filename) = unit_path.file_name().and_then(|n| n.to_str()) {
181 if filename.starts_with(&format!("{}-", id)) && filename.ends_with(".md") {
182 return Ok(unit_path);
183 }
184 }
185 }
186 }
187 }
188
189 Err(anyhow::anyhow!("Archived unit {} not found", id))
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use std::fs;
196
197 #[test]
198 fn finds_units_in_current_dir() {
199 let dir = tempfile::tempdir().unwrap();
200 fs::create_dir(dir.path().join(".mana")).unwrap();
201
202 let result = find_mana_dir(dir.path()).unwrap();
203 assert_eq!(result, dir.path().join(".mana"));
204 }
205
206 #[test]
207 fn finds_units_in_parent_dir() {
208 let dir = tempfile::tempdir().unwrap();
209 fs::create_dir(dir.path().join(".mana")).unwrap();
210 let child = dir.path().join("src");
211 fs::create_dir(&child).unwrap();
212
213 let result = find_mana_dir(&child).unwrap();
214 assert_eq!(result, dir.path().join(".mana"));
215 }
216
217 #[cfg(unix)]
218 #[test]
219 fn finds_units_through_symlinked_start_path() {
220 use std::os::unix::fs::symlink;
221
222 let dir = tempfile::tempdir().unwrap();
223 fs::create_dir(dir.path().join(".mana")).unwrap();
224 let real_child = dir.path().join("project");
225 fs::create_dir(&real_child).unwrap();
226
227 let symlink_root = tempfile::tempdir().unwrap();
228 let link_path = symlink_root.path().join("linked-project");
229 symlink(&real_child, &link_path).unwrap();
230
231 let result = find_mana_dir(&link_path).unwrap();
232 assert_eq!(
233 result.canonicalize().unwrap(),
234 dir.path().join(".mana").canonicalize().unwrap()
235 );
236 }
237
238 #[test]
239 fn finds_units_in_grandparent_dir() {
240 let dir = tempfile::tempdir().unwrap();
241 fs::create_dir(dir.path().join(".mana")).unwrap();
242 let child = dir.path().join("src").join("deep");
243 fs::create_dir_all(&child).unwrap();
244
245 let result = find_mana_dir(&child).unwrap();
246 assert_eq!(result, dir.path().join(".mana"));
247 }
248
249 #[test]
250 fn returns_error_when_no_units_exists() {
251 let dir = tempfile::tempdir().unwrap();
252 let child = dir.path().join("some").join("nested").join("dir");
253 fs::create_dir_all(&child).unwrap();
254
255 let result = find_mana_dir(&child);
256 assert!(result.is_err());
257 let err_msg = result.unwrap_err().to_string();
258 assert!(
259 err_msg.contains("No .mana/ directory found"),
260 "Error message was: {}",
261 err_msg
262 );
263 }
264
265 #[test]
266 fn prefers_closest_mana_dir() {
267 let dir = tempfile::tempdir().unwrap();
268 fs::create_dir(dir.path().join(".mana")).unwrap();
270 let child = dir.path().join("subproject");
272 fs::create_dir(&child).unwrap();
273 fs::create_dir(child.join(".mana")).unwrap();
274
275 let result = find_mana_dir(&child).unwrap();
276 assert_eq!(result, child.join(".mana"));
277 }
278
279 #[test]
284 fn find_unit_file_simple_id() {
285 let dir = tempfile::tempdir().unwrap();
286 let mana_dir = dir.path().join(".mana");
287 fs::create_dir(&mana_dir).unwrap();
288
289 fs::write(mana_dir.join("1-my-task.md"), "test content").unwrap();
291
292 let result = find_unit_file(&mana_dir, "1").unwrap();
293 assert_eq!(result, mana_dir.join("1-my-task.md"));
294 }
295
296 #[test]
297 fn find_unit_file_hierarchical_id() {
298 let dir = tempfile::tempdir().unwrap();
299 let mana_dir = dir.path().join(".mana");
300 fs::create_dir(&mana_dir).unwrap();
301
302 fs::write(mana_dir.join("11.1-refactor-parser.md"), "test content").unwrap();
304
305 let result = find_unit_file(&mana_dir, "11.1").unwrap();
306 assert_eq!(result, mana_dir.join("11.1-refactor-parser.md"));
307 }
308
309 #[test]
310 fn find_unit_file_three_level_id() {
311 let dir = tempfile::tempdir().unwrap();
312 let mana_dir = dir.path().join(".mana");
313 fs::create_dir(&mana_dir).unwrap();
314
315 fs::write(mana_dir.join("3.2.1-deep-task.md"), "test content").unwrap();
317
318 let result = find_unit_file(&mana_dir, "3.2.1").unwrap();
319 assert_eq!(result, mana_dir.join("3.2.1-deep-task.md"));
320 }
321
322 #[test]
323 fn find_unit_file_returns_first_match() {
324 let dir = tempfile::tempdir().unwrap();
325 let mana_dir = dir.path().join(".mana");
326 fs::create_dir(&mana_dir).unwrap();
327
328 fs::write(mana_dir.join("2-alpha.md"), "first").unwrap();
331 fs::write(mana_dir.join("2-beta.md"), "second").unwrap();
332
333 let result = find_unit_file(&mana_dir, "2").unwrap();
334 assert!(result.ends_with("2-alpha.md") || result.ends_with("2-beta.md"));
336 assert!(result
337 .file_name()
338 .unwrap()
339 .to_str()
340 .unwrap()
341 .ends_with(".md"));
342 }
343
344 #[test]
345 fn find_unit_file_not_found() {
346 let dir = tempfile::tempdir().unwrap();
347 let mana_dir = dir.path().join(".mana");
348 fs::create_dir(&mana_dir).unwrap();
349
350 let result = find_unit_file(&mana_dir, "999");
352 assert!(result.is_err());
353 let err_msg = result.unwrap_err().to_string();
354 assert!(err_msg.contains("Unit 999 not found"));
355 }
356
357 #[test]
358 fn find_unit_file_validates_id() {
359 let dir = tempfile::tempdir().unwrap();
360 let mana_dir = dir.path().join(".mana");
361 fs::create_dir(&mana_dir).unwrap();
362
363 let result = find_unit_file(&mana_dir, "../../../etc/passwd");
365 assert!(result.is_err());
366 let err_msg = result.unwrap_err().to_string();
367 assert!(err_msg.contains("Invalid unit ID") || err_msg.contains("path traversal"));
368 }
369
370 #[test]
371 fn find_unit_file_validates_empty_id() {
372 let dir = tempfile::tempdir().unwrap();
373 let mana_dir = dir.path().join(".mana");
374 fs::create_dir(&mana_dir).unwrap();
375
376 let result = find_unit_file(&mana_dir, "");
378 assert!(result.is_err());
379 let err_msg = result.unwrap_err().to_string();
380 assert!(err_msg.contains("cannot be empty") || err_msg.contains("invalid"));
381 }
382
383 #[test]
384 fn find_unit_file_with_long_slug() {
385 let dir = tempfile::tempdir().unwrap();
386 let mana_dir = dir.path().join(".mana");
387 fs::create_dir(&mana_dir).unwrap();
388
389 let long_slug = "implement-comprehensive-feature-with-full-test-coverage";
391 let filename = format!("5-{}.md", long_slug);
392 fs::write(mana_dir.join(&filename), "test content").unwrap();
393
394 let result = find_unit_file(&mana_dir, "5").unwrap();
395 assert!(result.to_str().unwrap().contains(long_slug));
396 }
397
398 #[test]
399 fn find_unit_file_supports_legacy_yaml_files() {
400 let dir = tempfile::tempdir().unwrap();
401 let mana_dir = dir.path().join(".mana");
402 fs::create_dir(&mana_dir).unwrap();
403
404 fs::write(mana_dir.join("7.yaml"), "old format").unwrap();
406
407 let result = find_unit_file(&mana_dir, "7");
409 assert!(result.is_ok());
410 assert!(result.unwrap().ends_with("7.yaml"));
411 }
412
413 #[test]
414 fn find_unit_file_prefers_md_over_yaml() {
415 let dir = tempfile::tempdir().unwrap();
416 let mana_dir = dir.path().join(".mana");
417 fs::create_dir(&mana_dir).unwrap();
418
419 fs::write(mana_dir.join("7-my-task.md"), "new format").unwrap();
421 fs::write(mana_dir.join("7.yaml"), "old format").unwrap();
422
423 let result = find_unit_file(&mana_dir, "7");
424 assert!(result.is_ok());
425 assert!(result.unwrap().ends_with("7-my-task.md"));
426 }
427
428 #[test]
429 fn find_unit_file_ignores_files_without_proper_prefix() {
430 let dir = tempfile::tempdir().unwrap();
431 let mana_dir = dir.path().join(".mana");
432 fs::create_dir(&mana_dir).unwrap();
433
434 fs::write(mana_dir.join("7-something-else.md"), "wrong file").unwrap();
436
437 let result = find_unit_file(&mana_dir, "8");
439 assert!(result.is_err());
440 }
441
442 #[test]
443 fn find_unit_file_handles_numeric_id_prefix_matching() {
444 let dir = tempfile::tempdir().unwrap();
445 let mana_dir = dir.path().join(".mana");
446 fs::create_dir(&mana_dir).unwrap();
447
448 fs::write(mana_dir.join("2-task.md"), "unit 2").unwrap();
450 fs::write(mana_dir.join("20-task.md"), "unit 20").unwrap();
451
452 let result = find_unit_file(&mana_dir, "2").unwrap();
454 assert_eq!(result, mana_dir.join("2-task.md"));
455 }
456
457 #[test]
458 fn find_unit_file_with_special_chars_in_slug() {
459 let dir = tempfile::tempdir().unwrap();
460 let mana_dir = dir.path().join(".mana");
461 fs::create_dir(&mana_dir).unwrap();
462
463 fs::write(mana_dir.join("6-v2-refactor-api.md"), "test").unwrap();
465
466 let result = find_unit_file(&mana_dir, "6").unwrap();
467 assert_eq!(result, mana_dir.join("6-v2-refactor-api.md"));
468 }
469
470 #[test]
471 fn find_unit_file_rejects_special_chars_in_id() {
472 let dir = tempfile::tempdir().unwrap();
473 let mana_dir = dir.path().join(".mana");
474 fs::create_dir(&mana_dir).unwrap();
475
476 assert!(find_unit_file(&mana_dir, "task@home").is_err());
478 assert!(find_unit_file(&mana_dir, "task#1").is_err());
479 assert!(find_unit_file(&mana_dir, "task$money").is_err());
480 }
481
482 #[test]
487 fn archive_path_for_unit_basic() {
488 let dir = tempfile::tempdir().unwrap();
489 let mana_dir = dir.path().join(".mana");
490
491 let date = chrono::NaiveDate::from_ymd_opt(2026, 1, 31).unwrap();
492 let path = archive_path_for_unit(&mana_dir, "12", "unit-archive-system", "md", date);
493
494 assert_eq!(
496 path,
497 mana_dir.join("archive/2026/01/12-unit-archive-system.md")
498 );
499 }
500
501 #[test]
502 fn archive_path_for_unit_hierarchical_id() {
503 let dir = tempfile::tempdir().unwrap();
504 let mana_dir = dir.path().join(".mana");
505
506 let date = chrono::NaiveDate::from_ymd_opt(2025, 12, 15).unwrap();
507 let path = archive_path_for_unit(&mana_dir, "11.1", "refactor-parser", "md", date);
508
509 assert_eq!(
510 path,
511 mana_dir.join("archive/2025/12/11.1-refactor-parser.md")
512 );
513 }
514
515 #[test]
516 fn archive_path_for_unit_single_digit_month() {
517 let dir = tempfile::tempdir().unwrap();
518 let mana_dir = dir.path().join(".mana");
519
520 let date = chrono::NaiveDate::from_ymd_opt(2026, 3, 5).unwrap();
521 let path = archive_path_for_unit(&mana_dir, "5", "task", "md", date);
522
523 assert_eq!(path, mana_dir.join("archive/2026/03/5-task.md"));
525 }
526
527 #[test]
528 fn archive_path_for_unit_three_level_id() {
529 let dir = tempfile::tempdir().unwrap();
530 let mana_dir = dir.path().join(".mana");
531
532 let date = chrono::NaiveDate::from_ymd_opt(2024, 8, 20).unwrap();
533 let path = archive_path_for_unit(&mana_dir, "3.2.1", "deep-task", "md", date);
534
535 assert_eq!(path, mana_dir.join("archive/2024/08/3.2.1-deep-task.md"));
536 }
537
538 #[test]
539 fn archive_path_for_unit_long_slug() {
540 let dir = tempfile::tempdir().unwrap();
541 let mana_dir = dir.path().join(".mana");
542
543 let date = chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
544 let long_slug = "implement-comprehensive-feature-with-full-test-coverage";
545 let path = archive_path_for_unit(&mana_dir, "42", long_slug, "md", date);
546
547 assert!(path.to_str().unwrap().contains(long_slug));
548 assert_eq!(
549 path,
550 mana_dir.join(
551 "archive/2026/01/42-implement-comprehensive-feature-with-full-test-coverage.md"
552 )
553 );
554 }
555
556 #[test]
557 fn archive_path_for_unit_yaml_extension() {
558 let dir = tempfile::tempdir().unwrap();
559 let mana_dir = dir.path().join(".mana");
560
561 let date = chrono::NaiveDate::from_ymd_opt(2026, 1, 31).unwrap();
562 let path = archive_path_for_unit(&mana_dir, "5", "yaml-task", "yaml", date);
563
564 assert_eq!(path, mana_dir.join("archive/2026/01/5-yaml-task.yaml"));
565 }
566
567 #[test]
572 fn find_archived_unit_simple_id() {
573 let dir = tempfile::tempdir().unwrap();
574 let mana_dir = dir.path().join(".mana");
575 let archive_dir = mana_dir.join("archive/2026/01");
576 fs::create_dir_all(&archive_dir).unwrap();
577
578 fs::write(archive_dir.join("12-unit-archive.md"), "archived content").unwrap();
580
581 let result = find_archived_unit(&mana_dir, "12").unwrap();
582 assert_eq!(result, archive_dir.join("12-unit-archive.md"));
583 }
584
585 #[test]
586 fn find_archived_unit_hierarchical_id() {
587 let dir = tempfile::tempdir().unwrap();
588 let mana_dir = dir.path().join(".mana");
589 let archive_dir = mana_dir.join("archive/2025/12");
590 fs::create_dir_all(&archive_dir).unwrap();
591
592 fs::write(
594 archive_dir.join("11.1-refactor-parser.md"),
595 "archived content",
596 )
597 .unwrap();
598
599 let result = find_archived_unit(&mana_dir, "11.1").unwrap();
600 assert_eq!(result, archive_dir.join("11.1-refactor-parser.md"));
601 }
602
603 #[test]
604 fn find_archived_unit_multiple_years() {
605 let dir = tempfile::tempdir().unwrap();
606 let mana_dir = dir.path().join(".mana");
607
608 fs::create_dir_all(mana_dir.join("archive/2024/06")).unwrap();
610 fs::create_dir_all(mana_dir.join("archive/2025/12")).unwrap();
611 fs::create_dir_all(mana_dir.join("archive/2026/01")).unwrap();
612
613 fs::write(
615 mana_dir.join("archive/2024/06/5-old-task.md"),
616 "old content",
617 )
618 .unwrap();
619
620 fs::write(
622 mana_dir.join("archive/2026/01/12-new-task.md"),
623 "new content",
624 )
625 .unwrap();
626
627 let result = find_archived_unit(&mana_dir, "5").unwrap();
629 assert!(result.to_str().unwrap().contains("2024/06"));
630
631 let result = find_archived_unit(&mana_dir, "12").unwrap();
632 assert!(result.to_str().unwrap().contains("2026/01"));
633 }
634
635 #[test]
636 fn find_archived_unit_multiple_months() {
637 let dir = tempfile::tempdir().unwrap();
638 let mana_dir = dir.path().join(".mana");
639
640 fs::create_dir_all(mana_dir.join("archive/2026/01")).unwrap();
642 fs::create_dir_all(mana_dir.join("archive/2026/02")).unwrap();
643 fs::create_dir_all(mana_dir.join("archive/2026/03")).unwrap();
644
645 fs::write(
647 mana_dir.join("archive/2026/01/10-january-task.md"),
648 "january",
649 )
650 .unwrap();
651
652 fs::write(mana_dir.join("archive/2026/03/10-march-task.md"), "march").unwrap();
653
654 let result = find_archived_unit(&mana_dir, "10").unwrap();
656 assert!(result.to_str().unwrap().contains("2026"));
657 assert!(result
658 .file_name()
659 .unwrap()
660 .to_str()
661 .unwrap()
662 .starts_with("10-"));
663 }
664
665 #[test]
666 fn find_archived_unit_not_found() {
667 let dir = tempfile::tempdir().unwrap();
668 let mana_dir = dir.path().join(".mana");
669 let archive_dir = mana_dir.join("archive/2026/01");
670 fs::create_dir_all(&archive_dir).unwrap();
671
672 fs::write(archive_dir.join("12-some-task.md"), "content").unwrap();
674
675 let result = find_archived_unit(&mana_dir, "999");
677 assert!(result.is_err());
678 assert!(result
679 .unwrap_err()
680 .to_string()
681 .contains("Archived unit 999 not found"));
682 }
683
684 #[test]
685 fn find_archived_unit_no_archive_dir() {
686 let dir = tempfile::tempdir().unwrap();
687 let mana_dir = dir.path().join(".mana");
688 fs::create_dir(&mana_dir).unwrap();
689
690 let result = find_archived_unit(&mana_dir, "12");
692 assert!(result.is_err());
693 let err_msg = result.unwrap_err().to_string();
694 assert!(err_msg.contains("Archived unit 12 not found"));
695 }
696
697 #[test]
698 fn find_archived_unit_validates_id() {
699 let dir = tempfile::tempdir().unwrap();
700 let mana_dir = dir.path().join(".mana");
701 fs::create_dir(&mana_dir).unwrap();
702
703 let result = find_archived_unit(&mana_dir, "../../../etc/passwd");
705 assert!(result.is_err());
706 assert!(result.unwrap_err().to_string().contains("Invalid unit ID"));
707
708 let result = find_archived_unit(&mana_dir, "");
709 assert!(result.is_err());
710 assert!(result.unwrap_err().to_string().contains("cannot be empty"));
711 }
712
713 #[test]
714 fn find_archived_unit_three_level_id() {
715 let dir = tempfile::tempdir().unwrap();
716 let mana_dir = dir.path().join(".mana");
717 let archive_dir = mana_dir.join("archive/2024/08");
718 fs::create_dir_all(&archive_dir).unwrap();
719
720 fs::write(archive_dir.join("3.2.1-deep-task.md"), "archived content").unwrap();
722
723 let result = find_archived_unit(&mana_dir, "3.2.1").unwrap();
724 assert_eq!(result, archive_dir.join("3.2.1-deep-task.md"));
725 }
726
727 #[test]
728 fn find_archived_unit_ignores_non_matching_ids() {
729 let dir = tempfile::tempdir().unwrap();
730 let mana_dir = dir.path().join(".mana");
731 let archive_dir = mana_dir.join("archive/2026/01");
732 fs::create_dir_all(&archive_dir).unwrap();
733
734 fs::write(archive_dir.join("1-first-task.md"), "unit 1").unwrap();
736 fs::write(archive_dir.join("10-tenth-task.md"), "unit 10").unwrap();
737 fs::write(archive_dir.join("100-hundredth-task.md"), "unit 100").unwrap();
738
739 let result = find_archived_unit(&mana_dir, "1").unwrap();
741 assert_eq!(result, archive_dir.join("1-first-task.md"));
742
743 let result = find_archived_unit(&mana_dir, "10").unwrap();
745 assert_eq!(result, archive_dir.join("10-tenth-task.md"));
746 }
747
748 #[test]
749 fn find_archived_unit_with_long_slug() {
750 let dir = tempfile::tempdir().unwrap();
751 let mana_dir = dir.path().join(".mana");
752 let archive_dir = mana_dir.join("archive/2026/01");
753 fs::create_dir_all(&archive_dir).unwrap();
754
755 let long_slug = "implement-comprehensive-feature-with-full-test-coverage";
756 let filename = format!("42-{}.md", long_slug);
757 fs::write(archive_dir.join(&filename), "archived").unwrap();
758
759 let result = find_archived_unit(&mana_dir, "42").unwrap();
760 assert!(result.to_str().unwrap().contains(long_slug));
761 }
762}