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::pathjail::jail_path(&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 lean = project_root.join(".leanctx.json");
87 if let Ok(s) = std::fs::read_to_string(&lean) {
88 return Some((lean, s));
89 }
90 let socrati = project_root.join(".socraticode.json");
91 if let Ok(s) = std::fs::read_to_string(&socrati) {
92 return Some((socrati, s));
93 }
94 None
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100
101 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
102
103 fn write_linked_config(root: &Path, linked: &Path) {
104 let cfg = serde_json::json!({
105 "linkedProjects": [linked.to_string_lossy()]
106 })
107 .to_string();
108 std::fs::write(root.join(".leanctx.json"), cfg).expect("write cfg");
109 }
110
111 #[test]
112 fn linked_projects_outside_root_are_rejected_without_allow_path() {
113 let _guard = ENV_LOCK.lock().expect("lock");
114 let root = tempfile::tempdir().expect("root");
115 let other = tempfile::tempdir().expect("other");
116
117 write_linked_config(root.path(), other.path());
118
119 std::env::remove_var("LEAN_CTX_ALLOW_PATH");
120 let res = load_linked_projects(root.path());
121 assert!(res.roots.is_empty());
122 assert!(
123 res.warnings
124 .iter()
125 .any(|w| w.contains("rejected by pathjail")),
126 "expected pathjail warning, got: {:?}",
127 res.warnings
128 );
129 }
130
131 #[test]
132 fn linked_projects_outside_root_are_allowed_with_allow_path() {
133 let _guard = ENV_LOCK.lock().expect("lock");
134 let root = tempfile::tempdir().expect("root");
135 let other = tempfile::tempdir().expect("other");
136
137 write_linked_config(root.path(), other.path());
138
139 std::env::set_var(
140 "LEAN_CTX_ALLOW_PATH",
141 other.path().to_string_lossy().to_string(),
142 );
143 let res = load_linked_projects(root.path());
144 assert_eq!(res.roots.len(), 1);
145 assert_eq!(res.roots[0], other.path().canonicalize().expect("canon"));
146
147 std::env::remove_var("LEAN_CTX_ALLOW_PATH");
148 }
149}