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 if let Some(path) = ctx.resolved_path("path") {
57 return Ok(ResolvedPaths {
58 roots: vec![path.to_string()],
59 is_multi: false,
60 });
61 }
62
63 if let Some(detail) = ctx.path_error("path") {
68 return Err(detail.to_string());
69 }
70
71 if let Some(session_lock) = ctx.session.as_ref() {
72 let (extra, jail_root) = tokio::task::block_in_place(|| {
73 let rt = tokio::runtime::Handle::current();
74 rt.block_on(async {
75 let session = session_lock.read().await;
76 let root = session
77 .project_root
78 .clone()
79 .unwrap_or_else(|| ".".to_string());
80 (session.extra_roots.clone(), root)
81 })
82 });
83 if !extra.is_empty() {
84 let jail = std::path::Path::new(&jail_root);
85 let mut roots = vec![ctx.project_root.clone()];
86 for r in &extra {
87 let p = std::path::Path::new(r);
88 if !p.is_dir() {
89 continue;
90 }
91 match crate::core::pathjail::jail_path(p, jail) {
92 Ok(_) => roots.push(r.clone()),
93 Err(e) => tracing::warn!("extra_root rejected by PathJail: {e}"),
94 }
95 }
96 if roots.len() > 1 {
97 return Ok(ResolvedPaths {
98 is_multi: true,
99 roots,
100 });
101 }
102 }
103 }
104
105 Ok(ResolvedPaths {
106 roots: vec![".".to_string()],
107 is_multi: false,
108 })
109}
110
111fn resolve_paths_sync(ctx: &ToolContext, raw: &[String]) -> Vec<String> {
112 let mut out = Vec::with_capacity(raw.len());
113 for p in raw {
114 match ctx.resolve_path_sync(p) {
115 Ok(resolved) => out.push(resolved),
116 Err(e) => {
117 tracing::warn!("multi-path resolve failed for {p}: {e}");
118 }
119 }
120 }
121 out
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127 use serde_json::json;
128
129 fn test_ctx() -> ToolContext {
130 ToolContext {
131 project_root: "/test/project".to_string(),
132 extra_roots: Vec::new(),
133 minimal: false,
134 resolved_paths: std::collections::HashMap::new(),
135 crp_mode: crate::tools::CrpMode::Off,
136 cache: None,
137 session: None,
138 tool_calls: None,
139 agent_id: None,
140 workflow: None,
141 ledger: None,
142 client_name: None,
143 pipeline_stats: None,
144 call_count: None,
145 autonomy: None,
146 pressure_snapshot: None,
147 path_errors: std::collections::HashMap::new(),
148 bm25_cache: None,
149 progress_sender: None,
150 }
151 }
152
153 #[test]
154 fn fallback_to_dot_when_nothing_set() {
155 let args = Map::new();
156 let ctx = test_ctx();
157 let result = resolve_tool_paths(&args, &ctx).expect("no explicit path → default");
158 assert_eq!(result.roots, vec!["."]);
159 assert!(!result.is_multi);
160 }
161
162 #[test]
163 fn uses_resolved_path_when_present() {
164 let args = Map::new();
165 let mut ctx = test_ctx();
166 ctx.resolved_paths
167 .insert("path".to_string(), "/resolved/dir".to_string());
168 let result = resolve_tool_paths(&args, &ctx).expect("resolved path");
169 assert_eq!(result.roots, vec!["/resolved/dir"]);
170 assert!(!result.is_multi);
171 }
172
173 #[test]
174 fn empty_paths_array_falls_back() {
175 let mut args = Map::new();
176 args.insert("paths".to_string(), json!([]));
177 let mut ctx = test_ctx();
178 ctx.resolved_paths
179 .insert("path".to_string(), "/fallback".to_string());
180 let result = resolve_tool_paths(&args, &ctx).expect("empty paths → fallback");
181 assert_eq!(result.roots, vec!["/fallback"]);
182 assert!(!result.is_multi);
183 }
184
185 #[test]
189 fn explicit_unresolvable_path_errors_instead_of_root_fallback() {
190 let mut args = Map::new();
191 args.insert(
192 "path".to_string(),
193 json!("/home/jules/.claude/skills/mpm-config"),
194 );
195 let mut ctx = test_ctx();
196 ctx.path_errors.insert(
199 "path".to_string(),
200 "path escapes project root: /home/jules/.claude/skills/mpm-config \
201 (root: /test/project)"
202 .to_string(),
203 );
204 let err = resolve_tool_paths(&args, &ctx)
205 .expect_err("out-of-jail explicit path must be an error");
206 assert!(
207 err.contains("escapes project root"),
208 "error must explain the path is out of scope: {err}"
209 );
210 }
211
212 #[cfg(not(feature = "no-jail"))]
223 #[test]
224 fn explicit_unresolvable_paths_array_errors() {
225 let base = tempfile::tempdir().unwrap();
226 let root = base.path().join("project");
227 let outside = base.path().join("outside");
228 std::fs::create_dir_all(&root).unwrap();
229 std::fs::create_dir_all(&outside).unwrap();
230
231 let mut ctx = test_ctx();
232 ctx.project_root = root.to_string_lossy().into_owned();
233
234 let mut args = Map::new();
235 args.insert(
236 "paths".to_string(),
237 json!([outside.to_string_lossy().into_owned()]),
238 );
239 let err = resolve_tool_paths(&args, &ctx)
240 .expect_err("a path outside the project root must be an error");
241 assert!(
242 err.contains("none of the requested paths"),
243 "error must report the unresolved paths: {err}"
244 );
245 }
246}