1use std::collections::HashMap;
3
4use chrono::{DateTime, Utc};
5use color_eyre::Help;
6use regex::Regex;
7
8use crate::errors::LostTheWay;
9
10const GITHUB_API_URL: &str = "https://api.github.com";
11const GITHUB_BASE_PATH: &str = "";
12const ACCEPT: &str = "application/vnd.github.v3+json";
13const USER_AGENT: &str = "the-way";
14
15fn gist_id_from_url(gist_url: &str) -> color_eyre::Result<Option<&str>> {
18 let re = Regex::new(r"https://gist\.github\.com/(.+/)?(?P<gist_id>[0-9a-f]+)$")?;
19 Ok(re
20 .captures(gist_url)
21 .and_then(|cap| cap.name("gist_id").map(|gist_id| gist_id.as_str())))
22}
23
24#[derive(Serialize, Debug)]
26pub struct GistContent<'a> {
27 pub content: &'a str,
28}
29
30#[derive(Serialize, Debug)]
31pub struct CreateGistPayload<'a> {
32 pub description: &'a str,
33 pub public: bool,
34 pub files: HashMap<String, GistContent<'a>>,
35}
36
37#[derive(Serialize, Debug)]
38pub struct UpdateGistPayload<'a> {
39 pub description: &'a str,
40 pub files: HashMap<String, Option<GistContent<'a>>>,
41}
42
43#[derive(Deserialize, Debug)]
44pub struct Gist {
45 pub html_url: String,
46 pub id: String,
47 pub updated_at: DateTime<Utc>,
48 pub description: String,
49 pub files: HashMap<String, GistFile>,
50}
51
52#[derive(Deserialize, Debug)]
53pub struct GistFile {
54 pub content: String,
55 pub language: String,
56}
57
58pub struct GistClient<'a> {
59 client: ureq::Agent,
60 access_token: Option<&'a str>,
61}
62
63impl<'a> GistClient<'a> {
64 pub fn new(access_token: Option<&'a str>) -> color_eyre::Result<Self> {
66 Ok(Self {
67 client: ureq::agent(),
68 access_token,
69 })
70 }
71
72 fn add_headers(&self, request: ureq::Request) -> ureq::Request {
73 let mut request = request
74 .set("user-agent", USER_AGENT)
75 .set("content-type", ACCEPT);
76 if let Some(access_token) = &self.access_token {
77 request = request.set("Authorization", &format!("token {access_token}"));
78 }
79 request
80 }
81
82 fn get_response(response: Result<ureq::Response, ureq::Error>) -> color_eyre::Result<Gist> {
83 match response {
84 Ok(response) => {
85 Ok(response
86 .into_json::<Gist>()
87 .map_err(|e| LostTheWay::SyncError {
88 message: format!("{e}"),
89 })?)
90 }
91 Err(ureq::Error::Status(code, response)) => Err(LostTheWay::SyncError {
92 message: format!("{code} {}", response.into_string()?),
93 })
94 .suggestion(
95 "Make sure your GitHub access token is valid.\n\
96 Get one from https://github.com/settings/tokens/new (add the \"gist\" scope).\n\
97 Set it to the environment variable $THE_WAY_GITHUB_TOKEN",
98 ),
99 Err(_) => Err(LostTheWay::SyncError {
100 message: "io/transport error".into(),
101 })
102 .suggestion(
103 "Make sure your GitHub access token is valid.\n\
104 Get one from https://github.com/settings/tokens/new (add the \"gist\" scope).\n\
105 Set it to the environment variable $THE_WAY_GITHUB_TOKEN",
106 ),
107 }
108 }
109
110 pub fn create_gist(&self, payload: &CreateGistPayload<'_>) -> color_eyre::Result<Gist> {
112 let url = format!("{GITHUB_API_URL}{GITHUB_BASE_PATH}/gists");
113 let response = self
114 .add_headers(self.client.post(&url))
115 .send_json(serde_json::to_value(payload)?);
116 Self::get_response(response)
117 }
118
119 pub fn update_gist(
121 &self,
122 gist_id: &str,
123 payload: &UpdateGistPayload<'_>,
124 ) -> color_eyre::Result<Gist> {
125 let url = format!("{GITHUB_API_URL}{GITHUB_BASE_PATH}/gists");
126 let response = self
127 .add_headers(self.client.request("PATCH", &format!("{url}/{gist_id}")))
128 .send_json(serde_json::to_value(payload)?);
129 Self::get_response(response)
130 }
131
132 pub fn get_gist(&self, gist_id: &str) -> color_eyre::Result<Gist> {
134 let url = format!("{GITHUB_API_URL}{GITHUB_BASE_PATH}/gists");
135 let response = self.add_headers(self.client.get(&format!("{url}/{gist_id}")));
136 Self::get_response(response.call())
137 }
138
139 pub fn get_gist_by_url(&self, gist_url: &str) -> color_eyre::Result<Gist> {
141 let gist_id = gist_id_from_url(gist_url)?;
142 match gist_id {
143 Some(gist_id) => self.get_gist(gist_id),
144 None => Err(LostTheWay::GistUrlError {
145 message: format!("Problem extracting gist ID from {gist_url}"),
146 })
147 .suggestion("The URL should look like https://gist.github.com/<user>/<gist_id>."),
148 }
149 }
150
151 pub fn delete_gist(&self, gist_id: &str) -> color_eyre::Result<()> {
153 let url = format!("{GITHUB_API_URL}{GITHUB_BASE_PATH}/gists");
154 let status = self.add_headers(self.client.delete(&format!("{url}/{gist_id}")));
155 if status.call().is_err() {
156 Err(LostTheWay::GistUrlError {
157 message: format!("Couldn't delete gist with ID {gist_id}"),
158 }
159 .into())
160 } else {
161 Ok(())
162 }
163 }
164}