lean_ctx/server/
multi_path.rs1use serde_json::{Map, Value};
2
3use crate::server::tool_trait::{ToolContext, get_str, get_str_array};
4
5#[derive(Debug)]
6pub struct ResolvedPaths {
7 pub roots: Vec<String>,
8 pub is_multi: bool,
9}
10
11pub fn resolve_tool_paths(
25 args: &Map<String, Value>,
26 ctx: &ToolContext,
27) -> Result<ResolvedPaths, String> {
28 if let Some(repo) = get_str(args, "repo")
29 && let Some(root) = crate::core::multi_repo::resolve_repo_root(&repo)
30 {
31 return Ok(ResolvedPaths {
32 roots: vec![root],
33 is_multi: false,
34 });
35 }
36
37 if let Some(paths) = get_str_array(args, "paths")
38 && !paths.is_empty()
39 {
40 let resolved = resolve_paths_sync(ctx, &paths);
41 if !resolved.is_empty() {
42 return Ok(ResolvedPaths {
43 is_multi: resolved.len() > 1,
44 roots: resolved,
45 });
46 }
47 return Err(format!(
50 "none of the requested paths could be resolved — they may not exist or are \
51 outside the project root: {}",
52 paths.join(", ")
53 ));
54 }
55
56 for key in &["path", "file_path"] {
61 if let Some(p) = ctx.resolved_path(key) {
62 return Ok(ResolvedPaths {
63 roots: vec![p.to_string()],
64 is_multi: false,
65 });
66 }
67 if let Some(detail) = ctx.path_error(key) {
72 return Err(detail.to_string());
73 }
74 }
75
76 if let Some(session_lock) = ctx.session.as_ref() {
77 let (extra, jail_root) = tokio::task::block_in_place(|| {
78 let rt = tokio::runtime::Handle::current();
79 rt.block_on(async {
80 let session = session_lock.read().await;
81 let root = session
82 .project_root
83 .clone()
84 .unwrap_or_else(|| ".".to_string());
85 (session.extra_roots.clone(), root)
86 })
87 });
88 if !extra.is_empty() {
89 let jail = std::path::Path::new(&jail_root);
90 let mut roots = vec![ctx.project_root.clone()];
91 for r in &extra {
92 let p = std::path::Path::new(r);
93 if !p.is_dir() {
94 continue;
95 }
96 match crate::core::pathjail::jail_path(p, jail) {
97 Ok(_) => roots.push(r.clone()),
98 Err(e) => tracing::warn!("extra_root rejected by PathJail: {e}"),
99 }
100 }
101 if roots.len() > 1 {
102 return Ok(ResolvedPaths {
103 is_multi: true,
104 roots,
105 });
106 }
107 }
108 }
109
110 Ok(ResolvedPaths {
111 roots: vec![".".to_string()],
112 is_multi: false,
113 })
114}
115
116fn resolve_paths_sync(ctx: &ToolContext, raw: &[String]) -> Vec<String> {
117 let mut out = Vec::with_capacity(raw.len());
118 for p in raw {
119 match ctx.resolve_path_sync(p) {
120 Ok(resolved) => out.push(resolved),
121 Err(e) => {
122 tracing::warn!("multi-path resolve failed for {p}: {e}");
123 }
124 }
125 }
126 out
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132 use serde_json::json;
133
134 fn test_ctx() -> ToolContext {
135 ToolContext {
136 project_root: "/test/project".to_string(),
137 extra_roots: Vec::new(),
138 minimal: false,
139 resolved_paths: std::collections::HashMap::new(),
140 crp_mode: crate::tools::CrpMode::Off,
141 cache: None,
142 session: None,
143 tool_calls: None,
144 agent_id: None,
145 workflow: None,
146 ledger: None,
147 client_name: None,
148 pipeline_stats: None,
149 call_count: None,
150 autonomy: None,
151 pressure_snapshot: None,
152 path_errors: std::collections::HashMap::new(),
153 bm25_cache: None,
154 progress_sender: None,
155 }
156 }
157
158 #[test]
159 fn fallback_to_dot_when_nothing_set() {
160 let args = Map::new();
161 let ctx = test_ctx();
162 let result = resolve_tool_paths(&args, &ctx).expect("no explicit path → default");
163 assert_eq!(result.roots, vec!["."]);
164 assert!(!result.is_multi);
165 }
166
167 #[test]
168 fn uses_resolved_path_when_present() {
169 let args = Map::new();
170 let mut ctx = test_ctx();
171 ctx.resolved_paths
172 .insert("path".to_string(), "/resolved/dir".to_string());
173 let result = resolve_tool_paths(&args, &ctx).expect("resolved path");
174 assert_eq!(result.roots, vec!["/resolved/dir"]);
175 assert!(!result.is_multi);
176 }
177
178 #[test]
179 fn empty_paths_array_falls_back() {
180 let mut args = Map::new();
181 args.insert("paths".to_string(), json!([]));
182 let mut ctx = test_ctx();
183 ctx.resolved_paths
184 .insert("path".to_string(), "/fallback".to_string());
185 let result = resolve_tool_paths(&args, &ctx).expect("empty paths → fallback");
186 assert_eq!(result.roots, vec!["/fallback"]);
187 assert!(!result.is_multi);
188 }
189
190 #[test]
194 fn explicit_unresolvable_path_errors_instead_of_root_fallback() {
195 let mut args = Map::new();
196 args.insert(
197 "path".to_string(),
198 json!("/home/jules/.claude/skills/mpm-config"),
199 );
200 let mut ctx = test_ctx();
201 ctx.path_errors.insert(
204 "path".to_string(),
205 "path escapes project root: /home/jules/.claude/skills/mpm-config \
206 (root: /test/project)"
207 .to_string(),
208 );
209 let err = resolve_tool_paths(&args, &ctx)
210 .expect_err("out-of-jail explicit path must be an error");
211 assert!(
212 err.contains("escapes project root"),
213 "error must explain the path is out of scope: {err}"
214 );
215 }
216
217 #[cfg(not(feature = "no-jail"))]
228 #[test]
229 fn explicit_unresolvable_paths_array_errors() {
230 let base = tempfile::tempdir().unwrap();
231 let root = base.path().join("project");
232 let outside = base.path().join("outside");
233 std::fs::create_dir_all(&root).unwrap();
234 std::fs::create_dir_all(&outside).unwrap();
235
236 let mut ctx = test_ctx();
237 ctx.project_root = root.to_string_lossy().into_owned();
238
239 let mut args = Map::new();
240 args.insert(
241 "paths".to_string(),
242 json!([outside.to_string_lossy().into_owned()]),
243 );
244 let err = resolve_tool_paths(&args, &ctx)
245 .expect_err("a path outside the project root must be an error");
246 assert!(
247 err.contains("none of the requested paths"),
248 "error must report the unresolved paths: {err}"
249 );
250 }
251}