lean_ctx/core/
workspace_config.rs1use std::path::{Path, PathBuf};
2
3use serde::Deserialize;
4
5#[derive(Debug, Default)]
6pub struct LinkedProjects {
7 pub roots: Vec<PathBuf>,
8 pub warnings: Vec<String>,
9 pub source: Option<PathBuf>,
10}
11
12#[derive(Debug, Default, Deserialize)]
13struct WorkspaceConfigFile {
14 #[serde(default, rename = "linkedProjects", alias = "linked_projects")]
15 linked_projects: Vec<String>,
16}
17
18pub fn load_linked_projects(project_root: &Path) -> LinkedProjects {
19 let mut out = LinkedProjects::default();
20
21 let Some((source, content)) = read_config_file(project_root) else {
22 return out;
23 };
24 out.source = Some(source.clone());
25
26 let cfg: WorkspaceConfigFile = match serde_json::from_str(&content) {
27 Ok(v) => v,
28 Err(e) => {
29 out.warnings.push(format!(
30 "workspace config parse failed ({}): {e}",
31 source.display()
32 ));
33 return out;
34 }
35 };
36
37 let root_canon = project_root
38 .canonicalize()
39 .unwrap_or_else(|_| project_root.to_path_buf());
40
41 for raw in cfg.linked_projects {
42 let s = raw.trim();
43 if s.is_empty() {
44 continue;
45 }
46
47 let candidate = if Path::new(s).is_absolute() {
48 PathBuf::from(s)
49 } else {
50 project_root.join(s)
51 };
52
53 let Ok(abs) = candidate.canonicalize() else {
54 out.warnings.push(format!(
55 "linked project missing/unreadable: {}",
56 candidate.to_string_lossy()
57 ));
58 continue;
59 };
60 if abs == root_canon {
61 continue;
62 }
63 if !abs.is_dir() {
64 out.warnings.push(format!(
65 "linked project is not a directory: {}",
66 abs.display()
67 ));
68 continue;
69 }
70
71 match crate::core::io_boundary::jail_and_check_path("linkedProjects", &abs, project_root) {
72 Ok((_, _)) => out.roots.push(abs),
73 Err(e) => out.warnings.push(format!(
74 "linked project rejected by pathjail: {} ({e})",
75 abs.display()
76 )),
77 }
78 }
79
80 out.roots.sort();
81 out.roots.dedup();
82 out
83}
84
85fn read_config_file(project_root: &Path) -> Option<(PathBuf, String)> {
86 let new = project_root.join(".lean-ctx.json");
87 if let Ok(s) = std::fs::read_to_string(&new) {
88 return Some((new, s));
89 }
90 let legacy = project_root.join(".leanctx.json");
91 if let Ok(s) = std::fs::read_to_string(&legacy) {
92 return Some((legacy, s));
93 }
94 let socrati = project_root.join(".socraticode.json");
95 if let Ok(s) = std::fs::read_to_string(&socrati) {
96 return Some((socrati, s));
97 }
98 None
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104
105 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
106
107 fn write_linked_config(root: &Path, linked: &Path) {
108 let cfg = serde_json::json!({
109 "linkedProjects": [linked.to_string_lossy()]
110 })
111 .to_string();
112 std::fs::write(root.join(".lean-ctx.json"), cfg).expect("write cfg");
113 }
114
115 #[cfg(not(feature = "no-jail"))]
116 #[test]
117 fn linked_projects_outside_root_are_rejected_without_allow_path() {
118 let _guard = ENV_LOCK.lock().expect("lock");
119 let root = tempfile::tempdir().expect("root");
120 let other = tempfile::tempdir().expect("other");
121
122 write_linked_config(root.path(), other.path());
123
124 std::env::remove_var("LEAN_CTX_ALLOW_PATH");
125 let res = load_linked_projects(root.path());
126 assert!(res.roots.is_empty());
127 assert!(
128 res.warnings
129 .iter()
130 .any(|w| w.contains("rejected by pathjail")),
131 "expected pathjail warning, got: {:?}",
132 res.warnings
133 );
134 }
135
136 #[test]
137 fn linked_projects_outside_root_are_allowed_with_allow_path() {
138 let _guard = ENV_LOCK.lock().expect("lock");
139 let root = tempfile::tempdir().expect("root");
140 let other = tempfile::tempdir().expect("other");
141
142 write_linked_config(root.path(), other.path());
143
144 std::env::set_var(
145 "LEAN_CTX_ALLOW_PATH",
146 other.path().to_string_lossy().to_string(),
147 );
148 let res = load_linked_projects(root.path());
149 assert_eq!(res.roots.len(), 1);
150 assert_eq!(res.roots[0], other.path().canonicalize().expect("canon"));
151
152 std::env::remove_var("LEAN_CTX_ALLOW_PATH");
153 }
154}