Skip to main content

lean_ctx/core/provenance/
tracker.rs

1use 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/// High-level provenance operations for a single project root.
14#[derive(Debug, Clone)]
15pub struct ProvenanceTracker {
16    store: ProvenanceStore,
17    project_root: PathBuf,
18}
19
20impl ProvenanceTracker {
21    /// Creates a tracker keyed like every other per-project store
22    /// (`project_hash::hash_project_root`: normalized path plus project identity).
23    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    /// [`Self::new`] below an explicit data directory. Releases up to 3.12.0
29    /// keyed the store by a SHA-256 of the raw path; such a store is moved to
30    /// the new key on first open, and read in place if the move fails.
31    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, &current).is_ok();
43                if moved { store } else { legacy }
44            }
45            _ => store,
46        };
47        Ok(Self {
48            store,
49            project_root,
50        })
51    }
52
53    /// Creates a tracker with explicit storage, useful when a caller isolates its data directory.
54    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    /// Associates an existing observation with `SolutionDecisionMeta` identifiers.
108    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    /// Scans a commit's changed paths and records a checkpoint linked to matching observations.
119    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    /// Repoints checkpoints after a rebase and marks them as linked again.
175    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}