Skip to main content

objects/blame/
types.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Persistence-friendly blame slice records.
3
4use std::path::{Component, Path};
5
6use serde::{Deserialize, Serialize};
7
8use crate::{
9    error::HeddleError,
10    object::{ContentHash, Origin, State, StateId},
11    util::{BudgetExceeded, LineDiffError, ResourceUsage},
12};
13
14/// Caps for one [`super::advance_file_blame_slice`] call.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16pub struct BlameSliceLimits {
17    pub states: u64,
18    pub decoded_bytes: u64,
19    pub lines: u64,
20    pub diff_work: u64,
21    pub scratch_bytes: u64,
22}
23
24impl BlameSliceLimits {
25    pub fn unlimited() -> Self {
26        Self {
27            states: u64::MAX,
28            decoded_bytes: u64::MAX,
29            lines: u64::MAX,
30            diff_work: u64::MAX,
31            scratch_bytes: u64::MAX,
32        }
33    }
34}
35
36/// Compact run mapping state-file lines onto target-file lines.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38pub struct BlameLineMap {
39    pub state_start: u32,
40    pub target_start: u32,
41    pub len: u32,
42}
43
44/// Prepared target file a persisted frontier may attribute.
45///
46/// Blob and line count alone are not a job: two paths or states can share
47/// the same bytes. The bound state and normalized path keep those jobs apart.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub struct BlameTarget {
50    pub blob: ContentHash,
51    pub line_count: u32,
52    pub state_id: StateId,
53    pub path: String,
54}
55
56impl BlameTarget {
57    /// Bind a prepared job. The path is stored in lookup-normalized form.
58    pub fn bind(
59        state_id: StateId,
60        path: &Path,
61        blob: ContentHash,
62        line_count: u32,
63    ) -> Result<Self, BlameSliceError> {
64        Ok(Self {
65            blob,
66            line_count,
67            state_id,
68            path: normalize_blame_path(path)?,
69        })
70    }
71
72    pub fn matches_path(&self, path: &Path) -> Result<bool, BlameSliceError> {
73        Ok(self.path == normalize_blame_path(path)?)
74    }
75}
76
77/// Stable repo path: normal components joined by `/`. `.` is ignored;
78/// `..` and non-UTF-8 names are rejected.
79pub(super) fn normalize_blame_path(path: &Path) -> Result<String, BlameSliceError> {
80    let mut parts = Vec::new();
81    for component in path.components() {
82        match component {
83            Component::Normal(name) => {
84                let Some(name) = name.to_str() else {
85                    return Err(BlameSliceError::InvalidFrontier(
86                        "target path is not valid UTF-8".into(),
87                    ));
88                };
89                parts.push(name);
90            }
91            Component::CurDir => {}
92            _ => {
93                return Err(BlameSliceError::InvalidFrontier(
94                    "target path is not a normalized repo path".into(),
95                ));
96            }
97        }
98    }
99    if parts.is_empty() {
100        return Err(BlameSliceError::InvalidFrontier(
101            "target path is empty".into(),
102        ));
103    }
104    Ok(parts.join("/"))
105}
106
107/// One unfinalized hunk still walking toward older ancestors.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct BlameFrontierRecord {
110    pub origin: Origin,
111    pub blob_hash: ContentHash,
112    pub state_line_count: u32,
113    pub mappings: Vec<BlameLineMap>,
114    pub target: BlameTarget,
115}
116
117impl BlameFrontierRecord {
118    pub fn state_id(&self) -> StateId {
119        self.origin.state_id
120    }
121}
122
123/// Canonical LIFO frontier group. Replay of the same group is deterministic.
124///
125/// `target` is the prepared file this group may attribute. A record bound to
126/// another job's target is `InvalidFrontier`, not a silent swap.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128pub struct BlameFrontierGroup {
129    pub target: BlameTarget,
130    pub records: Vec<BlameFrontierRecord>,
131}
132
133impl BlameFrontierGroup {
134    pub fn is_empty(&self) -> bool {
135        self.records.is_empty()
136    }
137
138    pub fn pop(&mut self) -> Option<BlameFrontierRecord> {
139        self.records.pop()
140    }
141
142    pub fn push(&mut self, record: BlameFrontierRecord) {
143        self.records.push(record);
144    }
145
146    /// Fail closed when this group is replayed against a different prepare.
147    pub fn require_target(&self, expected: &BlameTarget) -> Result<(), BlameSliceError> {
148        if &self.target != expected {
149            return Err(BlameSliceError::InvalidFrontier(
150                "frontier target does not match prepared target".into(),
151            ));
152        }
153        self.require_consistent_target()
154    }
155
156    /// Fail closed when an advance path is not this group's prepared path.
157    pub fn require_path(&self, path: &Path) -> Result<(), BlameSliceError> {
158        if !self.target.matches_path(path)? {
159            return Err(BlameSliceError::InvalidFrontier(
160                "frontier target path does not match advance path".into(),
161            ));
162        }
163        Ok(())
164    }
165
166    pub fn require_consistent_target(&self) -> Result<(), BlameSliceError> {
167        if self
168            .records
169            .iter()
170            .any(|record| record.target != self.target)
171        {
172            return Err(BlameSliceError::InvalidFrontier(
173                "frontier record target does not match group".into(),
174            ));
175        }
176        Ok(())
177    }
178}
179
180/// Finalized target-line range. Each target line is emitted at most once.
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182pub struct OriginRange {
183    pub target_start: u32,
184    pub len: u32,
185    pub origin: Origin,
186}
187
188/// Outcome of preparing the target path before any slice walk.
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub enum BlamePreparation {
191    MissingPath,
192    Unblamable,
193    Empty {
194        file_blob: ContentHash,
195        origin: Origin,
196    },
197    Active {
198        file_blob: ContentHash,
199        line_count: u32,
200        frontier: BlameFrontierGroup,
201    },
202}
203
204/// Bounded slice result. Callers persist this only on success.
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub enum BlameSliceAdvance {
207    Progress {
208        next: BlameFrontierGroup,
209        finalized: Vec<OriginRange>,
210        usage: ResourceUsage,
211    },
212    Complete {
213        finalized: Vec<OriginRange>,
214        usage: ResourceUsage,
215    },
216}
217
218/// Typed slice failure. Missing objects are distinct from budget exhaustion.
219#[derive(Debug, thiserror::Error)]
220pub enum BlameSliceError {
221    #[error("path is absent from the target state")]
222    MissingPath,
223    #[error("file is binary or otherwise unblamable")]
224    Unblamable,
225    #[error("missing {kind} {id}")]
226    MissingObject { kind: &'static str, id: String },
227    #[error(transparent)]
228    BudgetExceeded(#[from] BudgetExceeded),
229    #[error("invalid frontier: {0}")]
230    InvalidFrontier(String),
231    #[error("invalid origin coverage")]
232    InvalidCoverage,
233    #[error(transparent)]
234    Store(#[from] HeddleError),
235}
236
237impl From<LineDiffError> for BlameSliceError {
238    fn from(error: LineDiffError) -> Self {
239        match error {
240            LineDiffError::InvalidUtf8 => Self::Unblamable,
241            LineDiffError::BudgetExceeded(error) => Self::BudgetExceeded(error),
242            LineDiffError::Visitor(never) => match never {},
243        }
244    }
245}
246
247pub fn origin_from_state(state: &State) -> Origin {
248    Origin {
249        state_id: state.id(),
250        attribution: state.attribution.clone(),
251        created_at: state.created_at,
252        authored_at: state.authored_at,
253    }
254}