1use std::path::{Path, PathBuf};
19
20use serde::{Deserialize, Serialize};
21
22pub(crate) const RULE_FIRST_ROOT: &str = "first_root";
24pub(crate) const RULE_PROJECT_USE: &str = "project_use";
26pub(crate) const CHANGE_WITH: &str = "lean-ctx project use <path>";
28
29const RECORD_DIR: &str = "root_selection";
31const PREFERENCE_FILE: &str = "project_use.json";
33const MAX_PREFERENCES: usize = 64;
35const MAX_CANDIDATES: usize = 64;
37const MAX_FILE_BYTES: u64 = 256 * 1024;
39
40#[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 pub(crate) fn is_ambiguous(&self) -> bool {
53 self.candidates.len() > 1
54 }
55
56 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 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
94pub 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
138pub(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
178pub 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
206pub 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 roots: Vec<String>,
225}
226
227pub(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
242pub 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 let other = select(paths.clone(), &["/nowhere".to_owned()]).unwrap();
308 assert_eq!(other.chosen_root, paths[0]);
309 }
310
311 #[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 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 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}