Skip to main content

debcargo/
crates.rs

1use anyhow::{format_err, Context, Error};
2use cargo::{
3    core::{
4        manifest::ManifestMetadata, registry::PackageRegistry, resolver::features::CliFeatures,
5        Dependency, EitherManifest, FeatureMap, FeatureValue, Manifest, Package, PackageId,
6        Registry, SourceId, Summary, Target, TargetKind, Workspace,
7    },
8    ops::{self, PackageMessageFormat, PackageOpts, Packages},
9    sources::{
10        source::{MaybePackage, QueryKind, Source},
11        IndexSummary, RegistrySource, SourceConfigMap,
12    },
13    util::{
14        cache_lock::CacheLockMode, interning::InternedString, toml::read_manifest, FileLock,
15        GlobalContext,
16    },
17};
18use cargo_toml_edit::{self, DependencyFilter, FeatureFilter};
19use cfg_expr::targets::get_builtin_target_by_triple;
20use filetime::{set_file_times, FileTime};
21use flate2::read::GzDecoder;
22use glob::Pattern;
23use indexmap::{IndexMap, IndexSet};
24use itertools::Itertools;
25use regex::Regex;
26use semver::Version;
27use tar::Archive;
28use tempfile;
29
30use crate::config::{testing_ignore_debpolv, FilterTarget, TargetType};
31use crate::errors::Result;
32use crate::util::diff_files;
33use cargo::util::Filesystem;
34use std::fs;
35use std::io::{self, Read};
36use std::path::Path;
37use std::{self, ffi::OsStr};
38use std::{
39    collections::{BTreeMap, HashMap, HashSet},
40    str::FromStr,
41};
42
43pub struct CrateInfo {
44    // only used for to_registry_toml in extract_crate. DO NOT USE ELSEWHERE
45    package: Package,
46    // allows overriding package.manifest() e.g. via patches
47    manifest: Manifest,
48    crate_file: FileLock,
49    context: GlobalContext,
50    source_id: SourceId,
51    excludes: Vec<String>,
52    includes: Vec<String>,
53}
54
55pub type AutoPatches = IndexMap<String, Vec<u8>>;
56
57pub type CrateDepInfo = BTreeMap<
58    &'static str, // name of feature / optional dependency,
59    // or "" for the base package w/ no default features, guaranteed to be in the map
60    (
61        Vec<&'static str>, // dependencies: other features (of the current package)
62        Vec<Dependency>,
63    ),
64>;
65
66fn fetch_candidates(registry: &mut PackageRegistry, dep: &Dependency) -> Result<Vec<IndexSummary>> {
67    let mut summaries = match registry.query_vec(dep, QueryKind::Exact) {
68        std::task::Poll::Ready(res) => res?,
69        std::task::Poll::Pending => {
70            registry.block_until_ready()?;
71            return fetch_candidates(registry, dep);
72        }
73    };
74    summaries.sort_by(|a, b| b.package_id().partial_cmp(&a.package_id()).unwrap());
75    Ok(summaries)
76}
77
78pub fn invalidate_crates_io_cache() -> Result<()> {
79    let context = GlobalContext::default()?;
80    let _lock = context.acquire_package_cache_lock(CacheLockMode::DownloadExclusive)?;
81    let source_id = SourceId::crates_io_maybe_sparse_http(&context)?;
82    let yanked_whitelist = HashSet::new();
83    let mut r = RegistrySource::remote(source_id, &yanked_whitelist, &context)?;
84    r.invalidate_cache();
85    Ok(())
86}
87
88pub fn crate_name_ver_to_dep(crate_name: &str, version: Option<&str>) -> Result<Dependency> {
89    // note: this forces a network call
90    let context = GlobalContext::default()?;
91    let source_id = SourceId::crates_io_maybe_sparse_http(&context)?;
92    let version = version.and_then(|v| {
93        if v.is_empty() {
94            None
95        } else if v.starts_with(|c: char| c.is_ascii_digit()) {
96            Some(["=", v].concat())
97        } else {
98            Some(v.to_string())
99        }
100    });
101    Dependency::parse(crate_name, version.as_deref(), source_id)
102}
103
104// attempt to map back a version requirement to a version that can be used as last resort
105// fallback in case all versions satisfying the requirement are yanked
106fn ver_req_to_ver(dep: &Dependency) -> Option<Version> {
107    match dep.version_req() {
108        // if any version would satisfy the dep, but all of them are yanked, we probably don't want
109        // to use this crate anyway ;)
110        cargo::util::OptVersionReq::Any => None,
111        // a regular version requirement is only useful if it has a single element
112        cargo::util::OptVersionReq::Req(req) if req.comparators.len() == 1 => {
113            let comp = req.comparators.first().unwrap();
114            match comp.op {
115                // these are all satisfied by the full version, if one is set
116                semver::Op::Exact
117                | semver::Op::GreaterEq
118                | semver::Op::LessEq
119                | semver::Op::Tilde
120                | semver::Op::Caret => {
121                    if let Some(minor) = comp.minor {
122                        // if the requirement comes with a full version extract it
123                        comp.patch.map(|patch| Version {
124                            major: comp.major,
125                            minor,
126                            patch,
127                            pre: comp.pre.clone(),
128                            build: semver::BuildMetadata::default(),
129                        })
130                    } else {
131                        // we'd have to guess here.
132                        None
133                    }
134                }
135                // Greater, Less or Wildcard all require guessing as well
136                _ => None,
137            }
138        }
139        // requirements with multiple constraints
140        cargo::util::OptVersionReq::Req(_) => None,
141        // locked requirements contain an exact version
142        cargo::util::OptVersionReq::Locked(ver, _) => Some(ver.clone()),
143        // precise requirements contain an exact version
144        cargo::util::OptVersionReq::Precise(ver, _) => Some(ver.clone()),
145    }
146}
147
148pub fn show_dep(dep: &Dependency) -> String {
149    format!("{} {}", dep.package_name(), dep.version_req())
150}
151
152impl CrateInfo {
153    pub fn new(crate_name: &str, version: Option<&str>) -> Result<CrateInfo> {
154        CrateInfo::new_with_update(crate_name, version, true)
155    }
156
157    pub fn new_with_local_crate(
158        crate_name: &str,
159        version: Option<&str>,
160        crate_path: &Path,
161    ) -> Result<CrateInfo> {
162        let context = GlobalContext::default()?;
163        let crate_path = crate_path.canonicalize()?;
164        let source_id = SourceId::for_path(&crate_path)?;
165
166        let (package, crate_file) = {
167            let yanked_whitelist = HashSet::new();
168
169            let mut source = source_id.load(&context, &yanked_whitelist)?;
170
171            let package_id = match version {
172                None => {
173                    let dep = Dependency::parse(crate_name, None, source_id)?;
174                    let mut package_id: Option<PackageId> = None;
175                    loop {
176                        match source.query(&dep, QueryKind::Exact, &mut |p| {
177                            package_id = Some(p.package_id());
178                        }) {
179                            std::task::Poll::Ready(res) => {
180                                res?;
181                                break;
182                            }
183                            std::task::Poll::Pending => {
184                                source.block_until_ready()?;
185                            }
186                        }
187                    }
188                    package_id.unwrap()
189                }
190                Some(version) => {
191                    let version = version.parse::<Version>()?;
192                    PackageId::new(crate_name.into(), version, source_id)
193                }
194            };
195
196            let maybe_package = source.download(package_id)?;
197            let package = match maybe_package {
198                MaybePackage::Ready(p) => Ok(p),
199                _ => Err(format_err!(
200                    "Failed to 'download' local crate {} from {}",
201                    crate_name,
202                    crate_path.display()
203                )),
204            }?;
205
206            let crate_file = {
207                let workspace = Workspace::ephemeral(package.clone(), &context, None, true)?;
208
209                let opts = PackageOpts {
210                    gctx: &context,
211                    verify: false,
212                    list: false,
213                    check_metadata: true,
214                    allow_dirty: true,
215                    cli_features: CliFeatures::from_command_line(&[], true, false)?,
216                    jobs: None,
217                    targets: Vec::new(),
218                    to_package: Packages::Default,
219                    keep_going: false,
220                    reg_or_index: None,
221                    dry_run: false,
222                    include_lockfile: false,
223                    fmt: PackageMessageFormat::Human,
224                };
225                ops::package(&workspace, &opts)?;
226
227                let filename = format!("{}-{}.crate", crate_name, package_id.version());
228                workspace
229                    .target_dir()
230                    .join("package")
231                    .open_rw_exclusive_create(filename, &context, "crate file")?
232            };
233
234            (package, crate_file)
235        };
236
237        let manifest = package.manifest().clone();
238
239        Ok(CrateInfo {
240            package,
241            manifest,
242            crate_file,
243            context,
244            source_id,
245            excludes: vec![],
246            includes: vec![],
247        })
248    }
249
250    pub fn new_with_update(
251        crate_name: &str,
252        version: Option<&str>,
253        update: bool,
254    ) -> Result<CrateInfo> {
255        let dep = crate_name_ver_to_dep(crate_name, version)?;
256        Self::new_from_dependency(&dep, update)
257    }
258
259    pub fn new_from_dependency(dependency: &Dependency, update: bool) -> Result<CrateInfo> {
260        let mut context = GlobalContext::default()?;
261        if !update {
262            // unfriendly API from cargo; we'll have to make do with it for
263            // now as there is no other alternative
264            context.configure(
265                0,
266                false,
267                None,
268                false,
269                false,
270                true, // offline
271                &context.target_dir()?.map(Filesystem::into_path_unlocked),
272                &[],
273                &[],
274            )?;
275        }
276
277        let source_id = SourceId::crates_io_maybe_sparse_http(&context)?;
278        let registry_name = format!(
279            "{}-{}",
280            source_id.url().host_str().unwrap_or(""),
281            cargo::util::hex::short_hash(&source_id)
282        );
283        let get_package_info = |context: &GlobalContext,
284                                possibly_yanked_ver: Option<&Version>|
285         -> Result<_> {
286            let lock = context.acquire_package_cache_lock(CacheLockMode::DownloadExclusive)?;
287            let mut registry =
288                PackageRegistry::new_with_source_config(context, SourceConfigMap::new(context)?)?;
289
290            // if we have some exact version that should satisfy our dependency requirements, and
291            // we are on our third attempt of retrieving the crate info from the registry, add this
292            // version to the yanked whitelist as a last resort
293            if let Some(ver) = possibly_yanked_ver {
294                let pkgids = vec![PackageId::new(
295                    dependency.package_name(),
296                    ver.clone(),
297                    dependency.source_id(),
298                )];
299                debcargo_warn!(
300                    "Adding {} to yanked whitelist as last resort..",
301                    pkgids.first().unwrap()
302                );
303                registry.add_to_yanked_whitelist(pkgids.into_iter());
304            }
305            registry.lock_patches();
306            let summaries = fetch_candidates(&mut registry, dependency)?;
307            drop(lock);
308            let pkgids = summaries.into_iter().map(|s| s.package_id()).collect_vec();
309            let pkgid = pkgids.iter().max().ok_or_else(|| {
310                format_err!(
311                    concat!(
312                        "Couldn't find any crate matching {}\n",
313                        "Try `debcargo update` to update the crates.io index."
314                    ),
315                    show_dep(dependency)
316                )
317            })?;
318            let pkgset = registry.get(pkgids.as_slice())?;
319            let package = pkgset.get_one(*pkgid)?;
320
321            let manifest = package.manifest();
322            for f in dependency.features() {
323                // apparently, if offline is set then cargo sometimes selects
324                // an offline-available version that doesn't satisfy the
325                // requested features. this is dumb. if it happens, then we
326                // retry with online allowed.
327                if !manifest.summary().features().contains_key(f) {
328                    debcargo_bail!(
329                        "resolve ({} {}) -> ({}) failed to pick up required feature ({})\n\
330                        This can happen with very old or yanked crates. Try patching one of \
331                        its dependants, to drop or update the offending dependency.",
332                        dependency.package_name(),
333                        dependency.version_req(),
334                        pkgid,
335                        f,
336                    )
337                }
338            }
339            let filename = format!("{}-{}.crate", pkgid.name(), pkgid.version());
340            let crate_file = context
341                .registry_cache_path()
342                .join(&registry_name)
343                .open_ro_shared(&filename, context, &filename)?;
344            Ok((package.clone(), manifest.clone(), crate_file))
345        };
346        // if update is false but the user never downloaded the crate then the
347        // first call will error; re-try with online in that case
348        let (package, manifest, crate_file) = get_package_info(&context, None)
349            .or_else(|_| get_package_info(&GlobalContext::default()?, None))
350            .or_else(|err| {
351                let ver = ver_req_to_ver(dependency);
352                if ver.is_some() {
353                    get_package_info(&GlobalContext::default()?, ver.as_ref())
354                } else {
355                    Err(err)
356                }
357            })?;
358
359        Ok(CrateInfo {
360            package,
361            manifest,
362            crate_file,
363            context,
364            source_id,
365            excludes: vec![],
366            includes: vec![],
367        })
368    }
369
370    pub fn crate_name(&self) -> &'static str {
371        self.package_id().name().as_str()
372    }
373
374    pub fn version(&self) -> &Version {
375        self.package_id().version()
376    }
377
378    pub fn semver(&self) -> String {
379        match *self.package_id().version() {
380            Version {
381                major: 0, minor, ..
382            } => format!("0.{minor}"),
383            Version { major, .. } => format!("{major}"),
384        }
385    }
386
387    pub fn manifest(&self) -> &Manifest {
388        &self.manifest
389    }
390
391    pub fn replace_manifest(&mut self, path: &Path) -> Result<&Self> {
392        if let EitherManifest::Real(v) = read_manifest(path, self.source_id, &self.context)? {
393            self.manifest = v;
394        }
395        Ok(self)
396    }
397
398    pub fn metadata(&self) -> &ManifestMetadata {
399        self.manifest.metadata()
400    }
401
402    pub fn manifest_path(&self) -> &Path {
403        self.package.manifest_path()
404    }
405
406    pub fn rust_version(&self) -> Option<String> {
407        self.manifest.rust_version().map(ToString::to_string)
408    }
409
410    pub fn targets(&self) -> &[Target] {
411        self.manifest.targets()
412    }
413
414    pub fn is_lib(&self) -> bool {
415        let mut lib = false;
416        for target in self.manifest.targets() {
417            match *target.kind() {
418                TargetKind::Lib(_) => {
419                    lib = true;
420                    break;
421                }
422                _ => continue,
423            }
424        }
425        lib
426    }
427
428    pub fn get_binary_targets(&self) -> Vec<&str> {
429        let mut bins = Vec::new();
430        for target in self.manifest.targets() {
431            match *target.kind() {
432                TargetKind::Bin => {
433                    bins.push(target.name());
434                }
435                _ => continue,
436            }
437        }
438        bins.sort_unstable();
439        bins
440    }
441
442    pub fn summary(&self) -> &Summary {
443        self.manifest.summary()
444    }
445
446    pub fn checksum(&self) -> Option<&str> {
447        self.manifest.summary().checksum()
448    }
449
450    pub fn package_id(&self) -> PackageId {
451        self.manifest.summary().package_id()
452    }
453
454    pub fn crate_file(&self) -> &FileLock {
455        &self.crate_file
456    }
457
458    pub fn dependencies(&self) -> &[Dependency] {
459        self.manifest.dependencies()
460    }
461
462    pub fn dev_dependencies(&self) -> Vec<Dependency> {
463        use cargo::core::dependency::DepKind;
464        let mut deps = vec![];
465        for dep in self.dependencies() {
466            if dep.kind() == DepKind::Development {
467                deps.push(dep.clone());
468            }
469        }
470        deps
471    }
472
473    pub fn get_summary_description(&self) -> (Option<String>, Option<String>) {
474        let (summary, description) = if let Some(ref description) = self.metadata().description {
475            // Convention these days seems to be to do manual text
476            // wrapping in crate descriptions, boo. \n\n is a real line break.
477            let mut description = description
478                .replace("\n\n", "\r")
479                .replace('\n', " ")
480                .replace('\r', "\n")
481                .trim()
482                .to_string();
483            // Trim off common prefixes
484            let re = Regex::new(&format!(
485                r"^(?i)({}|This(\s+\w+)?)(\s*,|\s+is|\s+provides)\s+",
486                self.package_id().name()
487            ))
488            .unwrap();
489            description = re.replace(&description, "").to_string();
490            let re = Regex::new(r"^(?i)(a|an|the)\s+").unwrap();
491            description = re.replace(&description, "").to_string();
492            let re =
493                Regex::new(r"^(?i)(rust\s+)?(implementation|library|tool|crate)\s+(of|to|for)\s+")
494                    .unwrap();
495            description = re.replace(&description, "").to_string();
496
497            // https://stackoverflow.com/questions/38406793/why-is-capitalizing-the-first-letter-of-a-string-so-convoluted-in-rust
498            description = {
499                let mut d = description.chars();
500                match d.next() {
501                    None => String::new(),
502                    Some(f) => f.to_uppercase().chain(d).collect::<String>(),
503                }
504            };
505
506            // Use the first sentence or first line, whichever comes first, as the summary.
507            let p1 = description.find('\n');
508            let p2 = description.find(". ");
509            match p1.into_iter().chain(p2).min() {
510                Some(p) => {
511                    let s = description[..p].trim_end_matches('.').to_string();
512                    let d = description[p + 1..].trim();
513                    if d.is_empty() {
514                        (Some(s), None)
515                    } else {
516                        (Some(s), Some(d.to_string()))
517                    }
518                }
519                None => (Some(description.trim_end_matches('.').to_string()), None),
520            }
521        } else {
522            (None, None)
523        };
524
525        (summary, description)
526    }
527
528    /// To be called before `extract_crate`.
529    pub fn set_includes_excludes(
530        &mut self,
531        excludes: Option<&Vec<String>>,
532        includes: Option<&Vec<String>>,
533    ) {
534        self.excludes = excludes.cloned().unwrap_or_default();
535        self.includes = includes.cloned().unwrap_or_default();
536    }
537
538    pub fn filter_path(&self, path: &Path) -> std::result::Result<bool, String> {
539        let top_level = path
540            .ancestors()
541            .find(|p| p.parent() == Some(Path::new("")))
542            .unwrap()
543            .to_str()
544            .ok_or_else(|| format!("Failed to get top-level element of {path:?}"))?;
545
546        let matches = move |pattern: &String| -> bool {
547            Pattern::new(&format!("{top_level}/{pattern}"))
548                .unwrap()
549                .matches_path(path)
550        };
551
552        if self.excludes.iter().any(matches) {
553            return Ok(true);
554        }
555        let suspicious = match path.extension().map(OsStr::to_ascii_lowercase) {
556            Some(ext) => {
557                ext == "c"
558                    || ext == "a"
559                    || ext == "s"
560                    || ext == "o"
561                    || ext == "so"
562                    || ext == "cpp"
563                    || ext == "cc"
564                    || ext == "cxx"
565            }
566            _ => false,
567        };
568
569        if suspicious {
570            if self.includes.iter().any(matches) {
571                debcargo_info!("Suspicious file, on whitelist so ignored: {:?}", path);
572                Ok(false)
573            } else if testing_ignore_debpolv() {
574                debcargo_warn!("Suspicious file, ignoring as per override: {:?}", path);
575                Ok(false)
576            } else {
577                Err(format!(
578                    "Suspicious file, should probably be excluded in debcargo.toml: {path:?}"
579                ))
580            }
581        } else {
582            Ok(false)
583        }
584    }
585
586    pub fn extract_crate(&mut self, path: &Path) -> Result<(bool, Option<String>)> {
587        let mut archive = Archive::new(GzDecoder::new(self.crate_file.file()));
588        let tempdir = tempfile::Builder::new()
589            .prefix("debcargo")
590            .tempdir_in(".")
591            .context("Failed to create tempdir for extracting crate")?;
592        let mut source_modified = false;
593        let mut manifest_normalization_patch = None;
594        let mut last_mtime = 0;
595        let mut err = vec![];
596
597        for entry in archive
598            .entries()
599            .context("Failed iterating crate tarball")?
600        {
601            let mut entry = entry.context("Failed iterating crate tarball entry")?;
602            let entry_path = entry
603                .path()
604                .with_context(|| format!("Failed to convert entry path {:?}", entry.path_bytes()))?
605                .into_owned();
606            match self.filter_path(&entry_path) {
607                Err(e) => err.push(e),
608                Ok(r) => {
609                    if r {
610                        source_modified = true;
611                        continue;
612                    }
613                }
614            }
615
616            if !entry.unpack_in(tempdir.path()).with_context(|| {
617                format!("Failed to unpack {entry_path:?} entry of crate tarball")
618            })? {
619                debcargo_bail!("Crate contained path traversals via '..'");
620            }
621
622            if let Ok(mtime) = entry.header().mtime() {
623                if mtime > last_mtime {
624                    last_mtime = mtime;
625                }
626            }
627        }
628        if !err.is_empty() {
629            for e in err {
630                debcargo_warn!("{}", e);
631            }
632            debcargo_bail!(
633                "Suspicious files detected, aborting. Ask on #debian-rust if you are stuck."
634            )
635        }
636
637        let entries = tempdir
638            .path()
639            .read_dir()
640            .context("Failed reading extracted tarball tempdir")?
641            .collect::<io::Result<Vec<_>>>()
642            .context("Failed collecting extracted tarball tempdir contents")?;
643        if entries.len() != 1
644            || !entries[0]
645                .file_type()
646                .context(
647                    "Failed to determine file type of first dir entry of extracted tarball tempdir",
648                )?
649                .is_dir()
650        {
651            let pkgid = self.package_id();
652            debcargo_bail!(
653                "{}-{}.crate did not unpack to a single top-level directory",
654                pkgid.name(),
655                pkgid.version()
656            );
657        }
658
659        if let Err(e) = fs::rename(entries[0].path(), path) {
660            return Err(Error::from(e).context(format!(
661                concat!(
662                    "Could not create source directory {0}\n",
663                    "To regenerate, move or remove {0}"
664                ),
665                path.display()
666            )));
667        }
668
669        // Ensure an upstream debian/ dir is stripped, just like dpkg-source does
670        let _ = fs::remove_dir_all(path.join("debian"));
671
672        // Ensure that Cargo.toml is in standard form, e.g. does not contain
673        // path dependencies, so can be built standalone (see #4030).
674        let toml_path = path
675            .join("Cargo.toml")
676            .canonicalize()
677            .context("Failed to canonicalize Cargo.toml path in temporary crate unpack dir")?;
678        let mut actual_toml = String::new();
679        fs::File::open(&toml_path)
680            .context("Failed to open crate manifest in extracted crate tarball tempdir")?
681            .read_to_string(&mut actual_toml)
682            .context("Failed to read crate manifest in extracted crate tarball tempdir")?;
683
684        if !actual_toml.contains("AUTOMATICALLY GENERATED BY CARGO") {
685            // This logic should only fire for old crates, and that's what the
686            // if-conditional is supposed to check; modern versions of cargo
687            // already do this before uploading the crate, and we shouldn't need
688            // to handle it specially.
689            log::debug!("Cargo.toml not canonicalized..");
690            let orig_toml = actual_toml.clone();
691
692            // Some old uncanonicalized Cargo.toml files contain invalid relative references to
693            // license or readme files that newer cargo doesn't accept anymore. attempt to monkey
694            // patch them if possible
695            let mut check_reference = |name, value: Option<&str>| {
696                if let Some(referenced_path) = value {
697                    let full_referenced_path = path.join(referenced_path);
698                    if !full_referenced_path.exists() {
699                        debcargo_warn!(
700                            "Cargo.toml references non-existing license_file path: {:?}",
701                            full_referenced_path
702                        );
703                        if let Some((_prefix, file)) = referenced_path.rsplit_once('/') {
704                            if path.join(file).exists() {
705                                debcargo_info!("Replacing reference with '{}'", file);
706                                actual_toml = actual_toml.replace(referenced_path, file);
707                            } else {
708                                debcargo_info!("Removing reference");
709                                actual_toml = actual_toml
710                                    .replace(&format!("{name} = \"{referenced_path}\""), "");
711                            }
712                        } else {
713                            debcargo_info!("Removing reference");
714                            actual_toml =
715                                actual_toml.replace(&format!("{name} = \"{referenced_path}\""), "");
716                        }
717                        return true;
718                    }
719                }
720
721                false
722            };
723
724            if check_reference("license-file", self.metadata().license_file.as_deref())
725                || check_reference("readme", self.metadata().readme.as_deref())
726            {
727                fs::write(&toml_path, actual_toml.as_bytes())
728                    .context("Failed to overwrite Cargo.toml during normalization")?;
729            }
730            let updated = self
731                .replace_manifest(&toml_path)
732                .context("Failed to reload manifest after canonicalization")?;
733
734            let ws = Workspace::new(&toml_path, &updated.context)
735                .context("Failed to instantiate workspace after reloading manifest")?;
736            let opts = PackageOpts {
737                gctx: &updated.context,
738                list: false,
739                check_metadata: false,
740                allow_dirty: true,
741                verify: false,
742                jobs: None,
743                keep_going: false,
744                to_package: Packages::Default,
745                targets: Vec::new(),
746                cli_features: CliFeatures::new_all(true),
747                reg_or_index: None,
748                dry_run: false,
749                include_lockfile: false,
750                fmt: PackageMessageFormat::Human,
751            };
752            let files = ops::package(&ws, &opts)
753                .context("Failed to run `cargo package` for normalization")?;
754            let file = files
755                .first()
756                .ok_or_else(|| format_err!("No canonicalized archives found.."))?;
757            log::trace!("canonical crate tarball: {file:?}");
758            let file = file.path();
759            let file = fs::File::open(file)?;
760            log::trace!("{file:?}");
761            let mut archive = Archive::new(GzDecoder::new(file));
762
763            for entry in archive.entries()? {
764                let mut entry =
765                    entry.context("Failed to parse tar entry in normalized crate tarball")?;
766                let entry_path = entry.path().with_context(|| {
767                    format!(
768                        "Failed to convert crate tarball entry path to UTF8: {:?}",
769                        entry.path_bytes(),
770                    )
771                })?;
772                log::trace!("normalized .crate archive entry: {entry_path:?}");
773                let mut components = entry_path.iter();
774                if components.clone().count() == 2
775                    && components.next_back() == Some(OsStr::new("Cargo.toml"))
776                {
777                    entry
778                        .unpack(&toml_path)
779                        .context("Failed to extract normalized Cargo.toml")?;
780                    break;
781                }
782            }
783
784            log::debug!("generating Cargo.toml normalization patch");
785            fs::rename(path.join("Cargo.toml"), path.join("Cargo.toml.normalized"))
786                .context("Faield to rename normalized Cargo.toml")?;
787            fs::write(path.join("Cargo.toml"), orig_toml.as_bytes())
788                .context("Failed to write original Cargo.toml contents")?;
789            let diff = diff_files(
790                "Cargo.toml",
791                &path.join("Cargo.toml"),
792                &path.join("Cargo.toml.normalized"),
793            )?;
794            manifest_normalization_patch = Some({
795                let mut patch = String::from(
796                    "This patch was autogenerated using debcargo. Do not edit manually!\n",
797                );
798                patch.push_str(&diff);
799                patch
800            });
801            fs::remove_file(path.join("Cargo.toml.normalized"))
802                .context("Failed to remove temporary normalized Cargo.toml")?;
803
804            // avoid lintian errors about package-contains-ancient-file
805            // TODO: do we want to do this for unmodified tarballs? it would
806            // force us to modify them, but otherwise we get that ugly warning
807            let last_mtime = FileTime::from_unix_time(i64::try_from(last_mtime)?, 0);
808            set_file_times(toml_path, last_mtime, last_mtime)?;
809            debcargo_info!("Cargo.toml manually canonicalized!");
810        }
811        Ok((source_modified, manifest_normalization_patch))
812    }
813}
814
815/// Collect information about the dependency structure of features and
816/// their external crate dependencies, in a simple output format.
817pub fn all_dependencies_and_features(manifest: &Manifest) -> CrateDepInfo {
818    all_dependencies_and_features_filtered(manifest, false)
819}
820
821fn check_cycle_in_feature<'a>(feature_map: &'a FeatureMap, feature: &'a str) -> bool {
822    fn check_cycle_in_feature_impl<'a>(
823        feature_map: &'a FeatureMap,
824        current: &'a str,
825        processing: &mut Vec<&'a str>,
826    ) -> bool {
827        if processing.contains(&current) {
828            return true;
829        }
830
831        processing.push(current);
832
833        if let Some(deps) = feature_map.get(current) {
834            for dep in deps {
835                if let FeatureValue::Feature(dep_feature) = dep {
836                    if check_cycle_in_feature_impl(feature_map, dep_feature, processing) {
837                        return true;
838                    }
839                }
840            }
841        }
842
843        processing.pop();
844        false
845    }
846
847    let mut processing = Vec::new();
848    check_cycle_in_feature_impl(feature_map, feature, &mut processing)
849}
850
851/// Collect information about the dependency structure of features and
852/// their external crate dependencies, in a simple output format.
853/// If `include_dev_dependencies` is true, dev-dependencies will be included.
854pub fn all_dependencies_and_features_filtered(
855    manifest: &Manifest,
856    include_dev_dependencies: bool,
857) -> CrateDepInfo {
858    use cargo::core::dependency::DepKind;
859
860    let mut deps_by_name: BTreeMap<&str, Vec<&Dependency>> = BTreeMap::new();
861    for dep in manifest.dependencies() {
862        // we treat build-dependencies also as dependencies in Debian
863        if include_dev_dependencies || dep.kind() != DepKind::Development {
864            let s = dep.name_in_toml().as_str();
865            deps_by_name.entry(s).or_default().push(dep);
866        }
867    }
868    let deps_by_name = deps_by_name;
869
870    let mut features_with_deps = BTreeMap::new();
871
872    // calculate dependencies of this crate's features
873    let feature_map = manifest.summary().features();
874    for (feature, deps) in feature_map {
875        let mut feature_deps: Vec<&'static str> = vec![];
876        let mut other_deps: Vec<Dependency> = Vec::new();
877        for dep in deps {
878            use self::FeatureValue::{Dep, DepFeature, Feature};
879            match dep {
880                // another feature is a dependency
881                Feature(dep_feature) => {
882                    feature_deps.push(InternedString::new(dep_feature).as_str());
883                }
884                // another package is a dependency
885                Dep { dep_name } => {
886                    // unwrap is ok, valid Cargo.toml files must have this
887                    for &dep in deps_by_name.get(dep_name.as_str()).unwrap() {
888                        other_deps.push(dep.clone());
889                    }
890                }
891                // another package is a dependency
892                DepFeature {
893                    dep_name,
894                    dep_feature,
895                    ..
896                } => {
897                    if let Some(dd) = deps_by_name.get(dep_name.as_str()) {
898                        for &dep in dd {
899                            let mut dep = dep.clone();
900                            let mut features: Vec<InternedString> =
901                                vec![InternedString::new(dep_feature)];
902                            features.extend(dep.features());
903                            dep.set_features(features);
904                            dep.set_default_features(false);
905                            other_deps.push(dep);
906                        }
907                    } else {
908                        let mut expected = false;
909                        for dep in manifest.dependencies() {
910                            if dep.kind() == DepKind::Development {
911                                let s = dep.name_in_toml().as_str();
912                                if s == dep_name.as_str() {
913                                    expected = true;
914                                }
915                            }
916                        }
917                        if expected {
918                            debcargo_warn!(
919                                "Ignoring \"{}\" feature \"{}\" as it depends on a \
920                                     dev-dependency \"{}\"",
921                                manifest.package_id(),
922                                feature,
923                                dep_name
924                            );
925                        } else {
926                            panic!(
927                                "failed to account for dependency \"{}\" of \"{}\" feature \"{}\"",
928                                dep_name,
929                                manifest.package_id(),
930                                feature
931                            );
932                        }
933                    }
934                }
935            }
936        }
937
938        if feature_deps.is_empty()
939            || check_cycle_in_feature(feature_map, feature) && !feature_deps.contains(&"")
940        {
941            // everything depends on bare library
942            feature_deps.push("");
943        }
944
945        features_with_deps.insert(feature.as_str(), (feature_deps, other_deps));
946    }
947
948    // calculate required dependencies for implicit no-default-features
949    let mut deps_required: Vec<Dependency> = Vec::new();
950    for deps in deps_by_name.values() {
951        for &dep in deps {
952            if !dep.is_optional() {
953                deps_required.push(dep.clone());
954            }
955        }
956    }
957
958    // implicit no-default-features
959    features_with_deps.insert("", (vec![], deps_required));
960
961    // implicit default feature
962    if !features_with_deps.contains_key("default") {
963        features_with_deps.insert("default", (vec![""], vec![]));
964    }
965
966    features_with_deps
967}
968
969/// Add `[lints.rust.unexpected_cfgs]` `check-cfg` entries for `features`, so
970/// rustc doesn't warn about `#[cfg(feature = "name")]` guards that remain in
971/// the source. Existing entries are preserved and not duplicated. A malformed
972/// `[lints]` section (where one of the expected keys is not a table/array) is
973/// left untouched.
974fn add_unexpected_cfgs(doc: &mut toml_edit::DocumentMut, features: &[String]) {
975    use toml_edit::{Item, Table};
976
977    let cfg_value = |f: &str| format!("cfg(feature, values(\"{f}\"))");
978
979    let new_table = || {
980        let mut t = Table::new();
981        t.set_implicit(true);
982        Item::Table(t)
983    };
984    let unexpected = doc
985        .entry("lints")
986        .or_insert_with(new_table)
987        .as_table_mut()
988        .and_then(|t| t.entry("rust").or_insert_with(new_table).as_table_mut())
989        .map(|t| t.entry("unexpected_cfgs").or_insert_with(new_table));
990    // If lints / lints.rust / unexpected_cfgs already exist but aren't tables,
991    // the manifest is malformed; leave it untouched.
992    let Some(unexpected) = unexpected.and_then(Item::as_table_mut) else {
993        debcargo_warn!("Cargo.toml: [lints.rust.unexpected_cfs] is not a TOML table, not injecting removed features!");
994        return;
995    };
996    // This table is written explicitly, so don't let it stay implicit.
997    unexpected.set_implicit(false);
998    unexpected
999        .entry("level")
1000        .or_insert_with(|| toml_edit::value("warn"));
1001    let Some(check_cfg) = unexpected
1002        .entry("check-cfg")
1003        .or_insert_with(|| toml_edit::value(toml_edit::Array::new()))
1004        .as_array_mut()
1005    else {
1006        return;
1007    };
1008
1009    let existing: HashSet<&str> = check_cfg
1010        .iter()
1011        .filter_map(toml_edit::Value::as_str)
1012        .collect();
1013    let additions: Vec<String> = features
1014        .iter()
1015        .map(|f| cfg_value(f))
1016        .filter(|v| !existing.contains(v.as_str()))
1017        .collect();
1018    for entry in additions {
1019        // Cargo.toml should be normalized, this is the format used there
1020        let formatted = toml_edit::Value::from(entry).decorated("\n    ", "");
1021        check_cfg.push_formatted(formatted);
1022    }
1023}
1024
1025/// Remove named features from the `[features]` section of a parsed Cargo.toml,
1026/// also removing any references to those features in the dependency lists of
1027/// remaining features, and removing optional dependencies that are exclusively
1028/// enabled by the removed features. Returns true if the document was modified.
1029fn apply_remove_features(
1030    doc: toml_edit::DocumentMut,
1031    features: &[String],
1032) -> Result<Option<String>> {
1033    let mut manifest = cargo_toml_edit::Manifest::from(doc);
1034
1035    let mut filter = FeatureFilter::empty();
1036    filter.features = features.to_vec();
1037    filter.unsafe_mode = true;
1038    log::debug!("Feature filters: {filter:?}");
1039
1040    let filtered = manifest.filter_features(&filter)?;
1041    if filtered.removed_features.is_empty() {
1042        Ok(None)
1043    } else {
1044        debcargo_info!("Removed features:");
1045        for (feature, _item) in &filtered.removed_features {
1046            debcargo_info!(format!("- {feature}"));
1047        }
1048        debcargo_info!("Dependencies:");
1049        for (dependency, _opt) in &filtered.removed_dependencies {
1050            debcargo_info!(format!("- {dependency}"));
1051        }
1052
1053        let mut doc = toml_edit::DocumentMut::from_str(&manifest.to_string())?;
1054        add_unexpected_cfgs(&mut doc, features);
1055        Ok(Some(doc.to_string()))
1056    }
1057}
1058
1059/// Generate an auto-patch that removes the named features from a Cargo.toml,
1060/// appending it to `auto_patches`.
1061///
1062/// `path` is the extracted (original) manifest, with any earlier auto-patches
1063/// already collected in `auto_patches` (e.g. Cargo.toml normalization). The
1064/// feature-removal patch is generated on top of those, so the whole set stacks
1065/// cleanly in the quilt series. The new patch is inserted into `auto_patches`
1066/// under `auto-remove-features.patch`; nothing is inserted if removing the
1067/// features did not change anything.
1068pub fn remove_features_patch(
1069    path: &Path,
1070    features: &[String],
1071    auto_patches: &mut AutoPatches,
1072) -> Result<()> {
1073    let dir = path
1074        .parent()
1075        .ok_or_else(|| format_err!("{path:?} has no parent directory"))?;
1076
1077    // Bring the on-disk manifest up to date with the earlier auto-patches, edit
1078    // it, capture the diff, then restore it. The orig manifest is left untouched
1079    // once we are done.
1080    apply_auto_patches(path, auto_patches)?;
1081    let base_content = fs::read_to_string(path)?;
1082
1083    // Edit the manifest with toml_edit so the diff only touches the lines we
1084    // actually change, rather than reformatting the whole file.
1085    let doc: toml_edit::DocumentMut = base_content.parse()?;
1086    let modified = apply_remove_features(doc, features)?;
1087
1088    let result = if let Some(modified) = modified {
1089        let base_path = dir.join("Cargo.toml.featured");
1090        let modified_path = dir.join("Cargo.toml.stripped");
1091        fs::write(&base_path, &base_content)?;
1092        fs::write(&modified_path, modified)?;
1093        let diff = diff_files("Cargo.toml", &base_path, &modified_path)?;
1094        fs::remove_file(&base_path)?;
1095        fs::remove_file(&modified_path)?;
1096        let mut patch =
1097            String::from("This patch was autogenerated using debcargo via `remove_features`. Do not edit manually!\n");
1098        patch.push_str(&diff);
1099        Some(patch.into_bytes())
1100    } else {
1101        None
1102    };
1103
1104    unapply_auto_patches(path, auto_patches)?;
1105
1106    if let Some(patch) = result {
1107        auto_patches.insert("auto-remove-features.patch".to_string(), patch);
1108    }
1109    Ok(())
1110}
1111
1112fn apply_remove_target_type(
1113    mut doc: toml_edit::DocumentMut,
1114    target_type: &TargetType,
1115) -> Result<Option<String>> {
1116    log::debug!("Removing target type {target_type}");
1117    let mut modified = false;
1118    let Some(package_item) = doc.get_mut("package") else {
1119        debcargo_bail!("Cargo.toml must contain 'package' section");
1120    };
1121    let Some(package_table) = package_item.as_table_mut() else {
1122        debcargo_bail!("Cargo.toml must contain 'package' table");
1123    };
1124
1125    let autokey = match target_type {
1126        TargetType::Bench => "autobenches",
1127        TargetType::Example => "autoexamples",
1128        TargetType::Test => "autotests",
1129    };
1130
1131    if let Some(value) = package_table.get_mut(autokey) {
1132        let Some(enabled) = value.as_bool() else {
1133            debcargo_bail!("Malformed manifest: package.{autokey} is not a boolean value: {item}");
1134        };
1135        if enabled {
1136            modified = true;
1137            *value = false.into();
1138        }
1139    } else {
1140        modified = true;
1141        package_table.insert(autokey, false.into());
1142    }
1143
1144    let section_key = match target_type {
1145        TargetType::Bench => "bench",
1146        TargetType::Example => "example",
1147        TargetType::Test => "test",
1148    };
1149    let removed = doc.remove(section_key);
1150    if removed.is_some() {
1151        modified = true;
1152    }
1153
1154    if modified {
1155        return Ok(Some(doc.to_string()));
1156    }
1157
1158    Ok(None)
1159}
1160
1161pub fn remove_target_types_patch(
1162    path: &Path,
1163    target_types: &[TargetType],
1164    auto_patches: &mut AutoPatches,
1165) -> Result<()> {
1166    let dir = path
1167        .parent()
1168        .ok_or_else(|| format_err!("{path:?} has no parent directory"))?;
1169
1170    // Bring the on-disk manifest up to date with the earlier auto-patches, edit
1171    // it, capture the diff, then restore it. The orig manifest is left untouched
1172    // once we are done.
1173    apply_auto_patches(path, auto_patches)?;
1174    for target_type in target_types {
1175        let base_content = fs::read_to_string(path)?;
1176
1177        // Edit the manifest with toml_edit so the diff only touches the lines we
1178        // actually change, rather than reformatting the whole file.
1179        let doc: toml_edit::DocumentMut = base_content.parse()?;
1180        let modified = apply_remove_target_type(doc, target_type)?;
1181
1182        let result = if let Some(modified) = modified {
1183            let base_path = dir.join("Cargo.toml.base");
1184            let modified_path = dir.join(format!("Cargo.toml.stripped_{target_type}"));
1185            fs::write(&base_path, &base_content)?;
1186            fs::write(&modified_path, &modified)?;
1187            let diff = diff_files("Cargo.toml", &base_path, &modified_path)?;
1188            fs::remove_file(&base_path)?;
1189            fs::remove_file(&modified_path)?;
1190            let mut patch = String::from(
1191                "This patch was autogenerated using debcargo via `remove_target_types`. Do not edit manually!\n",
1192            );
1193            patch.push_str(&diff);
1194            Some(patch.into_bytes())
1195        } else {
1196            None
1197        };
1198        if let Some(patch) = result {
1199            run_patch(path, &patch, false)?;
1200            auto_patches.insert(format!("auto-remove-{target_type}.patch"), patch);
1201        }
1202    }
1203
1204    unapply_auto_patches(path, auto_patches)?;
1205
1206    Ok(())
1207}
1208
1209fn apply_remove_targets(
1210    doc: toml_edit::DocumentMut,
1211    filter_targets: &[FilterTarget],
1212) -> Result<Option<String>> {
1213    let mut manifest = cargo_toml_edit::Manifest::from(doc);
1214
1215    let targets: Result<Vec<_>> = filter_targets
1216        .iter()
1217        .flat_map(FilterTarget::target_list)
1218        .map(|t| {
1219            get_builtin_target_by_triple(t)
1220                .ok_or_else(|| format_err!("Triple '{t}' is not known to cfg-expr"))
1221        })
1222        .collect();
1223    let targets = targets?;
1224    let mut filter = DependencyFilter::empty();
1225    filter.targets = targets;
1226    log::debug!("Target filters: {filter:?}");
1227
1228    let filtered = manifest.filter_dependencies(&filter)?;
1229    if filtered.removed_targets.is_empty() {
1230        Ok(None)
1231    } else {
1232        debcargo_info!("Removed dependencies of following targets:");
1233        for (target, _item) in &filtered.removed_targets {
1234            debcargo_info!(format!("- {target}"));
1235        }
1236        debcargo_info!("Dependencies:");
1237        for (dependency, _opt) in &filtered.removed_dependencies {
1238            debcargo_info!(format!("- {dependency}"));
1239        }
1240        Ok(Some(manifest.to_string()))
1241    }
1242}
1243
1244/// Generate an auto-patch that removes all dependencies (and references to them in features) that
1245/// do not match our desired target triples.
1246///
1247/// `path` is the extracted (original) manifest, with any earlier auto-patches already collected in
1248/// `auto_patches`. The target-removal patch is generated on top of those, so the whole set stacks
1249/// cleanly in the quilt series. The new patch is inserted into `auto_patches` under
1250/// `auto-remove-targets.patch`; nothing is inserted if removing the features did not change
1251/// anything.
1252pub fn remove_targets_patch(
1253    path: &Path,
1254    filter_targets: &[FilterTarget],
1255    auto_patches: &mut AutoPatches,
1256) -> Result<()> {
1257    let dir = path
1258        .parent()
1259        .ok_or_else(|| format_err!("{path:?} has no parent directory"))?;
1260
1261    // Bring the on-disk manifest up to date with the earlier auto-patches, edit
1262    // it, capture the diff, then restore it. The orig manifest is left untouched
1263    // once we are done.
1264    apply_auto_patches(path, auto_patches)?;
1265    let base_content = fs::read_to_string(path)?;
1266
1267    // Edit the manifest with toml_edit so the diff only touches the lines we
1268    // actually change, rather than reformatting the whole file.
1269    let doc: toml_edit::DocumentMut = base_content.parse()?;
1270    let modified = apply_remove_targets(doc, filter_targets)?;
1271
1272    let result = if let Some(modified) = modified {
1273        let base_path = dir.join("Cargo.toml.all_targets");
1274        let modified_path = dir.join("Cargo.toml.stripped_targets");
1275        fs::write(&base_path, &base_content)?;
1276        fs::write(&modified_path, &modified)?;
1277        let diff = diff_files("Cargo.toml", &base_path, &modified_path)?;
1278        fs::remove_file(&base_path)?;
1279        fs::remove_file(&modified_path)?;
1280        let mut patch =
1281            String::from("This patch was autogenerated using debcargo via `remove_targets`. Do not edit manually!\n");
1282        patch.push_str(&diff);
1283        Some(patch.into_bytes())
1284    } else {
1285        None
1286    };
1287
1288    unapply_auto_patches(path, auto_patches)?;
1289
1290    if let Some(patch) = result {
1291        auto_patches.insert("auto-remove-targets.patch".to_string(), patch);
1292    }
1293    Ok(())
1294}
1295
1296/// Apply every auto-patch in `auto_patches`, in series, to `path` in place.
1297fn apply_auto_patches(path: &Path, auto_patches: &AutoPatches) -> Result<()> {
1298    for patch in auto_patches.values() {
1299        run_patch(path, patch, false)?;
1300    }
1301    Ok(())
1302}
1303
1304/// Reverse every auto-patch in `auto_patches`, in reverse order, restoring
1305/// `path` to the state it had before `apply_auto_patches`.
1306fn unapply_auto_patches(path: &Path, auto_patches: &AutoPatches) -> Result<()> {
1307    for patch in auto_patches.values().rev() {
1308        run_patch(path, patch, true)?;
1309    }
1310    Ok(())
1311}
1312
1313/// Apply (or, with `reverse`, un-apply) a unified-diff patch to `path` in
1314/// place, using the `patch` tool.
1315fn run_patch(path: &Path, patch: &[u8], reverse: bool) -> Result<()> {
1316    use std::io::Write;
1317    use std::process::Stdio;
1318
1319    let mut cmd = std::process::Command::new("patch");
1320    cmd.arg("--quiet").arg(path);
1321    if reverse {
1322        cmd.arg("--reverse");
1323    }
1324    let mut child = cmd.stdin(Stdio::piped()).spawn()?;
1325    child
1326        .stdin
1327        .take()
1328        .ok_or_else(|| format_err!("failed to open stdin for patch"))?
1329        .write_all(patch)?;
1330    let status = child.wait()?;
1331    if !status.success() {
1332        debcargo_bail!("failed to apply auto-patch to {:?}", path);
1333    }
1334    Ok(())
1335}
1336
1337/// Calculate all feature-dependencies and external-dependencies of a given
1338/// feature, using the information previously generated by
1339/// `all_dependencies_and_features`.
1340pub fn transitive_deps<'a>(
1341    features_with_deps: &'a CrateDepInfo,
1342    feature: &'a str,
1343) -> Result<(Vec<&'a str>, Vec<Dependency>)> {
1344    fn transitive_deps_impl<'a>(
1345        features_with_deps: &'a CrateDepInfo,
1346        feature: &'a str,
1347        cache: &mut HashMap<&'a str, (IndexSet<&'a str>, IndexSet<Dependency>)>,
1348        processing: &mut Vec<&'a str>,
1349    ) -> Result<(IndexSet<&'a str>, IndexSet<Dependency>)> {
1350        let mut all_features = IndexSet::new();
1351        let mut all_deps = IndexSet::new();
1352        processing.push(feature);
1353        if let Some((ff, dd)) = features_with_deps.get(feature) {
1354            all_features.extend(ff.clone());
1355            all_deps.extend(dd.clone());
1356
1357            for f in ff {
1358                if processing.contains(f) {
1359                    continue;
1360                }
1361
1362                let (ff1, dd1) = if let Some(cached) = cache.get(f) {
1363                    cached.clone()
1364                } else {
1365                    transitive_deps_impl(features_with_deps, f, cache, processing)?
1366                };
1367
1368                let ff1: Vec<_> = ff1
1369                    .iter()
1370                    .filter(|f| **f != feature && !processing.contains(f))
1371                    .collect();
1372
1373                all_features.extend(ff1);
1374                all_deps.extend(dd1);
1375            }
1376        } else {
1377            log::trace!("{features_with_deps:?}");
1378            debcargo_bail!(
1379                "requested feature '{}' not found in resolved map of features+dependencies",
1380                feature
1381            );
1382        }
1383        let result = (all_features, all_deps);
1384        cache.insert(feature, result.clone());
1385        processing.pop();
1386        Ok(result)
1387    }
1388
1389    let mut cache = HashMap::new();
1390    let mut processing = Vec::new();
1391    let (all_features, all_deps) =
1392        transitive_deps_impl(features_with_deps, feature, &mut cache, &mut processing)?;
1393
1394    Ok((
1395        all_features.into_iter().collect_vec(),
1396        all_deps.into_iter().collect_vec(),
1397    ))
1398}
1399
1400#[cfg(test)]
1401mod tests {
1402    use super::*;
1403
1404    fn doc_from(s: &str) -> toml_edit::DocumentMut {
1405        s.parse().unwrap()
1406    }
1407
1408    #[test]
1409    fn apply_remove_features_keeps_dep_referenced_only_via_slash_syntax() {
1410        let doc = doc_from(
1411            r#"
1412[package]
1413name = "test-crate"
1414version = "0.1.0"
1415edition = "2021"
1416
1417[features]
1418default = []
1419unstable = ["bar/some-feature"]
1420stable = ["bar?/other-feature"]
1421
1422[dependencies]
1423bar = { version = "1", optional = true }
1424"#,
1425        );
1426        let modified =
1427            apply_remove_features(doc.clone(), &["unstable".to_string(), "stable".to_string()])
1428                .expect("Removing features should not return error")
1429                .expect("Removing features should return modified Cargo.toml");
1430        let doc = doc_from(&modified);
1431        // bar is never named via `dep:bar`, so cargo synthesizes an implicit
1432        // `bar` feature; the dependency stays enablable on its own and is kept.
1433        let deps = doc["dependencies"].as_table().unwrap();
1434        assert!(deps.contains_key("bar"));
1435    }
1436
1437    #[test]
1438    fn apply_remove_features_noop_when_feature_absent() {
1439        let doc = doc_from(
1440            r#"
1441[package]
1442name = "test-crate"
1443version = "0.1.0"
1444edition = "2021"
1445
1446[features]
1447stable = []
1448"#,
1449        );
1450        assert!(
1451            apply_remove_features(doc.clone(), &["nonexistent".to_string()])
1452                .expect("Removing features should not return error")
1453                .is_none()
1454        );
1455    }
1456
1457    #[test]
1458    fn apply_remove_features_removes_named_feature() {
1459        let doc = doc_from(
1460            r#"
1461[features]
1462default = ["stable"]
1463stable = []
1464unstable = []
1465"#,
1466        );
1467        let modified = apply_remove_features(doc.clone(), &["unstable".to_string()])
1468            .expect("Removing features should not return error")
1469            .expect("Removing features should return modified Cargo.toml");
1470        let doc = doc_from(&modified);
1471        let feats = doc["features"].as_table().unwrap();
1472        assert!(!feats.contains_key("unstable"));
1473        assert!(feats.contains_key("stable"));
1474    }
1475
1476    #[test]
1477    fn apply_remove_features_minimal_diff_preserves_formatting() {
1478        // A multi-line array in an unrelated feature must stay untouched.
1479        let doc = doc_from(
1480            r#"[features]
1481default = []
1482keep = [
1483    "a",
1484    "b",
1485]
1486drop = []
1487"#,
1488        );
1489        let out = apply_remove_features(doc.clone(), &["drop".to_string()])
1490            .expect("Removing features should not return error")
1491            .expect("Removing features should return modified Cargo.toml");
1492        assert!(out.contains("keep = [\n    \"a\",\n    \"b\",\n]"));
1493        assert!(!out.contains("drop = []"));
1494    }
1495}