sampo_core/
publish.rs

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
10/// Publishes all publishable crates in a workspace to crates.io in dependency order.
11///
12/// This function discovers all crates in the workspace, determines which ones are
13/// publishable to crates.io, validates their dependencies, and publishes them in
14/// topological order (dependencies first).
15///
16/// # Arguments
17/// * `root` - Path to the workspace root directory
18/// * `dry_run` - If true, performs validation and shows what would be published without actually publishing
19/// * `cargo_args` - Additional arguments to pass to `cargo publish`
20///
21/// # Examples
22/// ```no_run
23/// use std::path::Path;
24/// use sampo_core::run_publish;
25///
26/// // Dry run to see what would be published
27/// run_publish(Path::new("."), true, &[]).unwrap();
28///
29/// // Actual publish with custom cargo args
30/// run_publish(Path::new("."), false, &["--allow-dirty".to_string()]).unwrap();
31/// ```
32pub 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    // Determine which crates are publishable to crates.io
36    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    // Validate internal deps do not include non-publishable crates
52    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    // Compute publish order (topological: deps first) for all publishable crates.
79    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    // Execute cargo publish in order
87    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        // Skip if the exact version already exists on crates.io
96        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        // Create an annotated git tag after successful publish (not in dry-run)
136        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
153/// Determines if a crate is publishable to crates.io based on its Cargo.toml manifest.
154///
155/// Checks the `publish` field in the `[package]` section according to Cargo's rules:
156/// - No `publish` field: publishable (default)
157/// - `publish = false`: not publishable
158/// - `publish = ["registry1", "registry2"]`: publishable only if "crates-io" is in the array
159///
160/// # Arguments
161/// * `manifest_path` - Path to the Cargo.toml file to check
162///
163/// # Examples
164/// ```no_run
165/// use std::path::Path;
166/// use sampo_core::is_publishable_to_crates_io;
167///
168/// // Check if a crate is publishable
169/// let publishable = is_publishable_to_crates_io(Path::new("./Cargo.toml")).unwrap();
170/// if publishable {
171///     println!("This crate can be published to crates.io");
172/// }
173/// ```
174///
175/// # Errors
176/// Returns an error if:
177/// - The manifest file cannot be read
178/// - The TOML is malformed
179/// - The manifest has no `[package]` section (returns `Ok(false)`)
180pub 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 publish = false => skip
192    if let Some(val) = pkg.get("publish") {
193        match val {
194            toml::Value::Boolean(false) => return Ok(false),
195            toml::Value::Array(arr) => {
196                // Only publish if the array contains "crates-io"
197                // (Cargo uses this to whitelist registries.)
198                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    // Default case: publishable
209    Ok(true)
210}
211
212/// Creates an annotated git tag for a published crate.
213///
214/// Creates a tag in the format `{crate_name}-v{version}` (e.g., "my-crate-v1.2.3")
215/// with a descriptive message. Skips tagging if not in a git repository or if
216/// the tag already exists.
217///
218/// # Arguments
219/// * `repo_root` - Path to the git repository root
220/// * `crate_name` - Name of the crate that was published
221/// * `version` - Version that was published
222///
223/// # Examples
224/// ```no_run
225/// use std::path::Path;
226/// use sampo_core::tag_published_crate;
227///
228/// // Tag a published crate
229/// tag_published_crate(Path::new("."), "my-crate", "1.2.3").unwrap();
230/// // Creates tag: "my-crate-v1.2.3" with message "Release my-crate 1.2.3"
231/// ```
232pub fn tag_published_crate(repo_root: &Path, crate_name: &str, version: &str) -> io::Result<()> {
233    if !repo_root.join(".git").exists() {
234        // Not a git repo, skip
235        return Ok(());
236    }
237    let tag = format!("{}-v{}", crate_name, version);
238    // If tag already exists, do not recreate
239    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
273/// Checks if a specific version of a crate already exists on crates.io.
274///
275/// Makes an HTTP request to the crates.io API to determine if the exact
276/// version is already published. Useful for skipping redundant publishes.
277///
278/// # Arguments
279/// * `crate_name` - Name of the crate to check
280/// * `version` - Exact version string to check
281///
282/// # Examples
283/// ```no_run
284/// use sampo_core::version_exists_on_crates_io;
285///
286/// // Check if serde 1.0.0 exists (it does)
287/// let exists = version_exists_on_crates_io("serde", "1.0.0").unwrap();
288/// assert!(exists);
289///
290/// // Check if a fictional version exists
291/// let exists = version_exists_on_crates_io("serde", "999.999.999").unwrap();
292/// assert!(!exists);
293/// ```
294pub fn version_exists_on_crates_io(crate_name: &str, version: &str) -> io::Result<bool> {
295    // Query crates.io: https://crates.io/api/v1/crates/<name>/<version>
296    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        // Include a short, normalized snippet of the response body for diagnostics
316        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
333/// Computes topological ordering for publishing crates (dependencies first).
334///
335/// Given a set of crates and their internal dependencies, returns the order
336/// in which they should be published so that dependencies are always published
337/// before the crates that depend on them.
338///
339/// # Arguments
340/// * `name_to_crate` - Map from crate names to their info
341/// * `include` - Set of crate names to include in the ordering
342///
343/// # Examples
344/// ```no_run
345/// use std::collections::{BTreeMap, BTreeSet};
346/// use sampo_core::{topo_order, types::CrateInfo};
347/// use std::path::PathBuf;
348///
349/// let mut crates = BTreeMap::new();
350/// let mut include = BTreeSet::new();
351///
352/// // Setup crates: foundation -> middleware -> app
353/// // ... (create CrateInfo instances) ...
354///
355/// let order = topo_order(&crates, &include).unwrap();
356/// // Returns: ["foundation", "middleware", "app"]
357/// ```
358pub fn topo_order(
359    name_to_crate: &BTreeMap<String, &CrateInfo>,
360    include: &BTreeSet<String>,
361) -> Result<Vec<String>, String> {
362    // Build graph: edge dep -> crate
363    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                // dep -> name
378                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    /// Test workspace builder for publish testing
430    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            // Create basic workspace structure
442            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            // Create minimal src/lib.rs so cargo can build the crate
466            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        // Build a small fake graph using CrateInfo structures
532        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        // Create a circular dependency: a -> b -> a
572        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        // Should succeed with no output
619        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        // Dry run should succeed and show correct order
649        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        // Test publish = false
658        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        // Test publish = ["custom-registry"] (not crates-io)
667        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        // Test publish = ["crates-io"] (explicitly allowed)
676        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        // Test default (no publish field)
685        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        // Should return false (not publishable) for manifests without [package]
701        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(); // Missing closing bracket
709
710        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}