Skip to main content

lean_ctx/core/
root_selection.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Which of several workspace roots a session binds to, made visible
3//! (AP04-G7, owner decision 19.10.).
4//!
5//! A host that reports several roots (MCP `roots/list`, Cursor's
6//! `WORKSPACE_FOLDER_PATHS`) still gets the first one (#699): the first root
7//! with a project marker, else the first safe directory. What changed is that
8//! the choice is no longer silent. Every selection among more than one
9//! candidate is recorded under the chosen project's partition, so the MCP
10//! session (`ctx_session status`) and the dashboard (`GET /api/scope`) can
11//! name the chosen project, the other candidates and how to change it:
12//! `lean-ctx project use <path>` records a preference that later selections
13//! honour when that folder is among the candidates.
14//!
15//! Records hold paths only — no timestamps or counters — so every text built
16//! from them is a deterministic function of the stored state (#498).
17
18use std::path::{Path, PathBuf};
19
20use serde::{Deserialize, Serialize};
21
22/// The rule that picked the first marker root (or first safe directory).
23pub(crate) const RULE_FIRST_ROOT: &str = "first_root";
24/// The rule that picked a root the user chose with `lean-ctx project use`.
25pub(crate) const RULE_PROJECT_USE: &str = "project_use";
26/// How a user changes the choice; `<path>` is one of the candidates.
27pub(crate) const CHANGE_WITH: &str = "lean-ctx project use <path>";
28
29/// Directory of the selection records, one file per chosen partition.
30const RECORD_DIR: &str = "root_selection";
31/// The preference store of `lean-ctx project use`.
32const PREFERENCE_FILE: &str = "project_use.json";
33/// Most preferences kept (most recent first).
34const MAX_PREFERENCES: usize = 64;
35/// Most candidates one record keeps.
36const MAX_CANDIDATES: usize = 64;
37/// Largest record or preference file read.
38const MAX_FILE_BYTES: u64 = 256 * 1024;
39
40/// One root selection: the chosen root, every candidate in the order the
41/// host reported it, and the rule that chose.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct RootSelection {
44    pub(crate) rule: String,
45    pub chosen_root: String,
46    pub(crate) candidates: Vec<String>,
47}
48
49impl RootSelection {
50    /// True when more than one root was a candidate — the only case that is
51    /// reported.
52    pub(crate) fn is_ambiguous(&self) -> bool {
53        self.candidates.len() > 1
54    }
55
56    /// The `selection` object of `GET /api/scope`.
57    pub fn scope_json(&self) -> serde_json::Value {
58        serde_json::json!({
59            "rule": self.rule,
60            "chosen_root": self.chosen_root,
61            "candidates": self.candidates,
62            "change_with": CHANGE_WITH,
63        })
64    }
65
66    /// One line for an MCP response that names the binding.
67    pub fn notice(&self) -> String {
68        let others: Vec<&str> = self
69            .candidates
70            .iter()
71            .filter(|candidate| **candidate != self.chosen_root)
72            .map(String::as_str)
73            .collect();
74        let why = if self.rule == RULE_PROJECT_USE {
75            "chosen with `lean-ctx project use`"
76        } else {
77            "the first workspace root"
78        };
79        format!(
80            "Roots: bound to {} ({why}, of {} workspace roots; others: {}). Change: {CHANGE_WITH}",
81            self.chosen_root,
82            self.candidates.len(),
83            others.join(", ")
84        )
85    }
86}
87
88fn canonical(path: &str) -> String {
89    crate::core::pathutil::safe_canonicalize_or_self(Path::new(path))
90        .to_string_lossy()
91        .into_owned()
92}
93
94/// Choose among `paths` (absolute directory paths, in host order).
95///
96/// Only existing directories count. The rule is the one sessions always
97/// used: the first path with a project marker, else the first safe
98/// directory. A broad or unsafe directory (HOME, `/`, agent dirs) is never a
99/// fallback and is listed as a candidate only when a marker made it the
100/// choice. A `preferred` root (most recent first) that is among the
101/// candidates wins over the rule.
102pub fn select(paths: Vec<String>, preferred: &[String]) -> Option<RootSelection> {
103    let mut dirs: Vec<String> = Vec::new();
104    for path in paths {
105        if Path::new(&path).is_dir() && !dirs.contains(&path) {
106            dirs.push(path);
107        }
108    }
109    let unsafe_root = |p: &str| crate::core::pathutil::is_broad_or_unsafe_root(Path::new(p));
110    let first = dirs
111        .iter()
112        .find(|p| crate::core::pathutil::has_project_marker(Path::new(p)))
113        .or_else(|| dirs.iter().find(|p| !unsafe_root(p)))?
114        .clone();
115    let candidates: Vec<String> = dirs
116        .into_iter()
117        .filter(|p| *p == first || !unsafe_root(p))
118        .take(MAX_CANDIDATES)
119        .collect();
120    if candidates.len() > 1 {
121        for wanted in preferred {
122            if let Some(found) = candidates.iter().find(|c| canonical(c) == *wanted) {
123                return Some(RootSelection {
124                    rule: RULE_PROJECT_USE.to_owned(),
125                    chosen_root: found.clone(),
126                    candidates,
127                });
128            }
129        }
130    }
131    Some(RootSelection {
132        rule: RULE_FIRST_ROOT.to_owned(),
133        chosen_root: first,
134        candidates,
135    })
136}
137
138/// [`select`] with the stored `lean-ctx project use` preferences.
139pub(crate) fn select_with_preferences(paths: Vec<String>) -> Option<RootSelection> {
140    select(paths, &preferred_roots())
141}
142
143fn data_dir() -> Option<PathBuf> {
144    crate::core::data_dir::lean_ctx_data_dir().ok()
145}
146
147fn record_path(dir: &Path, root: &str) -> PathBuf {
148    dir.join(RECORD_DIR).join(format!(
149        "{}.json",
150        crate::core::project_hash::hash_project_root(root)
151    ))
152}
153
154fn read_bounded(path: &Path) -> Option<String> {
155    use std::io::Read as _;
156    let file = std::fs::File::open(path).ok()?;
157    let mut text = String::new();
158    file.take(MAX_FILE_BYTES + 1)
159        .read_to_string(&mut text)
160        .ok()?;
161    (text.len() as u64 <= MAX_FILE_BYTES).then_some(text)
162}
163
164fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
165    if let Some(parent) = path.parent() {
166        std::fs::create_dir_all(parent)?;
167    }
168    #[cfg(unix)]
169    let permissions = {
170        use std::os::unix::fs::PermissionsExt as _;
171        Some(std::fs::Permissions::from_mode(0o600))
172    };
173    #[cfg(not(unix))]
174    let permissions = None;
175    crate::core::atomic_fs::try_atomic_write(path, bytes, permissions.as_ref())
176}
177
178/// Record a session's selection so other surfaces can show it. A selection
179/// among several candidates is stored for the chosen root, and the records of
180/// the other candidates (no longer chosen) are removed; a selection without
181/// alternatives removes the chosen root's record. Best effort: a failure only
182/// means the choice is not shown.
183pub fn record(selection: &RootSelection) {
184    let Some(dir) = data_dir() else { return };
185    record_in(&dir, selection);
186}
187
188fn record_in(dir: &Path, selection: &RootSelection) {
189    for candidate in &selection.candidates {
190        if *candidate != selection.chosen_root {
191            let _ = std::fs::remove_file(record_path(dir, candidate));
192        }
193    }
194    let path = record_path(dir, &selection.chosen_root);
195    if !selection.is_ambiguous() {
196        let _ = std::fs::remove_file(path);
197        return;
198    }
199    if let Ok(bytes) = serde_json::to_vec(selection)
200        && let Err(error) = write_private(&path, &bytes)
201    {
202        tracing::debug!("root selection record not written: {error}");
203    }
204}
205
206/// The recorded selection that chose `root` among several candidates.
207pub fn recorded_for(root: &str) -> Option<RootSelection> {
208    recorded_in(&data_dir()?, root)
209}
210
211fn recorded_in(dir: &Path, root: &str) -> Option<RootSelection> {
212    let selection: RootSelection =
213        serde_json::from_str(&read_bounded(&record_path(dir, root))?).ok()?;
214    (selection.is_ambiguous()
215        && selection.candidates.contains(&selection.chosen_root)
216        && canonical(&selection.chosen_root) == canonical(root))
217    .then_some(selection)
218}
219
220#[derive(Debug, Default, Serialize, Deserialize)]
221struct Preferences {
222    schema_version: u32,
223    /// Canonical roots, most recent first.
224    roots: Vec<String>,
225}
226
227/// The roots chosen with `lean-ctx project use`, most recent first.
228pub(crate) fn preferred_roots() -> Vec<String> {
229    data_dir().map_or_else(Vec::new, |dir| preferences_in(&dir).roots)
230}
231
232fn preferences_in(dir: &Path) -> Preferences {
233    read_bounded(&dir.join(PREFERENCE_FILE))
234        .and_then(|text| serde_json::from_str::<Preferences>(&text).ok())
235        .map(|mut preferences| {
236            preferences.roots.truncate(MAX_PREFERENCES);
237            preferences
238        })
239        .unwrap_or_default()
240}
241
242/// Prefer `root` (canonical, an existing safe directory) whenever it is one
243/// of several workspace roots.
244pub fn prefer(root: &str) -> Result<(), String> {
245    let dir = crate::core::data_dir::lean_ctx_data_dir()?;
246    prefer_in(&dir, root)
247}
248
249fn prefer_in(dir: &Path, root: &str) -> Result<(), String> {
250    let mut preferences = preferences_in(dir);
251    preferences.schema_version = 1;
252    preferences.roots.retain(|seen| seen != root);
253    preferences.roots.insert(0, root.to_owned());
254    preferences.roots.truncate(MAX_PREFERENCES);
255    let bytes = serde_json::to_vec_pretty(&preferences).map_err(|error| error.to_string())?;
256    write_private(&dir.join(PREFERENCE_FILE), &bytes)
257        .map_err(|error| format!("could not store the preference: {error}"))
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    fn dirs(names: &[&str]) -> (tempfile::TempDir, Vec<String>) {
265        let tmp = tempfile::tempdir().unwrap();
266        let paths = names
267            .iter()
268            .map(|name| {
269                let path = tmp.path().join(name);
270                std::fs::create_dir_all(&path).unwrap();
271                canonical(&path.to_string_lossy())
272            })
273            .collect();
274        (tmp, paths)
275    }
276
277    #[test]
278    fn first_root_is_kept_and_alternatives_are_listed() {
279        let (_tmp, paths) = dirs(&["a", "b"]);
280        let selection = select(paths.clone(), &[]).unwrap();
281        assert_eq!(selection.rule, RULE_FIRST_ROOT);
282        assert_eq!(selection.chosen_root, paths[0]);
283        assert_eq!(selection.candidates, paths);
284        assert!(selection.is_ambiguous());
285    }
286
287    #[test]
288    fn marker_root_wins_and_single_root_is_not_ambiguous() {
289        let (_tmp, paths) = dirs(&["plain", "repo"]);
290        std::fs::create_dir(Path::new(&paths[1]).join(".git")).unwrap();
291        let selection = select(paths.clone(), &[]).unwrap();
292        assert_eq!(selection.chosen_root, paths[1]);
293        assert!(selection.is_ambiguous());
294
295        let single = select(vec![paths[0].clone()], &[]).unwrap();
296        assert!(!single.is_ambiguous());
297        assert!(select(Vec::new(), &[]).is_none());
298    }
299
300    #[test]
301    fn a_preference_among_the_candidates_wins() {
302        let (_tmp, paths) = dirs(&["a", "b"]);
303        let selection = select(paths.clone(), &[paths[1].clone()]).unwrap();
304        assert_eq!(selection.rule, RULE_PROJECT_USE);
305        assert_eq!(selection.chosen_root, paths[1]);
306        // A preference that is not a candidate changes nothing.
307        let other = select(paths.clone(), &["/nowhere".to_owned()]).unwrap();
308        assert_eq!(other.chosen_root, paths[0]);
309    }
310
311    /// A later choice of another candidate, or the root opened alone, must
312    /// clear the stale record (the dashboard would name a wrong choice).
313    #[test]
314    fn stale_records_are_cleared() {
315        let (_tmp, paths) = dirs(&["a", "b"]);
316        let store = tempfile::tempdir().unwrap();
317        record_in(store.path(), &select(paths.clone(), &[]).unwrap());
318        assert!(recorded_in(store.path(), &paths[0]).is_some());
319
320        // The other root chosen later: the first record goes, the new one stays.
321        let switched = select(paths.clone(), &[paths[1].clone()]).unwrap();
322        record_in(store.path(), &switched);
323        assert!(recorded_in(store.path(), &paths[0]).is_none());
324        assert_eq!(
325            recorded_in(store.path(), &paths[1]).unwrap().rule,
326            RULE_PROJECT_USE
327        );
328
329        // Opened alone: nothing to report any more.
330        let alone = select(vec![paths[1].clone()], &[]).unwrap();
331        record_in(store.path(), &alone);
332        assert!(recorded_in(store.path(), &paths[1]).is_none());
333    }
334
335    #[test]
336    fn preferences_are_bounded_and_most_recent_first() {
337        let store = tempfile::tempdir().unwrap();
338        for index in 0..(MAX_PREFERENCES + 5) {
339            prefer_in(store.path(), &format!("/p/{index}")).unwrap();
340        }
341        prefer_in(store.path(), "/p/3").unwrap();
342        let roots = preferences_in(store.path()).roots;
343        assert!(roots.len() <= MAX_PREFERENCES);
344        assert_eq!(roots[0], "/p/3");
345        assert_eq!(roots.iter().filter(|r| *r == "/p/3").count(), 1);
346    }
347}