1use crate::adapters::PackageAdapter;
2use crate::types::PackageInfo;
3use crate::{
4 Config, current_branch, discover_workspace,
5 errors::{Result, SampoError},
6 filters::should_ignore_package,
7};
8use std::collections::{BTreeMap, BTreeSet, VecDeque};
9use std::path::Path;
10use std::process::Command;
11
12pub fn run_publish(root: &std::path::Path, dry_run: bool, publish_args: &[String]) -> Result<()> {
35 let ws = discover_workspace(root)?;
36 let config = Config::load(&ws.root)?;
37
38 let branch = current_branch()?;
39 if !config.is_release_branch(&branch) {
40 return Err(SampoError::Release(format!(
41 "Branch '{}' is not configured for publishing (allowed: {:?})",
42 branch,
43 config.release_branches().into_iter().collect::<Vec<_>>()
44 )));
45 }
46
47 let mut id_to_package: BTreeMap<String, &PackageInfo> = BTreeMap::new();
49 let mut publishable: BTreeSet<String> = BTreeSet::new();
50 for c in &ws.members {
51 if should_ignore_package(&config, &ws, c)? {
53 continue;
54 }
55
56 let adapter = match c.kind {
57 crate::types::PackageKind::Cargo => PackageAdapter::Cargo,
58 crate::types::PackageKind::Npm => PackageAdapter::Npm,
59 crate::types::PackageKind::Hex => PackageAdapter::Hex,
60 };
61
62 let manifest = adapter.manifest_path(&c.path);
63 if !adapter.is_publishable(&manifest)? {
64 continue;
65 }
66
67 let identifier = c.canonical_identifier().to_string();
68 publishable.insert(identifier.clone());
69 id_to_package.insert(identifier, c);
70 }
71
72 if publishable.is_empty() {
73 println!("No publishable packages were found in the workspace.");
74 return Ok(());
75 }
76
77 let mut errors: Vec<String> = Vec::new();
79 for identifier in &publishable {
80 let c = id_to_package.get(identifier).ok_or_else(|| {
81 SampoError::Publish(format!(
82 "internal error: package '{}' not found in workspace",
83 identifier
84 ))
85 })?;
86 for dep in &c.internal_deps {
87 if !publishable.contains(dep) {
88 errors.push(format!(
89 "package '{}' depends on internal package '{}' which is not publishable",
90 c.name, dep
91 ));
92 }
93 }
94 }
95 if !errors.is_empty() {
96 for e in errors {
97 eprintln!("{e}");
98 }
99 return Err(SampoError::Publish(
100 "cannot publish due to non-publishable internal dependencies".into(),
101 ));
102 }
103
104 let order = topo_order(&id_to_package, &publishable)?;
106
107 println!("Publish plan:");
108 for identifier in &order {
109 if let Some(info) = id_to_package.get(identifier) {
110 println!(" - {}", info.display_name(true));
111 } else {
112 println!(" - {identifier}");
113 }
114 }
115
116 for identifier in &order {
118 let c = id_to_package.get(identifier).ok_or_else(|| {
119 SampoError::Publish(format!(
120 "internal error: crate '{}' not found in workspace",
121 identifier
122 ))
123 })?;
124 let adapter = match c.kind {
125 crate::types::PackageKind::Cargo => PackageAdapter::Cargo,
126 crate::types::PackageKind::Npm => PackageAdapter::Npm,
127 crate::types::PackageKind::Hex => PackageAdapter::Hex,
128 };
129 let manifest = adapter.manifest_path(&c.path);
130
131 match adapter.version_exists(&c.name, &c.version, Some(&manifest)) {
133 Ok(true) => {
134 println!(
135 "Skipping {}@{} (already exists on {})",
136 c.display_name(true),
137 c.version,
138 c.kind.display_name()
139 );
140 continue;
141 }
142 Ok(false) => {}
143 Err(e) => {
144 eprintln!(
145 "Warning: could not check {} registry for {}@{}: {}. Attempting publish…",
146 c.kind.display_name(),
147 c.name,
148 c.version,
149 e
150 );
151 }
152 }
153
154 adapter.publish(&manifest, dry_run, publish_args)?;
156
157 if !dry_run && let Err(e) = tag_published_crate(&ws.root, &c.name, &c.version) {
159 eprintln!(
160 "Warning: failed to create tag for {}@{}: {}",
161 c.name, c.version, e
162 );
163 }
164 }
165
166 if dry_run {
167 println!("Dry-run complete.");
168 } else {
169 println!("Publish complete.");
170 }
171
172 Ok(())
173}
174
175pub fn tag_published_crate(repo_root: &Path, crate_name: &str, version: &str) -> Result<()> {
196 if !repo_root.join(".git").exists() {
197 return Ok(());
199 }
200 let tag = format!("{}-v{}", crate_name, version);
201 let out = Command::new("git")
203 .arg("-C")
204 .arg(repo_root)
205 .arg("tag")
206 .arg("--list")
207 .arg(&tag)
208 .output()?;
209 if out.status.success() {
210 let s = String::from_utf8_lossy(&out.stdout);
211 if s.lines().any(|l| l.trim() == tag) {
212 return Ok(());
213 }
214 }
215
216 let msg = format!("Release {} {}", crate_name, version);
217 let status = Command::new("git")
218 .arg("-C")
219 .arg(repo_root)
220 .arg("tag")
221 .arg("-a")
222 .arg(&tag)
223 .arg("-m")
224 .arg(&msg)
225 .status()?;
226 if status.success() {
227 Ok(())
228 } else {
229 Err(SampoError::Publish(format!(
230 "git tag failed with status {}",
231 status
232 )))
233 }
234}
235
236pub fn topo_order(
262 name_to_package: &BTreeMap<String, &PackageInfo>,
263 include: &BTreeSet<String>,
264) -> Result<Vec<String>> {
265 let mut indegree: BTreeMap<&str, usize> = BTreeMap::new();
267 let mut forward: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
268
269 for name in include {
270 indegree.insert(name.as_str(), 0);
271 forward.entry(name.as_str()).or_default();
272 }
273
274 for name in include {
275 let c = name_to_package
276 .get(name)
277 .ok_or_else(|| SampoError::Publish(format!("missing package info for '{}'", name)))?;
278 for dep in &c.internal_deps {
279 if include.contains(dep) {
280 let entry = forward.entry(dep.as_str()).or_default();
282 entry.push(name.as_str());
283 *indegree.get_mut(name.as_str()).unwrap() += 1;
284 }
285 }
286 }
287
288 let mut q: VecDeque<&str> = indegree
289 .iter()
290 .filter_map(|(k, &d)| if d == 0 { Some(*k) } else { None })
291 .collect();
292 let mut out: Vec<String> = Vec::new();
293
294 while let Some(n) = q.pop_front() {
295 out.push(n.to_string());
296 if let Some(children) = forward.get(n) {
297 for &m in children {
298 if let Some(d) = indegree.get_mut(m) {
299 *d -= 1;
300 if *d == 0 {
301 q.push_back(m);
302 }
303 }
304 }
305 }
306 }
307
308 if out.len() != include.len() {
309 return Err(SampoError::Publish(
310 "dependency cycle detected among publishable crates".into(),
311 ));
312 }
313 Ok(out)
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use crate::types::{PackageInfo, PackageKind, Workspace};
320 use rustc_hash::FxHashMap;
321 use std::{
322 fs,
323 path::PathBuf,
324 sync::{Mutex, MutexGuard, OnceLock},
325 };
326
327 struct TestWorkspace {
329 root: PathBuf,
330 _temp_dir: tempfile::TempDir,
331 crates: FxHashMap<String, PathBuf>,
332 }
333
334 static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
335
336 fn env_lock() -> &'static Mutex<()> {
337 ENV_MUTEX.get_or_init(|| Mutex::new(()))
338 }
339
340 struct EnvVarGuard {
341 key: &'static str,
342 original: Option<String>,
343 _lock: MutexGuard<'static, ()>,
344 }
345
346 impl EnvVarGuard {
347 fn set(key: &'static str, value: &str) -> Self {
348 let lock = env_lock().lock().unwrap();
349 let original = std::env::var(key).ok();
350 unsafe {
351 std::env::set_var(key, value);
352 }
353 Self {
354 key,
355 original,
356 _lock: lock,
357 }
358 }
359 }
360
361 impl Drop for EnvVarGuard {
362 fn drop(&mut self) {
363 unsafe {
364 if let Some(ref value) = self.original {
365 std::env::set_var(self.key, value);
366 } else {
367 std::env::remove_var(self.key);
368 }
369 }
370 }
371 }
372
373 impl TestWorkspace {
374 fn new() -> Self {
375 let temp_dir = tempfile::tempdir().unwrap();
376 let root = temp_dir.path().to_path_buf();
377
378 {
379 let _lock = env_lock().lock().unwrap();
380 unsafe {
381 std::env::set_var("SAMPO_RELEASE_BRANCH", "main");
382 }
383 }
384
385 fs::write(
387 root.join("Cargo.toml"),
388 "[workspace]\nmembers=[\"crates/*\"]\n",
389 )
390 .unwrap();
391
392 Self {
393 root,
394 _temp_dir: temp_dir,
395 crates: FxHashMap::default(),
396 }
397 }
398
399 fn add_crate(&mut self, name: &str, version: &str) -> &mut Self {
400 let crate_dir = self.root.join("crates").join(name);
401 fs::create_dir_all(&crate_dir).unwrap();
402
403 fs::write(
404 crate_dir.join("Cargo.toml"),
405 format!("[package]\nname=\"{}\"\nversion=\"{}\"\n", name, version),
406 )
407 .unwrap();
408
409 fs::create_dir_all(crate_dir.join("src")).unwrap();
411 fs::write(crate_dir.join("src/lib.rs"), "// test crate").unwrap();
412
413 self.crates.insert(name.to_string(), crate_dir);
414 self
415 }
416
417 fn add_dependency(&mut self, from: &str, to: &str, version: &str) -> &mut Self {
418 let from_dir = self.crates.get(from).expect("from crate must exist");
419 let current_manifest = fs::read_to_string(from_dir.join("Cargo.toml")).unwrap();
420
421 let dependency_section = format!(
422 "\n[dependencies]\n{} = {{ path=\"../{}\", version=\"{}\" }}\n",
423 to, to, version
424 );
425
426 fs::write(
427 from_dir.join("Cargo.toml"),
428 current_manifest + &dependency_section,
429 )
430 .unwrap();
431
432 self
433 }
434
435 fn set_publishable(&self, crate_name: &str, publishable: bool) -> &Self {
436 let crate_dir = self.crates.get(crate_name).expect("crate must exist");
437 let manifest_path = crate_dir.join("Cargo.toml");
438 let current_manifest = fs::read_to_string(&manifest_path).unwrap();
439
440 let new_manifest = if publishable {
441 current_manifest
442 } else {
443 current_manifest + "\npublish = false\n"
444 };
445
446 fs::write(manifest_path, new_manifest).unwrap();
447 self
448 }
449
450 fn set_config(&self, content: &str) -> &Self {
451 fs::create_dir_all(self.root.join(".sampo")).unwrap();
452 fs::write(self.root.join(".sampo/config.toml"), content).unwrap();
453 self
454 }
455
456 fn run_publish(&self, dry_run: bool) -> Result<()> {
457 run_publish(&self.root, dry_run, &[])
458 }
459
460 fn assert_publishable_crates(&self, expected: &[&str]) {
461 let ws = discover_workspace(&self.root).unwrap();
462 let mut actual_publishable = Vec::new();
463 let adapter = PackageAdapter::Cargo;
464
465 for c in &ws.members {
466 let manifest = adapter.manifest_path(&c.path);
467 if adapter.is_publishable(&manifest).unwrap() {
468 actual_publishable.push(c.name.clone());
469 }
470 }
471
472 actual_publishable.sort();
473 let mut expected_sorted: Vec<String> = expected.iter().map(|s| s.to_string()).collect();
474 expected_sorted.sort();
475
476 assert_eq!(actual_publishable, expected_sorted);
477 }
478 }
479
480 #[test]
481 fn run_publish_rejects_unconfigured_branch() {
482 let mut workspace = TestWorkspace::new();
483 workspace.add_crate("foo", "0.1.0");
484 workspace.set_publishable("foo", false);
485 workspace.set_config("[git]\nrelease_branches = [\"main\"]\n");
486
487 let _guard = EnvVarGuard::set("SAMPO_RELEASE_BRANCH", "feature");
488 let branch = current_branch().expect("branch should be readable");
489 assert_eq!(branch, "feature");
490 let err = workspace.run_publish(true).unwrap_err();
491 match err {
492 SampoError::Release(message) => {
493 assert!(
494 message.contains("not configured for publishing"),
495 "unexpected message: {message}"
496 );
497 }
498 other => panic!("expected Release error, got {other:?}"),
499 }
500 }
501
502 #[test]
503 fn run_publish_allows_configured_branch() {
504 let mut workspace = TestWorkspace::new();
505 workspace.add_crate("foo", "0.1.0");
506 workspace.set_publishable("foo", false);
507 workspace.set_config("[git]\nrelease_branches = [\"3.x\"]\n");
508
509 let _guard = EnvVarGuard::set("SAMPO_RELEASE_BRANCH", "3.x");
510 workspace
511 .run_publish(true)
512 .expect("publish should succeed on configured branch");
513 }
514
515 #[test]
516 fn topo_orders_deps_first() {
517 let a = PackageInfo {
519 name: "a".into(),
520 identifier: "cargo/a".into(),
521 version: "0.1.0".into(),
522 path: PathBuf::from("/tmp/a"),
523 internal_deps: BTreeSet::new(),
524 kind: PackageKind::Cargo,
525 };
526 let mut deps_b = BTreeSet::new();
527 deps_b.insert("cargo/a".into());
528 let b = PackageInfo {
529 name: "b".into(),
530 identifier: "cargo/b".into(),
531 version: "0.1.0".into(),
532 path: PathBuf::from("/tmp/b"),
533 internal_deps: deps_b,
534 kind: PackageKind::Cargo,
535 };
536 let mut deps_c = BTreeSet::new();
537 deps_c.insert("cargo/b".into());
538 let c = PackageInfo {
539 name: "c".into(),
540 identifier: "cargo/c".into(),
541 version: "0.1.0".into(),
542 path: PathBuf::from("/tmp/c"),
543 internal_deps: deps_c,
544 kind: PackageKind::Cargo,
545 };
546
547 let mut map: BTreeMap<String, &PackageInfo> = BTreeMap::new();
548 map.insert("cargo/a".into(), &a);
549 map.insert("cargo/b".into(), &b);
550 map.insert("cargo/c".into(), &c);
551
552 let mut include = BTreeSet::new();
553 include.insert("cargo/a".into());
554 include.insert("cargo/b".into());
555 include.insert("cargo/c".into());
556
557 let order = topo_order(&map, &include).unwrap();
558 assert_eq!(order, vec!["cargo/a", "cargo/b", "cargo/c"]);
559 }
560
561 #[test]
562 fn detects_dependency_cycle() {
563 let mut deps_a = BTreeSet::new();
565 deps_a.insert("cargo/b".into());
566 let a = PackageInfo {
567 name: "a".into(),
568 identifier: "cargo/a".into(),
569 version: "0.1.0".into(),
570 path: PathBuf::from("/tmp/a"),
571 internal_deps: deps_a,
572 kind: PackageKind::Cargo,
573 };
574
575 let mut deps_b = BTreeSet::new();
576 deps_b.insert("cargo/a".into());
577 let b = PackageInfo {
578 name: "b".into(),
579 identifier: "cargo/b".into(),
580 version: "0.1.0".into(),
581 path: PathBuf::from("/tmp/b"),
582 internal_deps: deps_b,
583 kind: PackageKind::Cargo,
584 };
585
586 let mut map: BTreeMap<String, &PackageInfo> = BTreeMap::new();
587 map.insert("cargo/a".into(), &a);
588 map.insert("cargo/b".into(), &b);
589
590 let mut include = BTreeSet::new();
591 include.insert("cargo/a".into());
592 include.insert("cargo/b".into());
593
594 let result = topo_order(&map, &include);
595 assert!(result.is_err());
596 assert!(format!("{}", result.unwrap_err()).contains("dependency cycle"));
597 }
598
599 #[test]
600 fn identifies_publishable_crates() {
601 let mut workspace = TestWorkspace::new();
602 workspace
603 .add_crate("publishable", "0.1.0")
604 .add_crate("not-publishable", "0.1.0")
605 .set_publishable("not-publishable", false);
606
607 workspace.assert_publishable_crates(&["publishable"]);
608 }
609
610 #[test]
611 fn handles_empty_workspace() {
612 let workspace = TestWorkspace::new();
613
614 let result = workspace.run_publish(true);
616 assert!(result.is_ok());
617 }
618
619 #[test]
620 fn rejects_invalid_internal_dependencies() {
621 let mut workspace = TestWorkspace::new();
622 workspace
623 .add_crate("publishable", "0.1.0")
624 .add_crate("not-publishable", "0.1.0")
625 .add_dependency("publishable", "not-publishable", "0.1.0")
626 .set_publishable("not-publishable", false);
627
628 let result = workspace.run_publish(true);
629 assert!(result.is_err());
630 let error_msg = format!("{}", result.unwrap_err());
631 assert!(error_msg.contains("cannot publish due to non-publishable internal dependencies"));
632 }
633
634 #[test]
635 fn dry_run_publishes_in_dependency_order() {
636 let mut workspace = TestWorkspace::new();
637 workspace
638 .add_crate("foundation", "0.1.0")
639 .add_crate("middleware", "0.1.0")
640 .add_crate("app", "0.1.0")
641 .add_dependency("middleware", "foundation", "0.1.0")
642 .add_dependency("app", "middleware", "0.1.0");
643
644 let result = workspace.run_publish(true);
646 assert!(result.is_ok());
647 }
648
649 #[test]
650 fn parses_manifest_publish_field_correctly() {
651 let temp_dir = tempfile::tempdir().unwrap();
652 let adapter = PackageAdapter::Cargo;
653
654 let manifest_false = temp_dir.path().join("false.toml");
656 fs::write(
657 &manifest_false,
658 "[package]\nname=\"test\"\nversion=\"0.1.0\"\npublish = false\n",
659 )
660 .unwrap();
661 assert!(!adapter.is_publishable(&manifest_false).unwrap());
662
663 let manifest_custom = temp_dir.path().join("custom.toml");
665 fs::write(
666 &manifest_custom,
667 "[package]\nname=\"test\"\nversion=\"0.1.0\"\npublish = [\"custom-registry\"]\n",
668 )
669 .unwrap();
670 assert!(!adapter.is_publishable(&manifest_custom).unwrap());
671
672 let manifest_allowed = temp_dir.path().join("allowed.toml");
674 fs::write(
675 &manifest_allowed,
676 "[package]\nname=\"test\"\nversion=\"0.1.0\"\npublish = [\"crates-io\"]\n",
677 )
678 .unwrap();
679 assert!(adapter.is_publishable(&manifest_allowed).unwrap());
680
681 let manifest_default = temp_dir.path().join("default.toml");
683 fs::write(
684 &manifest_default,
685 "[package]\nname=\"test\"\nversion=\"0.1.0\"\n",
686 )
687 .unwrap();
688 assert!(adapter.is_publishable(&manifest_default).unwrap());
689 }
690
691 #[test]
692 fn handles_missing_package_section() {
693 let temp_dir = tempfile::tempdir().unwrap();
694 let manifest_path = temp_dir.path().join("no-package.toml");
695 fs::write(&manifest_path, "[dependencies]\nserde = \"1.0\"\n").unwrap();
696
697 let adapter = PackageAdapter::Cargo;
698 assert!(!adapter.is_publishable(&manifest_path).unwrap());
700 }
701
702 #[test]
703 fn handles_malformed_toml() {
704 let temp_dir = tempfile::tempdir().unwrap();
705 let manifest_path = temp_dir.path().join("broken.toml");
706 fs::write(&manifest_path, "[package\nname=\"test\"\n").unwrap(); let adapter = PackageAdapter::Cargo;
709 let result = adapter.is_publishable(&manifest_path);
710 assert!(result.is_err());
711 assert!(format!("{}", result.unwrap_err()).contains("Invalid data"));
712 }
713
714 #[test]
715 fn skips_ignored_packages_during_publish() {
716 use std::collections::BTreeSet;
717
718 let temp_dir = tempfile::tempdir().unwrap();
719 let root = temp_dir.path();
720
721 let config_dir = root.join(".sampo");
723 fs::create_dir_all(&config_dir).unwrap();
724 fs::write(
725 config_dir.join("config.toml"),
726 "[packages]\nignore = [\"examples/*\"]\n",
727 )
728 .unwrap();
729
730 let main_pkg = root.join("main-package");
732 let examples_pkg = root.join("examples/demo");
733
734 fs::create_dir_all(&main_pkg).unwrap();
735 fs::create_dir_all(&examples_pkg).unwrap();
736
737 let main_toml = r#"
739[package]
740name = "main-package"
741version = "1.0.0"
742edition = "2021"
743"#;
744 let examples_toml = r#"
745[package]
746name = "examples-demo"
747version = "1.0.0"
748edition = "2021"
749"#;
750
751 fs::write(main_pkg.join("Cargo.toml"), main_toml).unwrap();
752 fs::write(examples_pkg.join("Cargo.toml"), examples_toml).unwrap();
753
754 let workspace = Workspace {
756 root: root.to_path_buf(),
757 members: vec![
758 PackageInfo {
759 name: "main-package".to_string(),
760 identifier: PackageInfo::dependency_identifier(
761 PackageKind::Cargo,
762 "main-package",
763 ),
764 version: "1.0.0".to_string(),
765 path: main_pkg,
766 internal_deps: BTreeSet::new(),
767 kind: PackageKind::Cargo,
768 },
769 PackageInfo {
770 name: "examples-demo".to_string(),
771 identifier: PackageInfo::dependency_identifier(
772 PackageKind::Cargo,
773 "examples-demo",
774 ),
775 version: "1.0.0".to_string(),
776 path: examples_pkg,
777 internal_deps: BTreeSet::new(),
778 kind: PackageKind::Cargo,
779 },
780 ],
781 };
782
783 let config = crate::Config::load(&workspace.root).unwrap();
784
785 let mut publishable: BTreeSet<String> = BTreeSet::new();
787 let adapter = PackageAdapter::Cargo;
788 for c in &workspace.members {
789 if should_ignore_package(&config, &workspace, c).unwrap() {
791 continue;
792 }
793
794 let manifest = adapter.manifest_path(&c.path);
795 if adapter.is_publishable(&manifest).unwrap() {
796 publishable.insert(c.name.clone());
797 }
798 }
799
800 assert_eq!(publishable.len(), 1);
802 assert!(publishable.contains("main-package"));
803 assert!(!publishable.contains("examples-demo"));
804 }
805}