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    let mut chars = name.chars();
506    let Some(first) = chars.next() else {
507        return false;
508    };
509    if first.is_ascii_digit() || first == '-' {
510        return false;
511    }
512    let valid = |c: char| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_';
513    valid(first) && chars.all(valid)
514}
515
516/// All workspace member package names.
517pub fn workspace_member_names(metadata: &WorkspaceMetadata) -> Vec<String> {
518    metadata
519        .workspace_members()
520        .iter()
521        .map(|p| p.name.to_string())
522        .collect()
523}
524
525#[cfg(test)]
526mod tests {
527    use std::fs;
528    use std::path::{Path, PathBuf};
529
530    use serial_test::serial;
531    use tempfile::tempdir;
532
533    use super::*;
534
535    fn write_fake_cargo(bin_dir: &Path) -> PathBuf {
536        #[cfg(windows)]
537        {
538            let path = bin_dir.join("cargo.cmd");
539            fs::write(
540                &path,
541                "@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",
542            )
543            .expect("write fake cargo");
544            path
545        }
546
547        #[cfg(not(windows))]
548        {
549            use std::os::unix::fs::PermissionsExt;
550
551            let path = bin_dir.join("cargo");
552            fs::write(
553                &path,
554                "#!/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",
555            )
556            .expect("write fake cargo");
557            let mut perms = fs::metadata(&path).expect("meta").permissions();
558            perms.set_mode(0o755);
559            fs::set_permissions(&path, perms).expect("chmod");
560            path
561        }
562    }
563
564    #[test]
565    #[serial]
566    fn cargo_publish_passes_flags_and_captures_output() {
567        let td = tempdir().expect("tempdir");
568        let bin = td.path().join("bin");
569        fs::create_dir_all(&bin).expect("mkdir");
570        let fake_cargo = write_fake_cargo(&bin);
571
572        let args_log = td.path().join("args.txt");
573        let cwd_log = td.path().join("cwd.txt");
574
575        let ws = td.path().join("workspace");
576        fs::create_dir_all(&ws).expect("mkdir ws");
577
578        temp_env::with_vars(
579            [
580                (
581                    "SHIPPER_CARGO_BIN",
582                    Some(fake_cargo.to_str().expect("fake cargo utf8")),
583                ),
584                ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
585                ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
586                ("SHIPPER_EXIT_CODE", Some("7")),
587            ],
588            || {
589                let out = cargo_publish(&ws, "my-crate", "private-reg", true, true, 50, None)
590                    .expect("publish");
591
592                assert_eq!(out.exit_code, 7);
593                assert!(out.stdout_tail.contains("fake-stdout"));
594                assert!(out.stderr_tail.contains("fake-stderr"));
595
596                let args = fs::read_to_string(&args_log).expect("args");
597                assert!(args.contains("publish"));
598                assert!(args.contains("-p my-crate"));
599                assert!(args.contains("--registry private-reg"));
600                assert!(args.contains("--allow-dirty"));
601                assert!(args.contains("--no-verify"));
602
603                let cwd = fs::read_to_string(&cwd_log).expect("cwd");
604                assert!(cwd.trim_end().ends_with("workspace"));
605            },
606        );
607    }
608
609    #[test]
610    #[serial]
611    fn cargo_publish_omits_registry_for_crates_io() {
612        let td = tempdir().expect("tempdir");
613        let bin = td.path().join("bin");
614        fs::create_dir_all(&bin).expect("mkdir");
615        let fake_cargo = write_fake_cargo(&bin);
616
617        let args_log = td.path().join("args.txt");
618        let cwd_log = td.path().join("cwd.txt");
619
620        let ws = td.path().join("workspace");
621        fs::create_dir_all(&ws).expect("mkdir ws");
622
623        temp_env::with_vars(
624            [
625                (
626                    "SHIPPER_CARGO_BIN",
627                    Some(fake_cargo.to_str().expect("fake cargo utf8")),
628                ),
629                ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
630                ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
631                ("SHIPPER_EXIT_CODE", Some("0")),
632            ],
633            || {
634                let _ = cargo_publish(&ws, "my-crate", "crates-io", false, false, 50, None)
635                    .expect("publish");
636
637                let args = fs::read_to_string(&args_log).expect("args");
638                assert!(!args.contains("--registry"));
639                assert!(!args.contains("--allow-dirty"));
640                assert!(!args.contains("--no-verify"));
641            },
642        );
643    }
644
645    #[test]
646    #[serial]
647    fn cargo_publish_errors_when_command_missing() {
648        let td = tempdir().expect("tempdir");
649        let missing = td.path().join("does-not-exist-cargo");
650
651        temp_env::with_var(
652            "SHIPPER_CARGO_BIN",
653            Some(missing.to_str().expect("utf8")),
654            || {
655                let err = cargo_publish(td.path(), "x", "crates-io", false, false, 50, None)
656                    .expect_err("must fail");
657                assert!(format!("{err:#}").contains("failed to execute cargo publish"));
658            },
659        );
660    }
661
662    #[test]
663    #[serial]
664    fn cargo_yank_passes_flags_and_captures_output() {
665        let td = tempdir().expect("tempdir");
666        let bin = td.path().join("bin");
667        fs::create_dir_all(&bin).expect("mkdir");
668        let fake_cargo = write_fake_cargo(&bin);
669
670        let args_log = td.path().join("args.txt");
671        let cwd_log = td.path().join("cwd.txt");
672
673        let ws = td.path().join("workspace");
674        fs::create_dir_all(&ws).expect("mkdir ws");
675
676        temp_env::with_vars(
677            [
678                (
679                    "SHIPPER_CARGO_BIN",
680                    Some(fake_cargo.to_str().expect("fake cargo utf8")),
681                ),
682                ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
683                ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
684                ("SHIPPER_EXIT_CODE", Some("0")),
685            ],
686            || {
687                let out =
688                    cargo_yank(&ws, "my-crate", "1.2.3", "private-reg", 50, None).expect("yank");
689
690                assert_eq!(out.exit_code, 0);
691                assert!(out.stdout_tail.contains("fake-stdout"));
692
693                let args = fs::read_to_string(&args_log).expect("args");
694                assert!(args.contains("yank"));
695                assert!(args.contains("my-crate"));
696                assert!(args.contains("--version=1.2.3"));
697                assert!(args.contains("--registry private-reg"));
698
699                let cwd = fs::read_to_string(&cwd_log).expect("cwd");
700                assert!(cwd.trim_end().ends_with("workspace"));
701            },
702        );
703    }
704
705    #[test]
706    #[serial]
707    fn cargo_yank_omits_registry_for_crates_io() {
708        let td = tempdir().expect("tempdir");
709        let bin = td.path().join("bin");
710        fs::create_dir_all(&bin).expect("mkdir");
711        let fake_cargo = write_fake_cargo(&bin);
712
713        let args_log = td.path().join("args.txt");
714        let cwd_log = td.path().join("cwd.txt");
715
716        let ws = td.path().join("workspace");
717        fs::create_dir_all(&ws).expect("mkdir ws");
718
719        temp_env::with_vars(
720            [
721                (
722                    "SHIPPER_CARGO_BIN",
723                    Some(fake_cargo.to_str().expect("fake cargo utf8")),
724                ),
725                ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
726                ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
727                ("SHIPPER_EXIT_CODE", Some("0")),
728            ],
729            || {
730                let _ = cargo_yank(&ws, "my-crate", "0.1.0", "crates-io", 50, None).expect("yank");
731
732                let args = fs::read_to_string(&args_log).expect("args");
733                assert!(!args.contains("--registry"));
734                assert!(args.contains("yank"));
735                assert!(args.contains("--version=0.1.0"));
736            },
737        );
738    }
739
740    #[test]
741    #[serial]
742    fn cargo_yank_propagates_nonzero_exit_code() {
743        let td = tempdir().expect("tempdir");
744        let bin = td.path().join("bin");
745        fs::create_dir_all(&bin).expect("mkdir");
746        let fake_cargo = write_fake_cargo(&bin);
747
748        let args_log = td.path().join("args.txt");
749        let cwd_log = td.path().join("cwd.txt");
750
751        let ws = td.path().join("workspace");
752        fs::create_dir_all(&ws).expect("mkdir ws");
753
754        temp_env::with_vars(
755            [
756                (
757                    "SHIPPER_CARGO_BIN",
758                    Some(fake_cargo.to_str().expect("fake cargo utf8")),
759                ),
760                ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
761                ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
762                ("SHIPPER_EXIT_CODE", Some("101")),
763            ],
764            || {
765                let out =
766                    cargo_yank(&ws, "my-crate", "1.2.3", "crates-io", 50, None).expect("spawn");
767                assert_eq!(out.exit_code, 101);
768                assert!(out.stderr_tail.contains("fake-stderr"));
769            },
770        );
771    }
772
773    #[test]
774    #[serial]
775    fn cargo_publish_dry_run_package_passes_flags() {
776        let td = tempdir().expect("tempdir");
777        let bin = td.path().join("bin");
778        fs::create_dir_all(&bin).expect("mkdir");
779        let fake_cargo = write_fake_cargo(&bin);
780
781        let args_log = td.path().join("args.txt");
782        let cwd_log = td.path().join("cwd.txt");
783
784        let ws = td.path().join("workspace");
785        fs::create_dir_all(&ws).expect("mkdir ws");
786
787        temp_env::with_vars(
788            [
789                (
790                    "SHIPPER_CARGO_BIN",
791                    Some(fake_cargo.to_str().expect("fake cargo utf8")),
792                ),
793                ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
794                ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
795                ("SHIPPER_EXIT_CODE", Some("0")),
796            ],
797            || {
798                let out = cargo_publish_dry_run_package(&ws, "my-crate", "private-reg", true, 50)
799                    .expect("dry-run");
800
801                assert_eq!(out.exit_code, 0);
802                let args = fs::read_to_string(&args_log).expect("args");
803                assert!(args.contains("publish"));
804                assert!(args.contains("-p my-crate"));
805                assert!(args.contains("--dry-run"));
806                assert!(args.contains("--registry private-reg"));
807                assert!(args.contains("--allow-dirty"));
808            },
809        );
810    }
811
812    // ── redact_sensitive tests ──
813
814    #[test]
815    fn redact_authorization_bearer_header() {
816        let input = "Authorization: Bearer cio_abc123secret";
817        let out = redact_sensitive(input);
818        assert_eq!(out, "Authorization: Bearer [REDACTED]");
819    }
820
821    #[test]
822    fn redact_token_assignment_quoted() {
823        let input = r#"token = "cio_mysecrettoken""#;
824        let out = redact_sensitive(input);
825        assert!(out.contains("[REDACTED]"));
826        assert!(!out.contains("cio_mysecrettoken"));
827    }
828
829    #[test]
830    fn redact_cargo_registry_token_env() {
831        let input = "CARGO_REGISTRY_TOKEN=cio_secret123";
832        let out = redact_sensitive(input);
833        assert_eq!(out, "CARGO_REGISTRY_TOKEN=[REDACTED]");
834    }
835
836    #[test]
837    fn redact_cargo_registries_named_token_env() {
838        let input = "CARGO_REGISTRIES_MY_REG_TOKEN=secret456";
839        let out = redact_sensitive(input);
840        assert_eq!(out, "CARGO_REGISTRIES_MY_REG_TOKEN=[REDACTED]");
841    }
842
843    #[test]
844    fn redact_preserves_non_sensitive_content() {
845        let input = "Compiling demo v0.1.0\nFinished release target";
846        let out = redact_sensitive(input);
847        assert_eq!(out, input);
848    }
849
850    #[test]
851    fn redact_handles_empty_input() {
852        assert_eq!(redact_sensitive(""), "");
853    }
854
855    #[test]
856    fn redact_multiple_sensitive_patterns() {
857        let input = "Authorization: Bearer tok123\nCARGO_REGISTRY_TOKEN=secret";
858        let out = redact_sensitive(input);
859        assert!(out.contains("Bearer [REDACTED]"));
860        assert!(out.contains("CARGO_REGISTRY_TOKEN=[REDACTED]"));
861        assert!(!out.contains("tok123"));
862        assert!(!out.contains("secret"));
863    }
864
865    #[test]
866    fn tail_lines_redacts_sensitive_output() {
867        let input = "line1\nline2\nAuthorization: Bearer secret_token\nline4";
868        let result = tail_lines(input, 50);
869        assert!(result.contains("Bearer [REDACTED]"));
870        assert!(!result.contains("secret_token"));
871    }
872
873    #[test]
874    fn redact_mixed_case_authorization() {
875        let input = "AUTHORIZATION: Bearer supersecret";
876        let out = redact_sensitive(input);
877        assert_eq!(out, "AUTHORIZATION: Bearer [REDACTED]");
878        assert!(!out.contains("supersecret"));
879    }
880
881    #[test]
882    fn redact_mixed_case_token() {
883        let input = r#"Token = "mysecret""#;
884        let out = redact_sensitive(input);
885        assert!(out.contains("[REDACTED]"));
886        assert!(!out.contains("mysecret"));
887    }
888
889    #[test]
890    fn redact_non_ascii_near_sensitive_pattern_no_panic() {
891        // Non-ASCII characters near the pattern should not cause a panic
892        let input = "some data \u{00e9}\u{00f1} Authorization: Bearer secret123";
893        let out = redact_sensitive(input);
894        assert!(out.contains("[REDACTED]"));
895        assert!(!out.contains("secret123"));
896    }
897
898    #[test]
899    fn redaction_matches_output_sanitizer_contract() {
900        let input = [
901            "line one",
902            "Authorization: Bearer secret_value",
903            "CARGO_REGISTRIES_PRIVATE_REG_TOKEN=secret_value",
904        ]
905        .join("\n");
906
907        assert_eq!(
908            redact_sensitive(&input),
909            shipper_output_sanitizer::redact_sensitive(&input)
910        );
911        assert_eq!(
912            tail_lines(&input, 2),
913            shipper_output_sanitizer::tail_lines(&input, 2)
914        );
915    }
916
917    // ── Token redaction: position variants ──
918
919    #[test]
920    fn redact_token_at_start_of_output() {
921        let input = "CARGO_REGISTRY_TOKEN=start_secret\nnormal line after";
922        let out = redact_sensitive(input);
923        assert!(out.starts_with("CARGO_REGISTRY_TOKEN=[REDACTED]"));
924        assert!(!out.contains("start_secret"));
925    }
926
927    #[test]
928    fn redact_token_at_end_of_output() {
929        let input = "normal line\nCARGO_REGISTRY_TOKEN=end_secret";
930        let out = redact_sensitive(input);
931        assert!(out.ends_with("CARGO_REGISTRY_TOKEN=[REDACTED]"));
932        assert!(!out.contains("end_secret"));
933    }
934
935    #[test]
936    fn redact_bearer_at_start_of_output() {
937        let input = "Authorization: Bearer first_tok\nother stuff";
938        let out = redact_sensitive(input);
939        assert!(out.starts_with("Authorization: Bearer [REDACTED]"));
940        assert!(!out.contains("first_tok"));
941    }
942
943    #[test]
944    fn redact_bearer_at_end_of_output() {
945        let input = "stuff before\nAuthorization: Bearer last_tok";
946        let out = redact_sensitive(input);
947        assert!(out.ends_with("Authorization: Bearer [REDACTED]"));
948        assert!(!out.contains("last_tok"));
949    }
950
951    #[test]
952    fn redact_token_as_only_line() {
953        let out = redact_sensitive("CARGO_REGISTRY_TOKEN=only");
954        assert_eq!(out, "CARGO_REGISTRY_TOKEN=[REDACTED]");
955    }
956
957    // ── Multiple tokens in same output ──
958
959    #[test]
960    fn redact_three_different_token_types_multiline() {
961        let input = "Authorization: Bearer bearer_secret\n\
962                      CARGO_REGISTRY_TOKEN=env_secret\n\
963                      CARGO_REGISTRIES_STAGING_TOKEN=staging_secret";
964        let out = redact_sensitive(input);
965        assert!(!out.contains("bearer_secret"));
966        assert!(!out.contains("env_secret"));
967        assert!(!out.contains("staging_secret"));
968        assert_eq!(out.matches("[REDACTED]").count(), 3);
969    }
970
971    #[test]
972    fn redact_same_token_type_repeated() {
973        let input = "CARGO_REGISTRY_TOKEN=aaa\nsome stuff\nCARGO_REGISTRY_TOKEN=bbb";
974        let out = redact_sensitive(input);
975        assert!(!out.contains("aaa"));
976        assert!(!out.contains("bbb"));
977        assert_eq!(
978            out,
979            "CARGO_REGISTRY_TOKEN=[REDACTED]\nsome stuff\nCARGO_REGISTRY_TOKEN=[REDACTED]"
980        );
981    }
982
983    #[test]
984    fn redact_multiple_named_registries() {
985        let input = "CARGO_REGISTRIES_ALPHA_TOKEN=tok_a\n\
986                      CARGO_REGISTRIES_BETA_TOKEN=tok_b\n\
987                      CARGO_REGISTRIES_GAMMA_TOKEN=tok_c";
988        let out = redact_sensitive(input);
989        assert!(!out.contains("tok_a"));
990        assert!(!out.contains("tok_b"));
991        assert!(!out.contains("tok_c"));
992        assert_eq!(out.matches("[REDACTED]").count(), 3);
993    }
994
995    // ── Unicode in cargo output ──
996
997    #[test]
998    fn redact_preserves_cjk_characters() {
999        let input = "コンパイル中: mycrate v1.0.0\n完了";
1000        let out = redact_sensitive(input);
1001        assert_eq!(out, input);
1002    }
1003
1004    #[test]
1005    fn redact_preserves_emoji_in_output() {
1006        let input = "🚀 Publishing crate 📦\n✅ Done!";
1007        let out = redact_sensitive(input);
1008        assert_eq!(out, input);
1009    }
1010
1011    #[test]
1012    fn redact_unicode_surrounding_bearer_token() {
1013        let input = "日本語テスト Authorization: Bearer abc_secret 中文テスト";
1014        let out = redact_sensitive(input);
1015        assert!(!out.contains("abc_secret"));
1016        assert!(out.contains("日本語テスト"));
1017        // Bearer redaction truncates after token, so 中文テスト is part of the token value
1018        assert!(out.contains("[REDACTED]"));
1019    }
1020
1021    #[test]
1022    fn redact_accented_characters_preserved() {
1023        let input = "Résultat: réussi\nDéploiement terminé";
1024        let out = redact_sensitive(input);
1025        assert_eq!(out, input);
1026    }
1027
1028    #[test]
1029    fn tail_lines_with_unicode_content() {
1030        let input = "first 日本語\nsecond émoji 🎉\nthird 中文";
1031        let out = tail_lines(input, 2);
1032        assert_eq!(out, "second émoji 🎉\nthird 中文");
1033    }
1034
1035    // ── Very long output lines ──
1036
1037    #[test]
1038    fn redact_very_long_line_no_token() {
1039        let long_line = "x".repeat(500_000);
1040        let out = redact_sensitive(&long_line);
1041        assert_eq!(out.len(), 500_000);
1042        assert_eq!(out, long_line);
1043    }
1044
1045    #[test]
1046    fn redact_token_embedded_in_very_long_line() {
1047        let prefix = "a".repeat(200_000);
1048        let suffix = "b".repeat(200_000);
1049        let input = format!("{prefix} CARGO_REGISTRY_TOKEN=hidden {suffix}");
1050        let out = redact_sensitive(&input);
1051        assert!(!out.contains("hidden"));
1052        assert!(out.contains("[REDACTED]"));
1053    }
1054
1055    #[test]
1056    fn tail_lines_with_very_long_lines() {
1057        let long = "y".repeat(100_000);
1058        let input = format!("short\n{long}\nlast");
1059        let out = tail_lines(&input, 2);
1060        assert!(out.contains(&long));
1061        assert!(out.contains("last"));
1062        assert!(!out.contains("short"));
1063    }
1064
1065    // ── Empty output handling ──
1066
1067    #[test]
1068    fn tail_lines_empty_string() {
1069        assert_eq!(tail_lines("", 10), "");
1070    }
1071
1072    #[test]
1073    fn tail_lines_only_newlines() {
1074        let input = "\n\n\n";
1075        let out = tail_lines(input, 2);
1076        // .lines() yields three empty strings for "\n\n\n"
1077        assert!(out.lines().all(|l| l.is_empty()));
1078    }
1079
1080    #[test]
1081    fn tail_lines_single_newline() {
1082        let out = tail_lines("\n", 5);
1083        // "\n".lines() yields one empty string
1084        assert_eq!(out, "\n");
1085    }
1086
1087    #[test]
1088    fn redact_whitespace_only_input() {
1089        let input = "   \t  ";
1090        assert_eq!(redact_sensitive(input), input);
1091    }
1092
1093    #[test]
1094    fn tail_lines_whitespace_only_lines() {
1095        let input = "  \n\t\n   ";
1096        let out = tail_lines(input, 2);
1097        assert_eq!(out, "\t\n   ");
1098    }
1099
1100    // ── Timeout behavior ──
1101
1102    #[test]
1103    #[serial]
1104    fn cargo_publish_with_timeout_captures_timed_out_flag() {
1105        let td = tempdir().expect("tempdir");
1106        let bin = td.path().join("bin");
1107        fs::create_dir_all(&bin).expect("mkdir");
1108
1109        // Write a fake cargo that sleeps, ensuring it exceeds the timeout
1110        #[cfg(windows)]
1111        {
1112            let path = bin.join("cargo.cmd");
1113            fs::write(
1114                &path,
1115                "@echo off\r\nping -n 5 127.0.0.1 >nul\r\necho should-not-see\r\n",
1116            )
1117            .expect("write slow fake cargo");
1118        }
1119        #[cfg(not(windows))]
1120        {
1121            use std::os::unix::fs::PermissionsExt;
1122            let path = bin.join("cargo");
1123            fs::write(&path, "#!/usr/bin/env sh\nsleep 10\necho should-not-see\n")
1124                .expect("write slow fake cargo");
1125            let mut perms = fs::metadata(&path).expect("meta").permissions();
1126            perms.set_mode(0o755);
1127            fs::set_permissions(&path, perms).expect("chmod");
1128        }
1129
1130        let fake_cargo_path = if cfg!(windows) {
1131            bin.join("cargo.cmd")
1132        } else {
1133            bin.join("cargo")
1134        };
1135
1136        let ws = td.path().join("workspace");
1137        fs::create_dir_all(&ws).expect("mkdir ws");
1138
1139        temp_env::with_vars(
1140            [(
1141                "SHIPPER_CARGO_BIN",
1142                Some(fake_cargo_path.to_str().expect("utf8")),
1143            )],
1144            || {
1145                let out = cargo_publish(
1146                    &ws,
1147                    "test-crate",
1148                    "crates-io",
1149                    false,
1150                    false,
1151                    50,
1152                    Some(Duration::from_secs(1)),
1153                )
1154                .expect("publish with timeout");
1155
1156                assert!(out.timed_out, "expected timed_out flag to be set");
1157                assert_eq!(out.exit_code, -1);
1158                assert!(out.stderr_tail.contains("timed out"));
1159            },
1160        );
1161    }
1162
1163    #[test]
1164    #[serial]
1165    fn cargo_publish_no_timeout_completes_normally() {
1166        let td = tempdir().expect("tempdir");
1167        let bin = td.path().join("bin");
1168        fs::create_dir_all(&bin).expect("mkdir");
1169        let fake_cargo = write_fake_cargo(&bin);
1170
1171        let args_log = td.path().join("args.txt");
1172        let cwd_log = td.path().join("cwd.txt");
1173
1174        let ws = td.path().join("workspace");
1175        fs::create_dir_all(&ws).expect("mkdir ws");
1176
1177        temp_env::with_vars(
1178            [
1179                (
1180                    "SHIPPER_CARGO_BIN",
1181                    Some(fake_cargo.to_str().expect("utf8")),
1182                ),
1183                ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
1184                ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
1185                ("SHIPPER_EXIT_CODE", Some("0")),
1186            ],
1187            || {
1188                let out = cargo_publish(&ws, "crate-x", "crates-io", false, false, 50, None)
1189                    .expect("publish");
1190                assert!(!out.timed_out, "should not time out");
1191                assert_eq!(out.exit_code, 0);
1192            },
1193        );
1194    }
1195
1196    // ── Environment variable resolution / cargo_program ──
1197
1198    #[test]
1199    #[serial]
1200    fn cargo_program_uses_env_override() {
1201        temp_env::with_var("SHIPPER_CARGO_BIN", Some("/custom/cargo"), || {
1202            assert_eq!(cargo_program(), "/custom/cargo");
1203        });
1204    }
1205
1206    #[test]
1207    #[serial]
1208    fn cargo_program_defaults_to_cargo() {
1209        temp_env::with_var("SHIPPER_CARGO_BIN", None::<&str>, || {
1210            assert_eq!(cargo_program(), "cargo");
1211        });
1212    }
1213
1214    #[test]
1215    #[serial]
1216    fn cargo_program_with_empty_env_uses_empty_string() {
1217        // Empty string is a valid env value; cargo_program returns it as-is
1218        temp_env::with_var("SHIPPER_CARGO_BIN", Some(""), || {
1219            assert_eq!(cargo_program(), "");
1220        });
1221    }
1222
1223    // ── Registry name handling ──
1224
1225    #[test]
1226    #[serial]
1227    fn cargo_publish_omits_registry_for_empty_string() {
1228        let td = tempdir().expect("tempdir");
1229        let bin = td.path().join("bin");
1230        fs::create_dir_all(&bin).expect("mkdir");
1231        let fake_cargo = write_fake_cargo(&bin);
1232
1233        let args_log = td.path().join("args.txt");
1234        let cwd_log = td.path().join("cwd.txt");
1235
1236        let ws = td.path().join("workspace");
1237        fs::create_dir_all(&ws).expect("mkdir ws");
1238
1239        temp_env::with_vars(
1240            [
1241                (
1242                    "SHIPPER_CARGO_BIN",
1243                    Some(fake_cargo.to_str().expect("utf8")),
1244                ),
1245                ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
1246                ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
1247                ("SHIPPER_EXIT_CODE", Some("0")),
1248            ],
1249            || {
1250                let _ = cargo_publish(&ws, "crate-y", "", false, false, 50, None).expect("publish");
1251                let args = fs::read_to_string(&args_log).expect("args");
1252                assert!(
1253                    !args.contains("--registry"),
1254                    "empty registry name should not produce --registry flag"
1255                );
1256            },
1257        );
1258    }
1259
1260    #[test]
1261    #[serial]
1262    fn cargo_publish_omits_registry_for_whitespace_only() {
1263        let td = tempdir().expect("tempdir");
1264        let bin = td.path().join("bin");
1265        fs::create_dir_all(&bin).expect("mkdir");
1266        let fake_cargo = write_fake_cargo(&bin);
1267
1268        let args_log = td.path().join("args.txt");
1269        let cwd_log = td.path().join("cwd.txt");
1270
1271        let ws = td.path().join("workspace");
1272        fs::create_dir_all(&ws).expect("mkdir ws");
1273
1274        temp_env::with_vars(
1275            [
1276                (
1277                    "SHIPPER_CARGO_BIN",
1278                    Some(fake_cargo.to_str().expect("utf8")),
1279                ),
1280                ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
1281                ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
1282                ("SHIPPER_EXIT_CODE", Some("0")),
1283            ],
1284            || {
1285                let _ =
1286                    cargo_publish(&ws, "crate-z", "   ", false, false, 50, None).expect("publish");
1287                let args = fs::read_to_string(&args_log).expect("args");
1288                assert!(
1289                    !args.contains("--registry"),
1290                    "whitespace-only registry name should not produce --registry flag"
1291                );
1292            },
1293        );
1294    }
1295
1296    // ── Dry-run workspace variant ──
1297
1298    #[test]
1299    #[serial]
1300    fn cargo_publish_dry_run_workspace_passes_flags() {
1301        let td = tempdir().expect("tempdir");
1302        let bin = td.path().join("bin");
1303        fs::create_dir_all(&bin).expect("mkdir");
1304        let fake_cargo = write_fake_cargo(&bin);
1305
1306        let args_log = td.path().join("args.txt");
1307        let cwd_log = td.path().join("cwd.txt");
1308
1309        let ws = td.path().join("workspace");
1310        fs::create_dir_all(&ws).expect("mkdir ws");
1311
1312        temp_env::with_vars(
1313            [
1314                (
1315                    "SHIPPER_CARGO_BIN",
1316                    Some(fake_cargo.to_str().expect("utf8")),
1317                ),
1318                ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
1319                ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
1320                ("SHIPPER_EXIT_CODE", Some("0")),
1321            ],
1322            || {
1323                let out =
1324                    cargo_publish_dry_run_workspace(&ws, "my-reg", true, 50).expect("dry-run ws");
1325
1326                assert_eq!(out.exit_code, 0);
1327                let args = fs::read_to_string(&args_log).expect("args");
1328                assert!(args.contains("publish"));
1329                assert!(args.contains("--workspace"));
1330                assert!(args.contains("--dry-run"));
1331                assert!(args.contains("--registry my-reg"));
1332                assert!(args.contains("--allow-dirty"));
1333            },
1334        );
1335    }
1336
1337    #[test]
1338    #[serial]
1339    fn cargo_publish_dry_run_workspace_omits_registry_for_crates_io() {
1340        let td = tempdir().expect("tempdir");
1341        let bin = td.path().join("bin");
1342        fs::create_dir_all(&bin).expect("mkdir");
1343        let fake_cargo = write_fake_cargo(&bin);
1344
1345        let args_log = td.path().join("args.txt");
1346        let cwd_log = td.path().join("cwd.txt");
1347
1348        let ws = td.path().join("workspace");
1349        fs::create_dir_all(&ws).expect("mkdir ws");
1350
1351        temp_env::with_vars(
1352            [
1353                (
1354                    "SHIPPER_CARGO_BIN",
1355                    Some(fake_cargo.to_str().expect("utf8")),
1356                ),
1357                ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
1358                ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
1359                ("SHIPPER_EXIT_CODE", Some("0")),
1360            ],
1361            || {
1362                let _ =
1363                    cargo_publish_dry_run_workspace(&ws, "crates-io", false, 50).expect("dry-run");
1364                let args = fs::read_to_string(&args_log).expect("args");
1365                assert!(!args.contains("--registry"));
1366                assert!(!args.contains("--allow-dirty"));
1367            },
1368        );
1369    }
1370
1371    #[test]
1372    #[serial]
1373    fn cargo_publish_dry_run_workspace_errors_when_command_missing() {
1374        let td = tempdir().expect("tempdir");
1375        let missing = td.path().join("nonexistent-cargo");
1376
1377        temp_env::with_var(
1378            "SHIPPER_CARGO_BIN",
1379            Some(missing.to_str().expect("utf8")),
1380            || {
1381                let err = cargo_publish_dry_run_workspace(td.path(), "crates-io", false, 50)
1382                    .expect_err("must fail");
1383                assert!(format!("{err:#}").contains("failed to execute cargo publish"));
1384            },
1385        );
1386    }
1387
1388    // ── Dry-run package variant additional tests ──
1389
1390    #[test]
1391    #[serial]
1392    fn cargo_publish_dry_run_package_omits_registry_for_crates_io() {
1393        let td = tempdir().expect("tempdir");
1394        let bin = td.path().join("bin");
1395        fs::create_dir_all(&bin).expect("mkdir");
1396        let fake_cargo = write_fake_cargo(&bin);
1397
1398        let args_log = td.path().join("args.txt");
1399        let cwd_log = td.path().join("cwd.txt");
1400
1401        let ws = td.path().join("workspace");
1402        fs::create_dir_all(&ws).expect("mkdir ws");
1403
1404        temp_env::with_vars(
1405            [
1406                (
1407                    "SHIPPER_CARGO_BIN",
1408                    Some(fake_cargo.to_str().expect("utf8")),
1409                ),
1410                ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
1411                ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
1412                ("SHIPPER_EXIT_CODE", Some("0")),
1413            ],
1414            || {
1415                let _ = cargo_publish_dry_run_package(&ws, "pkg", "crates-io", false, 50)
1416                    .expect("dry-run");
1417                let args = fs::read_to_string(&args_log).expect("args");
1418                assert!(!args.contains("--registry"));
1419                assert!(!args.contains("--allow-dirty"));
1420            },
1421        );
1422    }
1423
1424    #[test]
1425    #[serial]
1426    fn cargo_publish_dry_run_package_errors_when_command_missing() {
1427        let td = tempdir().expect("tempdir");
1428        let missing = td.path().join("nonexistent-cargo");
1429
1430        temp_env::with_var(
1431            "SHIPPER_CARGO_BIN",
1432            Some(missing.to_str().expect("utf8")),
1433            || {
1434                let err = cargo_publish_dry_run_package(td.path(), "pkg", "crates-io", false, 50)
1435                    .expect_err("must fail");
1436                let msg = format!("{err:#}");
1437                assert!(msg.contains("failed to execute cargo publish --dry-run -p pkg"));
1438            },
1439        );
1440    }
1441
1442    // ── Output line truncation via tail_lines ──
1443
1444    #[test]
1445    fn tail_lines_truncates_to_requested_count() {
1446        let lines: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
1447        let input = lines.join("\n");
1448        let out = tail_lines(&input, 5);
1449        assert_eq!(out.lines().count(), 5);
1450        assert!(out.contains("line 95"));
1451        assert!(out.contains("line 99"));
1452        assert!(!out.contains("line 94"));
1453    }
1454
1455    #[test]
1456    fn tail_lines_one_line_requested() {
1457        let input = "first\nsecond\nthird";
1458        let out = tail_lines(input, 1);
1459        assert_eq!(out, "third");
1460    }
1461
1462    #[test]
1463    fn tail_lines_redacts_token_in_last_line() {
1464        let input = "safe1\nsafe2\nCARGO_REGISTRY_TOKEN=leaked";
1465        let out = tail_lines(input, 2);
1466        assert!(!out.contains("leaked"));
1467        assert!(out.contains("CARGO_REGISTRY_TOKEN=[REDACTED]"));
1468    }
1469
1470    #[test]
1471    fn tail_lines_token_outside_window_not_visible() {
1472        let input = "CARGO_REGISTRY_TOKEN=secret\nsafe1\nsafe2";
1473        let out = tail_lines(input, 2);
1474        assert!(!out.contains("secret"));
1475        assert!(!out.contains("CARGO_REGISTRY_TOKEN"));
1476        assert_eq!(out, "safe1\nsafe2");
1477    }
1478
1479    // ── Error message patterns ──
1480
1481    #[test]
1482    fn redact_token_in_error_message_context() {
1483        let input =
1484            "error: failed to publish: token = \"cio_leakedsecret\" was rejected by registry";
1485        let out = redact_sensitive(input);
1486        assert!(!out.contains("cio_leakedsecret"));
1487        assert!(out.contains("[REDACTED]"));
1488    }
1489
1490    #[test]
1491    fn redact_bearer_in_http_error() {
1492        let input =
1493            "error: HTTP 403 Forbidden\nAuthorization: Bearer expired_tok_abc\nBody: access denied";
1494        let out = redact_sensitive(input);
1495        assert!(!out.contains("expired_tok_abc"));
1496        assert!(out.contains("error: HTTP 403 Forbidden"));
1497        assert!(out.contains("Body: access denied"));
1498    }
1499
1500    #[test]
1501    fn redact_registry_token_in_debug_output() {
1502        let input = "debug: env CARGO_REGISTRY_TOKEN=cio_debug_tok resolved from environment";
1503        let out = redact_sensitive(input);
1504        assert!(!out.contains("cio_debug_tok"));
1505        assert!(out.contains("[REDACTED]"));
1506    }
1507
1508    // ── CargoOutput struct behavior ──
1509
1510    #[test]
1511    fn cargo_output_default_fields() {
1512        let out = CargoOutput {
1513            exit_code: 0,
1514            stdout_tail: String::new(),
1515            stderr_tail: String::new(),
1516            duration: Duration::from_secs(0),
1517            timed_out: false,
1518        };
1519        assert_eq!(out.exit_code, 0);
1520        assert!(out.stdout_tail.is_empty());
1521        assert!(out.stderr_tail.is_empty());
1522        assert!(!out.timed_out);
1523    }
1524
1525    #[test]
1526    fn cargo_output_clone_is_independent() {
1527        let out = CargoOutput {
1528            exit_code: 42,
1529            stdout_tail: "hello".to_string(),
1530            stderr_tail: "world".to_string(),
1531            duration: Duration::from_millis(500),
1532            timed_out: true,
1533        };
1534        let cloned = out.clone();
1535        assert_eq!(cloned.exit_code, out.exit_code);
1536        assert_eq!(cloned.stdout_tail, out.stdout_tail);
1537        assert_eq!(cloned.stderr_tail, out.stderr_tail);
1538        assert_eq!(cloned.timed_out, out.timed_out);
1539    }
1540
1541    #[test]
1542    fn cargo_output_debug_format() {
1543        let out = CargoOutput {
1544            exit_code: 1,
1545            stdout_tail: "out".to_string(),
1546            stderr_tail: "err".to_string(),
1547            duration: Duration::from_secs(1),
1548            timed_out: false,
1549        };
1550        let debug = format!("{out:?}");
1551        assert!(debug.contains("CargoOutput"));
1552        assert!(debug.contains("exit_code: 1"));
1553    }
1554
1555    // ── Redaction idempotency ──
1556
1557    #[test]
1558    fn redact_is_idempotent_bearer() {
1559        let input = "Authorization: Bearer secret_value";
1560        let once = redact_sensitive(input);
1561        let twice = redact_sensitive(&once);
1562        assert_eq!(once, twice);
1563    }
1564
1565    #[test]
1566    fn redact_is_idempotent_env_token() {
1567        let input = "CARGO_REGISTRY_TOKEN=secret";
1568        let once = redact_sensitive(input);
1569        let twice = redact_sensitive(&once);
1570        assert_eq!(once, twice);
1571    }
1572
1573    #[test]
1574    fn redact_is_idempotent_token_assignment() {
1575        let input = r#"token = "secret_value""#;
1576        let once = redact_sensitive(input);
1577        let twice = redact_sensitive(&once);
1578        assert_eq!(once, twice);
1579    }
1580
1581    // ── Non-default exit codes ──
1582
1583    #[test]
1584    #[serial]
1585    fn cargo_publish_captures_nonzero_exit_code() {
1586        let td = tempdir().expect("tempdir");
1587        let bin = td.path().join("bin");
1588        fs::create_dir_all(&bin).expect("mkdir");
1589        let fake_cargo = write_fake_cargo(&bin);
1590
1591        let args_log = td.path().join("args.txt");
1592        let cwd_log = td.path().join("cwd.txt");
1593
1594        let ws = td.path().join("workspace");
1595        fs::create_dir_all(&ws).expect("mkdir ws");
1596
1597        temp_env::with_vars(
1598            [
1599                (
1600                    "SHIPPER_CARGO_BIN",
1601                    Some(fake_cargo.to_str().expect("utf8")),
1602                ),
1603                ("SHIPPER_ARGS_LOG", Some(args_log.to_str().expect("utf8"))),
1604                ("SHIPPER_CWD_LOG", Some(cwd_log.to_str().expect("utf8"))),
1605                ("SHIPPER_EXIT_CODE", Some("101")),
1606            ],
1607            || {
1608                let out = cargo_publish(&ws, "crate-a", "crates-io", false, false, 50, None)
1609                    .expect("publish");
1610                assert_eq!(out.exit_code, 101);
1611                assert!(!out.timed_out);
1612            },
1613        );
1614    }
1615
1616    // ── tail_lines with output_lines = 0 (edge case for output truncation) ──
1617
1618    #[test]
1619    fn tail_lines_zero_returns_empty() {
1620        let input = "line1\nline2\nline3";
1621        assert_eq!(tail_lines(input, 0), "");
1622    }
1623
1624    // ── Redaction with special characters in token values ──
1625
1626    #[test]
1627    fn redact_token_with_special_chars() {
1628        let input = "CARGO_REGISTRY_TOKEN=abc!@#$%^&*()_+-=[]{}|;:',.<>?/";
1629        let out = redact_sensitive(input);
1630        assert_eq!(out, "CARGO_REGISTRY_TOKEN=[REDACTED]");
1631    }
1632
1633    #[test]
1634    fn redact_bearer_with_base64_padding() {
1635        let input = "Authorization: Bearer eyJhbGciOiJSUzI1NiJ9.payload.sig==";
1636        let out = redact_sensitive(input);
1637        assert_eq!(out, "Authorization: Bearer [REDACTED]");
1638    }
1639
1640    #[test]
1641    fn redact_token_value_with_newline_escapes() {
1642        // Token value should not contain literal newlines, but escaped ones may appear
1643        let input = r#"token = "secret\nwith\nescapes""#;
1644        let out = redact_sensitive(input);
1645        assert!(out.contains("[REDACTED]"));
1646        assert!(!out.contains("secret\\nwith"));
1647    }
1648
1649    // ── Absorbed from shipper-cargo: is_valid_package_name ──
1650
1651    #[test]
1652    fn is_valid_package_name_valid() {
1653        assert!(is_valid_package_name("my-crate"));
1654        assert!(is_valid_package_name("my_crate"));
1655        assert!(is_valid_package_name("mycrate"));
1656        assert!(is_valid_package_name("my-crate-123"));
1657        assert!(is_valid_package_name("a"));
1658    }
1659
1660    #[test]
1661    fn is_valid_package_name_invalid() {
1662        assert!(!is_valid_package_name(""));
1663        assert!(!is_valid_package_name("123-crate")); // starts with digit
1664        assert!(!is_valid_package_name("-crate")); // starts with hyphen
1665        assert!(!is_valid_package_name("MyCrate")); // uppercase
1666        assert!(!is_valid_package_name("my.crate")); // dot not allowed
1667        assert!(!is_valid_package_name("my crate")); // space not allowed
1668    }
1669
1670    #[test]
1671    fn is_valid_package_name_underscore_start() {
1672        assert!(is_valid_package_name("_"));
1673        assert!(is_valid_package_name("__"));
1674        assert!(is_valid_package_name("_my_crate"));
1675    }
1676
1677    #[test]
1678    fn is_valid_package_name_mixed_separators() {
1679        assert!(is_valid_package_name("my-cool_crate"));
1680        assert!(is_valid_package_name("a-b_c"));
1681    }
1682
1683    #[test]
1684    fn is_valid_package_name_numbers_after_first() {
1685        assert!(is_valid_package_name("a123"));
1686        assert!(is_valid_package_name("crate99"));
1687        assert!(is_valid_package_name("my-123-crate"));
1688    }
1689
1690    #[test]
1691    fn is_valid_package_name_trailing_hyphen() {
1692        assert!(is_valid_package_name("crate-"));
1693    }
1694
1695    #[test]
1696    fn is_valid_package_name_trailing_underscore() {
1697        assert!(is_valid_package_name("crate_"));
1698    }
1699
1700    #[test]
1701    fn is_valid_package_name_rejects_uppercase_variants() {
1702        assert!(!is_valid_package_name("MyPackage"));
1703        assert!(!is_valid_package_name("ALLCAPS"));
1704        assert!(!is_valid_package_name("camelCase"));
1705    }
1706
1707    #[test]
1708    fn is_valid_package_name_rejects_special_characters() {
1709        assert!(!is_valid_package_name("my@crate"));
1710        assert!(!is_valid_package_name("my!crate"));
1711        assert!(!is_valid_package_name("my#crate"));
1712        assert!(!is_valid_package_name("my$crate"));
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    }
1718
1719    #[test]
1720    fn is_valid_package_name_single_underscore() {
1721        assert!(is_valid_package_name("_"));
1722    }
1723
1724    #[test]
1725    fn is_valid_package_name_rejects_unicode() {
1726        assert!(!is_valid_package_name("my-crête"));
1727        assert!(!is_valid_package_name("日本語"));
1728        assert!(!is_valid_package_name("café"));
1729    }
1730
1731    #[test]
1732    fn is_valid_package_name_max_length_valid() {
1733        let name = "a".repeat(100);
1734        assert!(is_valid_package_name(&name));
1735    }
1736
1737    #[test]
1738    fn is_valid_package_name_consecutive_hyphens() {
1739        assert!(is_valid_package_name("my--crate"));
1740    }
1741
1742    #[test]
1743    fn is_valid_package_name_consecutive_underscores() {
1744        assert!(is_valid_package_name("my__crate"));
1745    }
1746
1747    // ── Absorbed from shipper-cargo: PackageInfo ──
1748
1749    #[test]
1750    fn package_info_from_package() {
1751        let info = PackageInfo {
1752            name: "test".to_string(),
1753            version: "1.0.0".to_string(),
1754            manifest_path: "Cargo.toml".to_string(),
1755            is_workspace_member: true,
1756            publish: vec![],
1757        };
1758
1759        assert_eq!(info.name, "test");
1760        assert_eq!(info.version, "1.0.0");
1761    }
1762
1763    #[test]
1764    fn package_info_serialization() {
1765        let info = PackageInfo {
1766            name: "my-crate".to_string(),
1767            version: "2.0.0".to_string(),
1768            manifest_path: "/path/to/Cargo.toml".to_string(),
1769            is_workspace_member: true,
1770            publish: vec!["crates-io".to_string()],
1771        };
1772
1773        let json = serde_json::to_string(&info).expect("serialize");
1774        assert!(json.contains("\"name\":\"my-crate\""));
1775        assert!(json.contains("\"version\":\"2.0.0\""));
1776    }
1777
1778    #[test]
1779    fn package_info_deserialization_roundtrip() {
1780        let info = PackageInfo {
1781            name: "my-crate".to_string(),
1782            version: "2.0.0".to_string(),
1783            manifest_path: "/path/to/Cargo.toml".to_string(),
1784            is_workspace_member: true,
1785            publish: vec!["crates-io".to_string()],
1786        };
1787
1788        let json = serde_json::to_string(&info).expect("serialize");
1789        let deserialized: PackageInfo = serde_json::from_str(&json).expect("deserialize");
1790        assert_eq!(deserialized.name, info.name);
1791        assert_eq!(deserialized.version, info.version);
1792        assert_eq!(deserialized.manifest_path, info.manifest_path);
1793        assert_eq!(deserialized.is_workspace_member, info.is_workspace_member);
1794        assert_eq!(deserialized.publish, info.publish);
1795    }
1796
1797    #[test]
1798    fn package_info_empty_publish_means_all_registries() {
1799        let info = PackageInfo {
1800            name: "my-crate".to_string(),
1801            version: "1.0.0".to_string(),
1802            manifest_path: "Cargo.toml".to_string(),
1803            is_workspace_member: true,
1804            publish: vec![],
1805        };
1806        assert!(info.publish.is_empty());
1807    }
1808
1809    #[test]
1810    fn package_info_multiple_registries() {
1811        let info = PackageInfo {
1812            name: "my-crate".to_string(),
1813            version: "1.0.0".to_string(),
1814            manifest_path: "Cargo.toml".to_string(),
1815            is_workspace_member: false,
1816            publish: vec!["crates-io".to_string(), "my-registry".to_string()],
1817        };
1818        assert_eq!(info.publish.len(), 2);
1819        assert!(!info.is_workspace_member);
1820    }
1821
1822    #[test]
1823    fn package_info_pretty_json_roundtrip() {
1824        let info = PackageInfo {
1825            name: "complex-name_123".to_string(),
1826            version: "0.1.0-beta.1".to_string(),
1827            manifest_path: "crates/foo/Cargo.toml".to_string(),
1828            is_workspace_member: true,
1829            publish: vec![],
1830        };
1831        let pretty = serde_json::to_string_pretty(&info).expect("pretty serialize");
1832        let back: PackageInfo = serde_json::from_str(&pretty).expect("deserialize");
1833        assert_eq!(back.name, info.name);
1834        assert_eq!(back.version, info.version);
1835    }
1836
1837    #[test]
1838    fn package_info_with_empty_fields() {
1839        let info = PackageInfo {
1840            name: String::new(),
1841            version: String::new(),
1842            manifest_path: String::new(),
1843            is_workspace_member: false,
1844            publish: vec![],
1845        };
1846        let json = serde_json::to_string(&info).expect("serialize");
1847        let back: PackageInfo = serde_json::from_str(&json).expect("deserialize");
1848        assert_eq!(back.name, "");
1849        assert_eq!(back.version, "");
1850    }
1851
1852    #[test]
1853    fn package_info_json_contains_all_fields() {
1854        let info = PackageInfo {
1855            name: "test-pkg".to_string(),
1856            version: "1.0.0".to_string(),
1857            manifest_path: "/some/path/Cargo.toml".to_string(),
1858            is_workspace_member: false,
1859            publish: vec!["custom-registry".to_string()],
1860        };
1861        let json = serde_json::to_string(&info).expect("serialize");
1862        assert!(json.contains("\"is_workspace_member\":false"));
1863        assert!(json.contains("\"publish\":[\"custom-registry\"]"));
1864    }
1865
1866    // ── Absorbed from shipper-cargo: WorkspaceMetadata ──
1867
1868    #[test]
1869    fn workspace_metadata_loads_current_workspace() {
1870        let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1871
1872        assert!(!metadata.all_packages().is_empty());
1873        assert!(metadata.workspace_root().exists());
1874    }
1875
1876    #[test]
1877    fn workspace_metadata_gets_package() {
1878        let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1879
1880        let pkg = metadata.get_package("shipper");
1881        assert!(pkg.is_some());
1882    }
1883
1884    #[test]
1885    fn workspace_metadata_topological_order() {
1886        let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1887
1888        let result = metadata.topological_order();
1889        // Just check it doesn't panic - the result depends on the workspace structure
1890        assert!(result.is_ok() || result.is_err());
1891    }
1892
1893    #[test]
1894    fn workspace_metadata_all_packages_has_multiple() {
1895        let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1896        let all = metadata.all_packages();
1897        assert!(all.len() > 1, "workspace should have multiple packages");
1898    }
1899
1900    #[test]
1901    fn workspace_metadata_workspace_members_nonempty() {
1902        let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1903        let members = metadata.workspace_members();
1904        assert!(!members.is_empty(), "workspace should have members");
1905    }
1906
1907    #[test]
1908    fn workspace_metadata_get_nonexistent_package_returns_none() {
1909        let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1910        assert!(
1911            metadata
1912                .get_package("nonexistent-package-xyz-12345")
1913                .is_none()
1914        );
1915    }
1916
1917    #[test]
1918    fn workspace_metadata_workspace_name_not_empty() {
1919        let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1920        assert!(!metadata.workspace_name().is_empty());
1921    }
1922
1923    #[test]
1924    fn workspace_metadata_workspace_root_is_directory() {
1925        let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1926        assert!(metadata.workspace_root().is_dir());
1927    }
1928
1929    #[test]
1930    fn workspace_metadata_publishable_packages_subset_of_all() {
1931        let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1932        let all = metadata.all_packages();
1933        let publishable = metadata.publishable_packages();
1934        assert!(
1935            publishable.len() <= all.len(),
1936            "publishable ({}) should be <= all ({})",
1937            publishable.len(),
1938            all.len()
1939        );
1940    }
1941
1942    #[test]
1943    fn workspace_member_names_contains_known_crates() {
1944        let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1945        let names = workspace_member_names(&metadata);
1946        assert!(
1947            names.contains(&"shipper".to_string()),
1948            "should contain shipper, got: {names:?}"
1949        );
1950    }
1951
1952    #[test]
1953    fn workspace_metadata_topological_order_contains_publishable() {
1954        let metadata = WorkspaceMetadata::load_from_current_dir().expect("load metadata");
1955        if let Ok(order) = metadata.topological_order() {
1956            let publishable: Vec<String> = metadata
1957                .publishable_packages()
1958                .iter()
1959                .map(|p| p.name.to_string())
1960                .collect();
1961            for name in &publishable {
1962                assert!(
1963                    order.contains(name),
1964                    "topological order should contain publishable package {name}"
1965                );
1966            }
1967        }
1968    }
1969
1970    // ── Absorbed from shipper-cargo: load_metadata ──
1971
1972    #[test]
1973    fn load_metadata_returns_valid_metadata() {
1974        let manifest = std::env::current_dir()
1975            .unwrap()
1976            .join("..")
1977            .join("..")
1978            .join("Cargo.toml");
1979        let metadata = load_metadata(&manifest).expect("load metadata");
1980        assert!(!metadata.packages.is_empty());
1981    }
1982
1983    #[test]
1984    fn load_metadata_fails_for_nonexistent_path() {
1985        let result = load_metadata(Path::new("/nonexistent/Cargo.toml"));
1986        assert!(result.is_err());
1987    }
1988
1989    // ── Absorbed proptests ──
1990
1991    mod proptests_absorbed {
1992        use super::*;
1993        use proptest::prelude::*;
1994
1995        proptest! {
1996            #[test]
1997            fn valid_package_name_only_has_valid_chars(
1998                name in "[a-z_][a-z0-9_-]{0,30}",
1999            ) {
2000                prop_assert!(is_valid_package_name(&name));
2001            }
2002
2003            #[test]
2004            fn package_name_starting_with_digit_is_invalid(
2005                rest in "[a-z0-9_-]{0,20}",
2006                digit in proptest::char::range('0', '9'),
2007            ) {
2008                let name = format!("{digit}{rest}");
2009                prop_assert!(!is_valid_package_name(&name));
2010            }
2011
2012            #[test]
2013            fn package_name_starting_with_hyphen_is_invalid(
2014                rest in "[a-z0-9_-]{0,20}",
2015            ) {
2016                let name = format!("-{rest}");
2017                prop_assert!(!is_valid_package_name(&name));
2018            }
2019
2020            #[test]
2021            fn package_name_with_uppercase_is_invalid(
2022                prefix in "[a-z_][a-z0-9_-]{0,10}",
2023                upper in "[A-Z]",
2024                suffix in "[a-z0-9_-]{0,10}",
2025            ) {
2026                let name = format!("{prefix}{upper}{suffix}");
2027                prop_assert!(!is_valid_package_name(&name));
2028            }
2029
2030            #[test]
2031            fn package_info_serde_roundtrip(
2032                name in "[a-z][a-z0-9_-]{0,20}",
2033                version in "[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}",
2034                manifest in "\\PC{1,50}",
2035                is_member in any::<bool>(),
2036            ) {
2037                let info = PackageInfo {
2038                    name: name.clone(),
2039                    version: version.clone(),
2040                    manifest_path: manifest.clone(),
2041                    is_workspace_member: is_member,
2042                    publish: vec![],
2043                };
2044                let json = serde_json::to_string(&info).unwrap();
2045                let back: PackageInfo = serde_json::from_str(&json).unwrap();
2046                prop_assert_eq!(&back.name, &name);
2047                prop_assert_eq!(&back.version, &version);
2048                prop_assert_eq!(&back.manifest_path, &manifest);
2049                prop_assert_eq!(back.is_workspace_member, is_member);
2050                prop_assert!(back.publish.is_empty());
2051            }
2052
2053            #[test]
2054            fn package_info_with_registries_roundtrip(
2055                reg_count in 0usize..5,
2056                name in "[a-z][a-z0-9-]{0,10}",
2057            ) {
2058                let registries: Vec<String> = (0..reg_count)
2059                    .map(|i| format!("registry-{i}"))
2060                    .collect();
2061                let info = PackageInfo {
2062                    name,
2063                    version: "1.0.0".to_string(),
2064                    manifest_path: "Cargo.toml".to_string(),
2065                    is_workspace_member: true,
2066                    publish: registries.clone(),
2067                };
2068                let json = serde_json::to_string(&info).unwrap();
2069                let back: PackageInfo = serde_json::from_str(&json).unwrap();
2070                prop_assert_eq!(back.publish.len(), registries.len());
2071                prop_assert_eq!(&back.publish, &registries);
2072            }
2073
2074            #[test]
2075            fn is_valid_package_name_rejects_any_non_ascii(
2076                prefix in "[a-z_][a-z0-9_-]{0,5}",
2077                ch in proptest::char::range('\u{0080}', '\u{FFFF}'),
2078                suffix in "[a-z0-9_-]{0,5}",
2079            ) {
2080                let name = format!("{prefix}{ch}{suffix}");
2081                prop_assert!(!is_valid_package_name(&name));
2082            }
2083
2084            #[test]
2085            fn package_info_json_always_contains_name(
2086                name in "[a-z][a-z0-9-]{0,15}",
2087            ) {
2088                let info = PackageInfo {
2089                    name: name.clone(),
2090                    version: "1.0.0".to_string(),
2091                    manifest_path: "Cargo.toml".to_string(),
2092                    is_workspace_member: true,
2093                    publish: vec![],
2094                };
2095                let json = serde_json::to_string(&info).unwrap();
2096                prop_assert!(json.contains(&name));
2097            }
2098        }
2099    }
2100
2101    // ── Absorbed snapshot tests ──
2102
2103    mod snapshot_tests_absorbed {
2104        use super::*;
2105        use insta::{assert_debug_snapshot, assert_yaml_snapshot};
2106
2107        #[test]
2108        fn snapshot_package_info_simple() {
2109            let info = PackageInfo {
2110                name: "shipper-cargo".to_string(),
2111                version: "0.3.0".to_string(),
2112                manifest_path: "crates/shipper-cargo/Cargo.toml".to_string(),
2113                is_workspace_member: true,
2114                publish: vec![],
2115            };
2116            assert_yaml_snapshot!(info);
2117        }
2118
2119        #[test]
2120        fn snapshot_package_info_with_registries() {
2121            let info = PackageInfo {
2122                name: "my-private-crate".to_string(),
2123                version: "1.2.3-beta.1".to_string(),
2124                manifest_path: "crates/my-private-crate/Cargo.toml".to_string(),
2125                is_workspace_member: false,
2126                publish: vec!["crates-io".to_string(), "my-private-registry".to_string()],
2127            };
2128            assert_yaml_snapshot!(info);
2129        }
2130
2131        #[test]
2132        fn snapshot_valid_package_names() {
2133            let names = vec!["my-crate", "my_crate", "a", "_private", "crate-with-123"];
2134            let results: Vec<(&str, bool)> = names
2135                .into_iter()
2136                .map(|n| (n, is_valid_package_name(n)))
2137                .collect();
2138            assert_debug_snapshot!(results);
2139        }
2140
2141        #[test]
2142        fn snapshot_invalid_package_names() {
2143            let names = vec![
2144                "",
2145                "123-start",
2146                "-hyphen-start",
2147                "MyCrate",
2148                "my.crate",
2149                "my crate",
2150                "my@crate",
2151            ];
2152            let results: Vec<(&str, bool)> = names
2153                .into_iter()
2154                .map(|n| (n, is_valid_package_name(n)))
2155                .collect();
2156            assert_debug_snapshot!(results);
2157        }
2158
2159        #[test]
2160        fn snapshot_package_info_prerelease_version() {
2161            let info = PackageInfo {
2162                name: "my-alpha-crate".to_string(),
2163                version: "0.0.1-alpha.0+build.123".to_string(),
2164                manifest_path: "crates/my-alpha-crate/Cargo.toml".to_string(),
2165                is_workspace_member: true,
2166                publish: vec![],
2167            };
2168            assert_yaml_snapshot!(info);
2169        }
2170    }
2171}