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