lean_ctx/server/
multi_path.rs1use serde_json::{Map, Value};
2
3use crate::server::tool_trait::{get_str, get_str_array, ToolContext};
4
5pub struct ResolvedPaths {
6 pub roots: Vec<String>,
7 pub is_multi: bool,
8}
9
10pub fn resolve_tool_paths(args: &Map<String, Value>, ctx: &ToolContext) -> ResolvedPaths {
19 if let Some(repo) = get_str(args, "repo") {
20 if let Some(root) = crate::core::multi_repo::resolve_repo_root(&repo) {
21 return ResolvedPaths {
22 roots: vec![root],
23 is_multi: false,
24 };
25 }
26 }
27
28 if let Some(paths) = get_str_array(args, "paths") {
29 if !paths.is_empty() {
30 let resolved = resolve_paths_sync(ctx, &paths);
31 if !resolved.is_empty() {
32 return ResolvedPaths {
33 is_multi: resolved.len() > 1,
34 roots: resolved,
35 };
36 }
37 }
38 }
39
40 if let Some(path) = ctx.resolved_path("path") {
41 return ResolvedPaths {
42 roots: vec![path.to_string()],
43 is_multi: false,
44 };
45 }
46
47 if let Some(session_lock) = ctx.session.as_ref() {
48 let (extra, jail_root) = tokio::task::block_in_place(|| {
49 let rt = tokio::runtime::Handle::current();
50 rt.block_on(async {
51 let session = session_lock.read().await;
52 let root = session
53 .project_root
54 .clone()
55 .unwrap_or_else(|| ".".to_string());
56 (session.extra_roots.clone(), root)
57 })
58 });
59 if !extra.is_empty() {
60 let jail = std::path::Path::new(&jail_root);
61 let mut roots = vec![ctx.project_root.clone()];
62 for r in &extra {
63 let p = std::path::Path::new(r);
64 if !p.is_dir() {
65 continue;
66 }
67 match crate::core::pathjail::jail_path(p, jail) {
68 Ok(_) => roots.push(r.clone()),
69 Err(e) => tracing::warn!("extra_root rejected by PathJail: {e}"),
70 }
71 }
72 if roots.len() > 1 {
73 return ResolvedPaths {
74 is_multi: true,
75 roots,
76 };
77 }
78 }
79 }
80
81 ResolvedPaths {
82 roots: vec![".".to_string()],
83 is_multi: false,
84 }
85}
86
87fn resolve_paths_sync(ctx: &ToolContext, raw: &[String]) -> Vec<String> {
88 let mut out = Vec::with_capacity(raw.len());
89 for p in raw {
90 match ctx.resolve_path_sync(p) {
91 Ok(resolved) => out.push(resolved),
92 Err(e) => {
93 tracing::warn!("multi-path resolve failed for {p}: {e}");
94 }
95 }
96 }
97 out
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103 use serde_json::json;
104
105 fn test_ctx() -> ToolContext {
106 ToolContext {
107 project_root: "/test/project".to_string(),
108 minimal: false,
109 resolved_paths: std::collections::HashMap::new(),
110 crp_mode: crate::tools::CrpMode::Off,
111 cache: None,
112 session: None,
113 tool_calls: None,
114 agent_id: None,
115 workflow: None,
116 ledger: None,
117 client_name: None,
118 pipeline_stats: None,
119 call_count: None,
120 autonomy: None,
121 pressure_snapshot: None,
122 path_errors: std::collections::HashMap::new(),
123 bm25_cache: None,
124 progress_sender: None,
125 }
126 }
127
128 #[test]
129 fn fallback_to_dot_when_nothing_set() {
130 let args = Map::new();
131 let ctx = test_ctx();
132 let result = resolve_tool_paths(&args, &ctx);
133 assert_eq!(result.roots, vec!["."]);
134 assert!(!result.is_multi);
135 }
136
137 #[test]
138 fn uses_resolved_path_when_present() {
139 let args = Map::new();
140 let mut ctx = test_ctx();
141 ctx.resolved_paths
142 .insert("path".to_string(), "/resolved/dir".to_string());
143 let result = resolve_tool_paths(&args, &ctx);
144 assert_eq!(result.roots, vec!["/resolved/dir"]);
145 assert!(!result.is_multi);
146 }
147
148 #[test]
149 fn empty_paths_array_falls_back() {
150 let mut args = Map::new();
151 args.insert("paths".to_string(), json!([]));
152 let mut ctx = test_ctx();
153 ctx.resolved_paths
154 .insert("path".to_string(), "/fallback".to_string());
155 let result = resolve_tool_paths(&args, &ctx);
156 assert_eq!(result.roots, vec!["/fallback"]);
157 assert!(!result.is_multi);
158 }
159}