lean_ctx/core/provenance/
tracker.rs1use std::collections::BTreeSet;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use chrono::Utc;
6use sha2::{Digest, Sha256};
7
8use super::{
9 CheckpointId, CheckpointLinkState, CheckpointRecord, DecisionId, ObservationConfidence,
10 ProvenanceId, ProvenanceRecord, ProvenanceStore,
11};
12
13#[derive(Debug, Clone)]
15pub struct ProvenanceTracker {
16 store: ProvenanceStore,
17 project_root: PathBuf,
18}
19
20impl ProvenanceTracker {
21 pub fn new(project_root: impl AsRef<Path>) -> Result<Self, String> {
24 let data_dir = crate::core::data_dir::lean_ctx_data_dir()?;
25 Self::open_in(&data_dir, project_root)
26 }
27
28 pub fn open_in(data_dir: &Path, project_root: impl AsRef<Path>) -> Result<Self, String> {
32 let project_root = project_root.as_ref().to_path_buf();
33 let root = project_root.to_string_lossy();
34 let project_hash = crate::core::project_hash::hash_project_root(&root);
35 let store = ProvenanceStore::with_data_dir(data_dir, project_hash)?;
36 let legacy = ProvenanceStore::with_data_dir(data_dir, sha256_hex(root.as_bytes()))?;
37 let store = match (store.parent_dir(), legacy.parent_dir()) {
38 (Ok(current), Ok(old)) if !current.exists() && old.exists() => {
39 let moved = current
40 .parent()
41 .is_some_and(|parent| std::fs::create_dir_all(parent).is_ok())
42 && std::fs::rename(&old, ¤t).is_ok();
43 if moved { store } else { legacy }
44 }
45 _ => store,
46 };
47 Ok(Self {
48 store,
49 project_root,
50 })
51 }
52
53 pub fn with_store(store: ProvenanceStore, project_root: impl AsRef<Path>) -> Self {
55 Self {
56 store,
57 project_root: project_root.as_ref().to_path_buf(),
58 }
59 }
60
61 pub fn store(&self) -> &ProvenanceStore {
62 &self.store
63 }
64
65 #[allow(clippy::too_many_arguments)]
66 pub fn observe_edit(
67 &self,
68 path: impl Into<String>,
69 tool: impl Into<String>,
70 before_sha256: impl Into<String>,
71 after_sha256: impl Into<String>,
72 lines_added: u64,
73 lines_removed: u64,
74 session_id: impl Into<String>,
75 agent_id: impl Into<String>,
76 ) -> Result<ProvenanceId, String> {
77 let path = path.into();
78 let tool = tool.into();
79 let before_sha256 = before_sha256.into();
80 let after_sha256 = after_sha256.into();
81 let session_id = session_id.into();
82 let agent_id = agent_id.into();
83 let operation_id = sha256_hex(
84 format!(
85 "{session_id}\0{agent_id}\0{tool}\0{path}\0{before_sha256}\0{after_sha256}\0{lines_added}\0{lines_removed}"
86 )
87 .as_bytes(),
88 );
89
90 self.store.record_file_touch(ProvenanceRecord {
91 project_hash: self.store.project_hash().to_owned(),
92 session_id,
93 agent_id,
94 operation_id,
95 path,
96 tool,
97 observed_at: Utc::now(),
98 before_sha256: (!before_sha256.is_empty()).then_some(before_sha256),
99 after_sha256: (!after_sha256.is_empty()).then_some(after_sha256),
100 lines_added,
101 lines_removed,
102 confidence: ObservationConfidence::Observed,
103 ..ProvenanceRecord::default()
104 })
105 }
106
107 pub fn link_decisions(
109 &self,
110 provenance_id: &str,
111 decision_ids: Vec<DecisionId>,
112 ) -> Result<(), String> {
113 self.store.update_file_touch(provenance_id, |record| {
114 record.decision_ids = decision_ids;
115 })
116 }
117
118 pub fn observe_commit(
120 &self,
121 commit_sha: &str,
122 session_id: &str,
123 ) -> Result<CheckpointId, String> {
124 let numstat = self.git_output([
125 "show",
126 "--format=",
127 "--numstat",
128 "--no-ext-diff",
129 commit_sha,
130 ])?;
131 let patch =
132 self.git_output(["show", "--format=", "--no-ext-diff", "--binary", commit_sha])?;
133 let (paths, insertions, deletions) = parse_numstat(&numstat);
134 let provenance_ids = self
135 .store
136 .query_by_session(session_id)
137 .into_iter()
138 .filter(|record| paths.contains(&record.path))
139 .map(|record| record.id)
140 .collect::<Vec<_>>();
141 let link_state = if provenance_ids.is_empty() {
142 CheckpointLinkState::Orphaned
143 } else {
144 CheckpointLinkState::Linked
145 };
146 let checkpoint_id = self.store.record_checkpoint(CheckpointRecord {
147 session_id: session_id.to_owned(),
148 commit_sha: commit_sha.to_owned(),
149 patch_id: Some(sha256_hex(&patch)),
150 link_state,
151 provenance_ids,
152 observed_at: Utc::now(),
153 files_touched: paths.len() as u64,
154 insertions,
155 deletions,
156 ..CheckpointRecord::default()
157 })?;
158
159 for provenance_id in self
160 .store
161 .query_checkpoints(None)
162 .into_iter()
163 .find(|record| record.id == checkpoint_id)
164 .into_iter()
165 .flat_map(|record| record.provenance_ids)
166 {
167 self.store.update_file_touch(&provenance_id, |record| {
168 record.checkpoint_id = Some(checkpoint_id.clone());
169 })?;
170 }
171 Ok(checkpoint_id)
172 }
173
174 pub fn reconcile_rebase(&self, old_sha: &str, new_sha: &str) -> Result<usize, String> {
176 let checkpoint_ids = self
177 .store
178 .query_checkpoints(None)
179 .into_iter()
180 .filter(|record| record.commit_sha == old_sha)
181 .map(|record| record.id)
182 .collect::<Vec<_>>();
183 for checkpoint_id in &checkpoint_ids {
184 self.store.update_checkpoint(checkpoint_id, |record| {
185 record.commit_sha.clone_from(&new_sha.to_owned());
186 record.link_state = if record.provenance_ids.is_empty() {
187 CheckpointLinkState::Orphaned
188 } else {
189 CheckpointLinkState::Linked
190 };
191 })?;
192 }
193 Ok(checkpoint_ids.len())
194 }
195
196 fn git_output<const N: usize>(&self, args: [&str; N]) -> Result<Vec<u8>, String> {
197 let output = Command::new("git")
198 .arg("-C")
199 .arg(&self.project_root)
200 .args(args)
201 .output()
202 .map_err(|error| format!("run git for provenance checkpoint: {error}"))?;
203 if !output.status.success() {
204 return Err(format!(
205 "git could not inspect provenance checkpoint: {}",
206 String::from_utf8_lossy(&output.stderr).trim()
207 ));
208 }
209 Ok(output.stdout)
210 }
211}
212
213fn parse_numstat(bytes: &[u8]) -> (BTreeSet<String>, u64, u64) {
214 let mut paths = BTreeSet::new();
215 let mut insertions = 0;
216 let mut deletions = 0;
217 for line in String::from_utf8_lossy(bytes).lines() {
218 let mut fields = line.splitn(3, '\t');
219 let Some(added) = fields.next() else {
220 continue;
221 };
222 let Some(removed) = fields.next() else {
223 continue;
224 };
225 let Some(path) = fields.next() else {
226 continue;
227 };
228 insertions += added.parse::<u64>().unwrap_or(0);
229 deletions += removed.parse::<u64>().unwrap_or(0);
230 paths.insert(path.to_owned());
231 }
232 (paths, insertions, deletions)
233}
234
235fn sha256_hex(bytes: &[u8]) -> String {
236 let digest = Sha256::digest(bytes);
237 digest
238 .iter()
239 .fold(String::with_capacity(64), |mut s, byte| {
240 use std::fmt::Write;
241 let _ = write!(s, "{byte:02x}");
242 s
243 })
244}