Skip to main content

lean_ctx/server/
multi_path.rs

1use 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
10/// Resolve tool paths with multi-root support.
11///
12/// Priority:
13/// 1. `paths` array argument (explicit multi-root)
14/// 2. `path` string argument (single root, pre-resolved by dispatch)
15/// 3. Session `extra_roots` (default multi-root from config/MCP)
16/// 4. Fallback to `"."` (project root)
17pub 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}