Skip to main content

repo/
repository_resolve_for_command.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Command-oriented state resolution with explicit per-caller policy.
3
4use objects::{
5    error::{HeddleError, Result as HeddleResult},
6    object::StateId,
7};
8
9use super::Repository;
10
11/// Bootstrap hook invoked when [`ResolvePolicy::bootstrap_on_empty_head`] is
12/// set and `HEAD` / `@` resolves against an empty current state.
13pub type EmptyHeadBootstrap<'a> = dyn Fn(&Repository) -> HeddleResult<()> + 'a;
14
15/// Policy knobs derived from the six pre-consolidation call sites.
16///
17/// * `git_import_guidance` — history_target + context (via the rich
18///   resolver): when lookup returns `None`, check tip-only Git-overlay
19///   branch/tag refs and surface a structured import-history failure.
20/// * `bootstrap_on_empty_head` — context only: before resolving `HEAD` /
21///   `@` with no current state, run the supplied bootstrap hook (typically
22///   `ensure_current_state` in the CLI).
23#[derive(Clone, Copy, Default)]
24pub struct ResolvePolicy<'a> {
25    pub git_import_guidance: bool,
26    pub bootstrap_on_empty_head: Option<&'a EmptyHeadBootstrap<'a>>,
27}
28
29impl<'a> ResolvePolicy<'a> {
30    /// purge / redact / core::diff: plain lookup, no hints, no bootstrap.
31    pub fn minimal() -> Self {
32        Self {
33            git_import_guidance: false,
34            bootstrap_on_empty_head: None,
35        }
36    }
37
38    /// history_target and other rich CLI resolvers.
39    #[cfg(feature = "git-overlay")]
40    pub fn with_git_overlay_hints() -> Self {
41        Self {
42            git_import_guidance: true,
43            bootstrap_on_empty_head: None,
44        }
45    }
46}
47
48/// A state spec successfully resolved for command use.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct ResolvedState {
51    pub state_id: StateId,
52}
53
54/// Structured lookup failures callers map to their own user-facing text.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum StateResolveFailure {
57    NotFound { spec: String },
58    GitBranchHistoryNotImported { branch: String },
59    GitTagHistoryNotImported { tag: String },
60}
61
62impl std::fmt::Display for StateResolveFailure {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        match self {
65            Self::NotFound { spec } => write!(f, "state not found: {spec}"),
66            Self::GitBranchHistoryNotImported { branch } => {
67                write!(f, "git branch history not imported: {branch}")
68            }
69            Self::GitTagHistoryNotImported { tag } => {
70                write!(f, "git tag history not imported: {tag}")
71            }
72        }
73    }
74}
75
76impl std::error::Error for StateResolveFailure {}
77
78/// Full error surface for command state resolution.
79#[derive(Debug)]
80pub enum StateResolveError {
81    Repository(HeddleError),
82    Failure(StateResolveFailure),
83}
84
85impl std::fmt::Display for StateResolveError {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        match self {
88            Self::Repository(err) => write!(f, "{err}"),
89            Self::Failure(failure) => write!(f, "{failure}"),
90        }
91    }
92}
93
94impl std::error::Error for StateResolveError {
95    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
96        match self {
97            Self::Repository(err) => Some(err),
98            Self::Failure(failure) => Some(failure),
99        }
100    }
101}
102
103impl From<HeddleError> for StateResolveError {
104    fn from(value: HeddleError) -> Self {
105        Self::Repository(value)
106    }
107}
108
109/// Resolve a state specifier for command execution under explicit policy.
110///
111/// Ambiguous short-prefix conflicts from [`Repository::resolve_state`] still
112/// surface as [`HeddleError::Conflict`] via [`StateResolveError::Repository`].
113pub fn resolve_state_for_command(
114    repo: &Repository,
115    spec: &str,
116    policy: ResolvePolicy<'_>,
117) -> Result<ResolvedState, StateResolveError> {
118    if let Some(bootstrap) = policy.bootstrap_on_empty_head
119        && matches!(spec, "HEAD" | "@")
120        && repo.current_state()?.is_none()
121    {
122        bootstrap(repo)?;
123    }
124
125    match repo.resolve_state(spec)? {
126        Some(state_id) => Ok(ResolvedState { state_id }),
127        None => resolve_missing_state(repo, spec, policy),
128    }
129}
130
131fn resolve_missing_state(
132    repo: &Repository,
133    spec: &str,
134    policy: ResolvePolicy<'_>,
135) -> Result<ResolvedState, StateResolveError> {
136    #[cfg(feature = "git-overlay")]
137    if policy.git_import_guidance {
138        if let Some(tip) = repo.git_overlay_branch_tip(spec)?
139            && !tip.history_imported
140        {
141            return Err(StateResolveError::Failure(
142                StateResolveFailure::GitBranchHistoryNotImported { branch: tip.branch },
143            ));
144        }
145        if let Some(tip) = repo.git_overlay_tag_tip(spec)?
146            && !tip.history_imported
147        {
148            return Err(StateResolveError::Failure(
149                StateResolveFailure::GitTagHistoryNotImported { tag: tip.tag },
150            ));
151        }
152    }
153    #[cfg(not(feature = "git-overlay"))]
154    let _ = (repo, policy);
155
156    Err(StateResolveError::Failure(StateResolveFailure::NotFound {
157        spec: spec.to_string(),
158    }))
159}
160
161#[cfg(test)]
162mod tests {
163    use std::sync::atomic::{AtomicBool, Ordering};
164
165    use tempfile::TempDir;
166
167    use super::*;
168    use crate::Repository;
169
170    fn repo_with_snapshot() -> (TempDir, Repository, StateId) {
171        let temp = TempDir::new().unwrap();
172        let repo = Repository::init_default(temp.path()).unwrap();
173        std::fs::write(temp.path().join("a.txt"), "a").unwrap();
174        let state = repo.snapshot(Some("first".into()), None).unwrap();
175        (temp, repo, state.id())
176    }
177
178    #[test]
179    fn minimal_policy_resolves_known_state() {
180        let (_temp, repo, id) = repo_with_snapshot();
181        let resolved =
182            resolve_state_for_command(&repo, &id.to_string_full(), ResolvePolicy::minimal())
183                .unwrap();
184        assert_eq!(resolved.state_id, id);
185    }
186
187    #[test]
188    fn minimal_policy_returns_not_found_without_hints() {
189        let (_temp, repo, _) = repo_with_snapshot();
190        let err = resolve_state_for_command(&repo, "hs-zzzzzzzzzzzz", ResolvePolicy::minimal())
191            .unwrap_err();
192        assert!(matches!(
193            err,
194            StateResolveError::Failure(StateResolveFailure::NotFound { .. })
195        ));
196    }
197
198    #[test]
199    fn bootstrap_runs_only_for_empty_head_spec() {
200        let (_temp, repo, id) = repo_with_snapshot();
201        let called = AtomicBool::new(false);
202        let bootstrap = |_: &Repository| {
203            called.store(true, Ordering::SeqCst);
204            Ok(())
205        };
206        let policy = ResolvePolicy {
207            git_import_guidance: false,
208            bootstrap_on_empty_head: Some(&bootstrap),
209        };
210
211        let resolved = resolve_state_for_command(&repo, "HEAD", policy).unwrap();
212        assert_eq!(resolved.state_id, id);
213        assert!(!called.load(Ordering::SeqCst));
214
215        let resolved = resolve_state_for_command(&repo, &id.to_string_full(), policy).unwrap();
216        assert_eq!(resolved.state_id, id);
217        assert!(!called.load(Ordering::SeqCst));
218    }
219
220    #[test]
221    fn bootstrap_runs_for_empty_head_before_resolving_head() {
222        let temp = TempDir::new().unwrap();
223        let repo = Repository::init(temp.path()).unwrap();
224        assert!(repo.current_state().unwrap().is_none());
225        std::fs::write(temp.path().join("a.txt"), "a").unwrap();
226
227        let bootstrapped = AtomicBool::new(false);
228        let bootstrap = |repo: &Repository| {
229            bootstrapped.store(true, Ordering::SeqCst);
230            repo.snapshot(Some("bootstrap".into()), None).map(|_| ())
231        };
232        let policy = ResolvePolicy {
233            git_import_guidance: false,
234            bootstrap_on_empty_head: Some(&bootstrap),
235        };
236
237        let resolved = resolve_state_for_command(&repo, "HEAD", policy).unwrap();
238        assert!(bootstrapped.load(Ordering::SeqCst));
239        assert_eq!(repo.head().unwrap(), Some(resolved.state_id));
240    }
241
242    #[cfg(feature = "git-overlay")]
243    #[test]
244    fn git_overlay_hint_policy_distinguishes_not_found() {
245        let (_temp, repo, _) = repo_with_snapshot();
246        let err = resolve_state_for_command(
247            &repo,
248            "missing-branch",
249            ResolvePolicy::with_git_overlay_hints(),
250        )
251        .unwrap_err();
252        assert!(matches!(
253            err,
254            StateResolveError::Failure(StateResolveFailure::NotFound { .. })
255        ));
256    }
257}