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