1use crate::types::CrateInfo;
2use crate::{
3 Config, 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 mut name_to_crate: BTreeMap<String, &CrateInfo> = BTreeMap::new();
41 let mut publishable: BTreeSet<String> = BTreeSet::new();
42 for c in &ws.members {
43 if should_ignore_crate(&config, &ws, c)? {
45 continue;
46 }
47
48 let manifest = c.path.join("Cargo.toml");
49 if is_publishable_to_crates_io(&manifest)? {
50 publishable.insert(c.name.clone());
51 name_to_crate.insert(c.name.clone(), c);
52 }
53 }
54
55 if publishable.is_empty() {
56 println!("No publishable crates for crates.io were found in the workspace.");
57 return Ok(());
58 }
59
60 let mut errors: Vec<String> = Vec::new();
62 for name in &publishable {
63 let c = name_to_crate.get(name).ok_or_else(|| {
64 SampoError::Publish(format!(
65 "internal error: crate '{}' not found in workspace",
66 name
67 ))
68 })?;
69 for dep in &c.internal_deps {
70 if !publishable.contains(dep) {
71 errors.push(format!(
72 "crate '{}' depends on internal crate '{}' which is not publishable",
73 name, dep
74 ));
75 }
76 }
77 }
78 if !errors.is_empty() {
79 for e in errors {
80 eprintln!("{e}");
81 }
82 return Err(SampoError::Publish(
83 "cannot publish due to non-publishable internal dependencies".into(),
84 ));
85 }
86
87 let order = topo_order(&name_to_crate, &publishable)?;
89
90 println!("Publish plan (crates.io):");
91 for name in &order {
92 println!(" - {name}");
93 }
94
95 for name in &order {
97 let c = name_to_crate.get(name).ok_or_else(|| {
98 SampoError::Publish(format!(
99 "internal error: crate '{}' not found in workspace",
100 name
101 ))
102 })?;
103 let manifest = c.path.join("Cargo.toml");
104 match version_exists_on_crates_io(&c.name, &c.version) {
106 Ok(true) => {
107 println!(
108 "Skipping {}@{} (already exists on crates.io)",
109 c.name, c.version
110 );
111 continue;
112 }
113 Ok(false) => {}
114 Err(e) => {
115 eprintln!(
116 "Warning: could not check crates.io for {}@{}: {}. Attempting publish…",
117 c.name, c.version, e
118 );
119 }
120 }
121
122 let mut cmd = Command::new("cargo");
123 cmd.arg("publish").arg("--manifest-path").arg(&manifest);
124 if dry_run {
125 cmd.arg("--dry-run");
126 }
127 if !cargo_args.is_empty() {
128 cmd.args(cargo_args);
129 }
130
131 println!(
132 "Running: {}",
133 format_command_display(cmd.get_program(), cmd.get_args())
134 );
135
136 let status = cmd.status()?;
137 if !status.success() {
138 return Err(SampoError::Publish(format!(
139 "cargo publish failed for crate '{}' with status {}",
140 name, status
141 )));
142 }
143
144 if !dry_run && let Err(e) = tag_published_crate(&ws.root, &c.name, &c.version) {
146 eprintln!(
147 "Warning: failed to create tag for {}@{}: {}",
148 c.name, c.version, e
149 );
150 }
151 }
152
153 if dry_run {
154 println!("Dry-run complete.");
155 } else {
156 println!("Publish complete.");
157 }
158
159 Ok(())
160}
161
162pub fn is_publishable_to_crates_io(manifest_path: &Path) -> Result<bool> {
190 let text = fs::read_to_string(manifest_path)
191 .map_err(|e| SampoError::Io(crate::errors::io_error_with_path(e, manifest_path)))?;
192 let value: toml::Value = text.parse().map_err(|e| {
193 SampoError::InvalidData(format!("invalid TOML in {}: {e}", manifest_path.display()))
194 })?;
195
196 let pkg = match value.get("package").and_then(|v| v.as_table()) {
197 Some(p) => p,
198 None => return Ok(false),
199 };
200
201 if let Some(val) = pkg.get("publish") {
203 match val {
204 toml::Value::Boolean(false) => return Ok(false),
205 toml::Value::Array(arr) => {
206 let allowed: Vec<String> = arr
209 .iter()
210 .filter_map(|v| v.as_str().map(|s| s.to_string()))
211 .collect();
212 return Ok(allowed.iter().any(|s| s == "crates-io"));
213 }
214 _ => {}
215 }
216 }
217
218 Ok(true)
220}
221
222pub fn tag_published_crate(repo_root: &Path, crate_name: &str, version: &str) -> Result<()> {
243 if !repo_root.join(".git").exists() {
244 return Ok(());
246 }
247 let tag = format!("{}-v{}", crate_name, version);
248 let out = Command::new("git")
250 .arg("-C")
251 .arg(repo_root)
252 .arg("tag")
253 .arg("--list")
254 .arg(&tag)
255 .output()?;
256 if out.status.success() {
257 let s = String::from_utf8_lossy(&out.stdout);
258 if s.lines().any(|l| l.trim() == tag) {
259 return Ok(());
260 }
261 }
262
263 let msg = format!("Release {} {}", crate_name, version);
264 let status = Command::new("git")
265 .arg("-C")
266 .arg(repo_root)
267 .arg("tag")
268 .arg("-a")
269 .arg(&tag)
270 .arg("-m")
271 .arg(&msg)
272 .status()?;
273 if status.success() {
274 Ok(())
275 } else {
276 Err(SampoError::Publish(format!(
277 "git tag failed with status {}",
278 status
279 )))
280 }
281}
282
283pub fn version_exists_on_crates_io(crate_name: &str, version: &str) -> Result<bool> {
305 let url = format!("https://crates.io/api/v1/crates/{}/{}", crate_name, version);
307
308 let client = reqwest::blocking::Client::builder()
309 .timeout(Duration::from_secs(10))
310 .user_agent(format!("sampo-core/{}", env!("CARGO_PKG_VERSION")))
311 .build()
312 .map_err(|e| SampoError::Publish(format!("failed to build HTTP client: {}", e)))?;
313
314 let res = client
315 .get(&url)
316 .send()
317 .map_err(|e| SampoError::Publish(format!("HTTP request failed: {}", e)))?;
318
319 let status = res.status();
320 if status == reqwest::StatusCode::OK {
321 Ok(true)
322 } else if status == reqwest::StatusCode::NOT_FOUND {
323 Ok(false)
324 } else {
325 let body = res.text().unwrap_or_default();
327 let snippet: String = body.trim().chars().take(500).collect();
328 let snippet = snippet.split_whitespace().collect::<Vec<_>>().join(" ");
329
330 let body_part = if snippet.is_empty() {
331 String::new()
332 } else {
333 format!(" body=\"{}\"", snippet)
334 };
335
336 Err(SampoError::Publish(format!(
337 "Crates.io {} response:{}",
338 status, body_part
339 )))
340 }
341}
342
343pub fn topo_order(
369 name_to_crate: &BTreeMap<String, &CrateInfo>,
370 include: &BTreeSet<String>,
371) -> Result<Vec<String>> {
372 let mut indegree: BTreeMap<&str, usize> = BTreeMap::new();
374 let mut forward: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
375
376 for name in include {
377 indegree.insert(name.as_str(), 0);
378 forward.entry(name.as_str()).or_default();
379 }
380
381 for name in include {
382 let c = name_to_crate
383 .get(name)
384 .ok_or_else(|| SampoError::Publish(format!("missing crate info for '{}'", name)))?;
385 for dep in &c.internal_deps {
386 if include.contains(dep) {
387 let entry = forward.entry(dep.as_str()).or_default();
389 entry.push(name.as_str());
390 *indegree.get_mut(name.as_str()).unwrap() += 1;
391 }
392 }
393 }
394
395 let mut q: VecDeque<&str> = indegree
396 .iter()
397 .filter_map(|(k, &d)| if d == 0 { Some(*k) } else { None })
398 .collect();
399 let mut out: Vec<String> = Vec::new();
400
401 while let Some(n) = q.pop_front() {
402 out.push(n.to_string());
403 if let Some(children) = forward.get(n) {
404 for &m in children {
405 if let Some(d) = indegree.get_mut(m) {
406 *d -= 1;
407 if *d == 0 {
408 q.push_back(m);
409 }
410 }
411 }
412 }
413 }
414
415 if out.len() != include.len() {
416 return Err(SampoError::Publish(
417 "dependency cycle detected among publishable crates".into(),
418 ));
419 }
420 Ok(out)
421}
422
423fn format_command_display(program: &std::ffi::OsStr, args: std::process::CommandArgs) -> String {
424 let prog = program.to_string_lossy();
425 let mut s = String::new();
426 s.push_str(&prog);
427 for a in args {
428 s.push(' ');
429 s.push_str(&a.to_string_lossy());
430 }
431 s
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437 use rustc_hash::FxHashMap;
438 use std::fs;
439 use std::path::PathBuf;
440
441 struct TestWorkspace {
443 root: PathBuf,
444 _temp_dir: tempfile::TempDir,
445 crates: FxHashMap<String, PathBuf>,
446 }
447
448 impl TestWorkspace {
449 fn new() -> Self {
450 let temp_dir = tempfile::tempdir().unwrap();
451 let root = temp_dir.path().to_path_buf();
452
453 fs::write(
455 root.join("Cargo.toml"),
456 "[workspace]\nmembers=[\"crates/*\"]\n",
457 )
458 .unwrap();
459
460 Self {
461 root,
462 _temp_dir: temp_dir,
463 crates: FxHashMap::default(),
464 }
465 }
466
467 fn add_crate(&mut self, name: &str, version: &str) -> &mut Self {
468 let crate_dir = self.root.join("crates").join(name);
469 fs::create_dir_all(&crate_dir).unwrap();
470
471 fs::write(
472 crate_dir.join("Cargo.toml"),
473 format!("[package]\nname=\"{}\"\nversion=\"{}\"\n", name, version),
474 )
475 .unwrap();
476
477 fs::create_dir_all(crate_dir.join("src")).unwrap();
479 fs::write(crate_dir.join("src/lib.rs"), "// test crate").unwrap();
480
481 self.crates.insert(name.to_string(), crate_dir);
482 self
483 }
484
485 fn add_dependency(&mut self, from: &str, to: &str, version: &str) -> &mut Self {
486 let from_dir = self.crates.get(from).expect("from crate must exist");
487 let current_manifest = fs::read_to_string(from_dir.join("Cargo.toml")).unwrap();
488
489 let dependency_section = format!(
490 "\n[dependencies]\n{} = {{ path=\"../{}\", version=\"{}\" }}\n",
491 to, to, version
492 );
493
494 fs::write(
495 from_dir.join("Cargo.toml"),
496 current_manifest + &dependency_section,
497 )
498 .unwrap();
499
500 self
501 }
502
503 fn set_publishable(&self, crate_name: &str, publishable: bool) -> &Self {
504 let crate_dir = self.crates.get(crate_name).expect("crate must exist");
505 let manifest_path = crate_dir.join("Cargo.toml");
506 let current_manifest = fs::read_to_string(&manifest_path).unwrap();
507
508 let new_manifest = if publishable {
509 current_manifest
510 } else {
511 current_manifest + "\npublish = false\n"
512 };
513
514 fs::write(manifest_path, new_manifest).unwrap();
515 self
516 }
517
518 fn run_publish(&self, dry_run: bool) -> Result<()> {
519 run_publish(&self.root, dry_run, &[])
520 }
521
522 fn assert_publishable_crates(&self, expected: &[&str]) {
523 let ws = discover_workspace(&self.root).unwrap();
524 let mut actual_publishable = Vec::new();
525
526 for c in &ws.members {
527 let manifest = c.path.join("Cargo.toml");
528 if is_publishable_to_crates_io(&manifest).unwrap() {
529 actual_publishable.push(c.name.clone());
530 }
531 }
532
533 actual_publishable.sort();
534 let mut expected_sorted: Vec<String> = expected.iter().map(|s| s.to_string()).collect();
535 expected_sorted.sort();
536
537 assert_eq!(actual_publishable, expected_sorted);
538 }
539 }
540
541 #[test]
542 fn topo_orders_deps_first() {
543 let a = CrateInfo {
545 name: "a".into(),
546 version: "0.1.0".into(),
547 path: PathBuf::from("/tmp/a"),
548 internal_deps: BTreeSet::new(),
549 };
550 let mut deps_b = BTreeSet::new();
551 deps_b.insert("a".into());
552 let b = CrateInfo {
553 name: "b".into(),
554 version: "0.1.0".into(),
555 path: PathBuf::from("/tmp/b"),
556 internal_deps: deps_b,
557 };
558 let mut deps_c = BTreeSet::new();
559 deps_c.insert("b".into());
560 let c = CrateInfo {
561 name: "c".into(),
562 version: "0.1.0".into(),
563 path: PathBuf::from("/tmp/c"),
564 internal_deps: deps_c,
565 };
566
567 let mut map: BTreeMap<String, &CrateInfo> = BTreeMap::new();
568 map.insert("a".into(), &a);
569 map.insert("b".into(), &b);
570 map.insert("c".into(), &c);
571
572 let mut include = BTreeSet::new();
573 include.insert("a".into());
574 include.insert("b".into());
575 include.insert("c".into());
576
577 let order = topo_order(&map, &include).unwrap();
578 assert_eq!(order, vec!["a", "b", "c"]);
579 }
580
581 #[test]
582 fn detects_dependency_cycle() {
583 let mut deps_a = BTreeSet::new();
585 deps_a.insert("b".into());
586 let a = CrateInfo {
587 name: "a".into(),
588 version: "0.1.0".into(),
589 path: PathBuf::from("/tmp/a"),
590 internal_deps: deps_a,
591 };
592
593 let mut deps_b = BTreeSet::new();
594 deps_b.insert("a".into());
595 let b = CrateInfo {
596 name: "b".into(),
597 version: "0.1.0".into(),
598 path: PathBuf::from("/tmp/b"),
599 internal_deps: deps_b,
600 };
601
602 let mut map: BTreeMap<String, &CrateInfo> = BTreeMap::new();
603 map.insert("a".into(), &a);
604 map.insert("b".into(), &b);
605
606 let mut include = BTreeSet::new();
607 include.insert("a".into());
608 include.insert("b".into());
609
610 let result = topo_order(&map, &include);
611 assert!(result.is_err());
612 assert!(format!("{}", result.unwrap_err()).contains("dependency cycle"));
613 }
614
615 #[test]
616 fn identifies_publishable_crates() {
617 let mut workspace = TestWorkspace::new();
618 workspace
619 .add_crate("publishable", "0.1.0")
620 .add_crate("not-publishable", "0.1.0")
621 .set_publishable("not-publishable", false);
622
623 workspace.assert_publishable_crates(&["publishable"]);
624 }
625
626 #[test]
627 fn handles_empty_workspace() {
628 let workspace = TestWorkspace::new();
629
630 let result = workspace.run_publish(true);
632 assert!(result.is_ok());
633 }
634
635 #[test]
636 fn rejects_invalid_internal_dependencies() {
637 let mut workspace = TestWorkspace::new();
638 workspace
639 .add_crate("publishable", "0.1.0")
640 .add_crate("not-publishable", "0.1.0")
641 .add_dependency("publishable", "not-publishable", "0.1.0")
642 .set_publishable("not-publishable", false);
643
644 let result = workspace.run_publish(true);
645 assert!(result.is_err());
646 let error_msg = format!("{}", result.unwrap_err());
647 assert!(error_msg.contains("cannot publish due to non-publishable internal dependencies"));
648 }
649
650 #[test]
651 fn dry_run_publishes_in_dependency_order() {
652 let mut workspace = TestWorkspace::new();
653 workspace
654 .add_crate("foundation", "0.1.0")
655 .add_crate("middleware", "0.1.0")
656 .add_crate("app", "0.1.0")
657 .add_dependency("middleware", "foundation", "0.1.0")
658 .add_dependency("app", "middleware", "0.1.0");
659
660 let result = workspace.run_publish(true);
662 assert!(result.is_ok());
663 }
664
665 #[test]
666 fn parses_manifest_publish_field_correctly() {
667 let temp_dir = tempfile::tempdir().unwrap();
668
669 let manifest_false = temp_dir.path().join("false.toml");
671 fs::write(
672 &manifest_false,
673 "[package]\nname=\"test\"\nversion=\"0.1.0\"\npublish = false\n",
674 )
675 .unwrap();
676 assert!(!is_publishable_to_crates_io(&manifest_false).unwrap());
677
678 let manifest_custom = temp_dir.path().join("custom.toml");
680 fs::write(
681 &manifest_custom,
682 "[package]\nname=\"test\"\nversion=\"0.1.0\"\npublish = [\"custom-registry\"]\n",
683 )
684 .unwrap();
685 assert!(!is_publishable_to_crates_io(&manifest_custom).unwrap());
686
687 let manifest_allowed = temp_dir.path().join("allowed.toml");
689 fs::write(
690 &manifest_allowed,
691 "[package]\nname=\"test\"\nversion=\"0.1.0\"\npublish = [\"crates-io\"]\n",
692 )
693 .unwrap();
694 assert!(is_publishable_to_crates_io(&manifest_allowed).unwrap());
695
696 let manifest_default = temp_dir.path().join("default.toml");
698 fs::write(
699 &manifest_default,
700 "[package]\nname=\"test\"\nversion=\"0.1.0\"\n",
701 )
702 .unwrap();
703 assert!(is_publishable_to_crates_io(&manifest_default).unwrap());
704 }
705
706 #[test]
707 fn handles_missing_package_section() {
708 let temp_dir = tempfile::tempdir().unwrap();
709 let manifest_path = temp_dir.path().join("no-package.toml");
710 fs::write(&manifest_path, "[dependencies]\nserde = \"1.0\"\n").unwrap();
711
712 assert!(!is_publishable_to_crates_io(&manifest_path).unwrap());
714 }
715
716 #[test]
717 fn handles_malformed_toml() {
718 let temp_dir = tempfile::tempdir().unwrap();
719 let manifest_path = temp_dir.path().join("broken.toml");
720 fs::write(&manifest_path, "[package\nname=\"test\"\n").unwrap(); let result = is_publishable_to_crates_io(&manifest_path);
723 assert!(result.is_err());
724 assert!(format!("{}", result.unwrap_err()).contains("Invalid data"));
725 }
726
727 #[test]
728 fn skips_ignored_packages_during_publish() {
729 use crate::types::{CrateInfo, Workspace};
730 use std::collections::BTreeSet;
731
732 let temp_dir = tempfile::tempdir().unwrap();
733 let root = temp_dir.path();
734
735 let config_dir = root.join(".sampo");
737 fs::create_dir_all(&config_dir).unwrap();
738 fs::write(
739 config_dir.join("config.toml"),
740 "[packages]\nignore = [\"examples/*\"]\n",
741 )
742 .unwrap();
743
744 let main_pkg = root.join("main-package");
746 let examples_pkg = root.join("examples/demo");
747
748 fs::create_dir_all(&main_pkg).unwrap();
749 fs::create_dir_all(&examples_pkg).unwrap();
750
751 let main_toml = r#"
753[package]
754name = "main-package"
755version = "1.0.0"
756edition = "2021"
757"#;
758 let examples_toml = r#"
759[package]
760name = "examples-demo"
761version = "1.0.0"
762edition = "2021"
763"#;
764
765 fs::write(main_pkg.join("Cargo.toml"), main_toml).unwrap();
766 fs::write(examples_pkg.join("Cargo.toml"), examples_toml).unwrap();
767
768 let workspace = Workspace {
770 root: root.to_path_buf(),
771 members: vec![
772 CrateInfo {
773 name: "main-package".to_string(),
774 version: "1.0.0".to_string(),
775 path: main_pkg,
776 internal_deps: BTreeSet::new(),
777 },
778 CrateInfo {
779 name: "examples-demo".to_string(),
780 version: "1.0.0".to_string(),
781 path: examples_pkg,
782 internal_deps: BTreeSet::new(),
783 },
784 ],
785 };
786
787 let config = crate::Config::load(&workspace.root).unwrap();
788
789 let mut publishable: BTreeSet<String> = BTreeSet::new();
791 for c in &workspace.members {
792 if should_ignore_crate(&config, &workspace, c).unwrap() {
794 continue;
795 }
796
797 let manifest = c.path.join("Cargo.toml");
798 if is_publishable_to_crates_io(&manifest).unwrap() {
799 publishable.insert(c.name.clone());
800 }
801 }
802
803 assert_eq!(publishable.len(), 1);
805 assert!(publishable.contains("main-package"));
806 assert!(!publishable.contains("examples-demo"));
807 }
808}