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