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