1#![forbid(unsafe_code)]
13#![deny(warnings, missing_docs)]
14
15use std::{
16 collections::HashMap,
17 env, fmt, fs,
18 io::{BufRead, Cursor},
19 path::{Path, PathBuf},
20 process::Command,
21 result,
22};
23
24use guppy::{
25 graph::{DependencyDirection, PackageGraph, PackageLink, PackageMetadata, PackageSource},
26 MetadataCommand, PackageId,
27};
28use log::{debug, error, info, log, trace, Level};
29use serde::Serialize;
30use toml_edit::{DocumentMut, InlineTable, Item, Table, Value};
31use url::Url;
32
33#[cfg(feature = "napi-rs")]
34use napi_derive::napi;
35
36mod error;
37mod itertools;
38mod logger;
39
40pub use error::{CargoTomlError, Error, Result};
41
42pub use logger::LoggerBuilder;
43
44use crate::itertools::Itertools;
45
46#[cfg(feature = "napi-rs")]
66#[napi]
67pub fn verify_conditions() -> Result<()> {
68 let maybe_manifest_path: Option<&'static str> = None;
69
70 internal_verify_conditions(None, maybe_manifest_path)
71}
72
73#[cfg(not(feature = "napi-rs"))]
93pub fn verify_conditions(manifest_path: Option<impl AsRef<Path>>) -> Result<()> {
94 internal_verify_conditions(None, manifest_path)
95}
96
97#[cfg(not(feature = "napi-rs"))]
122pub fn verify_conditions_with_alternate(
123 alternate_registry: Option<&str>,
124 manifest_path: Option<impl AsRef<Path>>,
125) -> Result<()> {
126 internal_verify_conditions(alternate_registry, manifest_path)
127}
128
129fn internal_verify_conditions(
130 alternate_registry: Option<&str>,
131 manifest_path: Option<impl AsRef<Path>>,
132) -> Result<()> {
133 let cargo_config = cargo_config2::Config::load()?;
134
135 let registry_token_set = match alternate_registry {
136 Some(alternate_registry_id) => {
137 let registry_value = cargo_config
140 .registries
141 .get(alternate_registry_id)
142 .or_else(|| {
143 let uppercased_registry = alternate_registry_id.to_uppercase();
144 cargo_config.registries.get(&uppercased_registry)
145 });
146
147 registry_value.and_then(|registry| registry.token.as_ref().map(|_| ()))
148 }
149 None => cargo_config.registry.token.map(|_| ()),
150 };
151
152 debug!("Checking cargo registry token is set");
153 registry_token_set.ok_or_else(|| {
154 let registry_id = alternate_registry.unwrap_or("crates-io");
155
156 Error::verify_error(format!(
157 "Registry token for {} empty or not set.",
158 ®istry_id
159 ))
160 })?;
161
162 debug!("Checking that workspace dependencies graph is buildable");
163 let graph = get_package_graph(manifest_path)?;
164
165 debug!("Checking that the workspace does not contain any cycles");
166 if let Some(cycle) = graph.cycles().all_cycles().next() {
167 assert!(cycle.len() >= 2);
168 let crate0 = get_crate_name(&graph, cycle[0]);
169 let crate1 = get_crate_name(&graph, cycle[1]);
170 let workspace_error = Error::WorkspaceCycles {
171 crate1: crate0.to_owned(),
172 crate2: crate1.to_owned(),
173 };
174
175 return Err(workspace_error.into());
176 }
177
178 debug!("Checking that dependencies are suitable for publishing");
179 for (from, links) in graph
180 .workspace()
181 .iter()
182 .flat_map(|package| package.direct_links())
183 .filter(|link| !link_is_publishable(link))
184 .chunk_by(PackageLink::from)
185 .into_iter()
186 {
187 debug!("Checking links for package {}", from.name());
188 let cargo = read_cargo_toml(from.manifest_path().as_std_path())?;
189 for link in links {
190 if link.normal().is_present() {
191 dependency_has_version(&cargo, &link, DependencyType::Normal)?;
192 }
193 if link.build().is_present() {
194 dependency_has_version(&cargo, &link, DependencyType::Build)?;
195 }
196 }
197 }
198
199 Ok(())
200}
201
202#[cfg(feature = "napi-rs")]
215#[napi]
216pub fn prepare(next_release_version: String) -> Result<()> {
217 let manifest_path: Option<&Path> = None;
218 internal_prepare(manifest_path, next_release_version)
219}
220
221#[cfg(not(feature = "napi-rs"))]
234pub fn prepare(manifest_path: Option<&Path>, next_release_version: String) -> Result<()> {
235 internal_prepare(manifest_path, next_release_version)
236}
237
238fn internal_prepare(manifest_path: Option<&Path>, next_release_version: String) -> Result<()> {
239 debug!("Building package graph");
240 let graph = get_package_graph(manifest_path)?;
241
242 let link_map = graph
243 .workspace()
244 .iter()
245 .flat_map(|package| package.direct_links())
246 .filter(|link| !link.dev_only() || !link.version_req().comparators.is_empty())
249 .filter(|link| link.to().in_workspace())
250 .map(|link| (link.from().id(), link))
251 .into_group_map();
252
253 debug!("Setting version information for packages in the workspace.");
254 for package in graph.workspace().iter() {
255 let path = package.manifest_path();
256 debug!("reading {}", path.as_str());
257 let mut cargo = read_cargo_toml(path.as_std_path())?;
258
259 info!(
260 "Setting the version of {} to {}",
261 package.name(),
262 &next_release_version
263 );
264 set_package_version(&mut cargo, &next_release_version)
265 .map_err(|err| err.into_error(path))?;
266
267 if let Some(links) = link_map.get(package.id()) {
268 for link in links {
269 if link.normal().is_present() {
270 info!(
271 "Upgrading dependency of {} to {}@{}",
272 link.to().name(),
273 package.name(),
274 &next_release_version
275 );
276 set_dependencies_version(
277 &mut cargo,
278 &next_release_version,
279 DependencyType::Normal,
280 link.to().name(),
281 )
282 .map_err(|err| err.into_error(path))?;
283 }
284 if link.build().is_present() {
285 info!(
286 "Upgrading build-dependency of {} to {}@{}",
287 link.to().name(),
288 package.name(),
289 &next_release_version
290 );
291 set_dependencies_version(
292 &mut cargo,
293 &next_release_version,
294 DependencyType::Build,
295 link.to().name(),
296 )
297 .map_err(|err| err.into_error(path))?;
298 }
299 if link.dev().is_present() {
300 info!(
301 "Upgrading dev-dependency of {} to {}@{}",
302 link.to().name(),
303 package.name(),
304 &next_release_version
305 );
306 set_dependencies_version(
307 &mut cargo,
308 &next_release_version,
309 DependencyType::Dev,
310 link.to().name(),
311 )
312 .map_err(|err| err.into_error(path))?;
313 }
314 }
315 }
316
317 debug!("writing {}", path.as_str());
318 write_cargo_toml(path.as_std_path(), cargo)?;
319
320 let lockfile_path = get_cargo_lock(path.as_std_path());
331 if lockfile_path.exists() {
332 debug!("reading {}", lockfile_path.to_string_lossy());
333 let mut lockfile = read_cargo_toml(&lockfile_path)?;
334
335 set_lockfile_self_describing_metadata(
336 &mut lockfile,
337 &next_release_version,
338 package.name(),
339 )?;
340
341 debug!("writing {}", lockfile_path.to_string_lossy());
342 write_cargo_toml(&lockfile_path, lockfile)?;
343 }
344 }
345
346 Ok(())
347}
348
349#[cfg_attr(feature = "napi-rs", napi(object))]
350#[derive(Debug, Default)]
351pub struct PublishArgs {
353 pub no_dirty: Option<bool>,
355
356 pub features: Option<HashMap<String, Vec<String>>>,
358
359 pub registry: Option<String>,
361}
362
363#[cfg(feature = "napi-rs")]
372#[napi]
373pub fn publish(opts: Option<PublishArgs>) -> Result<()> {
374 let manifest_path: Option<&Path> = None;
375 internal_publish(manifest_path, &opts.unwrap_or_default())
376}
377
378#[cfg(not(feature = "napi-rs"))]
387pub fn publish(manifest_path: Option<&Path>, opts: &PublishArgs) -> Result<()> {
388 internal_publish(manifest_path, opts)
389}
390
391fn internal_publish(manifest_path: Option<&Path>, opts: &PublishArgs) -> Result<()> {
392 debug!("Getting the package graph");
393 let graph = get_package_graph(manifest_path)?;
394 let optional_registry = opts.registry.as_deref();
395
396 let mut count = 0;
397 let mut last_id = None;
398
399 process_publishable_packages(&graph, optional_registry, |pkg| {
400 count += 1;
401 last_id = Some(pkg.id().clone());
402 publish_package(pkg, opts)
403 })?;
404
405 let main_crate = match graph.workspace().member_by_path("") {
406 Ok(pkg) if package_is_publishable(&pkg, optional_registry) => Some(pkg.name()),
407 _ => last_id.map(|id| {
408 graph
409 .metadata(&id)
410 .expect("id of a processed package not found in the package graph")
411 .name()
412 }),
413 };
414
415 if let Some(main_crate) = main_crate {
416 debug!("printing release record with main crate: {}", main_crate);
417 let name = format!(
418 "{} packages ({} packages published)",
419 optional_registry.unwrap_or("crates.io"),
420 count
421 );
422
423 let release_meta_json = if optional_registry.is_none() {
425 serde_json::to_string(&Release::new_crates_io_release(name, main_crate)?)
426 } else {
427 serde_json::to_string(&Release::new::<&str>(name, None, main_crate)?)
428 }
429 .map_err(|err| Error::write_release_error(err, main_crate))?;
430
431 info!("{:?}", release_meta_json);
432 } else {
433 debug!("no release record to print");
434 }
435
436 Ok(())
437}
438
439pub fn list_packages(manifest_path: Option<impl AsRef<Path>>) -> Result<()> {
449 internal_list_packages(None, manifest_path)
450}
451
452pub fn list_packages_with_arguments(
463 alternate_registry: Option<&str>,
464 manifest_path: Option<impl AsRef<Path>>,
465) -> Result<()> {
466 internal_list_packages(alternate_registry, manifest_path)
467}
468
469fn internal_list_packages(
470 alternate_registry: Option<&str>,
471 manifest_path: Option<impl AsRef<Path>>,
472) -> Result<()> {
473 info!("Building package graph");
474 let graph = get_package_graph(manifest_path)?;
475
476 process_publishable_packages(&graph, alternate_registry, |pkg| {
477 error!("{}({})", pkg.name(), pkg.version());
478 Ok(())
479 })
480}
481
482fn get_package_graph(manifest_path: Option<impl AsRef<Path>>) -> Result<PackageGraph> {
483 let manifest_path = manifest_path.as_ref().map(|path| path.as_ref());
484
485 let mut command = MetadataCommand::new();
486 if let Some(path) = manifest_path {
487 command.manifest_path(path);
488 }
489
490 debug!("manifest_path: {:?}", manifest_path);
491
492 command.build_graph().map_err(|err| {
493 let path = match manifest_path {
494 Some(path) => path.to_path_buf(),
495 None => env::current_dir()
496 .map(|path| path.join("Cargo.toml"))
497 .unwrap_or_else(|e| {
498 error!("Unable to get current directory: {}", e);
499 PathBuf::from("unknown manifest")
500 }),
501 };
502 Error::workspace_error(err, path).into()
503 })
504}
505
506fn target_source_is_publishable(source: PackageSource) -> bool {
514 source.is_workspace() || source.is_crates_io()
515}
516
517fn link_is_publishable(link: &PackageLink) -> bool {
523 let result = link.dev_only() || target_source_is_publishable(link.to().source());
524 if result {
525 trace!(
526 "Link from {} to {} is publishable.",
527 link.from().name(),
528 link.to().name()
529 );
530 }
531
532 result
533}
534
535fn package_is_publishable(pkg: &PackageMetadata, registry: Option<&str>) -> bool {
540 use guppy::graph::PackagePublish;
541 let registry_target = registry;
542
543 let result = match pkg.publish() {
544 guppy::graph::PackagePublish::Unrestricted => true,
545 guppy::graph::PackagePublish::Registries([registry]) => {
546 let registry_target = registry_target.unwrap_or(PackagePublish::CRATES_IO);
547 registry == registry_target
548 }
549 guppy::graph::PackagePublish::Registries([]) => false,
550 _ => todo!(),
551 };
552
553 if result {
554 trace!("package {} is publishable", pkg.name());
555 }
556
557 result
558}
559
560fn process_publishable_packages<F>(
561 graph: &PackageGraph,
562 alternate_registry: Option<&str>,
563 mut f: F,
564) -> Result<()>
565where
566 F: FnMut(&PackageMetadata) -> Result<()>,
567{
568 info!("iterating the workspace crates in dependency order");
569 for pkg in graph
570 .query_workspace()
571 .resolve_with_fn(|_, link| !link.dev_only())
572 .packages(DependencyDirection::Reverse)
573 .filter(|pkg| pkg.in_workspace() && package_is_publishable(pkg, alternate_registry))
574 {
575 f(&pkg)?;
576 }
577
578 Ok(())
579}
580
581fn get_crate_name<'a>(graph: &'a PackageGraph, id: &PackageId) -> &'a str {
583 graph
584 .metadata(id)
585 .unwrap_or_else(|_| panic!("id {} was not found in the graph {:?}", id, graph))
586 .name()
587}
588
589fn publish_package(pkg: &PackageMetadata, opts: &PublishArgs) -> Result<()> {
590 info!(
591 "Publishing version {} of {} to {} registry",
592 pkg.version(),
593 pkg.name(),
594 opts.registry.as_deref().unwrap_or("crates.io")
595 );
596
597 let cargo = env::var("CARGO")
598 .map(PathBuf::from)
599 .unwrap_or_else(|_| PathBuf::from("cargo"));
600
601 let mut command = Command::new(cargo);
602 command
603 .args(["publish", "--manifest-path"])
604 .arg(pkg.manifest_path());
605 if !opts.no_dirty.unwrap_or_default() {
606 command.arg("--allow-dirty");
607 }
608 if let Some(features) = opts.features.as_ref().and_then(|f| f.get(pkg.name())) {
609 command.arg("--features");
610 command.args(features);
611 }
612 if let Some(registry) = opts.registry.as_ref() {
613 command.arg("--registry");
614 command.arg(registry);
615 }
616
617 trace!("running: {:?}", command);
618
619 let output = command
620 .output()
621 .map_err(|err| Error::cargo_publish(err, pkg.manifest_path().as_std_path()))?;
622
623 let level = if output.status.success() {
624 Level::Trace
625 } else {
626 Level::Info
627 };
628
629 trace!("cargo publish stdout");
630 trace!("--------------------");
631 log_bytes(Level::Trace, &output.stdout);
632
633 log!(level, "cargo publish stderr");
634 log!(level, "--------------------");
635 log_bytes(level, &output.stderr);
636
637 if output.status.success() {
638 info!(
639 "Published {}@{} to {} registry",
640 pkg.name(),
641 pkg.version(),
642 opts.registry.as_deref().unwrap_or("crates.io")
643 );
644 Ok(())
645 } else {
646 error!(
647 "publishing package {} failed: {}",
648 pkg.name(),
649 output.status
650 );
651 Err(Error::cargo_publish_status(output.status, pkg.manifest_path().as_std_path()).into())
652 }
653}
654
655fn log_bytes(level: Level, bytes: &[u8]) {
656 let mut buffer = Cursor::new(bytes);
657 let mut string = String::new();
658
659 while let Ok(size) = buffer.read_line(&mut string) {
660 if size == 0 {
661 return;
662 }
663 log!(level, "{}", string);
664 string.clear();
665 }
666}
667
668fn get_cargo_lock(path: &Path) -> PathBuf {
671 path.parent().unwrap().join("Cargo.lock")
672}
673
674fn read_cargo_toml(path: &Path) -> Result<DocumentMut> {
675 fs::read_to_string(path)
676 .map_err(|err| Error::file_read_error(err, path))?
677 .parse()
678 .map_err(|err| Error::toml_error(err, path).into())
679}
680
681fn write_cargo_toml(path: &Path, cargo: DocumentMut) -> Result<()> {
682 fs::write(path, cargo.to_string()).map_err(|err| Error::file_write_error(err, path).into())
683}
684
685fn get_top_table<'a>(doc: &'a DocumentMut, key: &str) -> Option<&'a Table> {
686 doc.as_table().get(key).and_then(Item::as_table)
687}
688
689fn get_top_table_mut<'a>(doc: &'a mut DocumentMut, key: &str) -> Option<&'a mut Table> {
690 doc.get_key_value_mut(key)
691 .and_then(|(_key, value)| value.as_table_mut())
692}
693
694fn table_add_or_update_value(table: &mut Table, key: &str, value: Value) -> Option<()> {
695 let entry = table.entry(key);
696
697 match entry {
698 toml_edit::Entry::Occupied(mut val) => {
699 val.insert(Item::Value(value));
700 Some(())
701 }
702 toml_edit::Entry::Vacant(val) => {
703 val.insert(Item::Value(value));
704 Some(())
705 }
706 }
707}
708
709fn inline_table_add_or_update_value(table: &mut InlineTable, key: &str, value: Value) {
710 match table.get_mut(key) {
711 Some(ver) => *ver = value,
712 None => {
713 table.get_or_insert(key, value);
714 }
715 }
716}
717
718fn dependency_has_version(
719 doc: &DocumentMut,
720 link: &PackageLink,
721 typ: DependencyType,
722) -> Result<()> {
723 let top_key = typ.key();
724
725 trace!(
726 "Checking for version key for {} in {} section of {}",
727 link.to().name(),
728 top_key,
729 link.from().name()
730 );
731 get_top_table(doc, top_key)
732 .and_then(|deps| deps.get(link.to().name()))
733 .and_then(Item::as_table_like)
734 .and_then(|dep| dep.get("version"))
735 .map(|_| ())
736 .ok_or_else(|| Error::bad_dependency(link, typ).into())
737}
738
739fn set_package_version(doc: &mut DocumentMut, version: &str) -> result::Result<(), CargoTomlError> {
740 let table =
741 get_top_table_mut(doc, "package").ok_or_else(|| CargoTomlError::no_table("package"))?;
742 table_add_or_update_value(table, "version", version.into())
743 .ok_or_else(|| CargoTomlError::no_value("version"))
744}
745
746fn find_matching_dependency_key<'table>(
748 table: &'table mut Table,
749 name: &'table str,
750) -> Option<String> {
751 for (key, dependency_item) in table.iter() {
752 if key == name {
754 return Some(name.to_string());
755 }
756
757 let Some(Item::Value(Value::String(package_ident))) = dependency_item.get("package") else {
761 continue;
762 };
763
764 let Some(package_ident) = package_ident.as_repr() else {
765 continue;
766 };
767
768 let maybe_package_ident_str_repr = package_ident
769 .as_raw()
770 .as_str()
771 .map(|repr| repr.trim_matches('"'));
774 if maybe_package_ident_str_repr == Some(name) {
775 return Some(key.to_string());
776 }
777 }
778
779 None
780}
781
782fn set_dependency_version(table: &mut Table, version: &str, name: &str) -> Option<()> {
783 let dependency_key = match find_matching_dependency_key(table, name) {
784 Some(key) => key,
785 None => return Some(()),
786 };
787
788 match table.entry(&dependency_key) {
789 toml_edit::Entry::Occupied(mut req) => {
790 let item = req.get_mut();
791
792 if let Some(item) = item.as_inline_table_mut() {
793 inline_table_add_or_update_value(item, "version", version.into());
794 return Some(());
795 }
796 if let Some(item) = item.as_table_mut() {
797 return table_add_or_update_value(item, "version", version.into());
798 }
799
800 None
801 }
802 toml_edit::Entry::Vacant(_) => Some(()),
803 }
804}
805
806fn set_dependencies_version(
807 doc: &mut DocumentMut,
808 version: &str,
809 typ: DependencyType,
810 name: &str,
811) -> result::Result<(), CargoTomlError> {
812 if let Some(table) = get_top_table_mut(doc, typ.key()) {
813 set_dependency_version(table, version, name)
814 .ok_or_else(|| CargoTomlError::set_version(name, version))?;
815 }
816
817 if let Some(table) = get_top_table_mut(doc, "target") {
818 let targets: Vec<_> = table.iter().map(|(key, _)| key.to_owned()).collect();
819
820 for target in targets {
821 let target_deps = table.entry(&target);
822 match target_deps {
823 toml_edit::Entry::Occupied(mut target_deps) => {
824 if let Some(target_deps) = target_deps
825 .get_mut()
826 .as_table_mut()
827 .and_then(|inner| inner[typ.key()].as_table_mut())
828 {
829 set_dependency_version(target_deps, version, name)
830 .ok_or_else(|| CargoTomlError::set_version(name, version))?;
831 }
832 }
833 toml_edit::Entry::Vacant(_) => {}
834 };
835 }
836 };
837
838 Ok(())
839}
840
841fn set_lockfile_self_describing_metadata(
842 doc: &mut DocumentMut,
843 next_release_version: &str,
844 package_name: &str,
845) -> result::Result<(), Error> {
846 let packages_entry = doc.as_table_mut().entry("package");
847
848 match packages_entry {
849 toml_edit::Entry::Occupied(mut entry) => {
850 let tables = entry
851 .get_mut()
852 .as_array_of_tables_mut()
853 .expect("Expected lockfile to contain an array of tables named 'packages'");
854
855 let matching_index = tables.iter().position(|table| {
856 table
857 .get("name")
858 .and_then(|item| item.as_str())
859 .map(|name| name == package_name)
860 .unwrap_or_default()
861 });
862
863 if let Some(matching_index) = matching_index {
864 let table = tables
865 .get_mut(matching_index)
866 .expect("Expected lockfile to contain reference to self");
867 table_add_or_update_value(table, "version", next_release_version.into());
868 } else {
869 return Err(Error::CargoLockfileUpdate {
870 reason: "Unable to locate self-referential metadata in lockfile".into(),
871 package_name: package_name.to_owned(),
872 });
873 }
874 }
875 _ => {
876 return Err(Error::CargoLockfileUpdate {
877 reason: "Cargo lockfile does not contain 'packages' array of tables".into(),
878 package_name: package_name.to_owned(),
879 })
880 }
881 };
882
883 Ok(())
884}
885
886#[derive(Debug)]
888pub enum DependencyType {
889 Normal,
891
892 Build,
894
895 Dev,
897}
898
899impl DependencyType {
900 fn key(&self) -> &str {
901 use DependencyType::*;
902
903 match self {
904 Normal => "dependencies",
905 Build => "build-dependencies",
906 Dev => "dev-dependencies",
907 }
908 }
909}
910
911impl fmt::Display for DependencyType {
912 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
913 use DependencyType::*;
914
915 match self {
916 Normal => write!(f, "Dependency"),
917 Build => write!(f, "Build dependency"),
918 Dev => write!(f, "Dev dependency"),
919 }
920 }
921}
922
923#[derive(Debug, Serialize)]
924struct Release {
925 name: String,
926 url: Option<Url>,
927}
928
929impl Release {
930 fn new<URL: AsRef<str>>(
931 name: impl AsRef<str>,
932 url: Option<URL>,
933 main_crate: impl AsRef<str>,
934 ) -> Result<Self> {
935 let url = if let Some(url) = url {
936 let base = Url::parse(url.as_ref()).map_err(Error::url_parse_error)?;
937 let url = base
938 .join(main_crate.as_ref())
939 .map_err(Error::url_parse_error)?;
940 Some(url)
941 } else {
942 None
943 };
944
945 Ok(Self {
946 name: name.as_ref().to_owned(),
947 url,
948 })
949 }
950
951 fn new_crates_io_release(name: impl AsRef<str>, main_crate: impl AsRef<str>) -> Result<Self> {
952 let base = Url::parse("https://crates.io/crates/").map_err(Error::url_parse_error)?;
953 let url = base
954 .join(main_crate.as_ref())
955 .map_err(Error::url_parse_error)?;
956
957 Ok(Self {
958 name: name.as_ref().to_owned(),
959 url: Some(url),
960 })
961 }
962}