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) = {
78 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
79 let guard = loop {
80 if let Ok(g) = session_lock.clone().try_read_owned() {
81 break Some(g);
82 }
83 if std::time::Instant::now() >= deadline {
84 break None;
85 }
86 std::thread::sleep(std::time::Duration::from_millis(25));
87 };
88 match guard {
89 Some(session) => {
90 let root = session
91 .project_root
92 .clone()
93 .unwrap_or_else(|| ".".to_string());
94 (session.extra_roots.clone(), root)
95 }
96 None => (Vec::new(), ".".to_string()),
97 }
98 };
99 if !extra.is_empty() {
100 let jail = std::path::Path::new(&jail_root);
101 let mut roots = vec![ctx.project_root.clone()];
102 for r in &extra {
103 let p = std::path::Path::new(r);
104 if !p.is_dir() {
105 continue;
106 }
107 match crate::core::pathjail::jail_path(p, jail) {
108 Ok(_) => roots.push(r.clone()),
109 Err(e) => tracing::warn!("extra_root rejected by PathJail: {e}"),
110 }
111 }
112 if roots.len() > 1 {
113 return Ok(ResolvedPaths {
114 is_multi: true,
115 roots,
116 });
117 }
118 }
119 }
120
121 Ok(ResolvedPaths {
122 roots: vec![".".to_string()],
123 is_multi: false,
124 })
125}
126
127fn resolve_paths_sync(ctx: &ToolContext, raw: &[String]) -> Vec<String> {
128 let mut out = Vec::with_capacity(raw.len());
129 for p in raw {
130 match ctx.resolve_path_sync(p) {
131 Ok(resolved) => out.push(resolved),
132 Err(e) => {
133 tracing::warn!("multi-path resolve failed for {p}: {e}");
134 }
135 }
136 }
137 out
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143 use serde_json::json;
144
145 fn test_ctx() -> ToolContext {
146 ToolContext {
147 project_root: "/test/project".to_string(),
148 extra_roots: Vec::new(),
149 minimal: false,
150 resolved_paths: std::collections::HashMap::new(),
151 crp_mode: crate::tools::CrpMode::Off,
152 cache: None,
153 session: None,
154 tool_calls: None,
155 agent_id: None,
156 workflow: None,
157 ledger: None,
158 client_name: None,
159 client_role: None,
160 shell_access: None,
161 pipeline_stats: None,
162 call_count: None,
163 autonomy: None,
164 pressure_snapshot: None,
165 path_errors: std::collections::HashMap::new(),
166 bm25_cache: None,
167 progress_sender: None,
168 }
169 }
170
171 #[test]
172 fn fallback_to_dot_when_nothing_set() {
173 let args = Map::new();
174 let ctx = test_ctx();
175 let result = resolve_tool_paths(&args, &ctx).expect("no explicit path → default");
176 assert_eq!(result.roots, vec!["."]);
177 assert!(!result.is_multi);
178 }
179
180 #[test]
181 fn uses_resolved_path_when_present() {
182 let args = Map::new();
183 let mut ctx = test_ctx();
184 ctx.resolved_paths
185 .insert("path".to_string(), "/resolved/dir".to_string());
186 let result = resolve_tool_paths(&args, &ctx).expect("resolved path");
187 assert_eq!(result.roots, vec!["/resolved/dir"]);
188 assert!(!result.is_multi);
189 }
190
191 #[test]
192 fn empty_paths_array_falls_back() {
193 let mut args = Map::new();
194 args.insert("paths".to_string(), json!([]));
195 let mut ctx = test_ctx();
196 ctx.resolved_paths
197 .insert("path".to_string(), "/fallback".to_string());
198 let result = resolve_tool_paths(&args, &ctx).expect("empty paths → fallback");
199 assert_eq!(result.roots, vec!["/fallback"]);
200 assert!(!result.is_multi);
201 }
202
203 #[test]
207 fn explicit_unresolvable_path_errors_instead_of_root_fallback() {
208 let mut args = Map::new();
209 args.insert(
210 "path".to_string(),
211 json!("/home/jules/.claude/skills/mpm-config"),
212 );
213 let mut ctx = test_ctx();
214 ctx.path_errors.insert(
217 "path".to_string(),
218 "path escapes project root: /home/jules/.claude/skills/mpm-config \
219 (root: /test/project)"
220 .to_string(),
221 );
222 let err = resolve_tool_paths(&args, &ctx)
223 .expect_err("out-of-jail explicit path must be an error");
224 assert!(
225 err.contains("escapes project root"),
226 "error must explain the path is out of scope: {err}"
227 );
228 }
229
230 #[cfg(not(feature = "no-jail"))]
241 #[test]
242 fn explicit_unresolvable_paths_array_errors() {
243 let base = tempfile::tempdir().unwrap();
244 let root = base.path().join("project");
245 let outside = base.path().join("outside");
246 std::fs::create_dir_all(&root).unwrap();
247 std::fs::create_dir_all(&outside).unwrap();
248
249 let mut ctx = test_ctx();
250 ctx.project_root = root.to_string_lossy().into_owned();
251
252 let mut args = Map::new();
253 args.insert(
254 "paths".to_string(),
255 json!([outside.to_string_lossy().into_owned()]),
256 );
257 let err = resolve_tool_paths(&args, &ctx)
258 .expect_err("a path outside the project root must be an error");
259 assert!(
260 err.contains("none of the requested paths"),
261 "error must report the unresolved paths: {err}"
262 );
263 }
264}