Skip to main content

mana_core/
discovery.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{bail, Context, Result};
4
5/// Walk up from `start` looking for a `.mana/` directory.
6/// Returns the path to the `.mana/` directory if found.
7/// Errors if no `.mana/` directory exists in any ancestor.
8pub 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
37/// Find a unit file by ID, supporting both new and legacy naming conventions.
38///
39/// Searches for unit files in this order:
40/// 1. New format: `{id}-{slug}.md` (e.g., "1-my-task.md", "11.1-refactor-parser.md")
41/// 2. Legacy format: `{id}.yaml` (e.g., "1.yaml", "11.1.yaml")
42///
43/// Returns the full path if found.
44///
45/// # Examples
46/// - `find_unit_file(mana_dir, "1")` → `.mana/1-my-task.md` or `.mana/1.yaml`
47/// - `find_unit_file(mana_dir, "11.1")` → `.mana/11.1-refactor-parser.md` or `.mana/11.1.yaml`
48///
49/// # Arguments
50/// * `mana_dir` - Path to the `.mana/` directory
51/// * `id` - The unit ID to find (e.g., "1", "11.1", "3.2.1")
52///
53/// # Errors
54/// * If the ID is invalid (empty, contains path traversal, etc.)
55/// * If no unit file is found for the given ID
56/// * If glob pattern matching fails
57pub fn find_unit_file(mana_dir: &Path, id: &str) -> Result<PathBuf> {
58    // Validate ID to prevent path traversal attacks
59    crate::util::validate_unit_id(id)?;
60
61    // First, try the new naming convention: {id}-{slug}.md
62    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            // Check if filename matches {id}-*.md pattern exactly
67            if filename.starts_with(&format!("{}-", id)) && filename.ends_with(".md") {
68                return Ok(path);
69            }
70        }
71    }
72
73    // Fallback to legacy naming convention: {id}.yaml
74    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
82/// Compute the archive path for a unit given its ID, slug, and date.
83///
84/// Returns the path: `.mana/archive/YYYY/MM/<id>-<slug>.md`
85///
86/// # Arguments
87/// * `mana_dir` - Path to the `.mana/` directory
88/// * `id` - The unit ID (e.g., "1", "11.1", "3.2.1")
89/// * `slug` - The unit slug (derived from title)
90/// * `ext` - The file extension (e.g., "md", "yaml")
91/// * `date` - The date to use for year/month subdirectories
92///
93/// # Returns
94/// A PathBuf representing `.mana/archive/YYYY/MM/<id>-<slug>.<ext>`
95///
96/// # Examples
97/// ```ignore
98/// let path = archive_path_for_unit(
99///     Path::new(".mana"),
100///     "12",
101///     "unit-archive-system",
102///     "md",
103///     chrono::NaiveDate::from_ymd_opt(2026, 1, 31).unwrap()
104/// );
105/// // Returns: .mana/archive/2026/01/12-unit-archive-system.md
106/// ```
107pub 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
124/// Find an archived unit by ID within the `.mana/archive/` directory tree.
125///
126/// Searches recursively through `.mana/archive/**/` for a unit file matching the given ID.
127/// Returns the full path to the first matching unit file.
128///
129/// # Arguments
130/// * `mana_dir` - Path to the `.mana/` directory
131/// * `id` - The unit ID to search for
132///
133/// # Returns
134/// `Ok(PathBuf)` with the path to the archived unit file if found
135/// `Err` if the unit is not found in the archive
136///
137/// # Examples
138/// ```ignore
139/// let path = find_archived_unit(Path::new(".mana"), "12")?;
140/// // Returns: .mana/archive/2026/01/12-unit-archive-system.md
141/// ```
142pub fn find_archived_unit(mana_dir: &Path, id: &str) -> Result<PathBuf> {
143    // Validate ID to prevent path traversal attacks
144    crate::util::validate_unit_id(id)?;
145
146    let archive_dir = mana_dir.join("archive");
147
148    // If archive directory doesn't exist, unit is not archived
149    if !archive_dir.is_dir() {
150        bail!(
151            "Archived unit {} not found (archive directory does not exist)",
152            id
153        );
154    }
155
156    // Recursively search through year subdirectories
157    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        // Search through month subdirectories
164        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            // Search through unit files in month directory
171            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                // Check if filename matches the pattern {id}-*.md
180                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        // Parent has .mana
269        fs::create_dir(dir.path().join(".mana")).unwrap();
270        // Child also has .mana
271        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    // =====================================================================
280    // Tests for find_unit_file()
281    // =====================================================================
282
283    #[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        // Create a unit file with slug
290        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        // Create a unit file with hierarchical ID
303        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        // Create a unit file with three-level ID
316        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        // Create multiple files that start with the same ID prefix
329        // (shouldn't happen in practice, but test the behavior)
330        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        // Should return one of the files (glob order is implementation-dependent)
335        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        // Try to find a unit that doesn't exist
351        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        // Try to find with invalid ID (path traversal attempt)
364        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        // Try to find with empty ID
377        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        // Create a unit file with a long slug
390        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        // Create a .yaml file (legacy format - should be found as fallback)
405        fs::write(mana_dir.join("7.yaml"), "old format").unwrap();
406
407        // Should find the legacy .yaml file
408        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        // Create both formats - .md should be preferred
420        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        // Create a file that doesn't match the pattern
435        fs::write(mana_dir.join("7-something-else.md"), "wrong file").unwrap();
436
437        // Try to find "8" (which exists as "7-something-else.md")
438        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        // Create files: "2-task.md" and "20-task.md"
449        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        // Looking for "2" should only match "2-task.md", not "20-task.md"
453        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        // Create a unit file with hyphens and numbers in slug
464        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        // Try IDs with special characters that should be rejected
477        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    // =====================================================================
483    // Tests for archive_path_for_unit()
484    // =====================================================================
485
486    #[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        // Verify path structure
495        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        // Month should be zero-padded (03, not 3)
524        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    // =====================================================================
568    // Tests for find_archived_unit()
569    // =====================================================================
570
571    #[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        // Create an archived unit file
579        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        // Create an archived unit file
593        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        // Create archive structure with multiple years
609        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        // Create unit in 2024
614        fs::write(
615            mana_dir.join("archive/2024/06/5-old-task.md"),
616            "old content",
617        )
618        .unwrap();
619
620        // Create unit in 2026
621        fs::write(
622            mana_dir.join("archive/2026/01/12-new-task.md"),
623            "new content",
624        )
625        .unwrap();
626
627        // Should find the unit regardless of year
628        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        // Create archive structure with multiple months in same year
641        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        // Create units in different months
646        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        // Both should be found (returns first match)
655        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        // Create a different unit
673        fs::write(archive_dir.join("12-some-task.md"), "content").unwrap();
674
675        // Try to find a unit that doesn't exist
676        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        // Archive directory doesn't exist
691        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        // Try with invalid IDs (path traversal)
704        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        // Create an archived unit with three-level ID
721        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        // Create units with similar IDs
735        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        // Looking for "1" should only match "1-first-task.md", not "10-" or "100-"
740        let result = find_archived_unit(&mana_dir, "1").unwrap();
741        assert_eq!(result, archive_dir.join("1-first-task.md"));
742
743        // Looking for "10" should only match "10-tenth-task.md"
744        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}