1use std::borrow::Cow;
2use std::io;
3
4use proc_macro2::{Ident, TokenStream as TokenStream2};
5use quote::{quote, ToTokens};
6use syn::parse::Parse;
7use syn::token::Comma;
8use syn::{parenthesized, Error, LitStr};
9
10use crate::parse_utils::{self, LitStrOrExpr};
11
12#[derive(Default, Clone)]
13#[cfg_attr(feature = "debug", derive(Debug))]
14pub(super) struct Info<'i> {
15 title: Option<LitStrOrExpr>,
16 version: Option<LitStrOrExpr>,
17 description: Option<LitStrOrExpr>,
18 terms_of_service: Option<LitStrOrExpr>,
19 license: Option<License<'i>>,
20 contact: Option<Contact<'i>>,
21}
22
23impl Info<'_> {
24 pub fn from_env() -> Self {
31 let name = std::env::var("CARGO_PKG_NAME").ok();
32 let version = std::env::var("CARGO_PKG_VERSION").ok();
33 let description = std::env::var("CARGO_PKG_DESCRIPTION").ok();
34 let contact = std::env::var("CARGO_PKG_AUTHORS")
35 .ok()
36 .and_then(|authors| Contact::try_from(authors).ok())
37 .and_then(|contact| {
38 if contact.name.is_none() && contact.email.is_none() && contact.url.is_none() {
39 None
40 } else {
41 Some(contact)
42 }
43 });
44 let license = std::env::var("CARGO_PKG_LICENSE")
45 .ok()
46 .map(|spdx_expr| License {
47 name: Cow::Owned(spdx_expr.clone()),
48 identifier: Cow::Owned(spdx_expr),
51 ..Default::default()
52 });
53
54 Info {
55 title: name.map(|name| name.into()),
56 version: version.map(|version| version.into()),
57 description: description.map(|description| description.into()),
58 contact,
59 license,
60 ..Default::default()
61 }
62 }
63
64 pub fn merge_with_env_args(info: Option<Info>) -> Info {
66 let mut from_env = Info::from_env();
67 if let Some(info) = info {
68 if info.title.is_some() {
69 from_env.title = info.title;
70 }
71
72 if info.terms_of_service.is_some() {
73 from_env.terms_of_service = info.terms_of_service;
74 }
75
76 if info.description.is_some() {
77 from_env.description = info.description;
78 }
79
80 if info.license.is_some() {
81 from_env.license = info.license;
82 }
83
84 if info.contact.is_some() {
85 from_env.contact = info.contact;
86 }
87
88 if info.version.is_some() {
89 from_env.version = info.version;
90 }
91 }
92
93 from_env
94 }
95}
96
97impl Parse for Info<'_> {
98 fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
99 let mut info = Info::default();
100
101 while !input.is_empty() {
102 let ident = input.parse::<Ident>()?;
103 let attribute_name = &*ident.to_string();
104
105 match attribute_name {
106 "title" => {
107 info.title = Some(parse_utils::parse_next(input, || {
108 input.parse::<LitStrOrExpr>()
109 })?)
110 }
111 "version" => {
112 info.version = Some(parse_utils::parse_next(input, || {
113 input.parse::<LitStrOrExpr>()
114 })?)
115 }
116 "description" => {
117 info.description = Some(parse_utils::parse_next(input, || {
118 input.parse::<LitStrOrExpr>()
119 })?)
120 }
121 "terms_of_service" => {
122 info.terms_of_service = Some(parse_utils::parse_next(input, || {
123 input.parse::<LitStrOrExpr>()
124 })?)
125 }
126 "license" => {
127 let license_stream;
128 parenthesized!(license_stream in input);
129 info.license = Some(license_stream.parse()?)
130 }
131 "contact" => {
132 let contact_stream;
133 parenthesized!(contact_stream in input);
134 info.contact = Some(contact_stream.parse()?)
135 }
136 _ => {
137 return Err(Error::new(ident.span(), format!("unexpected attribute: {attribute_name}, expected one of: title, terms_of_service, version, description, license, contact")));
138 }
139 }
140 if !input.is_empty() {
141 input.parse::<Comma>()?;
142 }
143 }
144
145 Ok(info)
146 }
147}
148
149impl ToTokens for Info<'_> {
150 fn to_tokens(&self, tokens: &mut TokenStream2) {
151 let title = self.title.as_ref().map(|title| quote! { .title(#title) });
152 let version = self
153 .version
154 .as_ref()
155 .map(|version| quote! { .version(#version) });
156 let terms_of_service = self
157 .terms_of_service
158 .as_ref()
159 .map(|terms_of_service| quote! {.terms_of_service(Some(#terms_of_service))});
160 let description = self
161 .description
162 .as_ref()
163 .map(|description| quote! { .description(Some(#description)) });
164 let license = self
165 .license
166 .as_ref()
167 .map(|license| quote! { .license(Some(#license)) });
168 let contact = self
169 .contact
170 .as_ref()
171 .map(|contact| quote! { .contact(Some(#contact)) });
172
173 tokens.extend(quote! {
174 utoipa::openapi::InfoBuilder::new()
175 #title
176 #version
177 #terms_of_service
178 #description
179 #license
180 #contact
181 })
182 }
183}
184
185#[derive(Default, Clone)]
186#[cfg_attr(feature = "debug", derive(Debug))]
187pub(super) struct License<'l> {
188 name: Cow<'l, str>,
189 url: Option<Cow<'l, str>>,
190 identifier: Cow<'l, str>,
191}
192
193impl Parse for License<'_> {
194 fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
195 let mut license = License::default();
196
197 while !input.is_empty() {
198 let ident = input.parse::<Ident>()?;
199 let attribute_name = &*ident.to_string();
200
201 match attribute_name {
202 "name" => {
203 license.name = Cow::Owned(
204 parse_utils::parse_next(input, || input.parse::<LitStr>())?.value(),
205 )
206 }
207 "url" => {
208 license.url = Some(Cow::Owned(
209 parse_utils::parse_next(input, || input.parse::<LitStr>())?.value(),
210 ))
211 }
212 "identifier" => {
213 license.identifier = Cow::Owned(
214 parse_utils::parse_next(input, || input.parse::<LitStr>())?.value(),
215 )
216 }
217 _ => {
218 return Err(Error::new(
219 ident.span(),
220 format!(
221 "unexpected attribute: {attribute_name}, expected one of: name, url"
222 ),
223 ));
224 }
225 }
226 if !input.is_empty() {
227 input.parse::<Comma>()?;
228 }
229 }
230
231 Ok(license)
232 }
233}
234
235impl ToTokens for License<'_> {
236 fn to_tokens(&self, tokens: &mut TokenStream2) {
237 let name = &self.name;
238 let url = self.url.as_ref().map(|url| quote! { .url(Some(#url))});
239 let identifier = if !self.identifier.is_empty() {
240 let identifier = self.identifier.as_ref();
241 quote! { .identifier(Some(#identifier))}
242 } else {
243 TokenStream2::new()
244 };
245
246 tokens.extend(quote! {
247 utoipa::openapi::info::LicenseBuilder::new()
248 .name(#name)
249 #url
250 #identifier
251 .build()
252 })
253 }
254}
255
256impl From<String> for License<'_> {
257 fn from(string: String) -> Self {
258 License {
259 name: Cow::Owned(string),
260 ..Default::default()
261 }
262 }
263}
264
265#[derive(Default, Clone)]
266#[cfg_attr(feature = "debug", derive(Debug))]
267pub(super) struct Contact<'c> {
268 name: Option<Cow<'c, str>>,
269 email: Option<Cow<'c, str>>,
270 url: Option<Cow<'c, str>>,
271}
272
273impl Parse for Contact<'_> {
274 fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
275 let mut contact = Contact::default();
276
277 while !input.is_empty() {
278 let ident = input.parse::<Ident>()?;
279 let attribute_name = &*ident.to_string();
280
281 match attribute_name {
282 "name" => {
283 contact.name = Some(Cow::Owned(
284 parse_utils::parse_next(input, || input.parse::<LitStr>())?.value(),
285 ))
286 }
287 "email" => {
288 contact.email = Some(Cow::Owned(
289 parse_utils::parse_next(input, || input.parse::<LitStr>())?.value(),
290 ))
291 }
292 "url" => {
293 contact.url = Some(Cow::Owned(
294 parse_utils::parse_next(input, || input.parse::<LitStr>())?.value(),
295 ))
296 }
297 _ => {
298 return Err(Error::new(
299 ident.span(),
300 format!("unexpected attribute: {attribute_name}, expected one of: name, email, url"),
301 ));
302 }
303 }
304 if !input.is_empty() {
305 input.parse::<Comma>()?;
306 }
307 }
308
309 Ok(contact)
310 }
311}
312
313impl ToTokens for Contact<'_> {
314 fn to_tokens(&self, tokens: &mut TokenStream2) {
315 let name = self.name.as_ref().map(|name| quote! { .name(Some(#name)) });
316 let email = self
317 .email
318 .as_ref()
319 .map(|email| quote! { .email(Some(#email)) });
320 let url = self.url.as_ref().map(|url| quote! { .url(Some(#url)) });
321
322 tokens.extend(quote! {
323 utoipa::openapi::info::ContactBuilder::new()
324 #name
325 #email
326 #url
327 .build()
328 })
329 }
330}
331
332impl TryFrom<String> for Contact<'_> {
333 type Error = io::Error;
334
335 fn try_from(value: String) -> Result<Self, Self::Error> {
336 if let Some((name, email)) = get_parsed_author(value.split(':').next()) {
337 let non_empty = |value: &str| -> Option<Cow<'static, str>> {
338 if !value.is_empty() {
339 Some(Cow::Owned(value.to_string()))
340 } else {
341 None
342 }
343 };
344 Ok(Contact {
345 name: non_empty(name),
346 email: non_empty(email),
347 ..Default::default()
348 })
349 } else {
350 Err(io::Error::new(
351 io::ErrorKind::Other,
352 format!("invalid contact: {value}"),
353 ))
354 }
355 }
356}
357
358fn get_parsed_author(author: Option<&str>) -> Option<(&str, &str)> {
359 author.map(|author| {
360 let mut author_iter = author.split('<');
361
362 let name = author_iter.next().unwrap_or_default();
363 let mut email = author_iter.next().unwrap_or_default();
364 if !email.is_empty() {
365 email = &email[..email.len() - 1];
366 }
367
368 (name.trim_end(), email)
369 })
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 #[test]
377 fn parse_author_with_email_success() {
378 let author = "Tessu Tester <tessu@steps.com>";
379
380 if let Some((name, email)) = get_parsed_author(Some(author)) {
381 assert_eq!(
382 name, "Tessu Tester",
383 "expected name {} != {}",
384 "Tessu Tester", name
385 );
386 assert_eq!(
387 email, "tessu@steps.com",
388 "expected email {} != {}",
389 "tessu@steps.com", email
390 );
391 } else {
392 panic!("Expected Some(Tessu Tester, tessu@steps.com), but was none")
393 }
394 }
395
396 #[test]
397 fn parse_author_only_name() {
398 let author = "Tessu Tester";
399
400 if let Some((name, email)) = get_parsed_author(Some(author)) {
401 assert_eq!(
402 name, "Tessu Tester",
403 "expected name {} != {}",
404 "Tessu Tester", name
405 );
406 assert_eq!(email, "", "expected email {} != {}", "", email);
407 } else {
408 panic!("Expected Some(Tessu Tester, ), but was none")
409 }
410 }
411
412 #[test]
413 fn contact_from_only_name() {
414 let author = "Suzy Lin";
415 let contanct = Contact::try_from(author.to_string()).unwrap();
416
417 assert!(contanct.name.is_some(), "Suzy should have name");
418 assert!(contanct.email.is_none(), "Suzy should not have email");
419 }
420
421 #[test]
422 fn contact_from_name_and_email() {
423 let author = "Suzy Lin <suzy@lin.com>";
424 let contanct = Contact::try_from(author.to_string()).unwrap();
425
426 assert!(contanct.name.is_some(), "Suzy should have name");
427 assert!(contanct.email.is_some(), "Suzy should have email");
428 }
429
430 #[test]
431 fn contact_from_empty() {
432 let author = "";
433 let contact = Contact::try_from(author.to_string()).unwrap();
434
435 assert!(contact.name.is_none(), "Contact name should be empty");
436 assert!(contact.email.is_none(), "Contact email should be empty");
437 }
438
439 #[test]
440 fn info_from_env() {
441 let info = Info::from_env();
442
443 match info.title {
444 Some(LitStrOrExpr::LitStr(title)) => assert_eq!(title.value(), env!("CARGO_PKG_NAME")),
445 _ => panic!(),
446 }
447
448 match info.version {
449 Some(LitStrOrExpr::LitStr(version)) => {
450 assert_eq!(version.value(), env!("CARGO_PKG_VERSION"))
451 }
452 _ => panic!(),
453 }
454
455 match info.description {
456 Some(LitStrOrExpr::LitStr(description)) => {
457 assert_eq!(description.value(), env!("CARGO_PKG_DESCRIPTION"))
458 }
459 _ => panic!(),
460 }
461
462 assert!(matches!(info.terms_of_service, None));
463
464 match info.license {
465 Some(license) => {
466 assert_eq!(license.name, env!("CARGO_PKG_LICENSE"));
467 assert_eq!(license.identifier, env!("CARGO_PKG_LICENSE"));
468 assert_eq!(license.url, None);
469 }
470 None => panic!(),
471 }
472 }
473}