Skip to main content

shipper_core/ops/cargo/
mod.rs

1//! Cargo metadata loading + `cargo publish` invocation.
2//!
3//! Absorbed from the former `shipper-cargo` microcrate. See
4//! `docs/decrating-plan.md` §6 for the overall plan.
5
6use std::collections::{HashMap, HashSet};
7use std::env;
8use std::path::{Path, PathBuf};
9use std::time::{Duration, Instant};
10
11use anyhow::{Context, Result};
12use cargo_metadata::{Metadata, MetadataCommand, Package};
13use serde::{Deserialize, Serialize};
14pub use shipper_output_sanitizer::redact_sensitive;
15use shipper_output_sanitizer::tail_lines as sanitize_tail_lines;
16
17use crate::ops::process;
18
19#[derive(Debug, Clone)]
20pub struct CargoOutput {
21    pub exit_code: i32,
22    pub stdout_tail: String, // Last N lines (configurable, default 50)
23    pub stderr_tail: String,
24    pub duration: Duration,
25    pub timed_out: bool,
26}
27
28fn tail_lines(s: &str, n: usize) -> String {
29    sanitize_tail_lines(s, n)
30}
31
32/// Invoke `cargo yank` against the configured registry.
33///
34/// Yanks a specific `<crate>@<version>` so the registry refuses to resolve
35/// it for new dependency resolves. **This is containment, not undo**:
36/// existing lockfiles and already-downloaded copies are unaffected.
37/// See [`cargo yank` docs](https://doc.rust-lang.org/cargo/commands/cargo-yank.html).
38///
39/// Output is captured (stdout/stderr tails, exit code, elapsed). The
40/// caller is responsible for:
41/// - classifying the result via existing [`crate::runtime::execution::classify_cargo_failure`]
42/// - emitting a `PackageYanked` event if a state-dir is present
43/// - retrying on transient failures (network, 5xx, 429)
44///
45/// Used by `shipper yank` and (in follow-on PRs under #98) by
46/// `shipper plan-yank` / `shipper fix-forward` when executing a yank plan.
47pub fn cargo_yank(
48    workspace_root: &Path,
49    package_name: &str,
50    version: &str,
51    registry_name: &str,
52    output_lines: usize,
53    timeout: Option<Duration>,
54) -> Result<CargoOutput> {
55    let start = Instant::now();
56    let version_arg = format!("--version={version}");
57    let mut args: Vec<&str> = vec!["yank", package_name, &version_arg];
58
59    // If the user configured a non-default registry, pass it through.
60    if !registry_name.trim().is_empty() && registry_name != "crates-io" {
61        args.push("--registry");
62        args.push(registry_name);
63    }
64
65    let output =
66        process::run_command_with_timeout(&cargo_program(), &args, workspace_root, timeout)
67            .context("failed to execute cargo yank; is Cargo installed?")?;
68
69    Ok(CargoOutput {
70        exit_code: output.exit_code,
71        stdout_tail: tail_lines(&output.stdout, output_lines),
72        stderr_tail: tail_lines(&output.stderr, output_lines),
73        duration: start.elapsed(),
74        timed_out: output.timed_out,
75    })
76}
77
78/// Invoke `cargo install --registry <name> <crate> --version <v>` as a
79/// rehearsal smoke check (#97 PR 4).
80///
81/// Used by `shipper rehearse --smoke-install <crate>` to prove that, after
82/// a rehearsal publish, the crate actually **resolves and installs** via
83/// the registry's index — the end-to-end scenario that workspace-path
84/// dependencies defeat.
85///
86/// Installs to `install_root` (typically a tempdir) with `--force` so an
87/// already-installed version of the same crate doesn't shortcut the
88/// check. Output is captured and tailed like other cargo wrappers.
89pub fn cargo_install_smoke(
90    workspace_root: &Path,
91    package_name: &str,
92    version: &str,
93    registry_name: &str,
94    install_root: &Path,
95    output_lines: usize,
96    timeout: Option<Duration>,
97) -> Result<CargoOutput> {
98    let start = Instant::now();
99    let version_arg = format!("--version={version}");
100    let root_arg = install_root.display().to_string();
101    let mut args: Vec<&str> = vec![
102        "install",
103        package_name,
104        &version_arg,
105        "--root",
106        &root_arg,
107        "--force",
108        "--locked",
109    ];
110
111    if !registry_name.trim().is_empty() && registry_name != "crates-io" {
112        args.push("--registry");
113        args.push(registry_name);
114    }
115
116    let output =
117        process::run_command_with_timeout(&cargo_program(), &args, workspace_root, timeout)
118            .context("failed to execute cargo install; is Cargo installed?")?;
119
120    Ok(CargoOutput {
121        exit_code: output.exit_code,
122        stdout_tail: tail_lines(&output.stdout, output_lines),
123        stderr_tail: tail_lines(&output.stderr, output_lines),
124        duration: start.elapsed(),
125        timed_out: output.timed_out,
126    })
127}
128
129pub fn cargo_publish(
130    workspace_root: &Path,
131    package_name: &str,
132    registry_name: &str,
133    allow_dirty: bool,
134    no_verify: bool,
135    output_lines: usize,
136    timeout: Option<Duration>,
137) -> Result<CargoOutput> {
138    let start = Instant::now();
139    let mut args: Vec<&str> = Vec::new();
140    args.push("publish");
141    args.push("-p");
142    args.push(package_name);
143
144    // If the user configured a non-default registry, pass it through.
145    if !registry_name.trim().is_empty() && registry_name != "crates-io" {
146        args.push("--registry");
147        args.push(registry_name);
148    }
149
150    if allow_dirty {
151        args.push("--allow-dirty");
152    }
153    if no_verify {
154        args.push("--no-verify");
155    }
156
157    let output =
158        process::run_command_with_timeout(&cargo_program(), &args, workspace_root, timeout)
159            .context("failed to execute cargo publish; is Cargo installed?")?;
160
161    let exit_code = output.exit_code;
162    let stdout = output.stdout;
163    let stderr = output.stderr;
164    let timed_out = output.timed_out;
165
166    let duration = start.elapsed();
167
168    Ok(CargoOutput {
169        exit_code,
170        stdout_tail: tail_lines(&stdout, output_lines),
171        stderr_tail: tail_lines(&stderr, output_lines),
172        duration,
173        timed_out,
174    })
175}
176
177pub fn cargo_publish_dry_run_workspace(
178    workspace_root: &Path,
179    registry_name: &str,
180    allow_dirty: bool,
181    output_lines: usize,
182) -> Result<CargoOutput> {
183    let start = Instant::now();
184    let mut args: Vec<&str> = vec!["publish", "--workspace", "--dry-run"];
185
186    // If the user configured a non-default registry, pass it through.
187    if !registry_name.trim().is_empty() && registry_name != "crates-io" {
188        args.push("--registry");
189        args.push(registry_name);
190    }
191
192    if allow_dirty {
193        args.push("--allow-dirty");
194    }
195
196    let output = process::run_command_with_timeout(&cargo_program(), &args, workspace_root, None)
197        .context(
198        "failed to execute cargo publish --dry-run --workspace; is Cargo installed?",
199    )?;
200
201    let duration = start.elapsed();
202    let exit_code = output.exit_code;
203    let stdout = output.stdout;
204    let stderr = output.stderr;
205    let timed_out = output.timed_out;
206
207    Ok(CargoOutput {
208        exit_code,
209        stdout_tail: tail_lines(&stdout, output_lines),
210        stderr_tail: tail_lines(&stderr, output_lines),
211        duration,
212        timed_out,
213    })
214}
215
216pub fn cargo_publish_dry_run_package(
217    workspace_root: &Path,
218    package_name: &str,
219    registry_name: &str,
220    allow_dirty: bool,
221    output_lines: usize,
222) -> Result<CargoOutput> {
223    let start = Instant::now();
224    let mut args: Vec<&str> = vec!["publish", "-p", package_name, "--dry-run"];
225
226    if !registry_name.trim().is_empty() && registry_name != "crates-io" {
227        args.push("--registry");
228        args.push(registry_name);
229    }
230
231    if allow_dirty {
232        args.push("--allow-dirty");
233    }
234
235    let output = process::run_command_with_timeout(&cargo_program(), &args, workspace_root, None)
236        .with_context(|| {
237        format!("failed to execute cargo publish --dry-run -p {package_name}; is Cargo installed?")
238    })?;
239
240    let duration = start.elapsed();
241    let exit_code = output.exit_code;
242    let stdout = output.stdout;
243    let stderr = output.stderr;
244    let timed_out = output.timed_out;
245
246    Ok(CargoOutput {
247        exit_code,
248        stdout_tail: tail_lines(&stdout, output_lines),
249        stderr_tail: tail_lines(&stderr, output_lines),
250        duration,
251        timed_out,
252    })
253}
254
255fn cargo_program() -> String {
256    env::var("SHIPPER_CARGO_BIN").unwrap_or_else(|_| "cargo".to_string())
257}
258
259// ──────────────────────────────────────────────────────────────────────────
260// Workspace metadata (absorbed from shipper-cargo)
261// ──────────────────────────────────────────────────────────────────────────
262
263/// Load workspace metadata using `cargo metadata`.
264///
265/// Centralized here so plan-building (and any other consumer) share the
266/// same invocation and error-wrapping behavior.
267pub fn load_metadata(manifest_path: &Path) -> Result<Metadata> {
268    MetadataCommand::new()
269        .manifest_path(manifest_path)
270        .exec()
271        .context("failed to execute cargo metadata")
272}
273
274/// Workspace metadata wrapper.
275#[derive(Debug, Clone)]
276pub struct WorkspaceMetadata {
277    /// The underlying cargo metadata
278    metadata: Metadata,
279    /// Root directory of the workspace
280    workspace_root: PathBuf,
281}
282
283impl WorkspaceMetadata {
284    /// Load workspace metadata from a manifest path.
285    pub fn load(manifest_path: &Path) -> Result<Self> {
286        let metadata = MetadataCommand::new()
287            .manifest_path(manifest_path)
288            .exec()
289            .context("failed to load cargo metadata")?;
290
291        let workspace_root = metadata.workspace_root.clone().into_std_path_buf();
292
293        Ok(Self {
294            metadata,
295            workspace_root,
296        })
297    }
298
299    /// Load metadata from the current directory.
300    pub fn load_from_current_dir() -> Result<Self> {
301        let manifest_path = std::env::current_dir()
302            .context("failed to get current directory")?
303            .join("Cargo.toml");
304
305        Self::load(&manifest_path)
306    }
307
308    /// Workspace root directory.
309    pub fn workspace_root(&self) -> &Path {
310        &self.workspace_root
311    }
312
313    /// All packages in the workspace.
314    pub fn all_packages(&self) -> Vec<&Package> {
315        self.metadata.packages.iter().collect()
316    }
317
318    /// Packages that are publishable (not excluded from publishing).
319    pub fn publishable_packages(&self) -> Vec<&Package> {
320        self.metadata
321            .packages
322            .iter()
323            .filter(|p| self.is_publishable(p))
324            .collect()
325    }
326
327    /// Check if a package is publishable.
328    pub fn is_publishable(&self, package: &Package) -> bool {
329        if let Some(publish) = &package.publish
330            && publish.is_empty()
331        {
332            return false;
333        }
334
335        if package.version.to_string() == "0.0.0" {
336            return false;
337        }
338
339        true
340    }
341
342    /// Look up a package by name.
343    pub fn get_package(&self, name: &str) -> Option<&Package> {
344        self.metadata
345            .packages
346            .iter()
347            .find(|p| p.name.as_str() == name)
348    }
349
350    /// Workspace members.
351    pub fn workspace_members(&self) -> Vec<&Package> {
352        self.metadata
353            .workspace_members
354            .iter()
355            .filter_map(|id| self.metadata.packages.iter().find(|p| &p.id == id))
356            .collect()
357    }
358
359    /// Root package (if any).
360    pub fn root_package(&self) -> Option<&Package> {
361        self.metadata.root_package()
362    }
363
364    /// Workspace name (from the root package or directory name).
365    pub fn workspace_name(&self) -> &str {
366        self.root_package()
367            .map(|p| p.name.as_str())
368            .unwrap_or_else(|| {
369                self.workspace_root
370                    .file_name()
371                    .and_then(|n| n.to_str())
372                    .unwrap_or("workspace")
373            })
374    }
375
376    /// Packages in topological order (dependencies first).
377    pub fn topological_order(&self) -> Result<Vec<String>> {
378        let mut order = Vec::new();
379        let mut visited = HashSet::new();
380        let mut visiting = HashSet::new();
381
382        let dep_graph = self.build_dependency_graph();
383
384        for package in self.publishable_packages() {
385            let name = package.name.to_string();
386            self.visit_package(&name, &dep_graph, &mut visited, &mut visiting, &mut order)?;
387        }
388
389        Ok(order)
390    }
391
392    fn visit_package(
393        &self,
394        name: &str,
395        dep_graph: &HashMap<String, Vec<String>>,
396        visited: &mut HashSet<String>,
397        visiting: &mut HashSet<String>,
398        order: &mut Vec<String>,
399    ) -> Result<()> {
400        if visited.contains(name) {
401            return Ok(());
402        }
403
404        if visiting.contains(name) {
405            return Err(anyhow::anyhow!(
406                "circular dependency detected involving {}",
407                name
408            ));
409        }
410
411        visiting.insert(name.to_string());
412
413        if let Some(deps) = dep_graph.get(name) {
414            for dep in deps {
415                self.visit_package(dep, dep_graph, visited, visiting, order)?;
416            }
417        }
418
419        visiting.remove(name);
420        visited.insert(name.to_string());
421        order.push(name.to_string());
422
423        Ok(())
424    }
425
426    fn build_dependency_graph(&self) -> HashMap<String, Vec<String>> {
427        let mut graph = HashMap::new();
428
429        for package in self.publishable_packages() {
430            let deps: Vec<String> = package
431                .dependencies
432                .iter()
433                .filter_map(|dep| {
434                    self.metadata
435                        .packages
436                        .iter()
437                        .find(|p| p.name == dep.name)
438                        .map(|p| p.name.to_string())
439                })
440                .collect();
441
442            graph.insert(package.name.to_string(), deps);
443        }
444
445        graph
446    }
447}
448
449/// Simplified package information.
450#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct PackageInfo {
452    /// Package name
453    pub name: String,
454    /// Package version
455    pub version: String,
456    /// Path to package manifest
457    pub manifest_path: String,
458    /// Whether this is a workspace member
459    pub is_workspace_member: bool,
460    /// List of registry names this package can be published to (empty = all)
461    pub publish: Vec<String>,
462}
463
464impl From<&Package> for PackageInfo {
465    fn from(pkg: &Package) -> Self {
466        Self {
467            name: pkg.name.to_string(),
468            version: pkg.version.to_string(),
469            manifest_path: pkg.manifest_path.to_string(),
470            is_workspace_member: true, // Simplified
471            publish: pkg.publish.clone().unwrap_or_default(),
472        }
473    }
474}
475
476/// Get the version from a `Cargo.toml` file.
477pub fn get_version(manifest_path: &Path) -> Result<String> {
478    let metadata = WorkspaceMetadata::load(manifest_path)?;
479
480    if let Some(pkg) = metadata.root_package() {
481        return Ok(pkg.version.to_string());
482    }
483
484    Err(anyhow::anyhow!("no root package found"))
485}
486
487/// Get the package name from a `Cargo.toml` file.
488pub fn get_package_name(manifest_path: &Path) -> Result<String> {
489    let metadata = WorkspaceMetadata::load(manifest_path)?;
490
491    if let Some(pkg) = metadata.root_package() {
492        return Ok(pkg.name.to_string());
493    }
494
495    Err(anyhow::anyhow!("no root package found"))
496}
497
498/// Check if a package name is valid for crates.io.
499///
500/// Rules:
501/// - Non-empty
502/// - Cannot start with a digit or hyphen
503/// - Only ASCII lowercase letters, digits, hyphens, and underscores
504pub fn is_valid_package_name(name: &str) -> bool {
505    if name.is_empty() {
506        return false;
507    }
508
509    let chars: Vec<char> = name.chars().collect();
510
511    if chars[0].is_ascii_digit() || chars[0] == '-' {
512        return false;
513    }
514
515    chars
516        .iter()
517        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-' || *c == '_')
518}
519
520/// All workspace member package names.
521pub fn workspace_member_names(metadata: &WorkspaceMetadata) -> Vec<String> {
522    metadata
523        .workspace_members()
524        .iter()
525        .map(|p| p.name.to_string())
526        .collect()
527}
528
529#[cfg(test)]
530mod tests {
531    use std::fs;
532    use std::path::{Path, PathBuf};
533
534    use serial_test::serial;
535    use tempfile::tempdir;
536
537    use super::*;
538
539    fn write_fake_cargo(bin_dir: &Path) -> PathBuf {
540        #[cfg(windows)]
541        {
542            let path = bin_dir.join("cargo.cmd");
543            fs::write(
544                &path,
545                "@echo off\r\necho %*>\"%SHIPPER_ARGS_LOG%\"\r\necho %CD%>\"%SHIPPER_CWD_LOG%\"\r\necho fake-stdout\r\necho fake-stderr 1>&2\r\nexit /b %SHIPPER_EXIT_CODE%\r\n",
546            )
547            .expect("write fake cargo");
548            path
549        }
550
551        #[cfg(not(windows))]
552        {
553            use std::os::unix::fs::PermissionsExt;
554
555            let path = bin_dir.join("cargo");
556            fs::write(
557                &path,
558                "#!/usr/bin/env sh\nprintf '%s' \"$*\" >\"$SHIPPER_ARGS_LOG\"\npwd >\"$SHIPPER_CWD_LOG\"\necho fake-stdout\necho fake-stderr >&2\nexit \"${SHIPPER_EXIT_CODE:-0}\"\n",
559            )
560            .expect("write fake cargo");
561            let mut perms = fs::metadata(&path).expect("meta").permissions();
562            perms.set_mode(0o755);
563            fs::set_permissions(&path, perms).expect("chmod");
564            path
565        }
566    }
567
568    #[test]
569    #[serial]
570    fn cargo_publish_passes_flags_and_captures_output() {
571        let td = tempdir().expect("tempdir");
572        let bin = td.path().join("bin");
573        fs::create_dir_all(&bin).expect("mkdir");
574        let fake_cargo = write_fake_cargo(&bin);
575
576        let args_log = td.path().join("args.txt");
577        let cwd_log = td.path().join("cwd.txt");
578
579        let ws = td.path().join("workspace");
580        fs::create_dir_all(&ws).expect("mkdir ws");
581
582        temp_env::with_vars(
583            [
584                (
585                    "SHIPPER_CARGO_BIN",
586                    Some(fake_cargo.to_str().expect("fake cargo utf8")),
587                ),
588                ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
589                ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
590                ("SHIPPER_EXIT_CODE", Some("7")),
591            ],
592            || {
593                let out = cargo_publish(&ws, "my-crate", "private-reg", true, true, 50, None)
594                    .expect("publish");
595
596                assert_eq!(out.exit_code, 7);
597                assert!(out.stdout_tail.contains("fake-stdout"));
598                assert!(out.stderr_tail.contains("fake-stderr"));
599
600                let args = fs::read_to_string(&args_log).expect("args");
601                assert!(args.contains("publish"));
602                assert!(args.contains("-p my-crate"));
603                assert!(args.contains("--registry private-reg"));
604                assert!(args.contains("--allow-dirty"));
605                assert!(args.contains("--no-verify"));
606
607                let cwd = fs::read_to_string(&cwd_log).expect("cwd");
608                assert!(cwd.trim_end().ends_with("workspace"));
609            },
610        );
611    }
612
613    #[test]
614    #[serial]
615    fn cargo_publish_omits_registry_for_crates_io() {
616        let td = tempdir().expect("tempdir");
617        let bin = td.path().join("bin");
618        fs::create_dir_all(&bin).expect("mkdir");
619        let fake_cargo = write_fake_cargo(&bin);
620
621        let args_log = td.path().join("args.txt");
622        let cwd_log = td.path().join("cwd.txt");
623
624        let ws = td.path().join("workspace");
625        fs::create_dir_all(&ws).expect("mkdir ws");
626
627        temp_env::with_vars(
628            [
629                (
630                    "SHIPPER_CARGO_BIN",
631                    Some(fake_cargo.to_str().expect("fake cargo utf8")),
632                ),
633                ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
634                ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
635                ("SHIPPER_EXIT_CODE", Some("0")),
636            ],
637            || {
638                let _ = cargo_publish(&ws, "my-crate", "crates-io", false, false, 50, None)
639                    .expect("publish");
640
641                let args = fs::read_to_string(&args_log).expect("args");
642                assert!(!args.contains("--registry"));
643                assert!(!args.contains("--allow-dirty"));
644                assert!(!args.contains("--no-verify"));
645            },
646        );
647    }
648
649    #[test]
650    #[serial]
651    fn cargo_publish_errors_when_command_missing() {
652        let td = tempdir().expect("tempdir");
653        let missing = td.path().join("does-not-exist-cargo");
654
655        temp_env::with_var(
656            "SHIPPER_CARGO_BIN",
657            Some(missing.to_str().expect("utf8")),
658            || {
659                let err = cargo_publish(td.path(), "x", "crates-io", false, false, 50, None)
660                    .expect_err("must fail");
661                assert!(format!("{err:#}").contains("failed to execute cargo publish"));
662            },
663        );
664    }
665
666    #[test]
667    #[serial]
668    fn cargo_yank_passes_flags_and_captures_output() {
669        let td = tempdir().expect("tempdir");
670        let bin = td.path().join("bin");
671        fs::create_dir_all(&bin).expect("mkdir");
672        let fake_cargo = write_fake_cargo(&bin);
673
674        let args_log = td.path().join("args.txt");
675        let cwd_log = td.path().join("cwd.txt");
676
677        let ws = td.path().join("workspace");
678        fs::create_dir_all(&ws).expect("mkdir ws");
679
680        temp_env::with_vars(
681            [
682                (
683                    "SHIPPER_CARGO_BIN",
684                    Some(fake_cargo.to_str().expect("fake cargo utf8")),
685                ),
686                ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
687                ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
688                ("SHIPPER_EXIT_CODE", Some("0")),
689            ],
690            || {
691                let out =
692                    cargo_yank(&ws, "my-crate", "1.2.3", "private-reg", 50, None).expect("yank");
693
694                assert_eq!(out.exit_code, 0);
695                assert!(out.stdout_tail.contains("fake-stdout"));
696
697                let args = fs::read_to_string(&args_log).expect("args");
698                assert!(args.contains("yank"));
699                assert!(args.contains("my-crate"));
700                assert!(args.contains("--version=1.2.3"));
701                assert!(args.contains("--registry private-reg"));
702
703                let cwd = fs::read_to_string(&cwd_log).expect("cwd");
704                assert!(cwd.trim_end().ends_with("workspace"));
705            },
706        );
707    }
708
709    #[test]
710    #[serial]
711    fn cargo_yank_omits_registry_for_crates_io() {
712        let td = tempdir().expect("tempdir");
713        let bin = td.path().join("bin");
714        fs::create_dir_all(&bin).expect("mkdir");
715        let fake_cargo = write_fake_cargo(&bin);
716
717        let args_log = td.path().join("args.txt");
718        let cwd_log = td.path().join("cwd.txt");
719
720        let ws = td.path().join("workspace");
721        fs::create_dir_all(&ws).expect("mkdir ws");
722
723        temp_env::with_vars(
724            [
725                (
726                    "SHIPPER_CARGO_BIN",
727                    Some(fake_cargo.to_str().expect("fake cargo utf8")),
728                ),
729                ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
730                ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
731                ("SHIPPER_EXIT_CODE", Some("0")),
732            ],
733            || {
734                let _ = cargo_yank(&ws, "my-crate", "0.1.0", "crates-io", 50, None).expect("yank");
735
736                let args = fs::read_to_string(&args_log).expect("args");
737                assert!(!args.contains("--registry"));
738                assert!(args.contains("yank"));
739                assert!(args.contains("--version=0.1.0"));
740            },
741        );
742    }
743
744    #[test]
745    #[serial]
746    fn cargo_yank_propagates_nonzero_exit_code() {
747        let td = tempdir().expect("tempdir");
748        let bin = td.path().join("bin");
749        fs::create_dir_all(&bin).expect("mkdir");
750        let fake_cargo = write_fake_cargo(&bin);
751
752        let args_log = td.path().join("args.txt");
753        let cwd_log = td.path().join("cwd.txt");
754
755        let ws = td.path().join("workspace");
756        fs::create_dir_all(&ws).expect("mkdir ws");
757
758        temp_env::with_vars(
759            [
760                (
761                    "SHIPPER_CARGO_BIN",
762                    Some(fake_cargo.to_str().expect("fake cargo utf8")),
763                ),
764                ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
765                ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
766                ("SHIPPER_EXIT_CODE", Some("101")),
767            ],
768            || {
769                let out =
770                    cargo_yank(&ws, "my-crate", "1.2.3", "crates-io", 50, None).expect("spawn");
771                assert_eq!(out.exit_code, 101);
772                assert!(out.stderr_tail.contains("fake-stderr"));
773            },
774        );
775    }
776
777    #[test]
778    #[serial]
779    fn cargo_publish_dry_run_package_passes_flags() {
780        let td = tempdir().expect("tempdir");
781        let bin = td.path().join("bin");
782        fs::create_dir_all(&bin).expect("mkdir");
783        let fake_cargo = write_fake_cargo(&bin);
784
785        let args_log = td.path().join("args.txt");
786        let cwd_log = td.path().join("cwd.txt");
787
788        let ws = td.path().join("workspace");
789        fs::create_dir_all(&ws).expect("mkdir ws");
790
791        temp_env::with_vars(
792            [
793                (
794                    "SHIPPER_CARGO_BIN",
795                    Some(fake_cargo.to_str().expect("fake cargo utf8")),
796                ),
797                ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
798                ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
799                ("SHIPPER_EXIT_CODE", Some("0")),
800            ],
801            || {
802                let out = cargo_publish_dry_run_package(&ws, "my-crate", "private-reg", true, 50)
803                    .expect("dry-run");
804
805                assert_eq!(out.exit_code, 0);
806                let args = fs::read_to_string(&args_log).expect("args");
807                assert!(args.contains("publish"));
808                assert!(args.contains("-p my-crate"));
809                assert!(args.contains("--dry-run"));
810                assert!(args.contains("--registry private-reg"));
811                assert!(args.contains("--allow-dirty"));
812            },
813        );
814    }
815
816    // ── redact_sensitive tests ──
817
818    #[test]
819    fn redact_authorization_bearer_header() {
820        let input = "Authorization: Bearer cio_abc123secret";
821        let out = redact_sensitive(input);
822        assert_eq!(out, "Authorization: Bearer [REDACTED]");
823    }
824
825    #[test]
826    fn redact_token_assignment_quoted() {
827        let input = r#"token = "cio_mysecrettoken""#;
828        let out = redact_sensitive(input);
829        assert!(out.contains("[REDACTED]"));
830        assert!(!out.contains("cio_mysecrettoken"));
831    }
832
833    #[test]
834    fn redact_cargo_registry_token_env() {
835        let input = "CARGO_REGISTRY_TOKEN=cio_secret123";
836        let out = redact_sensitive(input);
837        assert_eq!(out, "CARGO_REGISTRY_TOKEN=[REDACTED]");
838    }
839
840    #[test]
841    fn redact_cargo_registries_named_token_env() {
842        let input = "CARGO_REGISTRIES_MY_REG_TOKEN=secret456";
843        let out = redact_sensitive(input);
844        assert_eq!(out, "CARGO_REGISTRIES_MY_REG_TOKEN=[REDACTED]");
845    }
846
847    #[test]
848    fn redact_preserves_non_sensitive_content() {
849        let input = "Compiling demo v0.1.0\nFinished release target";
850        let out = redact_sensitive(input);
851        assert_eq!(out, input);
852    }
853
854    #[test]
855    fn redact_handles_empty_input() {
856        assert_eq!(redact_sensitive(""), "");
857    }
858
859    #[test]
860    fn redact_multiple_sensitive_patterns() {
861        let input = "Authorization: Bearer tok123\nCARGO_REGISTRY_TOKEN=secret";
862        let out = redact_sensitive(input);
863        assert!(out.contains("Bearer [REDACTED]"));
864        assert!(out.contains("CARGO_REGISTRY_TOKEN=[REDACTED]"));
865        assert!(!out.contains("tok123"));
866        assert!(!out.contains("secret"));
867    }
868
869    #[test]
870    fn tail_lines_redacts_sensitive_output() {
871        let input = "line1\nline2\nAuthorization: Bearer secret_token\nline4";
872        let result = tail_lines(input, 50);
873        assert!(result.contains("Bearer [REDACTED]"));
874        assert!(!result.contains("secret_token"));
875    }
876
877    #[test]
878    fn redact_mixed_case_authorization() {
879        let input = "AUTHORIZATION: Bearer supersecret";
880        let out = redact_sensitive(input);
881        assert_eq!(out, "AUTHORIZATION: Bearer [REDACTED]");
882        assert!(!out.contains("supersecret"));
883    }
884
885    #[test]
886    fn redact_mixed_case_token() {
887        let input = r#"Token = "mysecret""#;
888        let out = redact_sensitive(input);
889        assert!(out.contains("[REDACTED]"));
890        assert!(!out.contains("mysecret"));
891    }
892
893    #[test]
894    fn redact_non_ascii_near_sensitive_pattern_no_panic() {
895        // Non-ASCII characters near the pattern should not cause a panic
896        let input = "some data \u{00e9}\u{00f1} Authorization: Bearer secret123";
897        let out = redact_sensitive(input);
898        assert!(out.contains("[REDACTED]"));
899        assert!(!out.contains("secret123"));
900    }
901
902    #[test]
903    fn redaction_matches_output_sanitizer_contract() {
904        let input = [
905            "line one",
906            "Authorization: Bearer secret_value",
907            "CARGO_REGISTRIES_PRIVATE_REG_TOKEN=secret_value",
908        ]
909        .join("\n");
910
911        assert_eq!(
912            redact_sensitive(&input),
913            shipper_output_sanitizer::redact_sensitive(&input)
914        );
915        assert_eq!(
916            tail_lines(&input, 2),
917            shipper_output_sanitizer::tail_lines(&input, 2)
918        );
919    }
920
921    // ── Token redaction: position variants ──
922
923    #[test]
924    fn redact_token_at_start_of_output() {
925        let input = "CARGO_REGISTRY_TOKEN=start_secret\nnormal line after";
926        let out = redact_sensitive(input);
927        assert!(out.starts_with("CARGO_REGISTRY_TOKEN=[REDACTED]"));
928        assert!(!out.contains("start_secret"));
929    }
930
931    #[test]
932    fn redact_token_at_end_of_output() {
933        let input = "normal line\nCARGO_REGISTRY_TOKEN=end_secret";
934        let out = redact_sensitive(input);
935        assert!(out.ends_with("CARGO_REGISTRY_TOKEN=[REDACTED]"));
936        assert!(!out.contains("end_secret"));
937    }
938
939    #[test]
940    fn redact_bearer_at_start_of_output() {
941        let input = "Authorization: Bearer first_tok\nother stuff";
942        let out = redact_sensitive(input);
943        assert!(out.starts_with("Authorization: Bearer [REDACTED]"));
944        assert!(!out.contains("first_tok"));
945    }
946
947    #[test]
948    fn redact_bearer_at_end_of_output() {
949        let input = "stuff before\nAuthorization: Bearer last_tok";
950        let out = redact_sensitive(input);
951        assert!(out.ends_with("Authorization: Bearer [REDACTED]"));
952        assert!(!out.contains("last_tok"));
953    }
954
955    #[test]
956    fn redact_token_as_only_line() {
957        let out = redact_sensitive("CARGO_REGISTRY_TOKEN=only");
958        assert_eq!(out, "CARGO_REGISTRY_TOKEN=[REDACTED]");
959    }
960
961    // ── Multiple tokens in same output ──
962
963    #[test]
964    fn redact_three_different_token_types_multiline() {
965        let input = "Authorization: Bearer bearer_secret\n\
966                      CARGO_REGISTRY_TOKEN=env_secret\n\
967                      CARGO_REGISTRIES_STAGING_TOKEN=staging_secret";
968        let out = redact_sensitive(input);
969        assert!(!out.contains("bearer_secret"));
970        assert!(!out.contains("env_secret"));
971        assert!(!out.contains("staging_secret"));
972        assert_eq!(out.matches("[REDACTED]").count(), 3);
973    }
974
975    #[test]
976    fn redact_same_token_type_repeated() {
977        let input = "CARGO_REGISTRY_TOKEN=aaa\nsome stuff\nCARGO_REGISTRY_TOKEN=bbb";
978        let out = redact_sensitive(input);
979        assert!(!out.contains("aaa"));
980        assert!(!out.contains("bbb"));
981        assert_eq!(
982            out,
983            "CARGO_REGISTRY_TOKEN=[REDACTED]\nsome stuff\nCARGO_REGISTRY_TOKEN=[REDACTED]"
984        );
985    }
986
987    #[test]
988    fn redact_multiple_named_registries() {
989        let input = "CARGO_REGISTRIES_ALPHA_TOKEN=tok_a\n\
990                      CARGO_REGISTRIES_BETA_TOKEN=tok_b\n\
991                      CARGO_REGISTRIES_GAMMA_TOKEN=tok_c";
992        let out = redact_sensitive(input);
993        assert!(!out.contains("tok_a"));
994        assert!(!out.contains("tok_b"));
995        assert!(!out.contains("tok_c"));
996        assert_eq!(out.matches("[REDACTED]").count(), 3);
997    }
998
999    // ── Unicode in cargo output ──
1000
1001    #[test]
1002    fn redact_preserves_cjk_characters() {
1003        let input = "コンパイル中: mycrate v1.0.0\n完了";
1004        let out = redact_sensitive(input);
1005        assert_eq!(out, input);
1006    }
1007
1008    #[test]
1009    fn redact_preserves_emoji_in_output() {
1010        let input = "🚀 Publishing crate 📦\n✅ Done!";
1011        let out = redact_sensitive(input);
1012        assert_eq!(out, input);
1013    }
1014
1015    #[test]
1016    fn redact_unicode_surrounding_bearer_token() {
1017        let input = "日本語テスト Authorization: Bearer abc_secret 中文テスト";
1018        let out = redact_sensitive(input);
1019        assert!(!out.contains("abc_secret"));
1020        assert!(out.contains("日本語テスト"));
1021        // Bearer redaction truncates after token, so 中文テスト is part of the token value
1022        assert!(out.contains("[REDACTED]"));
1023    }
1024
1025    #[test]
1026    fn redact_accented_characters_preserved() {
1027        let input = "Résultat: réussi\nDéploiement terminé";
1028        let out = redact_sensitive(input);
1029        assert_eq!(out, input);
1030    }
1031
1032    #[test]
1033    fn tail_lines_with_unicode_content() {
1034        let input = "first 日本語\nsecond émoji 🎉\nthird 中文";
1035        let out = tail_lines(input, 2);
1036        assert_eq!(out, "second émoji 🎉\nthird 中文");
1037    }
1038
1039    // ── Very long output lines ──
1040
1041    #[test]
1042    fn redact_very_long_line_no_token() {
1043        let long_line = "x".repeat(500_000);
1044        let out = redact_sensitive(&long_line);
1045        assert_eq!(out.len(), 500_000);
1046        assert_eq!(out, long_line);
1047    }
1048
1049    #[test]
1050    fn redact_token_embedded_in_very_long_line() {
1051        let prefix = "a".repeat(200_000);
1052        let suffix = "b".repeat(200_000);
1053        let input = format!("{prefix} CARGO_REGISTRY_TOKEN=hidden {suffix}");
1054        let out = redact_sensitive(&input);
1055        assert!(!out.contains("hidden"));
1056        assert!(out.contains("[REDACTED]"));
1057    }
1058
1059    #[test]
1060    fn tail_lines_with_very_long_lines() {
1061        let long = "y".repeat(100_000);
1062        let input = format!("short\n{long}\nlast");
1063        let out = tail_lines(&input, 2);
1064        assert!(out.contains(&long));
1065        assert!(out.contains("last"));
1066        assert!(!out.contains("short"));
1067    }
1068
1069    // ── Empty output handling ──
1070
1071    #[test]
1072    fn tail_lines_empty_string() {
1073        assert_eq!(tail_lines("", 10), "");
1074    }
1075
1076    #[test]
1077    fn tail_lines_only_newlines() {
1078        let input = "\n\n\n";
1079        let out = tail_lines(input, 2);
1080        // .lines() yields three empty strings for "\n\n\n"
1081        assert!(out.lines().all(|l| l.is_empty()));
1082    }
1083
1084    #[test]
1085    fn tail_lines_single_newline() {
1086        let out = tail_lines("\n", 5);
1087        // "\n".lines() yields one empty string
1088        assert_eq!(out, "\n");
1089    }
1090
1091    #[test]
1092    fn redact_whitespace_only_input() {
1093        let input = "   \t  ";
1094        assert_eq!(redact_sensitive(input), input);
1095    }
1096
1097    #[test]
1098    fn tail_lines_whitespace_only_lines() {
1099        let input = "  \n\t\n   ";
1100        let out = tail_lines(input, 2);
1101        assert_eq!(out, "\t\n   ");
1102    }
1103
1104    // ── Timeout behavior ──
1105
1106    #[test]
1107    #[serial]
1108    fn cargo_publish_with_timeout_captures_timed_out_flag() {
1109        let td = tempdir().expect("tempdir");
1110        let bin = td.path().join("bin");
1111        fs::create_dir_all(&bin).expect("mkdir");
1112
1113        // Write a fake cargo that sleeps, ensuring it exceeds the timeout
1114        #[cfg(windows)]
1115        {
1116            let path = bin.join("cargo.cmd");
1117            fs::write(
1118                &path,
1119                "@echo off\r\nping -n 5 127.0.0.1 >nul\r\necho should-not-see\r\n",
1120            )
1121            .expect("write slow fake cargo");
1122        }
1123        #[cfg(not(windows))]
1124        {
1125            use std::os::unix::fs::PermissionsExt;
1126            let path = bin.join("cargo");
1127            fs::write(&path, "#!/usr/bin/env sh\nsleep 10\necho should-not-see\n")
1128                .expect("write slow fake cargo");
1129            let mut perms = fs::metadata(&path).expect("meta").permissions();
1130            perms.set_mode(0o755);
1131            fs::set_permissions(&path, perms).expect("chmod");
1132        }
1133
1134        let fake_cargo_path = if cfg!(windows) {
1135            bin.join("cargo.cmd")
1136        } else {
1137            bin.join("cargo")
1138        };
1139
1140        let ws = td.path().join("workspace");
1141        fs::create_dir_all(&ws).expect("mkdir ws");
1142
1143        temp_env::with_vars(
1144            [(
1145                "SHIPPER_CARGO_BIN",
1146                Some(fake_cargo_path.to_str().expect("utf8")),
1147            )],
1148            || {
1149                let out = cargo_publish(
1150                    &ws,
1151                    "test-crate",
1152                    "crates-io",
1153                    false,
1154                    false,
1155                    50,
1156                    Some(Duration::from_secs(1)),
1157                )
1158                .expect("publish with timeout");
1159
1160                assert!(out.timed_out, "expected timed_out flag to be set");
1161                assert_eq!(out.exit_code, -1);
1162                assert!(out.stderr_tail.contains("timed out"));
1163            },
1164        );
1165    }
1166
1167    #[test]
1168    #[serial]
1169    fn cargo_publish_no_timeout_completes_normally() {
1170        let td = tempdir().expect("tempdir");
1171        let bin = td.path().join("bin");
1172        fs::create_dir_all(&bin).expect("mkdir");
1173        let fake_cargo = write_fake_cargo(&bin);
1174
1175        let args_log = td.path().join("args.txt");
1176        let cwd_log = td.path().join("cwd.txt");
1177
1178        let ws = td.path().join("workspace");
1179        fs::create_dir_all(&ws).expect("mkdir ws");
1180
1181        temp_env::with_vars(
1182            [
1183                (
1184                    "SHIPPER_CARGO_BIN",
1185                    Some(fake_cargo.to_str().expect("utf8")),
1186                ),
1187                ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
1188                ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
1189                ("SHIPPER_EXIT_CODE", Some("0")),
1190            ],
1191            || {
1192                let out = cargo_publish(&ws, "crate-x", "crates-io", false, false, 50, None)
1193                    .expect("publish");
1194                assert!(!out.timed_out, "should not time out");
1195                assert_eq!(out.exit_code, 0);
1196            },
1197        );
1198    }
1199
1200    // ── Environment variable resolution / cargo_program ──
1201
1202    #[test]
1203    #[serial]
1204    fn cargo_program_uses_env_override() {
1205        temp_env::with_var("SHIPPER_CARGO_BIN", Some("/custom/cargo"), || {
1206            assert_eq!(cargo_program(), "/custom/cargo");
1207        });
1208    }
1209
1210    #[test]
1211    #[serial]
1212    fn cargo_program_defaults_to_cargo() {
1213        temp_env::with_var("SHIPPER_CARGO_BIN", None::<&str>, || {
1214            assert_eq!(cargo_program(), "cargo");
1215        });
1216    }
1217
1218    #[test]
1219    #[serial]
1220    fn cargo_program_with_empty_env_uses_empty_string() {
1221        // Empty string is a valid env value; cargo_program returns it as-is
1222        temp_env::with_var("SHIPPER_CARGO_BIN", Some(""), || {
1223            assert_eq!(cargo_program(), "");
1224        });
1225    }
1226
1227    // ── Registry name handling ──
1228
1229    #[test]
1230    #[serial]
1231    fn cargo_publish_omits_registry_for_empty_string() {
1232        let td = tempdir().expect("tempdir");
1233        let bin = td.path().join("bin");
1234        fs::create_dir_all(&bin).expect("mkdir");
1235        let fake_cargo = write_fake_cargo(&bin);
1236
1237        let args_log = td.path().join("args.txt");
1238        let cwd_log = td.path().join("cwd.txt");
1239
1240        let ws = td.path().join("workspace");
1241        fs::create_dir_all(&ws).expect("mkdir ws");
1242
1243        temp_env::with_vars(
1244            [
1245                (
1246                    "SHIPPER_CARGO_BIN",
1247                    Some(fake_cargo.to_str().expect("utf8")),
1248                ),
1249                ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
1250                ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
1251                ("SHIPPER_EXIT_CODE", Some("0")),
1252            ],
1253            || {
1254                let _ = cargo_publish(&ws, "crate-y", "", false, false, 50, None).expect("publish");
1255                let args = fs::read_to_string(&args_log).expect("args");
1256                assert!(
1257                    !args.contains("--registry"),
1258                    "empty registry name should not produce --registry flag"
1259                );
1260            },
1261        );
1262    }
1263
1264    #[test]
1265    #[serial]
1266    fn cargo_publish_omits_registry_for_whitespace_only() {
1267        let td = tempdir().expect("tempdir");
1268        let bin = td.path().join("bin");
1269        fs::create_dir_all(&bin).expect("mkdir");
1270        let fake_cargo = write_fake_cargo(&bin);
1271
1272        let args_log = td.path().join("args.txt");
1273        let cwd_log = td.path().join("cwd.txt");
1274
1275        let ws = td.path().join("workspace");
1276        fs::create_dir_all(&ws).expect("mkdir ws");
1277
1278        temp_env::with_vars(
1279            [
1280                (
1281                    "SHIPPER_CARGO_BIN",
1282                    Some(fake_cargo.to_str().expect("utf8")),
1283                ),
1284                ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
1285                ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
1286                ("SHIPPER_EXIT_CODE", Some("0")),
1287            ],
1288            || {
1289                let _ =
1290                    cargo_publish(&ws, "crate-z", "   ", false, false, 50, None).expect("publish");
1291                let args = fs::read_to_string(&args_log).expect("args");
1292                assert!(
1293                    !args.contains("--registry"),
1294                    "whitespace-only registry name should not produce --registry flag"
1295                );
1296            },
1297        );
1298    }
1299
1300    // ── Dry-run workspace variant ──
1301
1302    #[test]
1303    #[serial]
1304    fn cargo_publish_dry_run_workspace_passes_flags() {
1305        let td = tempdir().expect("tempdir");
1306        let bin = td.path().join("bin");
1307        fs::create_dir_all(&bin).expect("mkdir");
1308        let fake_cargo = write_fake_cargo(&bin);
1309
1310        let args_log = td.path().join("args.txt");
1311        let cwd_log = td.path().join("cwd.txt");
1312
1313        let ws = td.path().join("workspace");
1314        fs::create_dir_all(&ws).expect("mkdir ws");
1315
1316        temp_env::with_vars(
1317            [
1318                (
1319                    "SHIPPER_CARGO_BIN",
1320                    Some(fake_cargo.to_str().expect("utf8")),
1321                ),
1322                ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
1323                ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
1324                ("SHIPPER_EXIT_CODE", Some("0")),
1325            ],
1326            || {
1327                let out =
1328                    cargo_publish_dry_run_workspace(&ws, "my-reg", true, 50).expect("dry-run ws");
1329
1330                assert_eq!(out.exit_code, 0);
1331                let args = fs::read_to_string(&args_log).expect("args");
1332                assert!(args.contains("publish"));
1333                assert!(args.contains("--workspace"));
1334                assert!(args.contains("--dry-run"));
1335                assert!(args.contains("--registry my-reg"));
1336                assert!(args.contains("--allow-dirty"));
1337            },
1338        );
1339    }
1340
1341    #[test]
1342    #[serial]
1343    fn cargo_publish_dry_run_workspace_omits_registry_for_crates_io() {
1344        let td = tempdir().expect("tempdir");
1345        let bin = td.path().join("bin");
1346        fs::create_dir_all(&bin).expect("mkdir");
1347        let fake_cargo = write_fake_cargo(&bin);
1348
1349        let args_log = td.path().join("args.txt");
1350        let cwd_log = td.path().join("cwd.txt");
1351
1352        let ws = td.path().join("workspace");
1353        fs::create_dir_all(&ws).expect("mkdir ws");
1354
1355        temp_env::with_vars(
1356            [
1357                (
1358                    "SHIPPER_CARGO_BIN",
1359                    Some(fake_cargo.to_str().expect("utf8")),
1360                ),
1361                ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
1362                ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
1363                ("SHIPPER_EXIT_CODE", Some("0")),
1364            ],
1365            || {
1366                let _ =
1367                    cargo_publish_dry_run_workspace(&ws, "crates-io", false, 50).expect("dry-run");
1368                let args = fs::read_to_string(&args_log).expect("args");
1369                assert!(!args.contains("--registry"));
1370                assert!(!args.contains("--allow-dirty"));
1371            },
1372        );
1373    }
1374
1375    #[test]
1376    #[serial]
1377    fn cargo_publish_dry_run_workspace_errors_when_command_missing() {
1378        let td = tempdir().expect("tempdir");
1379        let missing = td.path().join("nonexistent-cargo");
1380
1381        temp_env::with_var(
1382            "SHIPPER_CARGO_BIN",
1383            Some(missing.to_str().expect("utf8")),
1384            || {
1385                let err = cargo_publish_dry_run_workspace(td.path(), "crates-io", false, 50)
1386                    .expect_err("must fail");
1387                assert!(format!("{err:#}").contains("failed to execute cargo publish"));
1388            },
1389        );
1390    }
1391
1392    // ── Dry-run package variant additional tests ──
1393
1394    #[test]
1395    #[serial]
1396    fn cargo_publish_dry_run_package_omits_registry_for_crates_io() {
1397        let td = tempdir().expect("tempdir");
1398        let bin = td.path().join("bin");
1399        fs::create_dir_all(&bin).expect("mkdir");
1400        let fake_cargo = write_fake_cargo(&bin);
1401
1402        let args_log = td.path().join("args.txt");
1403        let cwd_log = td.path().join("cwd.txt");
1404
1405        let ws = td.path().join("workspace");
1406        fs::create_dir_all(&ws).expect("mkdir ws");
1407
1408        temp_env::with_vars(
1409            [
1410                (
1411                    "SHIPPER_CARGO_BIN",
1412                    Some(fake_cargo.to_str().expect("utf8")),
1413                ),
1414                ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
1415                ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
1416                ("SHIPPER_EXIT_CODE", Some("0")),
1417            ],
1418            || {
1419                let _ = cargo_publish_dry_run_package(&ws, "pkg", "crates-io", false, 50)
1420                    .expect("dry-run");
1421                let args = fs::read_to_string(&args_log).expect("args");
1422                assert!(!args.contains("--registry"));
1423                assert!(!args.contains("--allow-dirty"));
1424            },
1425        );
1426    }
1427
1428    #[test]
1429    #[serial]
1430    fn cargo_publish_dry_run_package_errors_when_command_missing() {
1431        let td = tempdir().expect("tempdir");
1432        let missing = td.path().join("nonexistent-cargo");
1433
1434        temp_env::with_var(
1435            "SHIPPER_CARGO_BIN",
1436            Some(missing.to_str().expect("utf8")),
1437            || {
1438                let err = cargo_publish_dry_run_package(td.path(), "pkg", "crates-io", false, 50)
1439                    .expect_err("must fail");
1440                let msg = format!("{err:#}");
1441                assert!(msg.contains("failed to execute cargo publish --dry-run -p pkg"));
1442            },
1443        );
1444    }
1445
1446    // ── Output line truncation via tail_lines ──
1447
1448    #[test]
1449    fn tail_lines_truncates_to_requested_count() {
1450        let lines: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
1451        let input = lines.join("\n");
1452        let out = tail_lines(&input, 5);
1453        assert_eq!(out.lines().count(), 5);
1454        assert!(out.contains("line 95"));
1455        assert!(out.contains("line 99"));
1456        assert!(!out.contains("line 94"));
1457    }
1458
1459    #[test]
1460    fn tail_lines_one_line_requested() {
1461        let input = "first\nsecond\nthird";
1462        let out = tail_lines(input, 1);
1463        assert_eq!(out, "third");
1464    }
1465
1466    #[test]
1467    fn tail_lines_redacts_token_in_last_line() {
1468        let input = "safe1\nsafe2\nCARGO_REGISTRY_TOKEN=leaked";
1469        let out = tail_lines(input, 2);
1470        assert!(!out.contains("leaked"));
1471        assert!(out.contains("CARGO_REGISTRY_TOKEN=[REDACTED]"));
1472    }
1473
1474    #[test]
1475    fn tail_lines_token_outside_window_not_visible() {
1476        let input = "CARGO_REGISTRY_TOKEN=secret\nsafe1\nsafe2";
1477        let out = tail_lines(input, 2);
1478        assert!(!out.contains("secret"));
1479        assert!(!out.contains("CARGO_REGISTRY_TOKEN"));
1480        assert_eq!(out, "safe1\nsafe2");
1481    }
1482
1483    // ── Error message patterns ──
1484
1485    #[test]
1486    fn redact_token_in_error_message_context() {
1487        let input =
1488            "error: failed to publish: token = \"cio_leakedsecret\" was rejected by registry";
1489        let out = redact_sensitive(input);
1490        assert!(!out.contains("cio_leakedsecret"));
1491        assert!(out.contains("[REDACTED]"));
1492    }
1493
1494    #[test]
1495    fn redact_bearer_in_http_error() {
1496        let input =
1497            "error: HTTP 403 Forbidden\nAuthorization: Bearer expired_tok_abc\nBody: access denied";
1498        let out = redact_sensitive(input);
1499        assert!(!out.contains("expired_tok_abc"));
1500        assert!(out.contains("error: HTTP 403 Forbidden"));
1501        assert!(out.contains("Body: access denied"));
1502    }
1503
1504    #[test]
1505    fn redact_registry_token_in_debug_output() {
1506        let input = "debug: env CARGO_REGISTRY_TOKEN=cio_debug_tok resolved from environment";
1507        let out = redact_sensitive(input);
1508        assert!(!out.contains("cio_debug_tok"));
1509        assert!(out.contains("[REDACTED]"));
1510    }
1511
1512    // ── CargoOutput struct behavior ──
1513
1514    #[test]
1515    fn cargo_output_default_fields() {
1516        let out = CargoOutput {
1517            exit_code: 0,
1518            stdout_tail: String::new(),
1519            stderr_tail: String::new(),
1520            duration: Duration::from_secs(0),
1521            timed_out: false,
1522        };
1523        assert_eq!(out.exit_code, 0);
1524        assert!(out.stdout_tail.is_empty());
1525        assert!(out.stderr_tail.is_empty());
1526        assert!(!out.timed_out);
1527    }
1528
1529    #[test]
1530    fn cargo_output_clone_is_independent() {
1531        let out = CargoOutput {
1532            exit_code: 42,
1533            stdout_tail: "hello".to_string(),
1534            stderr_tail: "world".to_string(),
1535            duration: Duration::from_millis(500),
1536            timed_out: true,
1537        };
1538        let cloned = out.clone();
1539        assert_eq!(cloned.exit_code, out.exit_code);
1540        assert_eq!(cloned.stdout_tail, out.stdout_tail);
1541        assert_eq!(cloned.stderr_tail, out.stderr_tail);
1542        assert_eq!(cloned.timed_out, out.timed_out);
1543    }
1544
1545    #[test]
1546    fn cargo_output_debug_format() {
1547        let out = CargoOutput {
1548            exit_code: 1,
1549            stdout_tail: "out".to_string(),
1550            stderr_tail: "err".to_string(),
1551            duration: Duration::from_secs(1),
1552            timed_out: false,
1553        };
1554        let debug = format!("{out:?}");
1555        assert!(debug.contains("CargoOutput"));
1556        assert!(debug.contains("exit_code: 1"));
1557    }
1558
1559    // ── Redaction idempotency ──
1560
1561    #[test]
1562    fn redact_is_idempotent_bearer() {
1563        let input = "Authorization: Bearer secret_value";
1564        let once = redact_sensitive(input);
1565        let twice = redact_sensitive(&once);
1566        assert_eq!(once, twice);
1567    }
1568
1569    #[test]
1570    fn redact_is_idempotent_env_token() {
1571        let input = "CARGO_REGISTRY_TOKEN=secret";
1572        let once = redact_sensitive(input);
1573        let twice = redact_sensitive(&once);
1574        assert_eq!(once, twice);
1575    }
1576
1577    #[test]
1578    fn redact_is_idempotent_token_assignment() {
1579        let input = r#"token = "secret_value""#;
1580        let once = redact_sensitive(input);
1581        let twice = redact_sensitive(&once);
1582        assert_eq!(once, twice);
1583    }
1584
1585    // ── Non-default exit codes ──
1586
1587    #[test]
1588    #[serial]
1589    fn cargo_publish_captures_nonzero_exit_code() {
1590        let td = tempdir().expect("tempdir");
1591        let bin = td.path().join("bin");
1592        fs::create_dir_all(&bin).expect("mkdir");
1593        let fake_cargo = write_fake_cargo(&bin);
1594
1595        let args_log = td.path().join("args.txt");
1596        let cwd_log = td.path().join("cwd.txt");
1597
1598        let ws = td.path().join("workspace");
1599        fs::create_dir_all(&ws).expect("mkdir ws");
1600
1601        temp_env::with_vars(
1602            [
1603                (
1604                    "SHIPPER_CARGO_BIN",
1605                    Some(fake_cargo.to_str().expect("utf8")),
1606                ),
1607                ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
1608                ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
1609                ("SHIPPER_EXIT_CODE", Some("101")),
1610            ],
1611            || {
1612                let out = cargo_publish(&ws, "crate-a", "crates-io", false, false, 50, None)
1613                    .expect("publish");
1614                assert_eq!(out.exit_code, 101);
1615                assert!(!out.timed_out);
1616            },
1617        );
1618    }
1619
1620    // ── tail_lines with output_lines = 0 (edge case for output truncation) ──
1621
1622    #[test]
1623    fn tail_lines_zero_returns_empty() {
1624        let input = "line1\nline2\nline3";
1625        assert_eq!(tail_lines(input, 0), "");
1626    }
1627
1628    // ── Redaction with special characters in token values ──
1629
1630    #[test]
1631    fn redact_token_with_special_chars() {
1632        let input = "CARGO_REGISTRY_TOKEN=abc!@#$%^&*()_+-=[]{}|;:',.<>?/";
1633        let out = redact_sensitive(input);
1634        assert_eq!(out, "CARGO_REGISTRY_TOKEN=[REDACTED]");
1635    }
1636
1637    #[test]
1638    fn redact_bearer_with_base64_padding() {
1639        let input = "Authorization: Bearer eyJhbGciOiJSUzI1NiJ9.payload.sig==";
1640        let out = redact_sensitive(input);
1641        assert_eq!(out, "Authorization: Bearer [REDACTED]");
1642    }
1643
1644    #[test]
1645    fn redact_token_value_with_newline_escapes() {
1646        // Token value should not contain literal newlines, but escaped ones may appear
1647        let input = r#"token = "secret\nwith\nescapes""#;
1648        let out = redact_sensitive(input);
1649        assert!(out.contains("[REDACTED]"));
1650        assert!(!out.contains("secret\\nwith"));
1651    }
1652
1653    // ── Absorbed from shipper-cargo: is_valid_package_name ──
1654
1655    #[test]
1656    fn is_valid_package_name_valid() {
1657        assert!(is_valid_package_name("my-crate"));
1658        assert!(is_valid_package_name("my_crate"));
1659        assert!(is_valid_package_name("mycrate"));
1660        assert!(is_valid_package_name("my-crate-123"));
1661        assert!(is_valid_package_name("a"));
1662    }
1663
1664    #[test]
1665    fn is_valid_package_name_invalid() {
1666        assert!(!is_valid_package_name(""));
1667        assert!(!is_valid_package_name("123-crate")); // starts with digit
1668        assert!(!is_valid_package_name("-crate")); // starts with hyphen
1669        assert!(!is_valid_package_name("MyCrate")); // uppercase
1670        assert!(!is_valid_package_name("my.crate")); // dot not allowed
1671        assert!(!is_valid_package_name("my crate")); // space not allowed
1672    }
1673
1674    #[test]
1675    fn is_valid_package_name_underscore_start() {
1676        assert!(is_valid_package_name("_"));
1677        assert!(is_valid_package_name("__"));
1678        assert!(is_valid_package_name("_my_crate"));
1679    }
1680
1681    #[test]
1682    fn is_valid_package_name_mixed_separators() {
1683        assert!(is_valid_package_name("my-cool_crate"));
1684        assert!(is_valid_package_name("a-b_c"));
1685    }
1686
1687    #[test]
1688    fn is_valid_package_name_numbers_after_first() {
1689        assert!(is_valid_package_name("a123"));
1690        assert!(is_valid_package_name("crate99"));
1691        assert!(is_valid_package_name("my-123-crate"));
1692    }
1693
1694    #[test]
1695    fn is_valid_package_name_trailing_hyphen() {
1696        assert!(is_valid_package_name("crate-"));
1697    }
1698
1699    #[test]
1700    fn is_valid_package_name_trailing_underscore() {
1701        assert!(is_valid_package_name("crate_"));
1702    }
1703
1704    #[test]
1705    fn is_valid_package_name_rejects_uppercase_variants() {
1706        assert!(!is_valid_package_name("MyPackage"));
1707        assert!(!is_valid_package_name("ALLCAPS"));
1708        assert!(!is_valid_package_name("camelCase"));
1709    }
1710
1711    #[test]
1712    fn is_valid_package_name_rejects_special_characters() {
1713        assert!(!is_valid_package_name("my@crate"));
1714        assert!(!is_valid_package_name("my!crate"));
1715        assert!(!is_valid_package_name("my#crate"));
1716        assert!(!is_valid_package_name("my$crate"));
1717        assert!(!is_valid_package_name("my/crate"));
1718        assert!(!is_valid_package_name("my\\crate"));
1719        assert!(!is_valid_package_name("my+crate"));
1720        assert!(!is_valid_package_name("my crate"));
1721    }
1722
1723    #[test]
1724    fn is_valid_package_name_single_underscore() {
1725        assert!(is_valid_package_name("_"));
1726    }
1727
1728    #[test]
1729    fn is_valid_package_name_rejects_unicode() {
1730        assert!(!is_valid_package_name("my-crête"));
1731        assert!(!is_valid_package_name("日本語"));
1732        assert!(!is_valid_package_name("café"));
1733    }
1734
1735    #[test]
1736    fn is_valid_package_name_max_length_valid() {
1737        let name = "a".repeat(100);
1738        assert!(is_valid_package_name(&name));
1739    }
1740
1741    #[test]
1742    fn is_valid_package_name_consecutive_hyphens() {
1743        assert!(is_valid_package_name("my--crate"));
1744    }
1745
1746    #[test]
1747    fn is_valid_package_name_consecutive_underscores() {
1748        assert!(is_valid_package_name("my__crate"));
1749    }
1750
1751    // ── Absorbed from shipper-cargo: PackageInfo ──
1752
1753    #[test]
1754    fn package_info_from_package() {
1755        let info = PackageInfo {
1756            name: "test".to_string(),
1757            version: "1.0.0".to_string(),
1758            manifest_path: "Cargo.toml".to_string(),
1759            is_workspace_member: true,
1760            publish: vec![],
1761        };
1762
1763        assert_eq!(info.name, "test");
1764        assert_eq!(info.version, "1.0.0");
1765    }
1766
1767    #[test]
1768    fn package_info_serialization() {
1769        let info = PackageInfo {
1770            name: "my-crate".to_string(),
1771            version: "2.0.0".to_string(),
1772            manifest_path: "/path/to/Cargo.toml".to_string(),
1773            is_workspace_member: true,
1774            publish: vec!["crates-io".to_string()],
1775        };
1776
1777        let json = serde_json::to_string(&info).expect("serialize");
1778        assert!(json.contains("\"name\":\"my-crate\""));
1779        assert!(json.contains("\"version\":\"2.0.0\""));
1780    }
1781
1782    #[test]
1783    fn package_info_deserialization_roundtrip() {
1784        let info = PackageInfo {
1785            name: "my-crate".to_string(),
1786            version: "2.0.0".to_string(),
1787            manifest_path: "/path/to/Cargo.toml".to_string(),
1788            is_workspace_member: true,
1789            publish: vec!["crates-io".to_string()],
1790        };
1791
1792        let json = serde_json::to_string(&info).expect("serialize");
1793        let deserialized: PackageInfo = serde_json::from_str(&json).expect("deserialize");
1794        assert_eq!(deserialized.name, info.name);
1795        assert_eq!(deserialized.version, info.version);
1796        assert_eq!(deserialized.manifest_path, info.manifest_path);
1797        assert_eq!(deserialized.is_workspace_member, info.is_workspace_member);
1798        assert_eq!(deserialized.publish, info.publish);
1799    }
1800
1801    #[test]
1802    fn package_info_empty_publish_means_all_registries() {
1803        let info = PackageInfo {
1804            name: "my-crate".to_string(),
1805            version: "1.0.0".to_string(),
1806            manifest_path: "Cargo.toml".to_string(),
1807            is_workspace_member: true,
1808            publish: vec![],
1809        };
1810        assert!(info.publish.is_empty());
1811    }
1812
1813    #[test]
1814    fn package_info_multiple_registries() {
1815        let info = PackageInfo {
1816            name: "my-crate".to_string(),
1817            version: "1.0.0".to_string(),
1818            manifest_path: "Cargo.toml".to_string(),
1819            is_workspace_member: false,
1820            publish: vec!["crates-io".to_string(), "my-registry".to_string()],
1821        };
1822        assert_eq!(info.publish.len(), 2);
1823        assert!(!info.is_workspace_member);
1824    }
1825
1826    #[test]
1827    fn package_info_pretty_json_roundtrip() {
1828        let info = PackageInfo {
1829            name: "complex-name_123".to_string(),
1830            version: "0.1.0-beta.1".to_string(),
1831            manifest_path: "crates/foo/Cargo.toml".to_string(),
1832            is_workspace_member: true,
1833            publish: vec![],
1834        };
1835        let pretty = serde_json::to_string_pretty(&info).expect("pretty serialize");
1836        let back: PackageInfo = serde_json::from_str(&pretty).expect("deserialize");
1837        assert_eq!(back.name, info.name);
1838        assert_eq!(back.version, info.version);
1839    }
1840
1841    #[test]
1842    fn package_info_with_empty_fields() {
1843        let info = PackageInfo {
1844            name: String::new(),
1845            version: String::new(),
1846            manifest_path: String::new(),
1847            is_workspace_member: false,
1848            publish: vec![],
1849        };
1850        let json = serde_json::to_string(&info).expect("serialize");
1851        let back: PackageInfo = serde_json::from_str(&json).expect("deserialize");
1852        assert_eq!(back.name, "");
1853        assert_eq!(back.version, "");
1854    }
1855
1856    #[test]
1857    fn package_info_json_contains_all_fields() {
1858        let info = PackageInfo {
1859            name: "test-pkg".to_string(),
1860            version: "1.0.0".to_string(),
1861            manifest_path: "/some/path/Cargo.toml".to_string(),
1862            is_workspace_member: false,
1863            publish: vec!["custom-registry".to_string()],
1864        };
1865        let json = serde_json::to_string(&info).expect("serialize");
1866        assert!(json.contains("\"is_workspace_member\":false"));
1867        assert!(json.contains("\"publish\":[\"custom-registry\"]"));
1868    }
1869
1870    // ── Absorbed from shipper-cargo: WorkspaceMetadata ──
1871
1872    #[test]
1873    fn workspace_metadata_loads_current_workspace() {
1874        let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1875
1876        assert!(!metadata.all_packages().is_empty());
1877        assert!(metadata.workspace_root().exists());
1878    }
1879
1880    #[test]
1881    fn workspace_metadata_gets_package() {
1882        let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1883
1884        let pkg = metadata.get_package("shipper");
1885        assert!(pkg.is_some());
1886    }
1887
1888    #[test]
1889    fn workspace_metadata_topological_order() {
1890        let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1891
1892        let result = metadata.topological_order();
1893        // Just check it doesn't panic - the result depends on the workspace structure
1894        assert!(result.is_ok() || result.is_err());
1895    }
1896
1897    #[test]
1898    fn workspace_metadata_all_packages_has_multiple() {
1899        let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1900        let all = metadata.all_packages();
1901        assert!(all.len() > 1, "workspace should have multiple packages");
1902    }
1903
1904    #[test]
1905    fn workspace_metadata_workspace_members_nonempty() {
1906        let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1907        let members = metadata.workspace_members();
1908        assert!(!members.is_empty(), "workspace should have members");
1909    }
1910
1911    #[test]
1912    fn workspace_metadata_get_nonexistent_package_returns_none() {
1913        let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1914        assert!(
1915            metadata
1916                .get_package("nonexistent-package-xyz-12345")
1917                .is_none()
1918        );
1919    }
1920
1921    #[test]
1922    fn workspace_metadata_workspace_name_not_empty() {
1923        let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1924        assert!(!metadata.workspace_name().is_empty());
1925    }
1926
1927    #[test]
1928    fn workspace_metadata_workspace_root_is_directory() {
1929        let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1930        assert!(metadata.workspace_root().is_dir());
1931    }
1932
1933    #[test]
1934    fn workspace_metadata_publishable_packages_subset_of_all() {
1935        let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1936        let all = metadata.all_packages();
1937        let publishable = metadata.publishable_packages();
1938        assert!(
1939            publishable.len() <= all.len(),
1940            "publishable ({}) should be <= all ({})",
1941            publishable.len(),
1942            all.len()
1943        );
1944    }
1945
1946    #[test]
1947    fn workspace_member_names_contains_known_crates() {
1948        let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1949        let names = workspace_member_names(&metadata);
1950        assert!(
1951            names.contains(&"shipper".to_string()),
1952            "should contain shipper, got: {names:?}"
1953        );
1954    }
1955
1956    #[test]
1957    fn workspace_metadata_topological_order_contains_publishable() {
1958        let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1959        if let Ok(order) = metadata.topological_order() {
1960            let publishable: Vec<String> = metadata
1961                .publishable_packages()
1962                .iter()
1963                .map(|p| p.name.to_string())
1964                .collect();
1965            for name in &publishable {
1966                assert!(
1967                    order.contains(name),
1968                    "topological order should contain publishable package {name}"
1969                );
1970            }
1971        }
1972    }
1973
1974    // ── Absorbed from shipper-cargo: load_metadata ──
1975
1976    #[test]
1977    fn load_metadata_returns_valid_metadata() {
1978        let manifest = std::env::current_dir()
1979            .unwrap()
1980            .join("..")
1981            .join("..")
1982            .join("Cargo.toml");
1983        let metadata = load_metadata(&manifest).expect("load metadata");
1984        assert!(!metadata.packages.is_empty());
1985    }
1986
1987    #[test]
1988    fn load_metadata_fails_for_nonexistent_path() {
1989        let result = load_metadata(Path::new("/nonexistent/Cargo.toml"));
1990        assert!(result.is_err());
1991    }
1992
1993    // ── Absorbed proptests ──
1994
1995    mod proptests_absorbed {
1996        use super::*;
1997        use proptest::prelude::*;
1998
1999        proptest! {
2000            #[test]
2001            fn valid_package_name_only_has_valid_chars(
2002                name in "[a-z_][a-z0-9_-]{0,30}",
2003            ) {
2004                prop_assert!(is_valid_package_name(&name));
2005            }
2006
2007            #[test]
2008            fn package_name_starting_with_digit_is_invalid(
2009                rest in "[a-z0-9_-]{0,20}",
2010                digit in proptest::char::range('0', '9'),
2011            ) {
2012                let name = format!("{digit}{rest}");
2013                prop_assert!(!is_valid_package_name(&name));
2014            }
2015
2016            #[test]
2017            fn package_name_starting_with_hyphen_is_invalid(
2018                rest in "[a-z0-9_-]{0,20}",
2019            ) {
2020                let name = format!("-{rest}");
2021                prop_assert!(!is_valid_package_name(&name));
2022            }
2023
2024            #[test]
2025            fn package_name_with_uppercase_is_invalid(
2026                prefix in "[a-z_][a-z0-9_-]{0,10}",
2027                upper in "[A-Z]",
2028                suffix in "[a-z0-9_-]{0,10}",
2029            ) {
2030                let name = format!("{prefix}{upper}{suffix}");
2031                prop_assert!(!is_valid_package_name(&name));
2032            }
2033
2034            #[test]
2035            fn package_info_serde_roundtrip(
2036                name in "[a-z][a-z0-9_-]{0,20}",
2037                version in "[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}",
2038                manifest in "\\PC{1,50}",
2039                is_member in any::<bool>(),
2040            ) {
2041                let info = PackageInfo {
2042                    name: name.clone(),
2043                    version: version.clone(),
2044                    manifest_path: manifest.clone(),
2045                    is_workspace_member: is_member,
2046                    publish: vec![],
2047                };
2048                let json = serde_json::to_string(&info).unwrap();
2049                let back: PackageInfo = serde_json::from_str(&json).unwrap();
2050                prop_assert_eq!(&back.name, &name);
2051                prop_assert_eq!(&back.version, &version);
2052                prop_assert_eq!(&back.manifest_path, &manifest);
2053                prop_assert_eq!(back.is_workspace_member, is_member);
2054                prop_assert!(back.publish.is_empty());
2055            }
2056
2057            #[test]
2058            fn package_info_with_registries_roundtrip(
2059                reg_count in 0usize..5,
2060                name in "[a-z][a-z0-9-]{0,10}",
2061            ) {
2062                let registries: Vec<String> = (0..reg_count)
2063                    .map(|i| format!("registry-{i}"))
2064                    .collect();
2065                let info = PackageInfo {
2066                    name,
2067                    version: "1.0.0".to_string(),
2068                    manifest_path: "Cargo.toml".to_string(),
2069                    is_workspace_member: true,
2070                    publish: registries.clone(),
2071                };
2072                let json = serde_json::to_string(&info).unwrap();
2073                let back: PackageInfo = serde_json::from_str(&json).unwrap();
2074                prop_assert_eq!(back.publish.len(), registries.len());
2075                prop_assert_eq!(&back.publish, &registries);
2076            }
2077
2078            #[test]
2079            fn is_valid_package_name_rejects_any_non_ascii(
2080                prefix in "[a-z_][a-z0-9_-]{0,5}",
2081                ch in proptest::char::range('\u{0080}', '\u{FFFF}'),
2082                suffix in "[a-z0-9_-]{0,5}",
2083            ) {
2084                let name = format!("{prefix}{ch}{suffix}");
2085                prop_assert!(!is_valid_package_name(&name));
2086            }
2087
2088            #[test]
2089            fn package_info_json_always_contains_name(
2090                name in "[a-z][a-z0-9-]{0,15}",
2091            ) {
2092                let info = PackageInfo {
2093                    name: name.clone(),
2094                    version: "1.0.0".to_string(),
2095                    manifest_path: "Cargo.toml".to_string(),
2096                    is_workspace_member: true,
2097                    publish: vec![],
2098                };
2099                let json = serde_json::to_string(&info).unwrap();
2100                prop_assert!(json.contains(&name));
2101            }
2102        }
2103    }
2104
2105    // ── Absorbed snapshot tests ──
2106
2107    mod snapshot_tests_absorbed {
2108        use super::*;
2109        use insta::{assert_debug_snapshot, assert_yaml_snapshot};
2110
2111        #[test]
2112        fn snapshot_package_info_simple() {
2113            let info = PackageInfo {
2114                name: "shipper-cargo".to_string(),
2115                version: "0.3.0".to_string(),
2116                manifest_path: "crates/shipper-cargo/Cargo.toml".to_string(),
2117                is_workspace_member: true,
2118                publish: vec![],
2119            };
2120            assert_yaml_snapshot!(info);
2121        }
2122
2123        #[test]
2124        fn snapshot_package_info_with_registries() {
2125            let info = PackageInfo {
2126                name: "my-private-crate".to_string(),
2127                version: "1.2.3-beta.1".to_string(),
2128                manifest_path: "crates/my-private-crate/Cargo.toml".to_string(),
2129                is_workspace_member: false,
2130                publish: vec!["crates-io".to_string(), "my-private-registry".to_string()],
2131            };
2132            assert_yaml_snapshot!(info);
2133        }
2134
2135        #[test]
2136        fn snapshot_valid_package_names() {
2137            let names = vec!["my-crate", "my_crate", "a", "_private", "crate-with-123"];
2138            let results: Vec<(&str, bool)> = names
2139                .into_iter()
2140                .map(|n| (n, is_valid_package_name(n)))
2141                .collect();
2142            assert_debug_snapshot!(results);
2143        }
2144
2145        #[test]
2146        fn snapshot_invalid_package_names() {
2147            let names = vec![
2148                "",
2149                "123-start",
2150                "-hyphen-start",
2151                "MyCrate",
2152                "my.crate",
2153                "my crate",
2154                "my@crate",
2155            ];
2156            let results: Vec<(&str, bool)> = names
2157                .into_iter()
2158                .map(|n| (n, is_valid_package_name(n)))
2159                .collect();
2160            assert_debug_snapshot!(results);
2161        }
2162
2163        #[test]
2164        fn snapshot_package_info_prerelease_version() {
2165            let info = PackageInfo {
2166                name: "my-alpha-crate".to_string(),
2167                version: "0.0.1-alpha.0+build.123".to_string(),
2168                manifest_path: "crates/my-alpha-crate/Cargo.toml".to_string(),
2169                is_workspace_member: true,
2170                publish: vec![],
2171            };
2172            assert_yaml_snapshot!(info);
2173        }
2174    }
2175}