1use objects::{
5 object::{Agent, ChangeId},
6 store::ObjectStore,
7};
8
9use super::{HeddleError, Repository, Result};
10
11impl Repository {
12 pub fn resolve_state(&self, spec: &str) -> Result<Option<ChangeId>> {
14 if let Some(steps) = parse_head_steps(spec) {
15 return resolve_head_steps(self, steps);
16 }
17
18 if let Some(id) = self.refs.resolve(spec)? {
19 return Ok(Some(id));
20 }
21
22 if self.capability() == super::RepositoryCapability::GitOverlay {
23 if let Some(id) = self.git_overlay_mapped_change_for_branch(spec)? {
24 return Ok(Some(id));
25 }
26 if let Some(id) = self.git_overlay_mapped_change_for_tag(spec)? {
27 return Ok(Some(id));
28 }
29 }
30
31 resolve_short_change_id(self, spec)
32 }
33
34 pub fn resolve_agent(&self) -> Option<Agent> {
35 let provider = std::env::var("HEDDLE_AGENT_PROVIDER")
36 .ok()
37 .or_else(|| self.config.agent.provider.clone());
38 let model = std::env::var("HEDDLE_AGENT_MODEL")
39 .ok()
40 .or_else(|| self.config.agent.model.clone());
41 let session_id = std::env::var("HEDDLE_SESSION_ID").ok();
42 let segment_id = std::env::var("HEDDLE_SESSION_SEGMENT").ok();
43 let policy_id = std::env::var("HEDDLE_AGENT_POLICY")
44 .ok()
45 .or_else(|| self.config.policies.default_policy.clone());
46
47 match (provider, model) {
48 (Some(provider), Some(model)) => {
49 let mut agent = Agent::new(provider, model);
50 if let (Some(sid), Some(segid)) = (session_id, segment_id) {
51 agent = agent.with_session(sid, segid);
52 }
53 if let Some(policy_id) = policy_id {
54 agent = agent.with_policy(policy_id);
55 }
56 Some(agent)
57 }
58 _ => None,
59 }
60 }
61}
62
63fn parse_head_steps(spec: &str) -> Option<usize> {
64 if spec == "HEAD" || spec == "@" {
65 return Some(0);
66 }
67
68 let rest = spec
69 .strip_prefix("HEAD~")
70 .or_else(|| spec.strip_prefix("@~"))?;
71 if rest.is_empty() {
72 return None;
73 }
74 rest.parse::<usize>().ok()
75}
76
77fn resolve_head_steps(repo: &Repository, steps: usize) -> Result<Option<ChangeId>> {
78 let mut current = repo.head()?;
79 if steps == 0 {
80 return Ok(current);
81 }
82
83 for _ in 0..steps {
84 let Some(id) = current else {
85 return Ok(None);
86 };
87 let state = repo.store.get_state(&id)?;
88 let Some(state) = state else {
89 return Ok(None);
90 };
91 current = state.first_parent().copied();
92 }
93
94 Ok(current)
95}
96
97fn resolve_short_change_id(repo: &Repository, spec: &str) -> Result<Option<ChangeId>> {
98 let prefix = spec.strip_prefix("hd-").unwrap_or(spec).to_lowercase();
99 if prefix.len() < 4 {
100 return Ok(None);
101 }
102
103 let mut matches = Vec::new();
104 for id in repo.store.list_states()? {
105 let full = id.to_string_full();
106 let full_norm = full.strip_prefix("hd-").unwrap_or(&full).to_lowercase();
107 if full_norm.starts_with(&prefix) {
108 matches.push(id);
109 }
110 }
111
112 match matches.len() {
113 0 => Ok(None),
114 1 => Ok(Some(matches[0])),
115 _ => {
116 let mut shown: Vec<String> = matches
119 .iter()
120 .take(5)
121 .map(|id| id.to_string_full())
122 .collect();
123 if matches.len() > shown.len() {
124 shown.push(format!("... ({} more)", matches.len() - shown.len()));
125 }
126 Err(HeddleError::Conflict(format!(
127 "ambiguous state ID prefix '{}' matches: {}",
128 spec,
129 shown.join(", ")
130 )))
131 }
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use std::fs;
138
139 use objects::{
140 object::{ChangeId, MarkerName},
141 store::ObjectStore,
142 };
143 use tempfile::TempDir;
144
145 use crate::{HeddleError, Repository};
146
147 fn repo_with_two_states() -> (TempDir, Repository, ChangeId, ChangeId) {
150 let temp = TempDir::new().unwrap();
151 let repo = Repository::init_default(temp.path()).unwrap();
152 fs::write(temp.path().join("a.txt"), "a").unwrap();
153 let s1 = repo.snapshot(Some("first".into()), None).unwrap();
154 fs::write(temp.path().join("b.txt"), "b").unwrap();
155 let s2 = repo.snapshot(Some("second".into()), None).unwrap();
156 (temp, repo, s1.change_id, s2.change_id)
157 }
158
159 #[test]
160 fn resolve_state_accepts_full_id() {
161 let (_t, repo, s1, _) = repo_with_two_states();
162 let full = s1.to_string_full();
163 let resolved = repo.resolve_state(&full).unwrap();
164 assert_eq!(resolved, Some(s1));
165 }
166
167 #[test]
168 fn resolve_state_accepts_short_prefix() {
169 let (_t, repo, s1, _) = repo_with_two_states();
170 let short = s1.short();
172 let resolved = repo.resolve_state(&short).unwrap();
173 assert_eq!(resolved, Some(s1));
174 }
175
176 #[test]
177 fn resolve_state_accepts_short_prefix_without_hd_prefix() {
178 let (_t, repo, s1, _) = repo_with_two_states();
180 let short = s1.short();
181 let bare = short.strip_prefix("hd-").unwrap();
182 let resolved = repo.resolve_state(bare).unwrap();
183 assert_eq!(resolved, Some(s1));
184 }
185
186 #[test]
187 fn resolve_state_returns_none_for_unknown_id() {
188 let (_t, repo, _, _) = repo_with_two_states();
189 assert_eq!(repo.resolve_state("hd-zzzz").unwrap(), None);
192 }
193
194 #[test]
195 fn resolve_state_returns_none_for_too_short_prefix() {
196 let (_t, repo, _, _) = repo_with_two_states();
197 assert_eq!(repo.resolve_state("hd").unwrap(), None);
198 }
199
200 #[test]
201 fn resolve_state_accepts_marker_name() {
202 let (_t, repo, s1, _) = repo_with_two_states();
203 repo.refs()
204 .create_marker(&MarkerName::new("milestone-1"), &s1)
205 .unwrap();
206 let resolved = repo.resolve_state("milestone-1").unwrap();
207 assert_eq!(resolved, Some(s1));
208 }
209
210 #[test]
211 fn resolve_state_accepts_head() {
212 let (_t, repo, _, s2) = repo_with_two_states();
213 let resolved = repo.resolve_state("HEAD").unwrap();
214 assert_eq!(resolved, Some(s2));
215 }
216
217 #[test]
218 fn resolve_state_accepts_head_steps() {
219 let (_t, repo, s1, s2) = repo_with_two_states();
220 assert_eq!(repo.resolve_state("HEAD").unwrap(), Some(s2));
221 assert_eq!(repo.resolve_state("HEAD~1").unwrap(), Some(s1));
222 }
223
224 #[test]
229 fn resolve_state_errors_on_ambiguous_prefix() {
230 use objects::object::{Attribution, State};
231 let temp = TempDir::new().unwrap();
232 let repo = Repository::init_default(temp.path()).unwrap();
233
234 let mut id_a_bytes = [0u8; 16];
239 let mut id_b_bytes = [0u8; 16];
240 id_a_bytes[..4].copy_from_slice(&[0xaa, 0xaa, 0xaa, 0xaa]);
241 id_b_bytes[..4].copy_from_slice(&[0xaa, 0xaa, 0xaa, 0xaa]);
242 id_a_bytes[15] = 0x01;
243 id_b_bytes[15] = 0x02;
244
245 let id_a = ChangeId::from_bytes(id_a_bytes);
246 let id_b = ChangeId::from_bytes(id_b_bytes);
247 assert_ne!(id_a, id_b);
248
249 let head = repo.head().unwrap().unwrap();
252 let head_state = repo.store().get_state(&head).unwrap().unwrap();
253 let principal = repo.get_principal().unwrap();
254 let state_a = State::new(
255 head_state.tree,
256 vec![head],
257 Attribution::human(principal.clone()),
258 )
259 .with_change_id(id_a);
260 let state_b = State::new(head_state.tree, vec![head], Attribution::human(principal))
261 .with_change_id(id_b);
262 repo.store().put_state(&state_a).unwrap();
263 repo.store().put_state(&state_b).unwrap();
264
265 let listed = repo.store().list_states().unwrap();
268 assert!(
269 listed.contains(&id_a),
270 "state A must be indexed: {listed:?}"
271 );
272 assert!(listed.contains(&id_b), "state B must be indexed");
273
274 let full_a = id_a.to_string_full();
278 let prefix = &full_a[..7];
279 assert!(prefix.starts_with("hd-"));
280
281 let err = repo.resolve_state(prefix).unwrap_err();
282 let msg = err.to_string();
283 assert!(
284 msg.contains("ambiguous state ID prefix"),
285 "unexpected error: {msg}"
286 );
287 assert!(msg.contains(prefix), "error should echo the prefix: {msg}");
288 assert!(matches!(err, HeddleError::Conflict(_)));
289 }
290}