Skip to main content

oapi_codegen/
deps.rs

1//! Computing the external crate dependencies the generated code requires.
2//!
3//! Unlike Go — where `go mod tidy` resolves imports from the generated source
4//! automatically — Cargo never infers dependencies from `use` paths, and a path
5//! like `http::StatusCode` reveals neither the crate version nor the Cargo
6//! features a consumer must enable. The generator is the only component that
7//! knows exactly what it emitted, so it reports the crates (with versions and
8//! features) a consumer must add to `Cargo.toml`.
9//!
10//! The set is derived by scanning the generated source for the crate-root paths
11//! and method calls the emitters produce. Deriving it from the actual output
12//! (rather than re-deriving from the IR) keeps this report automatically in step
13//! with the emitters: if they stop or start referencing a crate, the report
14//! follows without a parallel rule set to maintain.
15//!
16//! The report lists every crate the generated file references. It deliberately
17//! does not read the consumer's `Cargo.toml` to prune crates already present.
18//! Interpreting a consumer manifest (workspace inheritance, dev/target scopes,
19//! feature sufficiency) is Cargo's job — so on `--install-deps` the CLI
20//! runs `cargo add`, which merges with any existing declaration.
21
22/// This crate's own manifest, embedded at compile time so the versions the
23/// report recommends always match the versions the generated code is compiled
24/// and tested against here (see [`manifest_version`]).
25const MANIFEST: &str = include_str!("../Cargo.toml");
26
27/// The version requirement declared for `crate_name` in this crate's own
28/// `[dependencies]` or `[dev-dependencies]`.
29///
30/// Deriving the recommended version from our manifest (rather than a hardcoded
31/// constant) keeps the report in lockstep with the versions the generated
32/// goldens actually compile against, so a dependency bump here updates the
33/// report automatically. Every crate the report can name is a (dev-)dependency
34/// of this crate — enforced by `reported_crates_have_manifest_versions` — so an
35/// absent entry is a programming error, not runtime input.
36fn 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
45/// Find the version requirement declared for `crate_name` in `manifest`, or
46/// `None` when it is not declared.
47///
48/// Two layouts have to work: the one-line form the repository manifest uses,
49/// and the `[dependencies.name]` table that `cargo package` rewrites it into.
50/// The published binary reads the second.
51fn 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        // Require a token boundary so `serde` does not match `serde_json`.
73        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        // `name = "x"` gives the version directly. `name = { version = "x", .. }`
81        // needs the `version` *key* located — matched as a `version =` token so a
82        // feature like `conversion` (which contains "version") is not mistaken
83        // for it.
84        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
98/// Whether `header` names the dependency table for `crate_name`, in any of the
99/// three dependency scopes `cargo package` can write.
100fn 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
111/// The text between the first pair of double quotes in `text`.
112fn 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/// A crate the generated code references, with the version requirement and Cargo
118/// features a consumer must declare in `Cargo.toml`.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct Dependency {
121    /// The crates.io crate name (for example `axum-extra`).
122    pub name: &'static str,
123    /// Recommended version requirement (taken from this crate's manifest, so it
124    /// matches the version the generated code is built and tested against).
125    pub version: &'static str,
126    /// Whether the crate's default features are needed.
127    pub default_features: bool,
128    /// Cargo features the generated code relies on, in a stable order.
129    pub features: Vec<&'static str>,
130}
131
132impl Dependency {
133    /// Render the `Cargo.toml` `[dependencies]` entry for this crate.
134    ///
135    /// A crate needing neither features nor a `default-features` change renders
136    /// as the short `name = "version"` form. Otherwise the inline-table form.
137    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    /// Render the equivalent `cargo add` command.
158    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    /// The `cargo` arguments that add this dependency, for `std::process::Command`.
170    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
183/// Inspect generated `code` and return the external crates it references, in a
184/// stable order (shared model crates, then server crates, then client crates).
185pub 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
253/// A dependency whose version is taken from this crate's manifest, needing
254/// default features and no extra features.
255fn plain(name: &'static str) -> Dependency {
256    return with_features(name, true, Vec::new());
257}
258
259/// A dependency whose version is taken from this crate's manifest, with the
260/// given default-features flag and feature list.
261fn 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        // The fixture declares `axum = { features = ["conversion"], version = "0.8.9" }`.
277        // the `conversion` feature contains "version" but must not be matched.
278        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        // The shape `cargo package` writes: a table per dependency, rather than
285        // the one line per dependency the repository manifest uses. The crates
286        // are invented, so no dependency bump reaches this.
287        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            // The package table carries a `version` of its own.
329            ("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        // The bare `name = "x"` form and the `{ version = "x", .. }` table form
430        // are both read from this crate's own Cargo.toml.
431        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        // `serde` must resolve to the `serde` line, not `serde_json`/`serde_urlencoded`.
439        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        // A code blob that trips every detection branch. if any reported crate
446        // lacked a manifest entry, `manifest_version` will panic here.
447        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    /// Independent re-implementation used only to cross-check [`manifest_version`]:
457    /// find `name` in the embedded manifest and return the first quoted string
458    /// after the `version` key (or the bare value).
459    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                // `name = "x"` -> [.., "x", ..]. `{ version = "x", features = [..] }`
467                // -> the version is the first quoted token.
468                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}