Skip to main content

lean_ctx/core/
session_readset.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Session read set: which files a LeanCTX session actually read, relative
3//! to one project root.
4//!
5//! This is the `observed_reads` pillar of the ECCP change envelope
6//! (`docs/contracts/change-envelope-v1.md`): only LeanCTX knows what an agent
7//! read while it produced a change. `lean-ctx session readset --json` prints
8//! [`SessionReadSet`]; `scripts/eccp-envelope write --observed-reads-from-session`
9//! records it.
10//!
11//! The source is the persisted session (`files_touched`). A session keeps a
12//! bounded number of files; when older entries were evicted the read set is
13//! marked `complete: false`, so a consumer never mistakes a truncated set for
14//! the whole one.
15
16use std::collections::BTreeMap;
17use std::path::{Component, Path, PathBuf};
18
19use serde::Serialize;
20
21use crate::core::session::SessionState;
22
23/// Schema id of the JSON printed by `lean-ctx session readset --json`.
24pub const SCHEMA: &str = "leanctx.session-readset/v1";
25
26/// One file the session read, merged across the selected sessions.
27#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
28pub struct ReadEntry {
29    /// Path relative to the project root, `/`-separated.
30    pub path: String,
31    /// How often the file was read (sum over sessions).
32    pub read_count: u32,
33    /// The session also wrote the file.
34    pub modified: bool,
35    /// Read mode of the last read (`full`, `map`, `lines:N-M`, ...).
36    pub last_mode: String,
37}
38
39/// The read set of one or more sessions of a project.
40#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
41pub struct SessionReadSet {
42    pub schema: &'static str,
43    /// Session ids the set was taken from, in the order given.
44    pub sessions: Vec<String>,
45    /// Files under the project root, sorted by path.
46    pub reads: Vec<ReadEntry>,
47    /// Reads outside the project root; left out of `reads`.
48    pub outside_root: usize,
49    /// False when a session evicted older file entries (bounded history).
50    pub complete: bool,
51}
52
53/// Errors of [`load`].
54#[derive(Debug, PartialEq, Eq)]
55pub enum ReadSetError {
56    /// No saved session for the project root (or the given id is unknown).
57    NoSession(String),
58    /// The session with this id belongs to another project root.
59    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
73/// Loads the sessions (the latest one for `root` when `ids` is empty) and
74/// returns their read set relative to `root`.
75pub 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
96/// The read set of `sessions` relative to `root`. Pure: no disk access
97/// beyond canonicalizing `root`.
98pub 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
150/// `path` relative to one of `roots`, `/`-separated; `None` outside them.
151/// A relative path is taken as relative to the root already.
152fn 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    /// An absolute path on this platform: on Windows a path without a drive
184    /// prefix is not absolute, so the fixture would count as relative there.
185    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}