1use serde::{Deserialize, Serialize};
6
7use crate::client::{Client, decode};
8use crate::error::ApiError;
9use crate::models::{Lock, Ref};
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15pub struct CreateLockRequest {
16 pub path: String,
18 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub r#ref: Option<Ref>,
21}
22
23impl CreateLockRequest {
24 pub fn new(path: impl Into<String>) -> Self {
26 Self {
27 path: path.into(),
28 r#ref: None,
29 }
30 }
31
32 pub fn with_ref(mut self, r: Ref) -> Self {
34 self.r#ref = Some(r);
35 self
36 }
37}
38
39#[derive(Debug, Deserialize)]
40struct LockEnvelope {
41 lock: Lock,
42}
43
44#[derive(Debug, Deserialize)]
51struct CreateLockResponse {
52 #[serde(default)]
53 lock: Option<Lock>,
54 #[serde(default)]
55 message: Option<String>,
56}
57
58#[derive(Debug, thiserror::Error)]
65pub enum CreateLockError {
66 #[error("lock conflict: {message}")]
69 Conflict {
70 existing: Option<Lock>,
71 message: String,
72 },
73
74 #[error(transparent)]
76 Api(#[from] ApiError),
77}
78
79#[derive(Debug, Default, Clone, Serialize)]
84pub struct ListLocksFilter {
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub path: Option<String>,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub id: Option<String>,
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub cursor: Option<String>,
94 #[serde(skip_serializing_if = "Option::is_none")]
96 pub limit: Option<u32>,
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub refspec: Option<String>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
105pub struct LockList {
106 #[serde(default, deserialize_with = "deserialize_null_as_default")]
111 pub locks: Vec<Lock>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub next_cursor: Option<String>,
115}
116
117#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
122pub struct VerifyLocksRequest {
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub r#ref: Option<Ref>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub cursor: Option<String>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub limit: Option<u32>,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
136pub struct VerifyLocksResponse {
137 #[serde(default, deserialize_with = "deserialize_null_as_default")]
141 pub ours: Vec<Lock>,
142 #[serde(default, deserialize_with = "deserialize_null_as_default")]
144 pub theirs: Vec<Lock>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub next_cursor: Option<String>,
148}
149
150#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
154pub struct DeleteLockRequest {
155 #[serde(default, skip_serializing_if = "is_false")]
157 pub force: bool,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub r#ref: Option<Ref>,
161}
162
163fn is_false(b: &bool) -> bool {
164 !*b
165}
166
167fn deserialize_null_as_default<'de, D, T>(d: D) -> Result<T, D::Error>
173where
174 D: serde::Deserializer<'de>,
175 T: Default + serde::Deserialize<'de>,
176{
177 let opt = Option::<T>::deserialize(d)?;
178 Ok(opt.unwrap_or_default())
179}
180
181impl Client {
184 pub async fn create_lock(&self, req: &CreateLockRequest) -> Result<Lock, CreateLockError> {
190 let (base, ssh) = self
193 .resolve_ssh(crate::ssh::SshOperation::Upload)
194 .map_err(CreateLockError::Api)?;
195 let url = Client::join(&base, "locks").map_err(CreateLockError::Api)?;
196 let body_bytes = serde_json::to_vec(req)
200 .map_err(|e| CreateLockError::Api(ApiError::Decode(e.to_string())))?;
201 let resp = self
202 .send_with_auth_retry_response(|| {
203 self.request_with_headers(reqwest::Method::POST, url.clone(), &ssh)
204 .header(reqwest::header::CONTENT_TYPE, crate::client::LFS_MEDIA_TYPE)
205 .body(body_bytes.clone())
206 })
207 .await
208 .map_err(CreateLockError::Api)?;
209
210 let status = resp.status();
211 let request_url = resp.url().to_string();
212 let bytes = resp
213 .bytes()
214 .await
215 .map_err(|e| CreateLockError::Api(ApiError::Transport(e)))?;
216
217 if status.as_u16() == 409 {
221 let parsed: CreateLockResponse = serde_json::from_slice(&bytes)
222 .map_err(|e| CreateLockError::Api(ApiError::Decode(e.to_string())))?;
223 return Err(CreateLockError::Conflict {
224 existing: parsed.lock,
225 message: parsed.message.unwrap_or_else(|| "lock conflict".into()),
226 });
227 }
228
229 if !status.is_success() {
231 let body: Option<crate::error::ServerError> = serde_json::from_slice(&bytes).ok();
232 return Err(CreateLockError::Api(ApiError::Status {
233 status: status.as_u16(),
234 url: Some(request_url),
235 lfs_authenticate: None,
236 body,
237 retry_after: None,
238 }));
239 }
240
241 let parsed: CreateLockResponse = serde_json::from_slice(&bytes)
244 .map_err(|e| CreateLockError::Api(ApiError::Decode(e.to_string())))?;
245 if let Some(lock) = parsed.lock {
246 return Ok(lock);
247 }
248 if let Some(message) = parsed.message {
249 return Err(CreateLockError::Conflict {
250 existing: None,
251 message,
252 });
253 }
254 Err(CreateLockError::Api(ApiError::Decode(
255 "create-lock response had neither lock nor message".into(),
256 )))
257 }
258
259 pub async fn list_locks(&self, filter: &ListLocksFilter) -> Result<LockList, ApiError> {
262 self.get_json("locks", filter, crate::ssh::SshOperation::Download)
263 .await
264 }
265
266 pub async fn verify_locks(
273 &self,
274 req: &VerifyLocksRequest,
275 ) -> Result<VerifyLocksResponse, ApiError> {
276 self.post_json("locks/verify", req, crate::ssh::SshOperation::Upload)
280 .await
281 }
282
283 pub async fn delete_lock(&self, id: &str, req: &DeleteLockRequest) -> Result<Lock, ApiError> {
285 let encoded = url_path_segment(id);
287 let path = format!("locks/{encoded}/unlock");
288 let (base, ssh) = self.resolve_ssh(crate::ssh::SshOperation::Upload)?;
290 let url = Client::join(&base, &path)?;
291 let body_bytes = serde_json::to_vec(req).map_err(|e| ApiError::Decode(e.to_string()))?;
292 let resp = self
293 .send_with_auth_retry_response(|| {
294 self.request_with_headers(reqwest::Method::POST, url.clone(), &ssh)
295 .header(reqwest::header::CONTENT_TYPE, crate::client::LFS_MEDIA_TYPE)
296 .body(body_bytes.clone())
297 })
298 .await?;
299 let env: LockEnvelope = decode(resp).await?;
300 Ok(env.lock)
301 }
302}
303
304fn url_path_segment(s: &str) -> String {
307 let mut out = String::with_capacity(s.len());
308 for b in s.bytes() {
309 let unreserved = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~');
310 if unreserved {
311 out.push(b as char);
312 } else {
313 out.push_str(&format!("%{b:02X}"));
314 }
315 }
316 out
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 #[test]
324 fn list_filter_omits_none_fields() {
325 let f = ListLocksFilter {
329 path: Some("a.bin".into()),
330 ..Default::default()
331 };
332 let v = serde_json::to_value(&f).unwrap();
333 assert_eq!(v["path"], "a.bin");
334 assert!(v.get("id").is_none());
335 assert!(v.get("cursor").is_none());
336 assert!(v.get("limit").is_none());
337 assert!(v.get("refspec").is_none());
338 }
339
340 #[test]
341 fn delete_request_omits_force_when_false() {
342 let r = DeleteLockRequest::default();
343 let v = serde_json::to_value(&r).unwrap();
344 assert!(v.get("force").is_none());
345 }
346
347 #[test]
348 fn delete_request_includes_force_when_true() {
349 let r = DeleteLockRequest {
350 force: true,
351 ..Default::default()
352 };
353 assert_eq!(serde_json::to_value(&r).unwrap()["force"], true);
354 }
355
356 #[test]
357 fn parses_create_lock_envelope() {
358 let body = r#"{
359 "lock": {
360 "id": "some-uuid", "path": "foo/bar.zip",
361 "locked_at": "2016-05-17T15:49:06+00:00",
362 "owner": { "name": "Jane Doe" }
363 }
364 }"#;
365 let env: LockEnvelope = serde_json::from_str(body).unwrap();
366 assert_eq!(env.lock.path, "foo/bar.zip");
367 assert_eq!(env.lock.owner.unwrap().name, "Jane Doe");
368 }
369
370 #[test]
371 fn parses_create_lock_response_with_lock() {
372 let body = r#"{
373 "lock": { "id": "x", "path": "foo", "locked_at": "2016-01-01T00:00:00Z" }
374 }"#;
375 let parsed: CreateLockResponse = serde_json::from_str(body).unwrap();
376 assert!(parsed.lock.is_some());
377 assert_eq!(parsed.lock.unwrap().id, "x");
378 assert!(parsed.message.is_none());
379 }
380
381 #[test]
382 fn parses_create_lock_response_message_only() {
383 let body = r#"{"message":"lock already created"}"#;
385 let parsed: CreateLockResponse = serde_json::from_str(body).unwrap();
386 assert!(parsed.lock.is_none());
387 assert_eq!(parsed.message.as_deref(), Some("lock already created"));
388 }
389
390 #[test]
391 fn url_path_segment_encodes_special() {
392 assert_eq!(url_path_segment("abc-123_xyz.~"), "abc-123_xyz.~");
393 assert_eq!(url_path_segment("a/b"), "a%2Fb");
394 assert_eq!(url_path_segment("hello world"), "hello%20world");
395 }
396}