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