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::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}