lean_ctx/core/
command_root.rs1use std::path::Path;
11
12pub fn detect_project_root(args: &[String]) -> String {
14 detect_project_root_in(args, std::env::current_dir().ok().as_deref())
15}
16
17pub fn require_project_root() -> Result<String, String> {
20 require_project_root_in(std::env::current_dir().ok().as_deref())
21}
22
23fn require_project_root_in(cwd: Option<&Path>) -> Result<String, String> {
24 let root = detect_project_root_in(&[], cwd);
25 if crate::core::pathutil::is_broad_or_unsafe_root(Path::new(&root)) {
26 return Err(format!(
27 "{root} is not a project — run inside a project or set LEAN_CTX_PROJECT_ROOT"
28 ));
29 }
30 Ok(root)
31}
32
33fn detect_project_root_in(args: &[String], cwd: Option<&Path>) -> String {
34 let mut it = args.iter().peekable();
35 while let Some(a) = it.next() {
36 if let Some(v) = a.strip_prefix("--root=")
37 && !v.trim().is_empty()
38 {
39 return normalize_explicit_root(v);
40 }
41 if let Some(v) = a.strip_prefix("--project-root=")
42 && !v.trim().is_empty()
43 {
44 return normalize_explicit_root(v);
45 }
46 if (a == "--root" || a == "--project-root")
47 && let Some(v) = it.peek()
48 && !v.starts_with("--")
49 && !v.trim().is_empty()
50 {
51 return normalize_explicit_root(v);
52 }
53 }
54 let cwd_root = cwd.map(|cwd| promote_to_git_root(&cwd.to_string_lossy()));
55 if let Ok(root) = std::env::var("LEAN_CTX_PROJECT_ROOT")
56 && !root.trim().is_empty()
57 {
58 return crate::core::mcp_roots::reconcile_pinned_root(
59 normalize_explicit_root(&root),
60 cwd_root,
61 );
62 }
63 cwd_root.unwrap_or_else(|| ".".to_string())
64}
65
66fn normalize_explicit_root(path: &str) -> String {
67 let expanded = expand_home(path.trim());
68 crate::core::index_paths::normalize_project_root(&expanded)
69}
70
71fn expand_home(path: &str) -> String {
72 if path == "~" {
73 return dirs::home_dir().map_or_else(
74 || path.to_string(),
75 |home| home.to_string_lossy().to_string(),
76 );
77 }
78 if let Some(rest) = path.strip_prefix("~/") {
79 return dirs::home_dir().map_or_else(
80 || path.to_string(),
81 |home| home.join(rest).to_string_lossy().to_string(),
82 );
83 }
84 path.to_string()
85}
86
87pub fn promote_to_git_root(path: &str) -> String {
88 let mut p = Path::new(path);
89 loop {
90 if p.join(".git").exists() && !crate::core::pathutil::is_broad_or_unsafe_root(p) {
92 return p.to_string_lossy().to_string();
93 }
94 match p.parent() {
95 Some(parent) => p = parent,
96 None => return path.to_string(),
97 }
98 }
99}
100
101pub fn jail_cli_read_path(raw: &str, project_root: &str) -> Result<String, String> {
110 let candidate = if Path::new(raw).is_relative() {
111 std::env::current_dir().map_or_else(
112 |_| raw.to_string(),
113 |cwd| cwd.join(raw).to_string_lossy().into_owned(),
114 )
115 } else {
116 raw.to_string()
117 };
118 let jail_disabled = crate::core::config::Config::load().path_jail == Some(false);
119 if !jail_disabled && crate::core::pathutil::is_broad_or_unsafe_root(Path::new(project_root)) {
120 return Err(format!(
121 "path escapes project root: {candidate} (root: {project_root}). Access denied: \
122 no project detected — run inside a project, or pass --root <dir> \
123 (or set LEAN_CTX_PROJECT_ROOT)"
124 ));
125 }
126 crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &candidate)
127}
128
129pub fn cli_read_is_refused(raw: &str) -> bool {
133 jail_cli_read_path(raw, &detect_project_root(&[])).is_err()
134}
135
136#[cfg(test)]
137#[path = "command_root_tests.rs"]
138mod tests;