Skip to main content

lean_ctx/core/savings_ledger/
project_scope.rs

1// SPDX-License-Identifier: Apache-2.0
2//! One project's share of the savings ledger and the audit trail.
3//!
4//! The dashboard answers a read that names its project (`X-LeanCTX-Project`)
5//! for that project alone. Ledger events carry the hashed working directory
6//! of the process that recorded them (`super::hash_folder`) and, since v7,
7//! its session; audit entries carry the session. A folder counts for the
8//! project when it is the project, a folder inside it that is not a
9//! repository of its own, or a linked Git worktree of it: the grouping the
10//! Cockpit's project switcher shows.
11
12use std::collections::HashSet;
13use std::path::{Path, PathBuf};
14use std::sync::Mutex;
15use std::time::{Duration, Instant};
16
17use super::SavingsEvent;
18
19/// How long one project's folders and sessions are reused between reads.
20const SCOPE_TTL: Duration = Duration::from_secs(30);
21const SCOPE_CACHE_LIMIT: usize = 8;
22
23static SCOPES: Mutex<Vec<(PathBuf, Instant, ProjectScope)>> = Mutex::new(Vec::new());
24
25/// The ledger hashes and sessions that belong to one project.
26#[derive(Debug, Clone, Default, PartialEq, Eq)]
27pub struct ProjectScope {
28    project: PathBuf,
29    repo_hashes: HashSet<String>,
30    sessions: HashSet<String>,
31}
32
33impl ProjectScope {
34    /// The scope of `project` over every saved session (cached briefly: the
35    /// dashboard reads several figures per page).
36    pub fn for_project(project: &Path) -> Self {
37        let project = crate::core::pathutil::safe_canonicalize_or_self(project);
38        let mut cache = SCOPES
39            .lock()
40            .unwrap_or_else(std::sync::PoisonError::into_inner);
41        cache.retain(|(_, at, _)| at.elapsed() < SCOPE_TTL);
42        if let Some((_, _, scope)) = cache.iter().find(|(root, _, _)| *root == project) {
43            return scope.clone();
44        }
45        let sessions = crate::core::session::SessionState::list_sessions();
46        let scope = Self::from_sessions(
47            &project,
48            sessions
49                .iter()
50                .filter_map(|s| s.project_root.as_deref().map(|root| (s.id.as_str(), root))),
51        );
52        if cache.len() >= SCOPE_CACHE_LIMIT {
53            cache.remove(0);
54        }
55        cache.push((project, Instant::now(), scope.clone()));
56        scope
57    }
58
59    /// The scope of `project` over `(session id, session folder)` pairs.
60    pub fn from_sessions<'a>(
61        project: &Path,
62        sessions: impl Iterator<Item = (&'a str, &'a str)>,
63    ) -> Self {
64        let project = crate::core::pathutil::safe_canonicalize_or_self(project);
65        let mut scope = Self {
66            repo_hashes: HashSet::from([super::hash_folder(&project.to_string_lossy())]),
67            project,
68            sessions: HashSet::new(),
69        };
70        for (id, folder) in sessions {
71            let raw = Path::new(folder);
72            if !raw.is_absolute() {
73                continue;
74            }
75            let canonical = crate::core::pathutil::safe_canonicalize_or_self(raw);
76            if !belongs(&scope.project, &canonical) {
77                continue;
78            }
79            // A process hashes its working directory as it was spelled.
80            scope.repo_hashes.insert(super::hash_folder(folder));
81            scope
82                .repo_hashes
83                .insert(super::hash_folder(&canonical.to_string_lossy()));
84            if !id.is_empty() {
85                scope.sessions.insert(id.to_string());
86            }
87        }
88        scope
89    }
90
91    /// The project folder (canonical): the key of everything derived from this scope.
92    pub fn project(&self) -> &Path {
93        &self.project
94    }
95
96    /// The ledger folder hashes of the project, sorted.
97    pub fn repo_hashes(&self) -> Vec<String> {
98        let mut hashes: Vec<String> = self.repo_hashes.iter().cloned().collect();
99        hashes.sort();
100        hashes
101    }
102
103    /// The sessions recorded in the project's folders.
104    pub fn sessions(&self) -> &HashSet<String> {
105        &self.sessions
106    }
107
108    pub fn holds_event(&self, event: &SavingsEvent) -> bool {
109        self.holds(&event.repo_hash, event.session_id.as_deref())
110    }
111
112    /// Whether a record from the folder hashed `repo_hash` in `session` belongs here.
113    pub fn holds(&self, repo_hash: &str, session: Option<&str>) -> bool {
114        self.repo_hashes.contains(repo_hash) || session.is_some_and(|id| self.sessions.contains(id))
115    }
116
117    /// Whether an audit entry of `session` belongs here; an entry without a
118    /// session cannot be placed in any one project.
119    pub fn holds_session(&self, session: Option<&str>) -> bool {
120        session.is_some_and(|id| self.sessions.contains(id))
121    }
122}
123
124/// Whether `folder` (canonical) is `project`, inside it without a repository
125/// of its own, or a linked worktree of it. A folder that no longer exists is
126/// placed by the nearest folder above it that does.
127fn belongs(project: &Path, folder: &Path) -> bool {
128    folder == project || project_root_of(folder) == project
129}
130
131/// The project a folder belongs to: the main checkout of the repository it
132/// is in (a linked worktree counts as its main checkout), or the folder
133/// itself outside any repository. Canonical where the folder exists.
134pub fn project_root_of(folder: &Path) -> PathBuf {
135    let folder = crate::core::pathutil::safe_canonicalize_or_self(folder);
136    let top = folder
137        .ancestors()
138        .take_while(|dir| !crate::core::pathutil::is_broad_or_unsafe_root(dir))
139        .find(|dir| dir.join(".git").exists());
140    match top {
141        Some(top) => main_checkout(top).unwrap_or_else(|| top.to_path_buf()),
142        None => folder,
143    }
144}
145
146/// The main checkout of the linked worktree at `top`: its `.git` file names
147/// `<main>/.git/worktrees/<name>`. `None` for a `.git` directory (a
148/// repository of its own) or an unreadable link.
149fn main_checkout(top: &Path) -> Option<PathBuf> {
150    let text = std::fs::read_to_string(top.join(".git")).ok()?;
151    let git_dir = top.join(text.trim().strip_prefix("gitdir:")?.trim());
152    let git_dir = crate::core::pathutil::safe_canonicalize_or_self(&git_dir);
153    let worktrees = git_dir.parent()?;
154    let dot_git = worktrees.parent()?;
155    (worktrees.file_name()? == "worktrees" && dot_git.file_name()? == ".git")
156        .then(|| dot_git.parent().map(Path::to_path_buf))
157        .flatten()
158}
159
160/// The project key a record made in `folder` carries: the ledger hash of
161/// [`project_root_of`]. `None` for a folder too broad to be a project (the
162/// home folder, `/`).
163pub fn project_key_of(folder: &Path) -> Option<String> {
164    static KEYS: Mutex<Vec<(PathBuf, Option<String>)>> = Mutex::new(Vec::new());
165    const KEY_CACHE_LIMIT: usize = 1024;
166    let mut keys = KEYS
167        .lock()
168        .unwrap_or_else(std::sync::PoisonError::into_inner);
169    if let Some((_, key)) = keys.iter().find(|(known, _)| known == folder) {
170        return key.clone();
171    }
172    let key = (folder.is_absolute() && !crate::core::pathutil::is_broad_or_unsafe_root(folder))
173        .then(|| project_root_of(folder))
174        .filter(|root| !crate::core::pathutil::is_broad_or_unsafe_root(root))
175        .map(|root| super::hash_folder(&root.to_string_lossy()));
176    if keys.len() >= KEY_CACHE_LIMIT {
177        keys.remove(0);
178    }
179    keys.push((folder.to_path_buf(), key.clone()));
180    key
181}
182
183/// The project key of work done now: the project of the request being
184/// served (an MCP root, a dashboard read), else this process's folder.
185pub fn current_project_key() -> Option<String> {
186    let folder = crate::core::policy::runtime::REQUEST_PROJECT
187        .try_with(|slot| slot.borrow().clone())
188        .ok()
189        .flatten()
190        .filter(|p| !p.as_os_str().is_empty())
191        .or_else(|| std::env::current_dir().ok())?;
192    project_key_of(&folder)
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    fn repo(dir: &Path) {
200        std::fs::create_dir_all(dir.join(".git")).unwrap();
201    }
202
203    #[test]
204    fn subfolders_and_worktrees_count_but_nested_and_foreign_repositories_do_not() {
205        let tmp = tempfile::tempdir().unwrap();
206        let base = crate::core::pathutil::safe_canonicalize_or_self(tmp.path());
207        let project = base.join("app");
208        repo(&project);
209        let sub = project.join("src/deep");
210        std::fs::create_dir_all(&sub).unwrap();
211        let nested = project.join("website");
212        repo(&nested);
213        let other = base.join("other");
214        repo(&other);
215        // A linked worktree outside the project and one inside it.
216        let outside = base.join("app-wt");
217        let inside = project.join(".claude/worktrees/w1");
218        for (wt, name) in [(&outside, "wt"), (&inside, "w1")] {
219            std::fs::create_dir_all(wt).unwrap();
220            std::fs::create_dir_all(project.join(".git/worktrees").join(name)).unwrap();
221            let gitdir = project.join(".git/worktrees").join(name);
222            std::fs::write(wt.join(".git"), format!("gitdir: {}\n", gitdir.display())).unwrap();
223        }
224        let gone = project.join(".codex-worktrees/removed");
225
226        let folders = [
227            ("s-root", project.to_string_lossy().into_owned()),
228            ("s-sub", sub.to_string_lossy().into_owned()),
229            ("s-outside", outside.to_string_lossy().into_owned()),
230            ("s-inside", inside.to_string_lossy().into_owned()),
231            ("s-gone", gone.to_string_lossy().into_owned()),
232            ("s-nested", nested.to_string_lossy().into_owned()),
233            ("s-other", other.to_string_lossy().into_owned()),
234            ("s-relative", "app".to_string()),
235        ];
236        let scope =
237            ProjectScope::from_sessions(&project, folders.iter().map(|(id, f)| (*id, f.as_str())));
238
239        for (id, folder) in &folders[..5] {
240            assert!(scope.holds_session(Some(id)), "{id} belongs to the project");
241            assert!(scope.holds(&crate::core::savings_ledger::hash_folder(folder), None));
242        }
243        for (id, folder) in &folders[5..] {
244            assert!(!scope.holds_session(Some(id)), "{id} is another project");
245            assert!(!scope.holds(&crate::core::savings_ledger::hash_folder(folder), None));
246        }
247        assert!(!scope.holds_session(None));
248    }
249
250    #[test]
251    fn an_event_counts_by_its_folder_or_by_its_session() {
252        let tmp = tempfile::tempdir().unwrap();
253        let project = crate::core::pathutil::safe_canonicalize_or_self(tmp.path()).join("p");
254        repo(&project);
255        let root = project.to_string_lossy().into_owned();
256        let scope = ProjectScope::from_sessions(&project, [("s1", root.as_str())].into_iter());
257        assert!(scope.holds("unrelated", Some("s1")));
258        assert!(!scope.holds("unrelated", Some("s2")));
259        assert!(!scope.holds("unrelated", None));
260    }
261}