1const MANIFEST: &str = include_str!("../Cargo.toml");
26
27fn manifest_version(crate_name: &str) -> &'static str {
37 match parse_manifest_version(MANIFEST, crate_name) {
38 Some(version) => return version,
39 None => panic!(
40 "crate `{crate_name}` is not a declared dependency of oapi-codegen; cannot determine its version for the dependency report"
41 ),
42 }
43}
44
45fn parse_manifest_version<'a>(manifest: &'a str, crate_name: &str) -> Option<&'a str> {
52 let mut inside_table_for_crate = false;
53 for line in manifest.lines() {
54 let line = line.trim();
55
56 if let Some(header) = line.strip_prefix('[').and_then(|rest| return rest.strip_suffix(']')) {
57 inside_table_for_crate = is_dependency_table_for(header, crate_name);
58 continue;
59 }
60
61 if inside_table_for_crate
62 && let Some(rest) = line.strip_prefix("version")
63 && let Some(value) = rest.trim_start().strip_prefix('=')
64 && let Some(version) = first_quoted(value)
65 {
66 return Some(version);
67 }
68
69 let Some(rest) = line.strip_prefix(crate_name) else {
70 continue;
71 };
72 if !rest.starts_with([' ', '\t', '=']) {
74 continue;
75 }
76 let Some(value) = rest.trim_start().strip_prefix('=') else {
77 continue;
78 };
79 let value = value.trim_start();
80 let scan = match value.strip_prefix('{') {
85 Some(table) => match table.find("version =").or_else(|| return table.find("version=")) {
86 Some(index) => &table[index..],
87 None => continue,
88 },
89 None => value,
90 };
91 if let Some(version) = first_quoted(scan) {
92 return Some(version);
93 }
94 }
95 return None;
96}
97
98fn is_dependency_table_for(header: &str, crate_name: &str) -> bool {
101 for scope in ["dependencies.", "dev-dependencies.", "build-dependencies."] {
102 if let Some(name) = header.strip_prefix(scope)
103 && name == crate_name
104 {
105 return true;
106 }
107 }
108 return false;
109}
110
111fn first_quoted(text: &str) -> Option<&str> {
113 let after = text.split_once('"')?.1;
114 return after.split_once('"').map(|(value, _)| return value);
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct Dependency {
121 pub name: &'static str,
123 pub version: &'static str,
126 pub default_features: bool,
128 pub features: Vec<&'static str>,
130}
131
132impl Dependency {
133 pub fn toml(&self) -> String {
138 if self.default_features && self.features.is_empty() {
139 return format!("{} = \"{}\"", self.name, self.version);
140 }
141 let mut parts = vec![format!("version = \"{}\"", self.version)];
142 if !self.default_features {
143 parts.push("default-features = false".to_owned());
144 }
145 if !self.features.is_empty() {
146 let features = self
147 .features
148 .iter()
149 .map(|feature| return format!("\"{feature}\""))
150 .collect::<Vec<_>>()
151 .join(", ");
152 parts.push(format!("features = [{features}]"));
153 }
154 return format!("{} = {{ {} }}", self.name, parts.join(", "));
155 }
156
157 pub fn cargo_add(&self) -> String {
159 let mut command = format!("cargo add {}@{}", self.name, self.version);
160 if !self.default_features {
161 command.push_str(" --no-default-features");
162 }
163 if !self.features.is_empty() {
164 command.push_str(&format!(" --features {}", self.features.join(",")));
165 }
166 return command;
167 }
168
169 pub fn cargo_add_args(&self) -> Vec<String> {
171 let mut args = vec!["add".to_owned(), format!("{}@{}", self.name, self.version)];
172 if !self.default_features {
173 args.push("--no-default-features".to_owned());
174 }
175 if !self.features.is_empty() {
176 args.push("--features".to_owned());
177 args.push(self.features.join(","));
178 }
179 return args;
180 }
181}
182
183pub fn required_dependencies(code: &str) -> Vec<Dependency> {
186 let has = |needle: &str| return code.contains(needle);
187 let mut deps = Vec::new();
188
189 if has("serde::Serialize") || has("serde::Deserialize") {
190 deps.push(with_features("serde", true, vec!["derive"]));
191 }
192 if has("serde_json::") {
193 deps.push(plain("serde_json"));
194 }
195 if has("chrono::") {
196 deps.push(with_features("chrono", true, vec!["serde"]));
197 }
198 if has("uuid::") {
199 deps.push(with_features("uuid", true, vec!["serde"]));
200 }
201 if has("regex::") {
202 deps.push(with_features("regex", false, vec!["std", "perf", "unicode"]));
203 }
204 if has("http::") {
205 deps.push(plain("http"));
206 }
207 if has("axum::") {
208 let mut features = Vec::new();
209 if has("axum::extract::Multipart") {
210 features.push("multipart");
211 }
212 deps.push(with_features("axum", true, features));
213 }
214 if has("axum_extra::") {
215 let mut features = Vec::new();
216 if has("axum_extra::extract::Query") {
217 features.push("query");
218 }
219 if has("axum_extra::extract::CookieJar") {
220 features.push("cookie");
221 }
222 deps.push(with_features("axum-extra", true, features));
223 }
224 if has("reqwest::") {
225 let mut features = Vec::new();
226 if has("reqwest::blocking") {
227 features.push("blocking");
228 }
229 if has(".json(") {
230 features.push("json");
231 }
232 if has(".form(") {
233 features.push("form");
234 }
235 if has(".query(") {
236 features.push("query");
237 }
238 if has(".multipart(") || has("reqwest::blocking::multipart") {
239 features.push("multipart");
240 }
241 deps.push(with_features("reqwest", false, features));
242 }
243 if has("percent_encoding::") {
244 deps.push(plain("percent-encoding"));
245 }
246 if has("serde_urlencoded::") {
247 deps.push(plain("serde_urlencoded"));
248 }
249
250 return deps;
251}
252
253fn plain(name: &'static str) -> Dependency {
256 return with_features(name, true, Vec::new());
257}
258
259fn with_features(name: &'static str, default_features: bool, features: Vec<&'static str>) -> Dependency {
262 return Dependency {
263 name,
264 version: manifest_version(name),
265 default_features,
266 features,
267 };
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 #[test]
275 fn version_key_matched_as_token_not_substring() {
276 let manifest = include_str!("../tests/fixtures/manifests/reordered_version_key.toml");
279 assert_eq!(parse_manifest_version(manifest, "axum"), Some("0.8.9"));
280 }
281
282 #[test]
283 fn a_version_is_found_in_the_layout_cargo_publishes() {
284 let manifest = r#"
288 [package]
289 name = "specimen"
290 version = "9.9.9"
291
292 [dependencies.plain]
293 version = "1.2.3"
294
295 [dependencies.with-features]
296 version = "4.5.6"
297 features = ["one", "two"]
298
299 [dependencies.multi-line-features]
300 version = "7.8.9"
301 features = [
302 "one",
303 "two",
304 ]
305
306 [dependencies.prefix]
307 version = "0.1.0"
308
309 [dependencies.prefix_extended]
310 version = "0.2.0"
311
312 [dev-dependencies.only-for-tests]
313 version = "5.0.0"
314
315 [build-dependencies.only-for-build]
316 version = "6.0.0"
317 "#;
318
319 for (crate_name, expected) in [
320 ("plain", Some("1.2.3")),
321 ("with-features", Some("4.5.6")),
322 ("multi-line-features", Some("7.8.9")),
323 ("prefix", Some("0.1.0")),
324 ("prefix_extended", Some("0.2.0")),
325 ("only-for-tests", Some("5.0.0")),
326 ("only-for-build", Some("6.0.0")),
327 ("absent", None),
328 ("specimen", None),
330 ] {
331 assert_eq!(
332 parse_manifest_version(manifest, crate_name),
333 expected,
334 "the published layout should report `{crate_name}` as {expected:?}"
335 );
336 }
337 }
338
339 #[test]
340 fn toml_renders_short_and_table_forms() {
341 assert_eq!(
342 Dependency {
343 name: "http",
344 version: "1",
345 default_features: true,
346 features: vec![],
347 }
348 .toml(),
349 "http = \"1\""
350 );
351 assert_eq!(
352 Dependency {
353 name: "serde",
354 version: "1",
355 default_features: true,
356 features: vec!["derive"],
357 }
358 .toml(),
359 "serde = { version = \"1\", features = [\"derive\"] }"
360 );
361 assert_eq!(
362 Dependency {
363 name: "reqwest",
364 version: "0.13",
365 default_features: false,
366 features: vec!["blocking", "json"],
367 }
368 .toml(),
369 "reqwest = { version = \"0.13\", default-features = false, features = [\"blocking\", \"json\"] }"
370 );
371 }
372
373 #[test]
374 fn cargo_add_renders_flags() {
375 assert_eq!(
376 Dependency {
377 name: "http",
378 version: "1",
379 default_features: true,
380 features: vec![],
381 }
382 .cargo_add(),
383 "cargo add http@1"
384 );
385 assert_eq!(
386 Dependency {
387 name: "reqwest",
388 version: "0.13",
389 default_features: false,
390 features: vec!["blocking", "json"],
391 }
392 .cargo_add(),
393 "cargo add reqwest@0.13 --no-default-features --features blocking,json"
394 );
395 }
396
397 #[test]
398 fn cargo_add_args_split_for_process_execution() {
399 assert_eq!(
400 Dependency {
401 name: "http",
402 version: "1",
403 default_features: true,
404 features: vec![],
405 }
406 .cargo_add_args(),
407 vec!["add", "http@1"]
408 );
409 assert_eq!(
410 Dependency {
411 name: "reqwest",
412 version: "0.13",
413 default_features: false,
414 features: vec!["blocking", "json"],
415 }
416 .cargo_add_args(),
417 vec![
418 "add",
419 "reqwest@0.13",
420 "--no-default-features",
421 "--features",
422 "blocking,json"
423 ]
424 );
425 }
426
427 #[test]
428 fn versions_come_from_the_manifest_not_hardcoded() {
429 assert_eq!(manifest_version("http"), extract_manifest_version("http"));
432 assert_eq!(manifest_version("axum"), extract_manifest_version("axum"));
433 assert!(!manifest_version("serde_urlencoded").is_empty());
434 }
435
436 #[test]
437 fn serde_prefix_does_not_match_serde_json_or_urlencoded() {
438 assert_eq!(manifest_version("serde"), extract_manifest_version("serde"));
440 assert_ne!(manifest_version("serde"), manifest_version("serde_json"));
441 }
442
443 #[test]
444 fn every_reportable_crate_has_a_manifest_version() {
445 let code = "\
448 serde::Serialize serde_json::Value chrono::DateTime uuid::Uuid http::StatusCode \
449 axum::extract::Multipart axum_extra::extract::Query axum_extra::extract::CookieJar \
450 reqwest::blocking::multipart .json( .form( .query( percent_encoding::utf8 serde_urlencoded::from_str";
451 for dep in required_dependencies(code) {
452 assert!(!dep.version.is_empty(), "{} has an empty version", dep.name);
453 }
454 }
455
456 fn extract_manifest_version(name: &str) -> String {
460 for line in MANIFEST.lines() {
461 let line = line.trim();
462 if let Some(rest) = line.strip_prefix(name)
463 && rest.starts_with([' ', '\t', '='])
464 {
465 let quoted: Vec<&str> = line.split('"').collect();
466 if quoted.len() >= 2 {
469 return quoted[1].to_owned();
470 }
471 }
472 }
473 panic!("`{name}` not found in manifest");
474 }
475
476 #[test]
477 fn detects_server_stack_from_generated_paths() {
478 let code = "axum::Json axum::extract::Multipart axum_extra::extract::Query http::StatusCode serde::Serialize serde_json::Value";
479 let deps = required_dependencies(code);
480 let names: Vec<&str> = deps.iter().map(|dep| return dep.name).collect();
481 assert_eq!(names, vec!["serde", "serde_json", "http", "axum", "axum-extra"]);
482 let axum = deps.iter().find(|dep| return dep.name == "axum").expect("axum present");
483 assert_eq!(axum.features, vec!["multipart"]);
484 let extra = deps
485 .iter()
486 .find(|dep| return dep.name == "axum-extra")
487 .expect("axum-extra present");
488 assert_eq!(extra.features, vec!["query"]);
489 }
490
491 #[test]
492 fn detects_client_stack_from_generated_paths() {
493 let code = "reqwest::blocking::Client request.json(&body) percent_encoding::utf8 serde::Deserialize";
494 let deps = required_dependencies(code);
495 let reqwest = deps
496 .iter()
497 .find(|dep| return dep.name == "reqwest")
498 .expect("reqwest present");
499 assert!(!reqwest.default_features);
500 assert_eq!(reqwest.features, vec!["blocking", "json"]);
501 assert!(deps.iter().any(|dep| return dep.name == "percent-encoding"));
502 }
503
504 #[test]
505 fn axum_marker_does_not_match_axum_extra() {
506 let deps = required_dependencies("axum_extra::extract::CookieJar");
507 assert!(
508 !deps.iter().any(|dep| return dep.name == "axum"),
509 "`axum_extra::` must not be mistaken for the `axum` crate",
510 );
511 let extra = deps
512 .iter()
513 .find(|dep| return dep.name == "axum-extra")
514 .expect("axum-extra present");
515 assert_eq!(extra.features, vec!["cookie"]);
516 }
517
518 #[test]
519 fn no_dependencies_for_dependency_free_output() {
520 assert!(required_dependencies("pub const SERVER_URL: &str = \"https://x\";").is_empty());
521 }
522}