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,
17 #[serde(default, skip_serializing_if = "Option::is_none")]
18 pub r#ref: Option<Ref>,
19}
20
21impl CreateLockRequest {
22 pub fn new(path: impl Into<String>) -> Self {
23 Self {
24 path: path.into(),
25 r#ref: None,
26 }
27 }
28
29 pub fn with_ref(mut self, r: Ref) -> Self {
30 self.r#ref = Some(r);
31 self
32 }
33}
34
35#[derive(Debug, Deserialize)]
36struct LockEnvelope {
37 lock: Lock,
38}
39
40#[derive(Debug, Deserialize)]
47struct CreateLockResponse {
48 #[serde(default)]
49 lock: Option<Lock>,
50 #[serde(default)]
51 message: Option<String>,
52}
53
54#[derive(Debug, thiserror::Error)]
61pub enum CreateLockError {
62 #[error("lock conflict: {message}")]
63 Conflict {
64 existing: Option<Lock>,
65 message: String,
66 },
67
68 #[error(transparent)]
69 Api(#[from] ApiError),
70}
71
72#[derive(Debug, Default, Clone, Serialize)]
77pub struct ListLocksFilter {
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub path: Option<String>,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub id: Option<String>,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub cursor: Option<String>,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub limit: Option<u32>,
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub refspec: Option<String>,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
91pub struct LockList {
92 #[serde(default, deserialize_with = "deserialize_null_as_default")]
95 pub locks: Vec<Lock>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub next_cursor: Option<String>,
99}
100
101#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
104pub struct VerifyLocksRequest {
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub r#ref: Option<Ref>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub cursor: Option<String>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub limit: Option<u32>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
114pub struct VerifyLocksResponse {
115 #[serde(default, deserialize_with = "deserialize_null_as_default")]
119 pub ours: Vec<Lock>,
120 #[serde(default, deserialize_with = "deserialize_null_as_default")]
122 pub theirs: Vec<Lock>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub next_cursor: Option<String>,
125}
126
127#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
130pub struct DeleteLockRequest {
131 #[serde(default, skip_serializing_if = "is_false")]
133 pub force: bool,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub r#ref: Option<Ref>,
136}
137
138fn is_false(b: &bool) -> bool {
139 !*b
140}
141
142fn deserialize_null_as_default<'de, D, T>(d: D) -> Result<T, D::Error>
148where
149 D: serde::Deserializer<'de>,
150 T: Default + serde::Deserialize<'de>,
151{
152 let opt = Option::<T>::deserialize(d)?;
153 Ok(opt.unwrap_or_default())
154}
155
156impl Client {
159 pub async fn create_lock(&self, req: &CreateLockRequest) -> Result<Lock, CreateLockError> {
165 let (base, ssh) = self
168 .resolve_ssh(crate::ssh::SshOperation::Upload)
169 .map_err(CreateLockError::Api)?;
170 let url = Client::join(&base, "locks").map_err(CreateLockError::Api)?;
171 let body_bytes = serde_json::to_vec(req)
175 .map_err(|e| CreateLockError::Api(ApiError::Decode(e.to_string())))?;
176 let resp = self
177 .send_with_auth_retry_response(|| {
178 self.request_with_headers(reqwest::Method::POST, url.clone(), &ssh)
179 .header(reqwest::header::CONTENT_TYPE, crate::client::LFS_MEDIA_TYPE)
180 .body(body_bytes.clone())
181 })
182 .await
183 .map_err(CreateLockError::Api)?;
184
185 let status = resp.status();
186 let request_url = resp.url().to_string();
187 let bytes = resp
188 .bytes()
189 .await
190 .map_err(|e| CreateLockError::Api(ApiError::Transport(e)))?;
191
192 if status.as_u16() == 409 {
196 let parsed: CreateLockResponse = serde_json::from_slice(&bytes)
197 .map_err(|e| CreateLockError::Api(ApiError::Decode(e.to_string())))?;
198 return Err(CreateLockError::Conflict {
199 existing: parsed.lock,
200 message: parsed.message.unwrap_or_else(|| "lock conflict".into()),
201 });
202 }
203
204 if !status.is_success() {
206 let body: Option<crate::error::ServerError> = serde_json::from_slice(&bytes).ok();
207 return Err(CreateLockError::Api(ApiError::Status {
208 status: status.as_u16(),
209 url: Some(request_url),
210 lfs_authenticate: None,
211 body,
212 retry_after: None,
213 }));
214 }
215
216 let parsed: CreateLockResponse = serde_json::from_slice(&bytes)
219 .map_err(|e| CreateLockError::Api(ApiError::Decode(e.to_string())))?;
220 if let Some(lock) = parsed.lock {
221 return Ok(lock);
222 }
223 if let Some(message) = parsed.message {
224 return Err(CreateLockError::Conflict {
225 existing: None,
226 message,
227 });
228 }
229 Err(CreateLockError::Api(ApiError::Decode(
230 "create-lock response had neither lock nor message".into(),
231 )))
232 }
233
234 pub async fn list_locks(&self, filter: &ListLocksFilter) -> Result<LockList, ApiError> {
237 self.get_json("locks", filter, crate::ssh::SshOperation::Download)
238 .await
239 }
240
241 pub async fn verify_locks(
248 &self,
249 req: &VerifyLocksRequest,
250 ) -> Result<VerifyLocksResponse, ApiError> {
251 self.post_json("locks/verify", req, crate::ssh::SshOperation::Upload)
255 .await
256 }
257
258 pub async fn delete_lock(&self, id: &str, req: &DeleteLockRequest) -> Result<Lock, ApiError> {
260 let encoded = url_path_segment(id);
262 let path = format!("locks/{encoded}/unlock");
263 let (base, ssh) = self.resolve_ssh(crate::ssh::SshOperation::Upload)?;
265 let url = Client::join(&base, &path)?;
266 let body_bytes = serde_json::to_vec(req).map_err(|e| ApiError::Decode(e.to_string()))?;
267 let resp = self
268 .send_with_auth_retry_response(|| {
269 self.request_with_headers(reqwest::Method::POST, url.clone(), &ssh)
270 .header(reqwest::header::CONTENT_TYPE, crate::client::LFS_MEDIA_TYPE)
271 .body(body_bytes.clone())
272 })
273 .await?;
274 let env: LockEnvelope = decode(resp).await?;
275 Ok(env.lock)
276 }
277}
278
279fn url_path_segment(s: &str) -> String {
282 let mut out = String::with_capacity(s.len());
283 for b in s.bytes() {
284 let unreserved = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~');
285 if unreserved {
286 out.push(b as char);
287 } else {
288 out.push_str(&format!("%{b:02X}"));
289 }
290 }
291 out
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 #[test]
299 fn list_filter_omits_none_fields() {
300 let f = ListLocksFilter {
304 path: Some("a.bin".into()),
305 ..Default::default()
306 };
307 let v = serde_json::to_value(&f).unwrap();
308 assert_eq!(v["path"], "a.bin");
309 assert!(v.get("id").is_none());
310 assert!(v.get("cursor").is_none());
311 assert!(v.get("limit").is_none());
312 assert!(v.get("refspec").is_none());
313 }
314
315 #[test]
316 fn delete_request_omits_force_when_false() {
317 let r = DeleteLockRequest::default();
318 let v = serde_json::to_value(&r).unwrap();
319 assert!(v.get("force").is_none());
320 }
321
322 #[test]
323 fn delete_request_includes_force_when_true() {
324 let r = DeleteLockRequest {
325 force: true,
326 ..Default::default()
327 };
328 assert_eq!(serde_json::to_value(&r).unwrap()["force"], true);
329 }
330
331 #[test]
332 fn parses_create_lock_envelope() {
333 let body = r#"{
334 "lock": {
335 "id": "some-uuid", "path": "foo/bar.zip",
336 "locked_at": "2016-05-17T15:49:06+00:00",
337 "owner": { "name": "Jane Doe" }
338 }
339 }"#;
340 let env: LockEnvelope = serde_json::from_str(body).unwrap();
341 assert_eq!(env.lock.path, "foo/bar.zip");
342 assert_eq!(env.lock.owner.unwrap().name, "Jane Doe");
343 }
344
345 #[test]
346 fn parses_create_lock_response_with_lock() {
347 let body = r#"{
348 "lock": { "id": "x", "path": "foo", "locked_at": "2016-01-01T00:00:00Z" }
349 }"#;
350 let parsed: CreateLockResponse = serde_json::from_str(body).unwrap();
351 assert!(parsed.lock.is_some());
352 assert_eq!(parsed.lock.unwrap().id, "x");
353 assert!(parsed.message.is_none());
354 }
355
356 #[test]
357 fn parses_create_lock_response_message_only() {
358 let body = r#"{"message":"lock already created"}"#;
360 let parsed: CreateLockResponse = serde_json::from_str(body).unwrap();
361 assert!(parsed.lock.is_none());
362 assert_eq!(parsed.message.as_deref(), Some("lock already created"));
363 }
364
365 #[test]
366 fn url_path_segment_encodes_special() {
367 assert_eq!(url_path_segment("abc-123_xyz.~"), "abc-123_xyz.~");
368 assert_eq!(url_path_segment("a/b"), "a%2Fb");
369 assert_eq!(url_path_segment("hello world"), "hello%20world");
370 }
371}