1use std::collections::BTreeMap;
17use std::path::{Component, Path, PathBuf};
18
19use serde::Serialize;
20
21use crate::core::session::SessionState;
22
23pub const SCHEMA: &str = "leanctx.session-readset/v1";
25
26#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
28pub struct ReadEntry {
29 pub path: String,
31 pub read_count: u32,
33 pub modified: bool,
35 pub last_mode: String,
37}
38
39#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
41pub struct SessionReadSet {
42 pub schema: &'static str,
43 pub sessions: Vec<String>,
45 pub reads: Vec<ReadEntry>,
47 pub outside_root: usize,
49 pub complete: bool,
51}
52
53#[derive(Debug, PartialEq, Eq)]
55pub enum ReadSetError {
56 NoSession(String),
58 OtherProject(String),
60}
61
62impl std::fmt::Display for ReadSetError {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 match self {
65 Self::NoSession(what) => write!(f, "no saved LeanCTX session {what}"),
66 Self::OtherProject(id) => write!(f, "session {id} belongs to another project root"),
67 }
68 }
69}
70
71impl std::error::Error for ReadSetError {}
72
73pub fn load(root: &Path, ids: &[String]) -> Result<SessionReadSet, ReadSetError> {
76 let root_text = root.to_string_lossy();
77 let sessions = if ids.is_empty() {
78 vec![
79 SessionState::load_latest_for_project_root(&root_text)
80 .ok_or_else(|| ReadSetError::NoSession(format!("for {root_text}")))?,
81 ]
82 } else {
83 ids.iter()
84 .map(
85 |id| match SessionState::load_by_id_for_project_root(id, &root_text) {
86 Ok(Some(session)) => Ok(session),
87 Ok(None) => Err(ReadSetError::NoSession(id.clone())),
88 Err(_) => Err(ReadSetError::OtherProject(id.clone())),
89 },
90 )
91 .collect::<Result<Vec<_>, _>>()?
92 };
93 Ok(from_sessions(root, &sessions))
94}
95
96pub fn from_sessions(root: &Path, sessions: &[SessionState]) -> SessionReadSet {
99 let roots = root_spellings(root);
100 let mut merged: BTreeMap<String, ReadEntry> = BTreeMap::new();
101 let mut outside_root = 0;
102 let mut complete = true;
103 for session in sessions {
104 let kept: u64 = session
105 .files_touched
106 .iter()
107 .map(|file| u64::from(file.read_count))
108 .sum();
109 if u64::from(session.stats.files_read) > kept {
110 complete = false;
111 }
112 for file in &session.files_touched {
113 let Some(path) = relative_to(&roots, &file.path) else {
114 outside_root += 1;
115 continue;
116 };
117 merged
118 .entry(path.clone())
119 .and_modify(|entry| {
120 entry.read_count = entry.read_count.saturating_add(file.read_count);
121 entry.modified |= file.modified;
122 entry.last_mode.clone_from(&file.last_mode);
123 })
124 .or_insert_with(|| ReadEntry {
125 path,
126 read_count: file.read_count,
127 modified: file.modified,
128 last_mode: file.last_mode.clone(),
129 });
130 }
131 }
132 SessionReadSet {
133 schema: SCHEMA,
134 sessions: sessions.iter().map(|s| s.id.clone()).collect(),
135 reads: merged.into_values().collect(),
136 outside_root,
137 complete,
138 }
139}
140
141fn root_spellings(root: &Path) -> Vec<PathBuf> {
142 let canonical = crate::core::pathutil::safe_canonicalize_or_self(root);
143 if canonical == root {
144 vec![root.to_path_buf()]
145 } else {
146 vec![root.to_path_buf(), canonical]
147 }
148}
149
150fn relative_to(roots: &[PathBuf], path: &str) -> Option<String> {
153 let path = Path::new(path);
154 let relative = if path.is_absolute() {
155 roots.iter().find_map(|root| path.strip_prefix(root).ok())?
156 } else {
157 path
158 };
159 let mut parts = Vec::new();
160 for component in relative.components() {
161 match component {
162 Component::Normal(part) => parts.push(part.to_str()?.to_string()),
163 Component::CurDir => {}
164 _ => return None,
165 }
166 }
167 (!parts.is_empty()).then(|| parts.join("/"))
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 fn session(id: &str, files: &[(&str, &str)]) -> SessionState {
175 let mut s = SessionState::new();
176 s.id = id.to_string();
177 for (path, mode) in files {
178 s.touch_file(path, None, mode, 10);
179 }
180 s
181 }
182
183 fn abs(p: &str) -> String {
186 if cfg!(windows) {
187 format!("C:{}", p.replace('/', "\\"))
188 } else {
189 p.to_string()
190 }
191 }
192
193 #[test]
194 fn read_set_is_relative_merged_and_sorted() {
195 let root = PathBuf::from(abs("/nonexistent-readset-root/repo"));
196 let (b_rs, a_rs) = (
197 abs("/nonexistent-readset-root/repo/src/b.rs"),
198 abs("/nonexistent-readset-root/repo/src/a.rs"),
199 );
200 let other = abs("/nonexistent-readset-root/repo/../other/x.rs");
201 let mut a = session(
202 "a",
203 &[
204 (b_rs.as_str(), "full"),
205 (a_rs.as_str(), "map"),
206 ("/etc/hosts", "full"),
207 (other.as_str(), "full"),
208 ],
209 );
210 a.mark_modified(&a_rs);
211 let b = session(
212 "b",
213 &[
214 ("src/a.rs", "lines:1-9"),
215 ("./docs/c.md", "full"),
216 ("../escape.rs", "full"),
217 ],
218 );
219
220 let set = from_sessions(&root, &[a, b]);
221 assert_eq!(set.schema, SCHEMA);
222 assert_eq!(set.sessions, vec!["a", "b"]);
223 assert_eq!(set.outside_root, 3);
224 assert!(set.complete);
225 let paths: Vec<&str> = set.reads.iter().map(|r| r.path.as_str()).collect();
226 assert_eq!(paths, vec!["docs/c.md", "src/a.rs", "src/b.rs"]);
227 let a_rs = &set.reads[1];
228 assert_eq!(a_rs.read_count, 2);
229 assert!(a_rs.modified);
230 assert_eq!(a_rs.last_mode, "lines:1-9");
231 }
232
233 #[test]
234 fn evicted_history_marks_the_set_incomplete() {
235 let files: Vec<(String, &str)> = (0..60).map(|i| (format!("f{i:02}.rs"), "full")).collect();
236 let refs: Vec<(&str, &str)> = files.iter().map(|(p, m)| (p.as_str(), *m)).collect();
237 let set = from_sessions(
238 Path::new("/nonexistent-readset-root"),
239 &[session("s", &refs)],
240 );
241 assert!(!set.complete);
242 assert!(set.reads.len() < 60);
243 }
244}