1use crate::types::CrateInfo;
2use crate::{
3 Config, current_branch, discover_workspace,
4 errors::{Result, SampoError},
5 filters::should_ignore_crate,
6};
7use std::collections::{BTreeMap, BTreeSet, VecDeque};
8use std::fs;
9use std::path::Path;
10use std::process::Command;
11use std::time::Duration;
12
13pub fn run_publish(root: &std::path::Path, dry_run: bool, cargo_args: &[String]) -> Result<()> {
36 let ws = discover_workspace(root)?;
37 let config = Config::load(&ws.root)?;
38
39 let branch = current_branch()?;
40 if !config.is_release_branch(&branch) {
41 return Err(SampoError::Release(format!(
42 "Branch '{}' is not configured for publishing (allowed: {:?})",
43 branch,
44 config.release_branches().into_iter().collect::<Vec<_>>()
45 )));
46 }
47
48 let mut name_to_crate: BTreeMap<String, &CrateInfo> = BTreeMap::new();
50 let mut publishable: BTreeSet<String> = BTreeSet::new();
51 for c in &ws.members {
52 if should_ignore_crate(&config, &ws, c)? {
54 continue;
55 }
56
57 let manifest = c.path.join("Cargo.toml");
58 if is_publishable_to_crates_io(&manifest)? {
59 publishable.insert(c.name.clone());
60 name_to_crate.insert(c.name.clone(), c);
61 }
62 }
63
64 if publishable.is_empty() {
65 println!("No publishable crates for crates.io were found in the workspace.");
66 return Ok(());
67 }
68
69 let mut errors: Vec<String> = Vec::new();
71 for name in &publishable {
72 let c = name_to_crate.get(name).ok_or_else(|| {
73 SampoError::Publish(format!(
74 "internal error: crate '{}' not found in workspace",
75 name
76 ))
77 })?;
78 for dep in &c.internal_deps {
79 if !publishable.contains(dep) {
80 errors.push(format!(
81 "crate '{}' depends on internal crate '{}' which is not publishable",
82 name, dep
83 ));
84 }
85 }
86 }
87 if !errors.is_empty() {
88 for e in errors {
89 eprintln!("{e}");
90 }
91 return Err(SampoError::Publish(
92 "cannot publish due to non-publishable internal dependencies".into(),
93 ));
94 }
95
96 let order = topo_order(&name_to_crate, &publishable)?;
98
99 println!("Publish plan (crates.io):");
100 for name in &order {
101 println!(" - {name}");
102 }
103
104 for name in &order {
106 let c = name_to_crate.get(name).ok_or_else(|| {
107 SampoError::Publish(format!(
108 "internal error: crate '{}' not found in workspace",
109 name
110 ))
111 })?;
112 let manifest = c.path.join("Cargo.toml");
113 match version_exists_on_crates_io(&c.name, &c.version) {
115 Ok(true) => {
116 println!(
117 "Skipping {}@{} (already exists on crates.io)",
118 c.name, c.version
119 );
120 continue;
121 }
122 Ok(false) => {}
123 Err(e) => {
124 eprintln!(
125 "Warning: could not check crates.io for {}@{}: {}. Attempting publish…",
126 c.name, c.version, e
127 );
128 }
129 }
130
131 let mut cmd = Command::new("cargo");
132 cmd.arg("publish").arg("--manifest-path").arg(&manifest);
133 if dry_run {
134 cmd.arg("--dry-run");
135 }
136 if !cargo_args.is_empty() {
137 cmd.args(cargo_args);
138 }
139
140 println!(
141 "Running: {}",
142 format_command_display(cmd.get_program(), cmd.get_args())
143 );
144
145 let status = cmd.status()?;
146 if !status.success() {
147 return Err(SampoError::Publish(format!(
148 "cargo publish failed for crate '{}' with status {}",
149 name, status
150 )));
151 }
152
153 if !dry_run && let Err(e) = tag_published_crate(&ws.root, &c.name, &c.version) {
155 eprintln!(
156 "Warning: failed to create tag for {}@{}: {}",
157 c.name, c.version, e
158 );
159 }
160 }
161
162 if dry_run {
163 println!("Dry-run complete.");
164 } else {
165 println!("Publish complete.");
166 }
167
168 Ok(())
169}
170
171pub fn is_publishable_to_crates_io(manifest_path: &Path) -> Result<bool> {
199 let text = fs::read_to_string(manifest_path)
200 .map_err(|e| SampoError::Io(crate::errors::io_error_with_path(e, manifest_path)))?;
201 let value: toml::Value = text.parse().map_err(|e| {
202 SampoError::InvalidData(format!("invalid TOML in {}: {e}", manifest_path.display()))
203 })?;
204
205 let pkg = match value.get("package").and_then(|v| v.as_table()) {
206 Some(p) => p,
207 None => return Ok(false),
208 };
209
210 if let Some(val) = pkg.get("publish") {
212 match val {
213 toml::Value::Boolean(false) => return Ok(false),
214 toml::Value::Array(arr) => {
215 let allowed: Vec<String> = arr
218 .iter()
219 .filter_map(|v| v.as_str().map(|s| s.to_string()))
220 .collect();
221 return Ok(allowed.iter().any(|s| s == "crates-io"));
222 }
223 _ => {}
224 }
225 }
226
227 Ok(true)
229}
230
231pub fn tag_published_crate(repo_root: &Path, crate_name: &str, version: &str) -> Result<()> {
252 if !repo_root.join(".git").exists() {
253 return Ok(());
255 }
256 let tag = format!("{}-v{}", crate_name, version);
257 let out = Command::new("git")
259 .arg("-C")
260 .arg(repo_root)
261 .arg("tag")
262 .arg("--list")
263 .arg(&tag)
264 .output()?;
265 if out.status.success() {
266 let s = String::from_utf8_lossy(&out.stdout);
267 if s.lines().any(|l| l.trim() == tag) {
268 return Ok(());
269 }
270 }
271
272 let msg = format!("Release {} {}", crate_name, version);
273 let status = Command::new("git")
274 .arg("-C")
275 .arg(repo_root)
276 .arg("tag")
277 .arg("-a")
278 .arg(&tag)
279 .arg("-m")
280 .arg(&msg)
281 .status()?;
282 if status.success() {
283 Ok(())
284 } else {
285 Err(SampoError::Publish(format!(
286 "git tag failed with status {}",
287 status
288 )))
289 }
290}
291
292pub fn version_exists_on_crates_io(crate_name: &str, version: &str) -> Result<bool> {
314 let url = format!("https://crates.io/api/v1/crates/{}/{}", crate_name, version);
316
317 let client = reqwest::blocking::Client::builder()
318 .timeout(Duration::from_secs(10))
319 .user_agent(format!("sampo-core/{}", env!("CARGO_PKG_VERSION")))
320 .build()
321 .map_err(|e| SampoError::Publish(format!("failed to build HTTP client: {}", e)))?;
322
323 let res = client
324 .get(&url)
325 .send()
326 .map_err(|e| SampoError::Publish(format!("HTTP request failed: {}", e)))?;
327
328 let status = res.status();
329 if status == reqwest::StatusCode::OK {
330 Ok(true)
331 } else if status == reqwest::StatusCode::NOT_FOUND {
332 Ok(false)
333 } else {
334 let body = res.text().unwrap_or_default();
336 let snippet: String = body.trim().chars().take(500).collect();
337 let snippet = snippet.split_whitespace().collect::<Vec<_>>().join(" ");
338
339 let body_part = if snippet.is_empty() {
340 String::new()
341 } else {
342 format!(" body=\"{}\"", snippet)
343 };
344
345 Err(SampoError::Publish(format!(
346 "Crates.io {} response:{}",
347 status, body_part
348 )))
349 }
350}
351
352pub fn topo_order(
378 name_to_crate: &BTreeMap<String, &CrateInfo>,
379 include: &BTreeSet<String>,
380) -> Result<Vec<String>> {
381 let mut indegree: BTreeMap<&str, usize> = BTreeMap::new();
383 let mut forward: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
384
385 for name in include {
386 indegree.insert(name.as_str(), 0);
387 forward.entry(name.as_str()).or_default();
388 }
389
390 for name in include {
391 let c = name_to_crate
392 .get(name)
393 .ok_or_else(|| SampoError::Publish(format!("missing crate info for '{}'", name)))?;
394 for dep in &c.internal_deps {
395 if include.contains(dep) {
396 let entry = forward.entry(dep.as_str()).or_default();
398 entry.push(name.as_str());
399 *indegree.get_mut(name.as_str()).unwrap() += 1;
400 }
401 }
402 }
403
404 let mut q: VecDeque<&str> = indegree
405 .iter()
406 .filter_map(|(k, &d)| if d == 0 { Some(*k) } else { None })
407 .collect();
408 let mut out: Vec<String> = Vec::new();
409
410 while let Some(n) = q.pop_front() {
411 out.push(n.to_string());
412 if let Some(children) = forward.get(n) {
413 for &m in children {
414 if let Some(d) = indegree.get_mut(m) {
415 *d -= 1;
416 if *d == 0 {
417 q.push_back(m);
418 }
419 }
420 }
421 }
422 }
423
424 if out.len() != include.len() {
425 return Err(SampoError::Publish(
426 "dependency cycle detected among publishable crates".into(),
427 ));
428 }
429 Ok(out)
430}
431
432fn format_command_display(program: &std::ffi::OsStr, args: std::process::CommandArgs) -> String {
433 let prog = program.to_string_lossy();
434 let mut s = String::new();
435 s.push_str(&prog);
436 for a in args {
437 s.push(' ');
438 s.push_str(&a.to_string_lossy());
439 }
440 s
441}
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446 use rustc_hash::FxHashMap;
447 use std::{
448 fs,
449 path::PathBuf,
450 sync::{Mutex, MutexGuard, OnceLock},
451 };
452
453 struct TestWorkspace {
455 root: PathBuf,
456 _temp_dir: tempfile::TempDir,
457 crates: FxHashMap<String, PathBuf>,
458 }
459
460 static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
461
462 fn env_lock() -> &'static Mutex<()> {
463 ENV_MUTEX.get_or_init(|| Mutex::new(()))
464 }
465
466 struct EnvVarGuard {
467 key: &'static str,
468 original: Option<String>,
469 _lock: MutexGuard<'static, ()>,
470 }
471
472 impl EnvVarGuard {
473 fn set(key: &'static str, value: &str) -> Self {
474 let lock = env_lock().lock().unwrap();
475 let original = std::env::var(key).ok();
476 unsafe {
477 std::env::set_var(key, value);
478 }
479 Self {
480 key,
481 original,
482 _lock: lock,
483 }
484 }
485 }
486
487 impl Drop for EnvVarGuard {
488 fn drop(&mut self) {
489 unsafe {
490 if let Some(ref value) = self.original {
491 std::env::set_var(self.key, value);
492 } else {
493 std::env::remove_var(self.key);
494 }
495 }
496 }
497 }
498
499 impl TestWorkspace {
500 fn new() -> Self {
501 let temp_dir = tempfile::tempdir().unwrap();
502 let root = temp_dir.path().to_path_buf();
503
504 {
505 let _lock = env_lock().lock().unwrap();
506 unsafe {
507 std::env::set_var("SAMPO_RELEASE_BRANCH", "main");
508 }
509 }
510
511 fs::write(
513 root.join("Cargo.toml"),
514 "[workspace]\nmembers=[\"crates/*\"]\n",
515 )
516 .unwrap();
517
518 Self {
519 root,
520 _temp_dir: temp_dir,
521 crates: FxHashMap::default(),
522 }
523 }
524
525 fn add_crate(&mut self, name: &str, version: &str) -> &mut Self {
526 let crate_dir = self.root.join("crates").join(name);
527 fs::create_dir_all(&crate_dir).unwrap();
528
529 fs::write(
530 crate_dir.join("Cargo.toml"),
531 format!("[package]\nname=\"{}\"\nversion=\"{}\"\n", name, version),
532 )
533 .unwrap();
534
535 fs::create_dir_all(crate_dir.join("src")).unwrap();
537 fs::write(crate_dir.join("src/lib.rs"), "// test crate").unwrap();
538
539 self.crates.insert(name.to_string(), crate_dir);
540 self
541 }
542
543 fn add_dependency(&mut self, from: &str, to: &str, version: &str) -> &mut Self {
544 let from_dir = self.crates.get(from).expect("from crate must exist");
545 let current_manifest = fs::read_to_string(from_dir.join("Cargo.toml")).unwrap();
546
547 let dependency_section = format!(
548 "\n[dependencies]\n{} = {{ path=\"../{}\", version=\"{}\" }}\n",
549 to, to, version
550 );
551
552 fs::write(
553 from_dir.join("Cargo.toml"),
554 current_manifest + &dependency_section,
555 )
556 .unwrap();
557
558 self
559 }
560
561 fn set_publishable(&self, crate_name: &str, publishable: bool) -> &Self {
562 let crate_dir = self.crates.get(crate_name).expect("crate must exist");
563 let manifest_path = crate_dir.join("Cargo.toml");
564 let current_manifest = fs::read_to_string(&manifest_path).unwrap();
565
566 let new_manifest = if publishable {
567 current_manifest
568 } else {
569 current_manifest + "\npublish = false\n"
570 };
571
572 fs::write(manifest_path, new_manifest).unwrap();
573 self
574 }
575
576 fn set_config(&self, content: &str) -> &Self {
577 fs::create_dir_all(self.root.join(".sampo")).unwrap();
578 fs::write(self.root.join(".sampo/config.toml"), content).unwrap();
579 self
580 }
581
582 fn run_publish(&self, dry_run: bool) -> Result<()> {
583 run_publish(&self.root, dry_run, &[])
584 }
585
586 fn assert_publishable_crates(&self, expected: &[&str]) {
587 let ws = discover_workspace(&self.root).unwrap();
588 let mut actual_publishable = Vec::new();
589
590 for c in &ws.members {
591 let manifest = c.path.join("Cargo.toml");
592 if is_publishable_to_crates_io(&manifest).unwrap() {
593 actual_publishable.push(c.name.clone());
594 }
595 }
596
597 actual_publishable.sort();
598 let mut expected_sorted: Vec<String> = expected.iter().map(|s| s.to_string()).collect();
599 expected_sorted.sort();
600
601 assert_eq!(actual_publishable, expected_sorted);
602 }
603 }
604
605 #[test]
606 fn run_publish_rejects_unconfigured_branch() {
607 let mut workspace = TestWorkspace::new();
608 workspace.add_crate("foo", "0.1.0");
609 workspace.set_publishable("foo", false);
610 workspace.set_config("[git]\nrelease_branches = [\"main\"]\n");
611
612 let _guard = EnvVarGuard::set("SAMPO_RELEASE_BRANCH", "feature");
613 let branch = current_branch().expect("branch should be readable");
614 assert_eq!(branch, "feature");
615 let err = workspace.run_publish(true).unwrap_err();
616 match err {
617 SampoError::Release(message) => {
618 assert!(
619 message.contains("not configured for publishing"),
620 "unexpected message: {message}"
621 );
622 }
623 other => panic!("expected Release error, got {other:?}"),
624 }
625 }
626
627 #[test]
628 fn run_publish_allows_configured_branch() {
629 let mut workspace = TestWorkspace::new();
630 workspace.add_crate("foo", "0.1.0");
631 workspace.set_publishable("foo", false);
632 workspace.set_config("[git]\nrelease_branches = [\"3.x\"]\n");
633
634 let _guard = EnvVarGuard::set("SAMPO_RELEASE_BRANCH", "3.x");
635 workspace
636 .run_publish(true)
637 .expect("publish should succeed on configured branch");
638 }
639
640 #[test]
641 fn topo_orders_deps_first() {
642 let a = CrateInfo {
644 name: "a".into(),
645 version: "0.1.0".into(),
646 path: PathBuf::from("/tmp/a"),
647 internal_deps: BTreeSet::new(),
648 };
649 let mut deps_b = BTreeSet::new();
650 deps_b.insert("a".into());
651 let b = CrateInfo {
652 name: "b".into(),
653 version: "0.1.0".into(),
654 path: PathBuf::from("/tmp/b"),
655 internal_deps: deps_b,
656 };
657 let mut deps_c = BTreeSet::new();
658 deps_c.insert("b".into());
659 let c = CrateInfo {
660 name: "c".into(),
661 version: "0.1.0".into(),
662 path: PathBuf::from("/tmp/c"),
663 internal_deps: deps_c,
664 };
665
666 let mut map: BTreeMap<String, &CrateInfo> = BTreeMap::new();
667 map.insert("a".into(), &a);
668 map.insert("b".into(), &b);
669 map.insert("c".into(), &c);
670
671 let mut include = BTreeSet::new();
672 include.insert("a".into());
673 include.insert("b".into());
674 include.insert("c".into());
675
676 let order = topo_order(&map, &include).unwrap();
677 assert_eq!(order, vec!["a", "b", "c"]);
678 }
679
680 #[test]
681 fn detects_dependency_cycle() {
682 let mut deps_a = BTreeSet::new();
684 deps_a.insert("b".into());
685 let a = CrateInfo {
686 name: "a".into(),
687 version: "0.1.0".into(),
688 path: PathBuf::from("/tmp/a"),
689 internal_deps: deps_a,
690 };
691
692 let mut deps_b = BTreeSet::new();
693 deps_b.insert("a".into());
694 let b = CrateInfo {
695 name: "b".into(),
696 version: "0.1.0".into(),
697 path: PathBuf::from("/tmp/b"),
698 internal_deps: deps_b,
699 };
700
701 let mut map: BTreeMap<String, &CrateInfo> = BTreeMap::new();
702 map.insert("a".into(), &a);
703 map.insert("b".into(), &b);
704
705 let mut include = BTreeSet::new();
706 include.insert("a".into());
707 include.insert("b".into());
708
709 let result = topo_order(&map, &include);
710 assert!(result.is_err());
711 assert!(format!("{}", result.unwrap_err()).contains("dependency cycle"));
712 }
713
714 #[test]
715 fn identifies_publishable_crates() {
716 let mut workspace = TestWorkspace::new();
717 workspace
718 .add_crate("publishable", "0.1.0")
719 .add_crate("not-publishable", "0.1.0")
720 .set_publishable("not-publishable", false);
721
722 workspace.assert_publishable_crates(&["publishable"]);
723 }
724
725 #[test]
726 fn handles_empty_workspace() {
727 let workspace = TestWorkspace::new();
728
729 let result = workspace.run_publish(true);
731 assert!(result.is_ok());
732 }
733
734 #[test]
735 fn rejects_invalid_internal_dependencies() {
736 let mut workspace = TestWorkspace::new();
737 workspace
738 .add_crate("publishable", "0.1.0")
739 .add_crate("not-publishable", "0.1.0")
740 .add_dependency("publishable", "not-publishable", "0.1.0")
741 .set_publishable("not-publishable", false);
742
743 let result = workspace.run_publish(true);
744 assert!(result.is_err());
745 let error_msg = format!("{}", result.unwrap_err());
746 assert!(error_msg.contains("cannot publish due to non-publishable internal dependencies"));
747 }
748
749 #[test]
750 fn dry_run_publishes_in_dependency_order() {
751 let mut workspace = TestWorkspace::new();
752 workspace
753 .add_crate("foundation", "0.1.0")
754 .add_crate("middleware", "0.1.0")
755 .add_crate("app", "0.1.0")
756 .add_dependency("middleware", "foundation", "0.1.0")
757 .add_dependency("app", "middleware", "0.1.0");
758
759 let result = workspace.run_publish(true);
761 assert!(result.is_ok());
762 }
763
764 #[test]
765 fn parses_manifest_publish_field_correctly() {
766 let temp_dir = tempfile::tempdir().unwrap();
767
768 let manifest_false = temp_dir.path().join("false.toml");
770 fs::write(
771 &manifest_false,
772 "[package]\nname=\"test\"\nversion=\"0.1.0\"\npublish = false\n",
773 )
774 .unwrap();
775 assert!(!is_publishable_to_crates_io(&manifest_false).unwrap());
776
777 let manifest_custom = temp_dir.path().join("custom.toml");
779 fs::write(
780 &manifest_custom,
781 "[package]\nname=\"test\"\nversion=\"0.1.0\"\npublish = [\"custom-registry\"]\n",
782 )
783 .unwrap();
784 assert!(!is_publishable_to_crates_io(&manifest_custom).unwrap());
785
786 let manifest_allowed = temp_dir.path().join("allowed.toml");
788 fs::write(
789 &manifest_allowed,
790 "[package]\nname=\"test\"\nversion=\"0.1.0\"\npublish = [\"crates-io\"]\n",
791 )
792 .unwrap();
793 assert!(is_publishable_to_crates_io(&manifest_allowed).unwrap());
794
795 let manifest_default = temp_dir.path().join("default.toml");
797 fs::write(
798 &manifest_default,
799 "[package]\nname=\"test\"\nversion=\"0.1.0\"\n",
800 )
801 .unwrap();
802 assert!(is_publishable_to_crates_io(&manifest_default).unwrap());
803 }
804
805 #[test]
806 fn handles_missing_package_section() {
807 let temp_dir = tempfile::tempdir().unwrap();
808 let manifest_path = temp_dir.path().join("no-package.toml");
809 fs::write(&manifest_path, "[dependencies]\nserde = \"1.0\"\n").unwrap();
810
811 assert!(!is_publishable_to_crates_io(&manifest_path).unwrap());
813 }
814
815 #[test]
816 fn handles_malformed_toml() {
817 let temp_dir = tempfile::tempdir().unwrap();
818 let manifest_path = temp_dir.path().join("broken.toml");
819 fs::write(&manifest_path, "[package\nname=\"test\"\n").unwrap(); let result = is_publishable_to_crates_io(&manifest_path);
822 assert!(result.is_err());
823 assert!(format!("{}", result.unwrap_err()).contains("Invalid data"));
824 }
825
826 #[test]
827 fn skips_ignored_packages_during_publish() {
828 use crate::types::{CrateInfo, Workspace};
829 use std::collections::BTreeSet;
830
831 let temp_dir = tempfile::tempdir().unwrap();
832 let root = temp_dir.path();
833
834 let config_dir = root.join(".sampo");
836 fs::create_dir_all(&config_dir).unwrap();
837 fs::write(
838 config_dir.join("config.toml"),
839 "[packages]\nignore = [\"examples/*\"]\n",
840 )
841 .unwrap();
842
843 let main_pkg = root.join("main-package");
845 let examples_pkg = root.join("examples/demo");
846
847 fs::create_dir_all(&main_pkg).unwrap();
848 fs::create_dir_all(&examples_pkg).unwrap();
849
850 let main_toml = r#"
852[package]
853name = "main-package"
854version = "1.0.0"
855edition = "2021"
856"#;
857 let examples_toml = r#"
858[package]
859name = "examples-demo"
860version = "1.0.0"
861edition = "2021"
862"#;
863
864 fs::write(main_pkg.join("Cargo.toml"), main_toml).unwrap();
865 fs::write(examples_pkg.join("Cargo.toml"), examples_toml).unwrap();
866
867 let workspace = Workspace {
869 root: root.to_path_buf(),
870 members: vec![
871 CrateInfo {
872 name: "main-package".to_string(),
873 version: "1.0.0".to_string(),
874 path: main_pkg,
875 internal_deps: BTreeSet::new(),
876 },
877 CrateInfo {
878 name: "examples-demo".to_string(),
879 version: "1.0.0".to_string(),
880 path: examples_pkg,
881 internal_deps: BTreeSet::new(),
882 },
883 ],
884 };
885
886 let config = crate::Config::load(&workspace.root).unwrap();
887
888 let mut publishable: BTreeSet<String> = BTreeSet::new();
890 for c in &workspace.members {
891 if should_ignore_crate(&config, &workspace, c).unwrap() {
893 continue;
894 }
895
896 let manifest = c.path.join("Cargo.toml");
897 if is_publishable_to_crates_io(&manifest).unwrap() {
898 publishable.insert(c.name.clone());
899 }
900 }
901
902 assert_eq!(publishable.len(), 1);
904 assert!(publishable.contains("main-package"));
905 assert!(!publishable.contains("examples-demo"));
906 }
907}