1use anyhow::{anyhow, Result};
2use serde::{de::DeserializeOwned, Deserialize, Serialize};
3use tangled_config::session::Session;
4
5#[derive(Clone, Debug)]
6pub struct TangledClient {
7 base_url: String,
8}
9
10const REPO_CREATE: &str = "sh.tangled.repo.create";
11
12impl Default for TangledClient {
13 fn default() -> Self {
14 Self::new("https://tngl.sh")
15 }
16}
17
18impl TangledClient {
19 pub fn new(base_url: impl Into<String>) -> Self {
20 Self {
21 base_url: base_url.into(),
22 }
23 }
24
25 fn xrpc_url(&self, method: &str) -> String {
26 format!("{}/xrpc/{}", self.base_url.trim_end_matches('/'), method)
27 }
28
29 async fn post_json<TReq: Serialize, TRes: DeserializeOwned>(
30 &self,
31 method: &str,
32 req: &TReq,
33 bearer: Option<&str>,
34 ) -> Result<TRes> {
35 let url = self.xrpc_url(method);
36 let client = reqwest::Client::new();
37 let mut reqb = client
38 .post(url)
39 .header(reqwest::header::CONTENT_TYPE, "application/json");
40 if let Some(token) = bearer {
41 reqb = reqb.header(reqwest::header::AUTHORIZATION, format!("Bearer {}", token));
42 }
43 let res = reqb.json(req).send().await?;
44 let status = res.status();
45 if !status.is_success() {
46 let body = res.text().await.unwrap_or_default();
47 return Err(anyhow!("{}: {}", status, body));
48 }
49 Ok(res.json::<TRes>().await?)
50 }
51
52 async fn get_json<TRes: DeserializeOwned>(
53 &self,
54 method: &str,
55 params: &[(&str, String)],
56 bearer: Option<&str>,
57 ) -> Result<TRes> {
58 let url = self.xrpc_url(method);
59 let client = reqwest::Client::new();
60 let mut reqb = client
61 .get(&url)
62 .query(¶ms)
63 .header(reqwest::header::ACCEPT, "application/json");
64 if let Some(token) = bearer {
65 reqb = reqb.header(reqwest::header::AUTHORIZATION, format!("Bearer {}", token));
66 }
67 let res = reqb.send().await?;
68 let status = res.status();
69 let body = res.text().await.unwrap_or_default();
70 if !status.is_success() {
71 return Err(anyhow!("GET {} -> {}: {}", url, status, body));
72 }
73 serde_json::from_str::<TRes>(&body).map_err(|e| {
74 let snippet = body.chars().take(300).collect::<String>();
75 anyhow!(
76 "error decoding response from {}: {}\nBody (first 300 chars): {}",
77 url,
78 e,
79 snippet
80 )
81 })
82 }
83
84 pub async fn login_with_password(
85 &self,
86 handle: &str,
87 password: &str,
88 _pds: &str,
89 ) -> Result<Session> {
90 #[derive(Serialize)]
91 struct Req<'a> {
92 #[serde(rename = "identifier")]
93 identifier: &'a str,
94 #[serde(rename = "password")]
95 password: &'a str,
96 }
97 #[derive(Deserialize)]
98 struct Res {
99 #[serde(rename = "accessJwt")]
100 access_jwt: String,
101 #[serde(rename = "refreshJwt")]
102 refresh_jwt: String,
103 did: String,
104 handle: String,
105 }
106 let body = Req {
107 identifier: handle,
108 password,
109 };
110 let res: Res = self
111 .post_json("com.atproto.server.createSession", &body, None)
112 .await?;
113 Ok(Session {
114 access_jwt: res.access_jwt,
115 refresh_jwt: res.refresh_jwt,
116 did: res.did,
117 handle: res.handle,
118 ..Default::default()
119 })
120 }
121
122 pub async fn list_repos(
123 &self,
124 user: Option<&str>,
125 knot: Option<&str>,
126 starred: bool,
127 bearer: Option<&str>,
128 ) -> Result<Vec<Repository>> {
129 // NOTE: Repo listing is done via the user's PDS using com.atproto.repo.listRecords
130 // for the collection "sh.tangled.repo". This does not go through the Tangled API base.
131 // Here, `self.base_url` must be the PDS base (e.g., https://bsky.social).
132 // Resolve handle to DID if needed
133 let did = match user {
134 Some(u) if u.starts_with("did:") => u.to_string(),
135 Some(handle) => {
136 #[derive(Deserialize)]
137 struct Res {
138 did: String,
139 }
140 let params = [("handle", handle.to_string())];
141 let res: Res = self
142 .get_json("com.atproto.identity.resolveHandle", ¶ms, bearer)
143 .await?;
144 res.did
145 }
146 None => {
147 return Err(anyhow!(
148 "missing user for list_repos; provide handle or DID"
149 ));
150 }
151 };
152
153 #[derive(Deserialize)]
154 struct RecordItem {
155 uri: String,
156 value: Repository,
157 }
158 #[derive(Deserialize)]
159 struct ListRes {
160 #[serde(default)]
161 records: Vec<RecordItem>,
162 }
163
164 let params = vec![
165 ("repo", did),
166 ("collection", "sh.tangled.repo".to_string()),
167 ("limit", "100".to_string()),
168 ];
169
170 let res: ListRes = self
171 .get_json("com.atproto.repo.listRecords", ¶ms, bearer)
172 .await?;
173 let mut repos: Vec<Repository> = res
174 .records
175 .into_iter()
176 .map(|r| {
177 let mut val = r.value;
178 if val.rkey.is_none() {
179 if let Some(k) = Self::uri_rkey(&r.uri) {
180 val.rkey = Some(k);
181 }
182 }
183 if val.did.is_none() {
184 if let Some(d) = Self::uri_did(&r.uri) {
185 val.did = Some(d);
186 }
187 }
188 val
189 })
190 .collect();
191 // Apply optional filters client-side
192 if let Some(k) = knot {
193 repos.retain(|r| r.knot.as_deref().unwrap_or("") == k);
194 }
195 if starred {
196 // TODO: implement starred filtering when API is available. For now, no-op.
197 }
198 Ok(repos)
199 }
200
201 pub async fn create_repo(&self, opts: CreateRepoOptions<'_>) -> Result<()> {
202 // 1) Create the sh.tangled.repo record on the user's PDS
203 #[derive(Serialize)]
204 struct Record<'a> {
205 name: &'a str,
206 knot: &'a str,
207 #[serde(skip_serializing_if = "Option::is_none")]
208 description: Option<&'a str>,
209 #[serde(rename = "createdAt")]
210 created_at: String,
211 }
212 #[derive(Serialize)]
213 struct CreateRecordReq<'a> {
214 repo: &'a str,
215 collection: &'a str,
216 validate: bool,
217 record: Record<'a>,
218 }
219 #[derive(Deserialize)]
220 struct CreateRecordRes {
221 uri: String,
222 }
223
224 let now = chrono::Utc::now().to_rfc3339();
225 let rec = Record {
226 name: opts.name,
227 knot: opts.knot,
228 description: opts.description,
229 created_at: now,
230 };
231 let create_req = CreateRecordReq {
232 repo: opts.did,
233 collection: "sh.tangled.repo",
234 validate: true,
235 record: rec,
236 };
237
238 let pds_client = TangledClient::new(opts.pds_base);
239 let created: CreateRecordRes = pds_client
240 .post_json(
241 "com.atproto.repo.createRecord",
242 &create_req,
243 Some(opts.access_jwt),
244 )
245 .await?;
246
247 // Extract rkey from at-uri: at://did/collection/rkey
248 let rkey = created
249 .uri
250 .rsplit('/')
251 .next()
252 .ok_or_else(|| anyhow!("failed to parse rkey from uri"))?;
253
254 // 2) Obtain a service auth token for the Tangled server (aud = did:web:<host>)
255 let host = self
256 .base_url
257 .trim_end_matches('/')
258 .strip_prefix("https://")
259 .or_else(|| self.base_url.trim_end_matches('/').strip_prefix("http://"))
260 .ok_or_else(|| anyhow!("invalid base_url"))?;
261 let audience = format!("did:web:{}", host);
262
263 #[derive(Deserialize)]
264 struct GetSARes {
265 token: String,
266 }
267 let params = [
268 ("aud", audience),
269 ("exp", (chrono::Utc::now().timestamp() + 600).to_string()),
270 ];
271 let sa: GetSARes = pds_client
272 .get_json(
273 "com.atproto.server.getServiceAuth",
274 ¶ms,
275 Some(opts.access_jwt),
276 )
277 .await?;
278
279 // 3) Call sh.tangled.repo.create with the rkey
280 #[derive(Serialize)]
281 struct CreateRepoReq<'a> {
282 rkey: &'a str,
283 #[serde(skip_serializing_if = "Option::is_none")]
284 #[serde(rename = "defaultBranch")]
285 default_branch: Option<&'a str>,
286 #[serde(skip_serializing_if = "Option::is_none")]
287 source: Option<&'a str>,
288 }
289 let req = CreateRepoReq {
290 rkey,
291 default_branch: opts.default_branch,
292 source: opts.source,
293 };
294 // No output expected on success
295 let _: serde_json::Value = self.post_json(REPO_CREATE, &req, Some(&sa.token)).await?;
296 Ok(())
297 }
298
299 pub async fn get_repo_info(
300 &self,
301 owner: &str,
302 name: &str,
303 bearer: Option<&str>,
304 ) -> Result<RepoRecord> {
305 let did = if owner.starts_with("did:") {
306 owner.to_string()
307 } else {
308 #[derive(Deserialize)]
309 struct Res {
310 did: String,
311 }
312 let params = [("handle", owner.to_string())];
313 let res: Res = self
314 .get_json("com.atproto.identity.resolveHandle", ¶ms, bearer)
315 .await?;
316 res.did
317 };
318
319 #[derive(Deserialize)]
320 struct RecordItem {
321 uri: String,
322 value: Repository,
323 }
324 #[derive(Deserialize)]
325 struct ListRes {
326 #[serde(default)]
327 records: Vec<RecordItem>,
328 }
329 let params = vec![
330 ("repo", did.clone()),
331 ("collection", "sh.tangled.repo".to_string()),
332 ("limit", "100".to_string()),
333 ];
334 let res: ListRes = self
335 .get_json("com.atproto.repo.listRecords", ¶ms, bearer)
336 .await?;
337 for item in res.records {
338 if item.value.name == name {
339 let rkey =
340 Self::uri_rkey(&item.uri).ok_or_else(|| anyhow!("missing rkey in uri"))?;
341 let knot = item.value.knot.unwrap_or_default();
342 return Ok(RepoRecord {
343 did: did.clone(),
344 name: name.to_string(),
345 rkey,
346 knot,
347 description: item.value.description,
348 });
349 }
350 }
351 Err(anyhow!("repo not found for owner/name"))
352 }
353
354 pub async fn delete_repo(
355 &self,
356 did: &str,
357 name: &str,
358 pds_base: &str,
359 access_jwt: &str,
360 ) -> Result<()> {
361 let pds_client = TangledClient::new(pds_base);
362 let info = pds_client
363 .get_repo_info(did, name, Some(access_jwt))
364 .await?;
365
366 #[derive(Serialize)]
367 struct DeleteRecordReq<'a> {
368 repo: &'a str,
369 collection: &'a str,
370 rkey: &'a str,
371 }
372 let del = DeleteRecordReq {
373 repo: did,
374 collection: "sh.tangled.repo",
375 rkey: &info.rkey,
376 };
377 let _: serde_json::Value = pds_client
378 .post_json("com.atproto.repo.deleteRecord", &del, Some(access_jwt))
379 .await?;
380
381 let host = self
382 .base_url
383 .trim_end_matches('/')
384 .strip_prefix("https://")
385 .or_else(|| self.base_url.trim_end_matches('/').strip_prefix("http://"))
386 .ok_or_else(|| anyhow!("invalid base_url"))?;
387 let audience = format!("did:web:{}", host);
388 #[derive(Deserialize)]
389 struct GetSARes {
390 token: String,
391 }
392 let params = [
393 ("aud", audience),
394 ("exp", (chrono::Utc::now().timestamp() + 600).to_string()),
395 ];
396 let sa: GetSARes = pds_client
397 .get_json(
398 "com.atproto.server.getServiceAuth",
399 ¶ms,
400 Some(access_jwt),
401 )
402 .await?;
403
404 #[derive(Serialize)]
405 struct DeleteReq<'a> {
406 did: &'a str,
407 name: &'a str,
408 rkey: &'a str,
409 }
410 let body = DeleteReq {
411 did,
412 name,
413 rkey: &info.rkey,
414 };
415 let _: serde_json::Value = self
416 .post_json("sh.tangled.repo.delete", &body, Some(&sa.token))
417 .await?;
418 Ok(())
419 }
420
421 pub async fn update_repo_knot(
422 &self,
423 did: &str,
424 rkey: &str,
425 new_knot: &str,
426 pds_base: &str,
427 access_jwt: &str,
428 ) -> Result<()> {
429 let pds_client = TangledClient::new(pds_base);
430 #[derive(Deserialize, Serialize, Clone)]
431 struct Rec {
432 name: String,
433 knot: String,
434 #[serde(skip_serializing_if = "Option::is_none")]
435 description: Option<String>,
436 #[serde(rename = "createdAt")]
437 created_at: String,
438 }
439 #[derive(Deserialize)]
440 struct GetRes {
441 value: Rec,
442 }
443 let params = [
444 ("repo", did.to_string()),
445 ("collection", "sh.tangled.repo".to_string()),
446 ("rkey", rkey.to_string()),
447 ];
448 let got: GetRes = pds_client
449 .get_json("com.atproto.repo.getRecord", ¶ms, Some(access_jwt))
450 .await?;
451 let mut rec = got.value;
452 rec.knot = new_knot.to_string();
453 #[derive(Serialize)]
454 struct PutReq<'a> {
455 repo: &'a str,
456 collection: &'a str,
457 rkey: &'a str,
458 validate: bool,
459 record: Rec,
460 }
461 let req = PutReq {
462 repo: did,
463 collection: "sh.tangled.repo",
464 rkey,
465 validate: true,
466 record: rec,
467 };
468 let _: serde_json::Value = pds_client
469 .post_json("com.atproto.repo.putRecord", &req, Some(access_jwt))
470 .await?;
471 Ok(())
472 }
473
474 pub async fn get_default_branch(
475 &self,
476 knot_host: &str,
477 did: &str,
478 name: &str,
479 ) -> Result<DefaultBranch> {
480 #[derive(Deserialize)]
481 struct Res {
482 name: String,
483 hash: String,
484 #[serde(rename = "shortHash")]
485 short_hash: Option<String>,
486 when: String,
487 message: Option<String>,
488 }
489 let knot_client = TangledClient::new(knot_host);
490 let repo_param = format!("{}/{}", did, name);
491 let params = [("repo", repo_param)];
492 let res: Res = knot_client
493 .get_json("sh.tangled.repo.getDefaultBranch", ¶ms, None)
494 .await?;
495 Ok(DefaultBranch {
496 name: res.name,
497 hash: res.hash,
498 short_hash: res.short_hash,
499 when: res.when,
500 message: res.message,
501 })
502 }
503
504 pub async fn get_languages(&self, knot_host: &str, did: &str, name: &str) -> Result<Languages> {
505 let knot_client = TangledClient::new(knot_host);
506 let repo_param = format!("{}/{}", did, name);
507 let params = [("repo", repo_param)];
508 let res: serde_json::Value = knot_client
509 .get_json("sh.tangled.repo.languages", ¶ms, None)
510 .await?;
511 let langs = res
512 .get("languages")
513 .cloned()
514 .unwrap_or(serde_json::json!([]));
515 let languages: Vec<Language> = serde_json::from_value(langs)?;
516 let total_size = res.get("totalSize").and_then(|v| v.as_u64());
517 let total_files = res.get("totalFiles").and_then(|v| v.as_u64());
518 Ok(Languages {
519 languages,
520 total_size,
521 total_files,
522 })
523 }
524
525 pub async fn star_repo(
526 &self,
527 pds_base: &str,
528 access_jwt: &str,
529 subject_at_uri: &str,
530 user_did: &str,
531 ) -> Result<String> {
532 #[derive(Serialize)]
533 struct Rec<'a> {
534 subject: &'a str,
535 #[serde(rename = "createdAt")]
536 created_at: String,
537 }
538 #[derive(Serialize)]
539 struct Req<'a> {
540 repo: &'a str,
541 collection: &'a str,
542 validate: bool,
543 record: Rec<'a>,
544 }
545 #[derive(Deserialize)]
546 struct Res {
547 uri: String,
548 }
549 let now = chrono::Utc::now().to_rfc3339();
550 let rec = Rec {
551 subject: subject_at_uri,
552 created_at: now,
553 };
554 let req = Req {
555 repo: user_did,
556 collection: "sh.tangled.feed.star",
557 validate: true,
558 record: rec,
559 };
560 let pds_client = TangledClient::new(pds_base);
561 let res: Res = pds_client
562 .post_json("com.atproto.repo.createRecord", &req, Some(access_jwt))
563 .await?;
564 let rkey = Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in star uri"))?;
565 Ok(rkey)
566 }
567
568 pub async fn unstar_repo(
569 &self,
570 pds_base: &str,
571 access_jwt: &str,
572 subject_at_uri: &str,
573 user_did: &str,
574 ) -> Result<()> {
575 #[derive(Deserialize)]
576 struct Item {
577 uri: String,
578 value: StarRecord,
579 }
580 #[derive(Deserialize)]
581 struct ListRes {
582 #[serde(default)]
583 records: Vec<Item>,
584 }
585 let pds_client = TangledClient::new(pds_base);
586 let params = vec![
587 ("repo", user_did.to_string()),
588 ("collection", "sh.tangled.feed.star".to_string()),
589 ("limit", "100".to_string()),
590 ];
591 let res: ListRes = pds_client
592 .get_json("com.atproto.repo.listRecords", ¶ms, Some(access_jwt))
593 .await?;
594 let mut rkey = None;
595 for item in res.records {
596 if item.value.subject == subject_at_uri {
597 rkey = Self::uri_rkey(&item.uri);
598 if rkey.is_some() {
599 break;
600 }
601 }
602 }
603 let rkey = rkey.ok_or_else(|| anyhow!("star record not found"))?;
604 #[derive(Serialize)]
605 struct Del<'a> {
606 repo: &'a str,
607 collection: &'a str,
608 rkey: &'a str,
609 }
610 let del = Del {
611 repo: user_did,
612 collection: "sh.tangled.feed.star",
613 rkey: &rkey,
614 };
615 let _: serde_json::Value = pds_client
616 .post_json("com.atproto.repo.deleteRecord", &del, Some(access_jwt))
617 .await?;
618 Ok(())
619 }
620
621 fn uri_rkey(uri: &str) -> Option<String> {
622 uri.rsplit('/').next().map(|s| s.to_string())
623 }
624 fn uri_did(uri: &str) -> Option<String> {
625 let parts: Vec<&str> = uri.split('/').collect();
626 if parts.len() >= 3 {
627 Some(parts[2].to_string())
628 } else {
629 None
630 }
631 }
632}
633
634#[derive(Debug, Clone, Serialize, Deserialize, Default)]
635pub struct Repository {
636 pub did: Option<String>,
637 pub rkey: Option<String>,
638 pub name: String,
639 pub knot: Option<String>,
640 pub description: Option<String>,
641 #[serde(default)]
642 pub private: bool,
643}
644
645#[derive(Debug, Clone)]
646pub struct RepoRecord {
647 pub did: String,
648 pub name: String,
649 pub rkey: String,
650 pub knot: String,
651 pub description: Option<String>,
652}
653
654#[derive(Debug, Clone, Serialize, Deserialize)]
655pub struct DefaultBranch {
656 pub name: String,
657 pub hash: String,
658 #[serde(skip_serializing_if = "Option::is_none")]
659 pub short_hash: Option<String>,
660 pub when: String,
661 #[serde(skip_serializing_if = "Option::is_none")]
662 pub message: Option<String>,
663}
664
665#[derive(Debug, Clone, Serialize, Deserialize)]
666pub struct Language {
667 pub name: String,
668 pub size: u64,
669 pub percentage: u64,
670}
671
672#[derive(Debug, Clone, Serialize, Deserialize)]
673pub struct Languages {
674 pub languages: Vec<Language>,
675 #[serde(skip_serializing_if = "Option::is_none")]
676 pub total_size: Option<u64>,
677 #[serde(skip_serializing_if = "Option::is_none")]
678 pub total_files: Option<u64>,
679}
680
681#[derive(Debug, Clone, Serialize, Deserialize)]
682pub struct StarRecord {
683 pub subject: String,
684 #[serde(rename = "createdAt")]
685 pub created_at: String,
686}
687
688#[derive(Debug, Clone)]
689pub struct CreateRepoOptions<'a> {
690 pub did: &'a str,
691 pub name: &'a str,
692 pub knot: &'a str,
693 pub description: Option<&'a str>,
694 pub default_branch: Option<&'a str>,
695 pub source: Option<&'a str>,
696 pub pds_base: &'a str,
697 pub access_jwt: &'a str,
698}