1use std::path::{Path, PathBuf};
2
3use anyhow::{anyhow, Context, Result};
4
5use crate::handle::normalize_handle;
6use crate::index::Index;
7use crate::unit::Unit;
8
9#[derive(Debug)]
11pub struct ResolvedUnit {
12 pub unit: Unit,
13 pub path: PathBuf,
14}
15
16pub fn resolve_unit(mana_dir: &Path, reference: &str) -> Result<ResolvedUnit> {
21 let path = match crate::discovery::find_unit_file(mana_dir, reference) {
22 Ok(path) => path,
23 Err(id_error) => match resolve_unit_path_by_handle(mana_dir, reference) {
24 Ok(path) => path,
25 Err(handle_error) => {
26 let handle_message = handle_error.to_string();
27 if handle_message.contains("ambiguous") {
28 return Err(handle_error);
29 }
30 return Err(handle_error).with_context(|| {
31 format!("Unit not found by ID or handle: {reference} ({id_error})")
32 });
33 }
34 },
35 };
36
37 let unit = Unit::from_file(&path)
38 .with_context(|| format!("Failed to load unit: {}", path.display()))?;
39 Ok(ResolvedUnit { unit, path })
40}
41
42pub fn resolve_unit_path_by_handle(mana_dir: &Path, handle: &str) -> Result<PathBuf> {
44 let query = normalize_handle(handle);
45 if query.is_empty() {
46 return Err(anyhow!("Handle cannot be empty"));
47 }
48
49 let index = Index::load_or_rebuild(mana_dir)?;
50 let matches: Vec<_> = index
51 .units
52 .iter()
53 .filter(|entry| {
54 let normalized = entry.handle.as_deref().map(normalize_handle);
55 normalized.as_deref() == Some(query.as_str())
56 })
57 .collect();
58
59 match matches.as_slice() {
60 [] => Err(anyhow!("No unit with handle '{handle}'")),
61 [entry] => crate::discovery::find_unit_file(mana_dir, &entry.id),
62 many => {
63 let choices = many
64 .iter()
65 .map(|entry| format!(" {} — {}", entry.id, entry.title))
66 .collect::<Vec<_>>()
67 .join("\n");
68 Err(anyhow!(
69 "Handle '{handle}' is ambiguous; use a unit ID instead:\n{choices}"
70 ))
71 }
72 }
73}
74
75#[cfg(test)]
76mod tests {
77 use super::*;
78 use crate::config::Config;
79 use crate::ops::create::{create, CreateParams};
80 use crate::unit::Unit;
81 use std::fs;
82 use tempfile::TempDir;
83
84 fn setup() -> (TempDir, PathBuf) {
85 let dir = TempDir::new().unwrap();
86 let mana_dir = dir.path().join(".mana");
87 fs::create_dir(&mana_dir).unwrap();
88 Config::default().save(&mana_dir).unwrap();
89 (dir, mana_dir)
90 }
91
92 #[test]
93 fn resolve_unit_finds_unique_handle() {
94 let (_dir, mana_dir) = setup();
95 create(
96 &mana_dir,
97 CreateParams {
98 title: "Implement SQLite-derived index for mana agent context assembly".into(),
99 ..Default::default()
100 },
101 )
102 .unwrap();
103
104 let resolved = resolve_unit(&mana_dir, "sqlite derived index").unwrap();
105 assert_eq!(resolved.unit.id, "1");
106 assert_eq!(
107 resolved.unit.handle.as_deref(),
108 Some("sqlite derived index")
109 );
110 }
111
112 #[test]
113 fn resolve_unit_reports_ambiguous_handle() {
114 let (_dir, mana_dir) = setup();
115 let mut first = Unit::new("1", "First title");
116 first.handle = Some("shared handle".to_string());
117 first.to_file(mana_dir.join("1-first-title.md")).unwrap();
118 let mut second = Unit::new("2", "Second title");
119 second.handle = Some("shared handle".to_string());
120 second.to_file(mana_dir.join("2-second-title.md")).unwrap();
121 Index::build(&mana_dir).unwrap().save(&mana_dir).unwrap();
122
123 let error = resolve_unit(&mana_dir, "shared handle")
124 .unwrap_err()
125 .to_string();
126 assert!(error.contains("ambiguous"));
127 assert!(error.contains("1 — First title"));
128 assert!(error.contains("2 — Second title"));
129 }
130}