Skip to main content

lean_ctx/server/
multi_path.rs

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