Skip to main content

lean_ctx/core/
workspace_config.rs

1use 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    fn write_linked_config(root: &Path, linked: &Path) {
106        let cfg = serde_json::json!({
107            "linkedProjects": [linked.to_string_lossy()]
108        })
109        .to_string();
110        std::fs::write(root.join(".lean-ctx.json"), cfg).expect("write cfg");
111    }
112
113    #[cfg(not(feature = "no-jail"))]
114    #[test]
115    fn linked_projects_outside_root_are_rejected_without_allow_path() {
116        // Global lock (not module-local): every LEAN_CTX_ALLOW_PATH mutation
117        // process-wide serializes through test_env_lock (#695 flake report).
118        let _guard = crate::core::data_dir::test_env_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        crate::test_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 = crate::core::data_dir::test_env_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        crate::test_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        crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
153    }
154}