workspacer_pin/
lib.rs

1// ---------------- [ File: workspacer-pin/src/lib.rs ]
2#[macro_use] mod imports; use imports::*;
3
4x!{crate_pin_wildcard_deps}
5x!{fix_nested_tables}
6x!{get_version_of_local_dep}
7x!{insert_local_version_if_absent}
8x!{is_dependencies_key}
9x!{pick_highest_version}
10x!{pin_from_lock_or_warn}
11x!{pin_wildcard_dependencies_in_table}
12x!{workspace_pin_all_wildcard_deps}
13x!{pin_wildcard_inline_table_dependency}
14x!{pin_wildcard_string_dependency}
15x!{pin_wildcard_table_dependency}
16x!{pin_wildcards_in_doc}
17x!{replace_wildcard_version_with_local}
18x!{toml_pin_wildcard_deps}
19
20#[cfg(test)]
21mod test_end_to_end_scenario {
22    use super::*;
23    use std::{fs, path::PathBuf};
24    use tempfile::tempdir;
25    use tracing::{info, debug, trace};
26
27    /// An end-to-end test ensuring that:
28    /// - Wildcard crates like `regex = "*"` or `[dependencies.serde].version = "*"` are pinned from Cargo.lock.
29    /// - Local path dependencies without a version key get a version inserted from their local Cargo.toml.
30    /// - Existing pinned versions remain unchanged.
31    ///
32    /// We construct a temporary directory with this layout:
33    ///
34    ///   temp_dir/
35    ///     mycrate/                <-- Our main crate with the user snippet
36    ///       Cargo.toml
37    ///       Cargo.lock
38    ///     workspacer-config/      <-- Local crates that have no explicit version in snippet
39    ///       Cargo.toml
40    ///     workspacer-errors/
41    ///       Cargo.toml
42    ///     workspacer-toml-interface/
43    ///       Cargo.toml
44    ///     workspacer-3p/         <-- Local crates that already have version= in snippet
45    ///       Cargo.toml
46    ///     workspacer-interface/
47    ///       Cargo.toml
48    ///
49    /// The snippet in mycrate/Cargo.toml uses `path = "../X"`, so the pinning logic can find them.
50    #[traced_test]
51    async fn pins_local_and_wildcard_deps_from_snippet() {
52        info!("Starting test_end_to_end_scenario::pins_local_and_wildcard_deps_from_snippet");
53
54        // 1) Create the root temp directory
55        let temp = tempdir().expect("failed to create tempdir");
56        let base_path = temp.path();
57
58        // 2) Create a subdirectory for the main crate with the user snippet
59        let crate_dir = base_path.join("mycrate");
60        fs::create_dir_all(&crate_dir).expect("failed to create main crate dir");
61
62        // 3) Create subdirectories for each local crate dependency
63        //    We'll place them *alongside* mycrate, so that `mycrate` can reference them as `../some_local_dep`.
64        let local_crates_no_version = vec![
65            ("workspacer-config", "0.9.1"),
66            ("workspacer-errors", "0.9.2"),
67            ("workspacer-toml-interface", "0.9.3"),
68        ];
69        for (dir_name, ver) in &local_crates_no_version {
70            let dir = base_path.join(dir_name);
71            fs::create_dir_all(&dir).expect("failed to create local crate dir");
72            let local_cargo_toml = format!(
73                r#"[package]
74name = "{dir_name}"
75version = "{ver}"
76"#,
77            );
78            fs::write(dir.join("Cargo.toml"), local_cargo_toml)
79                .expect("failed to write local Cargo.toml");
80        }
81
82        // 4) Create local crates that already have a pinned version in the snippet
83        let pinned_locals = vec![
84            ("workspacer-3p", "0.5.9"),
85            ("workspacer-interface", "0.5.8"),
86        ];
87        for (dir_name, ver) in &pinned_locals {
88            let dir = base_path.join(dir_name);
89            fs::create_dir_all(&dir).expect("failed to create pinned local crate dir");
90            let local_cargo_toml = format!(
91                r#"[package]
92name = "{dir_name}"
93version = "{ver}"
94"#,
95            );
96            fs::write(dir.join("Cargo.toml"), local_cargo_toml)
97                .expect("failed to write pinned local Cargo.toml");
98        }
99
100        // 5) Write the user snippet Cargo.toml into `mycrate/`
101        //    Notice the path references now assume that `mycrate` uses ../workspacer-config, etc.
102        let cargo_toml_contents = r#"
103[dependencies]
104derive_builder = "0.20.2"
105regex = "*"
106
107[dependencies.serde]
108features = [ "derive" ]
109version = "*"
110
111[dependencies.serde_derive]
112version = "*"
113
114[dependencies.serde_json]
115version = "*"
116
117[dependencies.workspacer-3p]
118path = "../workspacer-3p"
119version = "0.5.0"
120
121[dependencies.workspacer-config]
122path = "../workspacer-config"
123
124[dependencies.workspacer-errors]
125path = "../workspacer-errors"
126
127[dependencies.workspacer-interface]
128path = "../workspacer-interface"
129version = "0.5.0"
130
131[dependencies.workspacer-toml-interface]
132path = "../workspacer-toml-interface"
133
134[package]
135authors = [ "klebs tpk3.mx@gmail.com" ]
136description = "A utility crate for parsing, validating, and handling Cargo.toml files as part of the Workspacer ecosystem."
137edition = "2024"
138license = "MIT OR Apache-2.0"
139name = "workspacer-toml"
140repository = "https://github.com/klebs6/klebs-general"
141version = "0.5.0"
142"#;
143        let main_cargo_toml_path = crate_dir.join("Cargo.toml");
144        fs::write(&main_cargo_toml_path, cargo_toml_contents)
145            .expect("failed to write main Cargo.toml in mycrate/");
146
147        // 6) Create a Cargo.lock in `mycrate/` that pins regex, serde, etc.
148        let cargo_lock_contents = r#"
149[[package]]
150name = "regex"
151version = "1.10.5"
152source = "registry+https://github.com/rust-lang/crates.io-index"
153
154[[package]]
155name = "serde"
156version = "1.0.153"
157source = "registry+https://github.com/rust-lang/crates.io-index"
158
159[[package]]
160name = "serde_derive"
161version = "1.0.153"
162source = "registry+https://github.com/rust-lang/crates.io-index"
163
164[[package]]
165name = "serde_json"
166version = "1.0.153"
167source = "registry+https://github.com/rust-lang/crates.io-index"
168"#;
169        fs::write(crate_dir.join("Cargo.lock"), cargo_lock_contents)
170            .expect("failed to write Cargo.lock in mycrate/");
171
172        // 7) Create a CrateHandle for `mycrate/`
173        let mut handle = match CrateHandle::new(&crate_dir).await {
174            Ok(ch) => ch,
175            Err(e) => panic!("Failed to create CrateHandle for test: {:?}", e),
176        };
177
178        // 8) Run pin_all_wildcard_dependencies()
179        let result = handle.pin_all_wildcard_dependencies().await;
180        assert!(result.is_ok(), "pin_all_wildcard_dependencies failed: {:?}", result);
181
182        // 9) Re-read the pinned Cargo.toml to verify changes
183        let pinned_toml = CargoToml::new(&main_cargo_toml_path)
184            .await
185            .expect("failed to re-open pinned Cargo.toml");
186        let doc = pinned_toml.document_clone().await
187            .expect("failed to clone pinned doc");
188
189        // Grab the top-level [dependencies] table
190        let deps_item = doc.as_table().get("dependencies")
191            .expect("no [dependencies] in pinned doc");
192        let deps = deps_item.as_table().expect("[dependencies] not a table?");
193
194        // (A) regex => 1.10.5
195        let pinned_regex = deps.get("regex").and_then(|i| i.as_str())
196            .expect("missing pinned regex");
197        assert_eq!(pinned_regex, "1.10.5", "Expected regex pinned from lockfile");
198
199        // (B) serde => 1.0.153
200        let pinned_serde = deps.get("serde")
201            .and_then(|i| i.as_table())
202            .and_then(|t| t.get("version"))
203            .and_then(|v| v.as_value())
204            .and_then(|v| v.as_str())
205            .expect("missing pinned serde version");
206        assert_eq!(pinned_serde, "1.0.153");
207
208        // (C) serde_derive => 1.0.153
209        let pinned_serde_derive = deps.get("serde_derive")
210            .and_then(|i| i.as_table())
211            .and_then(|t| t.get("version"))
212            .and_then(|v| v.as_value())
213            .and_then(|v| v.as_str())
214            .expect("missing pinned serde_derive version");
215        assert_eq!(pinned_serde_derive, "1.0.153");
216
217        // (D) serde_json => 1.0.153
218        let pinned_serde_json = deps.get("serde_json")
219            .and_then(|i| i.as_table())
220            .and_then(|t| t.get("version"))
221            .and_then(|v| v.as_value())
222            .and_then(|v| v.as_str())
223            .expect("missing pinned serde_json version");
224        assert_eq!(pinned_serde_json, "1.0.153");
225
226        // (E) Local crate with no version => "workspacer-config"
227        //     Must have inserted "0.9.1"
228        let pinned_config = deps.get("workspacer-config")
229            .and_then(|i| i.as_table())
230            .and_then(|t| t.get("version"))
231            .and_then(|v| v.as_value())
232            .and_then(|v| v.as_str())
233            .expect("missing inserted version for workspacer-config");
234        assert_eq!(pinned_config, "0.9.1");
235
236        let pinned_errors = deps.get("workspacer-errors")
237            .and_then(|i| i.as_table())
238            .and_then(|t| t.get("version"))
239            .and_then(|v| v.as_value())
240            .and_then(|v| v.as_str())
241            .expect("missing inserted version for workspacer-errors");
242        assert_eq!(pinned_errors, "0.9.2");
243
244        let pinned_toml_iface = deps.get("workspacer-toml-interface")
245            .and_then(|i| i.as_table())
246            .and_then(|t| t.get("version"))
247            .and_then(|v| v.as_value())
248            .and_then(|v| v.as_str())
249            .expect("missing inserted version for workspacer-toml-interface");
250        assert_eq!(pinned_toml_iface, "0.9.3");
251
252        // (F) Local crate with existing pinned version => e.g. "workspacer-3p" => "0.5.0"
253        //     Should remain as "0.5.0"
254        let pinned_3p = deps.get("workspacer-3p")
255            .and_then(|i| i.as_table())
256            .and_then(|t| t.get("version"))
257            .and_then(|v| v.as_value())
258            .and_then(|v| v.as_str())
259            .expect("missing pinned version for workspacer-3p");
260        assert_eq!(pinned_3p, "0.5.0");
261
262        let pinned_iface = deps.get("workspacer-interface")
263            .and_then(|i| i.as_table())
264            .and_then(|t| t.get("version"))
265            .and_then(|v| v.as_value())
266            .and_then(|v| v.as_str())
267            .expect("missing pinned version for workspacer-interface");
268        assert_eq!(pinned_iface, "0.5.0");
269
270        // (G) derive_builder = "0.20.2" => remains unchanged
271        let pinned_derive_builder = deps.get("derive_builder")
272            .and_then(|v| v.as_str())
273            .unwrap_or("<missing>");
274        assert_eq!(pinned_derive_builder, "0.20.2");
275
276        debug!("test_end_to_end_scenario::pins_local_and_wildcard_deps_from_snippet passed");
277    }
278}
279
280#[cfg(test)]
281mod test_multi_version_same_crate {
282    use super::*;
283    use tempfile::tempdir;
284    use std::{fs, path::PathBuf};
285    use tracing::{info, debug, trace};
286
287    /// This module tests scenarios where multiple crates in the same workspace
288    /// depend on different versions of the same crate (`error-tree`).
289    ///
290    /// We confirm that:
291    ///  1) The pinning logic picks the highest version if it encounters multiple
292    ///     versions in the lockfile.
293    ///  2) A user-specified pinned version in `Cargo.toml` remains unchanged, even
294    ///     if the lockfile has a higher version.
295    ///  3) A warning is emitted if the lockfile has multiple versions for the same crate.
296    ///  4) The rest of the workspace crates remain correct and consistent.
297    ///
298    /// We create ephemeral workspace directories with `batch-scribe/` and `batch-executor/`
299    /// subcrates, each referencing the workspace root. Then we create a shared
300    /// `Cargo.lock` listing two versions of `error-tree`, verifying the correct behavior
301    /// when pinning.
302
303    // A small utility to write a minimal Cargo.toml for a subcrate with optional
304    // pinned or wildcard dependency on "error-tree".
305    fn write_subcrate_cargo_toml(
306        dir: &PathBuf,
307        name: &str,
308        error_tree_spec: &str, // e.g. "*" or "0.3.6"
309    ) {
310        let contents = format!(
311            r#"
312[package]
313name = "{name}"
314version = "0.1.0"
315edition = "2021"
316
317[dependencies]
318error-tree = "{error_tree_spec}"
319        "#
320        );
321        fs::write(dir.join("Cargo.toml"), contents)
322            .expect("failed to write subcrate Cargo.toml");
323    }
324
325    /// A minimal Cargo.lock that has two versions of `error-tree`.
326    /// We also add a couple more crates just to simulate a real lockfile scenario.
327    fn multi_version_lockfile_contents() -> &'static str {
328        r#"
329[[package]]
330name = "error-tree"
331version = "0.3.6"
332source = "registry+https://github.com/rust-lang/crates.io-index"
333
334[[package]]
335name = "error-tree"
336version = "1.0.0"
337source = "registry+https://github.com/rust-lang/crates.io-index"
338
339[[package]]
340name = "something-else"
341version = "2.5.0"
342source = "registry+https://github.com/rust-lang/crates.io-index"
343"#
344    }
345
346    /// A minimal workspace Cargo.toml that references two subcrates: batch-scribe and batch-executor.
347    fn workspace_cargo_toml() -> &'static str {
348        r#"
349[workspace]
350members = [
351  "batch-scribe",
352  "batch-executor",
353]
354"#
355    }
356
357    #[traced_test]
358    #[allow(clippy::too_many_lines)]
359    async fn pins_to_highest_when_star_given() {
360        info!("Starting test_multi_version_same_crate::pins_to_highest_when_star_given");
361        // This test checks that when both crates have `error-tree = "*"`,
362        // we end up picking the highest version, i.e. "1.0.0".
363        // Also, we confirm that we see a warning for multiple versions in the lockfile
364        // (though we don't parse logs here, we trust that the code warns).
365
366        // 1) Create ephemeral workspace
367        let temp = tempdir().expect("failed to create tempdir");
368        let base_dir = temp.path().to_path_buf();
369
370        // 2) Write a top-level Cargo.toml (workspace)
371        fs::write(base_dir.join("Cargo.toml"), workspace_cargo_toml())
372            .expect("failed to write workspace Cargo.toml");
373
374        // 3) Create subdirectories for batch-scribe and batch-executor
375        let scribe_dir = base_dir.join("batch-scribe");
376        fs::create_dir_all(&scribe_dir).expect("failed to create batch-scribe dir");
377        write_subcrate_cargo_toml(&scribe_dir, "batch-scribe", "*");
378
379        let executor_dir = base_dir.join("batch-executor");
380        fs::create_dir_all(&executor_dir).expect("failed to create batch-executor dir");
381        write_subcrate_cargo_toml(&executor_dir, "batch-executor", "*");
382
383        // 4) Create top-level Cargo.lock with multiple versions
384        fs::write(base_dir.join("Cargo.lock"), multi_version_lockfile_contents())
385            .expect("failed to write multi-version Cargo.lock");
386
387        // 5) Initialize the workspace
388        let mut workspace = match Workspace::<PathBuf, CrateHandle>::new(&base_dir).await {
389            Ok(ws) => ws,
390            Err(e) => panic!("Not a valid workspace? error: {:?}", e),
391        };
392
393        // 6) Now pin
394        let result = workspace.pin_all_wildcard_dependencies().await;
395        assert!(result.is_ok(), "pin_all_wildcard_dependencies() failed: {:?}", result);
396
397        // 7) Read each subcrate's pinned Cargo.toml and confirm "error-tree" => "1.0.0"
398        for crate_dir in &[&scribe_dir, &executor_dir] {
399            let pinned_toml = crate_dir.join("Cargo.toml");
400            let pinned = CargoToml::new(&pinned_toml)
401                .await
402                .expect("failed to open pinned subcrate Cargo.toml");
403            let doc = pinned.document_clone().await
404                .expect("failed to clone pinned doc");
405            let deps_item = doc.as_table().get("dependencies")
406                .expect("no [dependencies] in pinned doc");
407            let deps_tbl = deps_item.as_table().expect("[dependencies] not table?");
408
409            // Expect error-tree pinned to "1.0.0"
410            let pinned_error_tree = deps_tbl.get("error-tree")
411                .and_then(|i| i.as_str())
412                .unwrap_or("<missing>");
413            assert_eq!(
414                pinned_error_tree,
415                "1.0.0",
416                "Expected star to pin to highest lockfile version"
417            );
418        }
419
420        debug!("test_multi_version_same_crate::pins_to_highest_when_star_given passed");
421    }
422
423    #[traced_test]
424    #[allow(clippy::too_many_lines)]
425    async fn leaves_existing_pinned_version_intact() {
426        info!("Starting test_multi_version_same_crate::leaves_existing_pinned_version_intact");
427        // This test checks if a crate explicitly pins error-tree = "0.3.6" in Cargo.toml,
428        // the pinning logic does NOT override it, even if the lockfile has a higher "1.0.0".
429        // Meanwhile, a sibling crate that uses error-tree = "*" should get pinned to "1.0.0".
430
431        // 1) ephemeral workspace
432        let temp = tempdir().expect("failed to create tempdir");
433        let base_dir = temp.path().to_path_buf();
434
435        // 2) top-level workspace
436        fs::write(base_dir.join("Cargo.toml"), workspace_cargo_toml())
437            .expect("failed to write workspace Cargo.toml");
438
439        // 3) subdirectories
440        let scribe_dir = base_dir.join("batch-scribe");
441        fs::create_dir_all(&scribe_dir).expect("failed to create batch-scribe dir");
442        // batch-scribe pins error-tree to 0.3.6 explicitly
443        write_subcrate_cargo_toml(&scribe_dir, "batch-scribe", "0.3.6");
444
445        let executor_dir = base_dir.join("batch-executor");
446        fs::create_dir_all(&executor_dir).expect("failed to create batch-executor dir");
447        // batch-executor uses star
448        write_subcrate_cargo_toml(&executor_dir, "batch-executor", "*");
449
450        // 4) multiple-version Cargo.lock
451        fs::write(base_dir.join("Cargo.lock"), multi_version_lockfile_contents())
452            .expect("failed to write multi-version Cargo.lock");
453
454        // 5) load workspace
455        let mut workspace = match Workspace::<PathBuf, CrateHandle>::new(&base_dir).await {
456            Ok(ws) => ws,
457            Err(e) => panic!("not a valid workspace? error: {:?}", e),
458        };
459
460        // 6) pin
461        let result = workspace.pin_all_wildcard_dependencies().await;
462        assert!(result.is_ok(), "pin_all_wildcard_dependencies() failed: {:?}", result);
463
464        // 7) verify subcrates
465        //    - batch-scribe => should remain pinned at 0.3.6
466        //    - batch-executor => pinned to 1.0.0 (highest)
467        {
468            let pinned_toml = scribe_dir.join("Cargo.toml");
469            let pinned = CargoToml::new(&pinned_toml)
470                .await
471                .expect("failed scribe CargoToml");
472            let doc = pinned.document_clone().await
473                .expect("scribe doc clone fail");
474            let deps = doc["dependencies"].as_table().expect("not a table");
475            let pinned_errtree = deps.get("error-tree")
476                .and_then(|i| i.as_str())
477                .unwrap_or("<missing>");
478            assert_eq!(pinned_errtree, "0.3.6",
479                "Explicit pinned version should remain unchanged");
480        }
481        {
482            let pinned_toml = executor_dir.join("Cargo.toml");
483            let pinned = CargoToml::new(&pinned_toml)
484                .await
485                .expect("failed executor CargoToml");
486            let doc = pinned.document_clone().await
487                .expect("executor doc clone fail");
488            let deps = doc["dependencies"].as_table().expect("not a table");
489            let pinned_errtree = deps.get("error-tree")
490                .and_then(|i| i.as_str())
491                .unwrap_or("<missing>");
492            assert_eq!(pinned_errtree, "1.0.0",
493                "Wildcard should pick highest from lockfile");
494        }
495
496        debug!("test_multi_version_same_crate::leaves_existing_pinned_version_intact passed");
497    }
498
499    #[traced_test]
500    #[allow(clippy::too_many_lines)]
501    async fn warns_when_multiple_versions_present() {
502        info!("Starting test_multi_version_same_crate::warns_when_multiple_versions_present");
503        // This test ensures that when multiple versions are present in the lockfile,
504        // we see a WARN-level log:
505        //
506        //   "pick_highest_version: crate 'error-tree' has multiple versions in lock map: {...}. Using the highest."
507        //
508        // We'll create a single crate with error-tree="*", to trigger picking from
509        // the set {0.3.6, 1.0.0}. We'll rely on the user code that logs a warning
510        // if .len() > 1 in pick_highest_version. We won't parse logs here but
511        // ensure the code path is triggered.
512
513        // ephemeral workspace with 1 crate using star
514        let temp = tempdir().expect("failed to create tempdir");
515        let base_dir = temp.path().to_path_buf();
516
517        // Minimal single crate scenario (so no [workspace], but we'll test the log).
518        let cargo_toml_contents = r#"
519[package]
520name = "single-test"
521version = "0.1.0"
522edition = "2021"
523
524[dependencies]
525error-tree = "*"
526"#;
527        fs::write(base_dir.join("Cargo.toml"), cargo_toml_contents)
528            .expect("failed to write single crate Cargo.toml");
529
530        // Cargo.lock
531        fs::write(base_dir.join("Cargo.lock"), multi_version_lockfile_contents())
532            .expect("failed to write multi-version Cargo.lock");
533
534        // 1) Create a CrateHandle
535        let mut handle = match CrateHandle::new(&base_dir).await {
536            Ok(ch) => ch,
537            Err(e) => panic!("Failed to create CrateHandle: {:?}", e),
538        };
539
540        // 2) pin
541        let result = handle.pin_all_wildcard_dependencies().await;
542        assert!(result.is_ok());
543
544        // 3) Confirm pinned version is the highest "1.0.0"
545        let pinned = CargoToml::new(&base_dir.join("Cargo.toml")).await
546            .expect("failed to re-open pinned single crate Cargo.toml");
547        let doc = pinned.document_clone().await
548            .expect("doc clone failed");
549        let deps = doc["dependencies"].as_table().expect("not a table");
550        let pinned_errtree = deps.get("error-tree").and_then(|i| i.as_str()).unwrap();
551        assert_eq!(pinned_errtree, "1.0.0");
552
553        // 4) We can't easily *assert* about a specific WARN log here, but we know
554        //    the code path in pick_highest_version(...) logs:
555        //       "pick_highest_version: crate 'error-tree' has multiple versions in lock map: ..."
556        //    if the set size is > 1. That's the intended behavior.
557
558        debug!("test_multi_version_same_crate::warns_when_multiple_versions_present passed");
559    }
560}