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 { path: path.into(), r#ref: None }
24 }
25
26 pub fn with_ref(mut self, r: Ref) -> Self {
27 self.r#ref = Some(r);
28 self
29 }
30}
31
32#[derive(Debug, Deserialize)]
33struct LockEnvelope {
34 lock: Lock,
35}
36
37#[derive(Debug, Deserialize)]
44struct CreateLockResponse {
45 #[serde(default)]
46 lock: Option<Lock>,
47 #[serde(default)]
48 message: Option<String>,
49}
50
51#[derive(Debug, thiserror::Error)]
58pub enum CreateLockError {
59 #[error("lock conflict: {message}")]
60 Conflict {
61 existing: Option<Lock>,
62 message: String,
63 },
64
65 #[error(transparent)]
66 Api(#[from] ApiError),
67}
68
69#[derive(Debug, Default, Clone, Serialize)]
74pub struct ListLocksFilter {
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub path: Option<String>,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub id: Option<String>,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub cursor: Option<String>,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub limit: Option<u32>,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub refspec: Option<String>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
88pub struct LockList {
89 #[serde(default, deserialize_with = "deserialize_null_as_default")]
92 pub locks: Vec<Lock>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub next_cursor: Option<String>,
96}
97
98#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
101pub struct VerifyLocksRequest {
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub r#ref: Option<Ref>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub cursor: Option<String>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub limit: Option<u32>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
111pub struct VerifyLocksResponse {
112 #[serde(default, deserialize_with = "deserialize_null_as_default")]
116 pub ours: Vec<Lock>,
117 #[serde(default, deserialize_with = "deserialize_null_as_default")]
119 pub theirs: Vec<Lock>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub next_cursor: Option<String>,
122}
123
124#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
127pub struct DeleteLockRequest {
128 #[serde(default, skip_serializing_if = "is_false")]
130 pub force: bool,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub r#ref: Option<Ref>,
133}
134
135fn is_false(b: &bool) -> bool {
136 !*b
137}
138
139fn deserialize_null_as_default<'de, D, T>(d: D) -> Result<T, D::Error>
145where
146 D: serde::Deserializer<'de>,
147 T: Default + serde::Deserialize<'de>,
148{
149 let opt = Option::<T>::deserialize(d)?;
150 Ok(opt.unwrap_or_default())
151}
152
153impl Client {
156 pub async fn create_lock(&self, req: &CreateLockRequest) -> Result<Lock, CreateLockError> {
162 let url = self.url("locks").map_err(CreateLockError::Api)?;
163 let body_bytes = serde_json::to_vec(req)
167 .map_err(|e| CreateLockError::Api(ApiError::Decode(e.to_string())))?;
168 let resp = self
169 .send_with_auth_retry_response(|| {
170 self.request(reqwest::Method::POST, url.clone())
171 .header(reqwest::header::CONTENT_TYPE, crate::client::LFS_MEDIA_TYPE)
172 .body(body_bytes.clone())
173 })
174 .await
175 .map_err(CreateLockError::Api)?;
176
177 let status = resp.status();
178 let bytes = resp.bytes().await.map_err(|e| {
179 CreateLockError::Api(ApiError::Transport(e))
180 })?;
181
182 if status.as_u16() == 409 {
186 let parsed: CreateLockResponse = serde_json::from_slice(&bytes)
187 .map_err(|e| CreateLockError::Api(ApiError::Decode(e.to_string())))?;
188 return Err(CreateLockError::Conflict {
189 existing: parsed.lock,
190 message: parsed.message.unwrap_or_else(|| "lock conflict".into()),
191 });
192 }
193
194 if !status.is_success() {
196 let body: Option<crate::error::ServerError> =
197 serde_json::from_slice(&bytes).ok();
198 return Err(CreateLockError::Api(ApiError::Status {
199 status: status.as_u16(),
200 lfs_authenticate: None,
201 body,
202 }));
203 }
204
205 let parsed: CreateLockResponse = serde_json::from_slice(&bytes)
208 .map_err(|e| CreateLockError::Api(ApiError::Decode(e.to_string())))?;
209 if let Some(lock) = parsed.lock {
210 return Ok(lock);
211 }
212 if let Some(message) = parsed.message {
213 return Err(CreateLockError::Conflict {
214 existing: None,
215 message,
216 });
217 }
218 Err(CreateLockError::Api(ApiError::Decode(
219 "create-lock response had neither lock nor message".into(),
220 )))
221 }
222
223 pub async fn list_locks(&self, filter: &ListLocksFilter) -> Result<LockList, ApiError> {
225 self.get_json("locks", filter).await
226 }
227
228 pub async fn verify_locks(
235 &self,
236 req: &VerifyLocksRequest,
237 ) -> Result<VerifyLocksResponse, ApiError> {
238 self.post_json("locks/verify", req).await
239 }
240
241 pub async fn delete_lock(
243 &self,
244 id: &str,
245 req: &DeleteLockRequest,
246 ) -> Result<Lock, ApiError> {
247 let encoded = url_path_segment(id);
249 let path = format!("locks/{encoded}/unlock");
250 let url = self.url(&path)?;
251 let body_bytes = serde_json::to_vec(req)
252 .map_err(|e| ApiError::Decode(e.to_string()))?;
253 let resp = self
254 .send_with_auth_retry_response(|| {
255 self.request(reqwest::Method::POST, url.clone())
256 .header(reqwest::header::CONTENT_TYPE, crate::client::LFS_MEDIA_TYPE)
257 .body(body_bytes.clone())
258 })
259 .await?;
260 let env: LockEnvelope = decode(resp).await?;
261 Ok(env.lock)
262 }
263}
264
265fn url_path_segment(s: &str) -> String {
268 let mut out = String::with_capacity(s.len());
269 for b in s.bytes() {
270 let unreserved = b.is_ascii_alphanumeric()
271 || matches!(b, b'-' | b'.' | b'_' | b'~');
272 if unreserved {
273 out.push(b as char);
274 } else {
275 out.push_str(&format!("%{b:02X}"));
276 }
277 }
278 out
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
286 fn list_filter_omits_none_fields() {
287 let f = ListLocksFilter { path: Some("a.bin".into()), ..Default::default() };
291 let v = serde_json::to_value(&f).unwrap();
292 assert_eq!(v["path"], "a.bin");
293 assert!(v.get("id").is_none());
294 assert!(v.get("cursor").is_none());
295 assert!(v.get("limit").is_none());
296 assert!(v.get("refspec").is_none());
297 }
298
299 #[test]
300 fn delete_request_omits_force_when_false() {
301 let r = DeleteLockRequest::default();
302 let v = serde_json::to_value(&r).unwrap();
303 assert!(v.get("force").is_none());
304 }
305
306 #[test]
307 fn delete_request_includes_force_when_true() {
308 let r = DeleteLockRequest { force: true, ..Default::default() };
309 assert_eq!(serde_json::to_value(&r).unwrap()["force"], true);
310 }
311
312 #[test]
313 fn parses_create_lock_envelope() {
314 let body = r#"{
315 "lock": {
316 "id": "some-uuid", "path": "foo/bar.zip",
317 "locked_at": "2016-05-17T15:49:06+00:00",
318 "owner": { "name": "Jane Doe" }
319 }
320 }"#;
321 let env: LockEnvelope = serde_json::from_str(body).unwrap();
322 assert_eq!(env.lock.path, "foo/bar.zip");
323 assert_eq!(env.lock.owner.unwrap().name, "Jane Doe");
324 }
325
326 #[test]
327 fn parses_create_lock_response_with_lock() {
328 let body = r#"{
329 "lock": { "id": "x", "path": "foo", "locked_at": "2016-01-01T00:00:00Z" }
330 }"#;
331 let parsed: CreateLockResponse = serde_json::from_str(body).unwrap();
332 assert!(parsed.lock.is_some());
333 assert_eq!(parsed.lock.unwrap().id, "x");
334 assert!(parsed.message.is_none());
335 }
336
337 #[test]
338 fn parses_create_lock_response_message_only() {
339 let body = r#"{"message":"lock already created"}"#;
341 let parsed: CreateLockResponse = serde_json::from_str(body).unwrap();
342 assert!(parsed.lock.is_none());
343 assert_eq!(parsed.message.as_deref(), Some("lock already created"));
344 }
345
346 #[test]
347 fn url_path_segment_encodes_special() {
348 assert_eq!(url_path_segment("abc-123_xyz.~"), "abc-123_xyz.~");
349 assert_eq!(url_path_segment("a/b"), "a%2Fb");
350 assert_eq!(url_path_segment("hello world"), "hello%20world");
351 }
352}