lean_ctx/server/
multi_path.rs1use serde_json::{Map, Value};
2
3use crate::server::tool_trait::{get_str, get_str_array, ToolContext};
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 if let Some(root) = crate::core::multi_repo::resolve_repo_root(&repo) {
30 return Ok(ResolvedPaths {
31 roots: vec![root],
32 is_multi: false,
33 });
34 }
35 }
36
37 if let Some(paths) = get_str_array(args, "paths") {
38 if !paths.is_empty() {
39 let resolved = resolve_paths_sync(ctx, &paths);
40 if !resolved.is_empty() {
41 return Ok(ResolvedPaths {
42 is_multi: resolved.len() > 1,
43 roots: resolved,
44 });
45 }
46 return Err(format!(
49 "none of the requested paths could be resolved — they may not exist or are \
50 outside the project root: {}",
51 paths.join(", ")
52 ));
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 minimal: false,
133 resolved_paths: std::collections::HashMap::new(),
134 crp_mode: crate::tools::CrpMode::Off,
135 cache: None,
136 session: None,
137 tool_calls: None,
138 agent_id: None,
139 workflow: None,
140 ledger: None,
141 client_name: None,
142 pipeline_stats: None,
143 call_count: None,
144 autonomy: None,
145 pressure_snapshot: None,
146 path_errors: std::collections::HashMap::new(),
147 bm25_cache: None,
148 progress_sender: None,
149 }
150 }
151
152 #[test]
153 fn fallback_to_dot_when_nothing_set() {
154 let args = Map::new();
155 let ctx = test_ctx();
156 let result = resolve_tool_paths(&args, &ctx).expect("no explicit path → default");
157 assert_eq!(result.roots, vec!["."]);
158 assert!(!result.is_multi);
159 }
160
161 #[test]
162 fn uses_resolved_path_when_present() {
163 let args = Map::new();
164 let mut ctx = test_ctx();
165 ctx.resolved_paths
166 .insert("path".to_string(), "/resolved/dir".to_string());
167 let result = resolve_tool_paths(&args, &ctx).expect("resolved path");
168 assert_eq!(result.roots, vec!["/resolved/dir"]);
169 assert!(!result.is_multi);
170 }
171
172 #[test]
173 fn empty_paths_array_falls_back() {
174 let mut args = Map::new();
175 args.insert("paths".to_string(), json!([]));
176 let mut ctx = test_ctx();
177 ctx.resolved_paths
178 .insert("path".to_string(), "/fallback".to_string());
179 let result = resolve_tool_paths(&args, &ctx).expect("empty paths → fallback");
180 assert_eq!(result.roots, vec!["/fallback"]);
181 assert!(!result.is_multi);
182 }
183
184 #[test]
188 fn explicit_unresolvable_path_errors_instead_of_root_fallback() {
189 let mut args = Map::new();
190 args.insert(
191 "path".to_string(),
192 json!("/home/jules/.claude/skills/mpm-config"),
193 );
194 let mut ctx = test_ctx();
195 ctx.path_errors.insert(
198 "path".to_string(),
199 "path escapes project root: /home/jules/.claude/skills/mpm-config \
200 (root: /test/project)"
201 .to_string(),
202 );
203 let err = resolve_tool_paths(&args, &ctx)
204 .expect_err("out-of-jail explicit path must be an error");
205 assert!(
206 err.contains("escapes project root"),
207 "error must explain the path is out of scope: {err}"
208 );
209 }
210
211 #[cfg(not(feature = "no-jail"))]
227 #[test]
228 fn explicit_unresolvable_paths_array_errors() {
229 let ctx = test_ctx();
230 let mut args = Map::new();
231 args.insert(
232 "paths".to_string(),
233 json!(["/lean-ctx-nonexistent-path/never/here"]),
234 );
235 let err = resolve_tool_paths(&args, &ctx)
236 .expect_err("a path outside the project root must be an error");
237 assert!(
238 err.contains("none of the requested paths"),
239 "error must report the unresolved paths: {err}"
240 );
241 }
242}