sampo_core/
publish.rs

1use crate::adapters::PackageAdapter;
2use crate::types::PackageInfo;
3use crate::{
4    Config, current_branch, discover_workspace,
5    errors::{Result, SampoError},
6    filters::should_ignore_package,
7};
8use std::collections::{BTreeMap, BTreeSet, VecDeque};
9use std::path::Path;
10use std::process::Command;
11
12/// Publishes all publishable crates in a workspace to crates.io in dependency order.
13///
14/// This function discovers all crates in the workspace, determines which ones are
15/// publishable to crates.io, validates their dependencies, and publishes them in
16/// topological order (dependencies first).
17///
18/// # Arguments
19/// * `root` - Path to the workspace root directory
20/// * `dry_run` - If true, performs validation and shows what would be published without actually publishing
21/// * `cargo_args` - Additional arguments to pass to `cargo publish`
22///
23/// # Examples
24/// ```no_run
25/// use std::path::Path;
26/// use sampo_core::run_publish;
27///
28/// // Dry run to see what would be published
29/// run_publish(Path::new("."), true, &[]).unwrap();
30///
31/// // Actual publish with custom cargo args
32/// run_publish(Path::new("."), false, &["--allow-dirty".to_string()]).unwrap();
33/// ```
34pub fn run_publish(root: &std::path::Path, dry_run: bool, cargo_args: &[String]) -> Result<()> {
35    let ws = discover_workspace(root)?;
36    let config = Config::load(&ws.root)?;
37
38    let branch = current_branch()?;
39    if !config.is_release_branch(&branch) {
40        return Err(SampoError::Release(format!(
41            "Branch '{}' is not configured for publishing (allowed: {:?})",
42            branch,
43            config.release_branches().into_iter().collect::<Vec<_>>()
44        )));
45    }
46
47    // Determine which packages are publishable to crates.io and not ignored
48    let mut id_to_package: BTreeMap<String, &PackageInfo> = BTreeMap::new();
49    let mut publishable: BTreeSet<String> = BTreeSet::new();
50    for c in &ws.members {
51        // Skip non-Cargo packages (only Cargo publishing is currently supported)
52        if c.kind != crate::types::PackageKind::Cargo {
53            continue;
54        }
55
56        // Skip ignored packages
57        if should_ignore_package(&config, &ws, c)? {
58            continue;
59        }
60
61        let adapter = PackageAdapter::Cargo;
62        let manifest = adapter.manifest_path(&c.path);
63        if adapter.is_publishable(&manifest)? {
64            let identifier = c.canonical_identifier().to_string();
65            publishable.insert(identifier.clone());
66            id_to_package.insert(identifier, c);
67        }
68    }
69
70    if publishable.is_empty() {
71        println!("No publishable crates for crates.io were found in the workspace.");
72        return Ok(());
73    }
74
75    // Validate internal deps do not include non-publishable crates
76    let mut errors: Vec<String> = Vec::new();
77    for identifier in &publishable {
78        let c = id_to_package.get(identifier).ok_or_else(|| {
79            SampoError::Publish(format!(
80                "internal error: crate '{}' not found in workspace",
81                identifier
82            ))
83        })?;
84        for dep in &c.internal_deps {
85            if !publishable.contains(dep) {
86                errors.push(format!(
87                    "crate '{}' depends on internal crate '{}' which is not publishable",
88                    c.name, dep
89                ));
90            }
91        }
92    }
93    if !errors.is_empty() {
94        for e in errors {
95            eprintln!("{e}");
96        }
97        return Err(SampoError::Publish(
98            "cannot publish due to non-publishable internal dependencies".into(),
99        ));
100    }
101
102    // Compute publish order (topological: deps first) for all publishable crates.
103    let order = topo_order(&id_to_package, &publishable)?;
104
105    println!("Publish plan (crates.io):");
106    for identifier in &order {
107        if let Some(info) = id_to_package.get(identifier) {
108            println!("  - {}", info.name);
109        } else {
110            println!("  - {identifier}");
111        }
112    }
113
114    // Execute cargo publish in order using the adapter
115    let adapter = PackageAdapter::Cargo;
116    for identifier in &order {
117        let c = id_to_package.get(identifier).ok_or_else(|| {
118            SampoError::Publish(format!(
119                "internal error: crate '{}' not found in workspace",
120                identifier
121            ))
122        })?;
123        let manifest = adapter.manifest_path(&c.path);
124
125        // Skip if the exact version already exists on crates.io
126        match adapter.version_exists(&c.name, &c.version) {
127            Ok(true) => {
128                println!(
129                    "Skipping {}@{} (already exists on crates.io)",
130                    c.name, c.version
131                );
132                continue;
133            }
134            Ok(false) => {}
135            Err(e) => {
136                eprintln!(
137                    "Warning: could not check crates.io for {}@{}: {}. Attempting publish…",
138                    c.name, c.version, e
139                );
140            }
141        }
142
143        // Publish using the adapter
144        adapter.publish(&manifest, dry_run, cargo_args)?;
145
146        // Create an annotated git tag after successful publish (not in dry-run)
147        if !dry_run && let Err(e) = tag_published_crate(&ws.root, &c.name, &c.version) {
148            eprintln!(
149                "Warning: failed to create tag for {}@{}: {}",
150                c.name, c.version, e
151            );
152        }
153    }
154
155    if dry_run {
156        println!("Dry-run complete.");
157    } else {
158        println!("Publish complete.");
159    }
160
161    Ok(())
162}
163
164/// Creates an annotated git tag for a published crate.
165///
166/// Creates a tag in the format `{crate_name}-v{version}` (e.g., "my-crate-v1.2.3")
167/// with a descriptive message. Skips tagging if not in a git repository or if
168/// the tag already exists.
169///
170/// # Arguments
171/// * `repo_root` - Path to the git repository root
172/// * `crate_name` - Name of the crate that was published
173/// * `version` - Version that was published
174///
175/// # Examples
176/// ```no_run
177/// use std::path::Path;
178/// use sampo_core::tag_published_crate;
179///
180/// // Tag a published crate
181/// tag_published_crate(Path::new("."), "my-crate", "1.2.3").unwrap();
182/// // Creates tag: "my-crate-v1.2.3" with message "Release my-crate 1.2.3"
183/// ```
184pub fn tag_published_crate(repo_root: &Path, crate_name: &str, version: &str) -> Result<()> {
185    if !repo_root.join(".git").exists() {
186        // Not a git repo, skip
187        return Ok(());
188    }
189    let tag = format!("{}-v{}", crate_name, version);
190    // If tag already exists, do not recreate
191    let out = Command::new("git")
192        .arg("-C")
193        .arg(repo_root)
194        .arg("tag")
195        .arg("--list")
196        .arg(&tag)
197        .output()?;
198    if out.status.success() {
199        let s = String::from_utf8_lossy(&out.stdout);
200        if s.lines().any(|l| l.trim() == tag) {
201            return Ok(());
202        }
203    }
204
205    let msg = format!("Release {} {}", crate_name, version);
206    let status = Command::new("git")
207        .arg("-C")
208        .arg(repo_root)
209        .arg("tag")
210        .arg("-a")
211        .arg(&tag)
212        .arg("-m")
213        .arg(&msg)
214        .status()?;
215    if status.success() {
216        Ok(())
217    } else {
218        Err(SampoError::Publish(format!(
219            "git tag failed with status {}",
220            status
221        )))
222    }
223}
224
225/// Computes topological ordering for publishing crates (dependencies first).
226///
227/// Given a set of crates and their internal dependencies, returns the order
228/// in which they should be published so that dependencies are always published
229/// before the crates that depend on them.
230///
231/// # Arguments
232/// * `name_to_package` - Map from package names to their info
233/// * `include` - Set of package names to include in the ordering
234///
235/// # Examples
236/// ```no_run
237/// use std::collections::{BTreeMap, BTreeSet};
238/// use sampo_core::{topo_order, types::PackageInfo};
239/// use std::path::PathBuf;
240///
241/// let mut packages = BTreeMap::new();
242/// let mut include = BTreeSet::new();
243///
244/// // Setup packages: foundation -> middleware -> app
245/// // ... (create PackageInfo instances) ...
246///
247/// let order = topo_order(&packages, &include).unwrap();
248/// // Returns: ["foundation", "middleware", "app"]
249/// ```
250pub fn topo_order(
251    name_to_package: &BTreeMap<String, &PackageInfo>,
252    include: &BTreeSet<String>,
253) -> Result<Vec<String>> {
254    // Build graph: edge dep -> crate
255    let mut indegree: BTreeMap<&str, usize> = BTreeMap::new();
256    let mut forward: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
257
258    for name in include {
259        indegree.insert(name.as_str(), 0);
260        forward.entry(name.as_str()).or_default();
261    }
262
263    for name in include {
264        let c = name_to_package
265            .get(name)
266            .ok_or_else(|| SampoError::Publish(format!("missing package info for '{}'", name)))?;
267        for dep in &c.internal_deps {
268            if include.contains(dep) {
269                // dep -> name
270                let entry = forward.entry(dep.as_str()).or_default();
271                entry.push(name.as_str());
272                *indegree.get_mut(name.as_str()).unwrap() += 1;
273            }
274        }
275    }
276
277    let mut q: VecDeque<&str> = indegree
278        .iter()
279        .filter_map(|(k, &d)| if d == 0 { Some(*k) } else { None })
280        .collect();
281    let mut out: Vec<String> = Vec::new();
282
283    while let Some(n) = q.pop_front() {
284        out.push(n.to_string());
285        if let Some(children) = forward.get(n) {
286            for &m in children {
287                if let Some(d) = indegree.get_mut(m) {
288                    *d -= 1;
289                    if *d == 0 {
290                        q.push_back(m);
291                    }
292                }
293            }
294        }
295    }
296
297    if out.len() != include.len() {
298        return Err(SampoError::Publish(
299            "dependency cycle detected among publishable crates".into(),
300        ));
301    }
302    Ok(out)
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use crate::types::{PackageInfo, PackageKind, Workspace};
309    use rustc_hash::FxHashMap;
310    use std::{
311        fs,
312        path::PathBuf,
313        sync::{Mutex, MutexGuard, OnceLock},
314    };
315
316    /// Test workspace builder for publish testing
317    struct TestWorkspace {
318        root: PathBuf,
319        _temp_dir: tempfile::TempDir,
320        crates: FxHashMap<String, PathBuf>,
321    }
322
323    static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
324
325    fn env_lock() -> &'static Mutex<()> {
326        ENV_MUTEX.get_or_init(|| Mutex::new(()))
327    }
328
329    struct EnvVarGuard {
330        key: &'static str,
331        original: Option<String>,
332        _lock: MutexGuard<'static, ()>,
333    }
334
335    impl EnvVarGuard {
336        fn set(key: &'static str, value: &str) -> Self {
337            let lock = env_lock().lock().unwrap();
338            let original = std::env::var(key).ok();
339            unsafe {
340                std::env::set_var(key, value);
341            }
342            Self {
343                key,
344                original,
345                _lock: lock,
346            }
347        }
348    }
349
350    impl Drop for EnvVarGuard {
351        fn drop(&mut self) {
352            unsafe {
353                if let Some(ref value) = self.original {
354                    std::env::set_var(self.key, value);
355                } else {
356                    std::env::remove_var(self.key);
357                }
358            }
359        }
360    }
361
362    impl TestWorkspace {
363        fn new() -> Self {
364            let temp_dir = tempfile::tempdir().unwrap();
365            let root = temp_dir.path().to_path_buf();
366
367            {
368                let _lock = env_lock().lock().unwrap();
369                unsafe {
370                    std::env::set_var("SAMPO_RELEASE_BRANCH", "main");
371                }
372            }
373
374            // Create basic workspace structure
375            fs::write(
376                root.join("Cargo.toml"),
377                "[workspace]\nmembers=[\"crates/*\"]\n",
378            )
379            .unwrap();
380
381            Self {
382                root,
383                _temp_dir: temp_dir,
384                crates: FxHashMap::default(),
385            }
386        }
387
388        fn add_crate(&mut self, name: &str, version: &str) -> &mut Self {
389            let crate_dir = self.root.join("crates").join(name);
390            fs::create_dir_all(&crate_dir).unwrap();
391
392            fs::write(
393                crate_dir.join("Cargo.toml"),
394                format!("[package]\nname=\"{}\"\nversion=\"{}\"\n", name, version),
395            )
396            .unwrap();
397
398            // Create minimal src/lib.rs so cargo can build the crate
399            fs::create_dir_all(crate_dir.join("src")).unwrap();
400            fs::write(crate_dir.join("src/lib.rs"), "// test crate").unwrap();
401
402            self.crates.insert(name.to_string(), crate_dir);
403            self
404        }
405
406        fn add_dependency(&mut self, from: &str, to: &str, version: &str) -> &mut Self {
407            let from_dir = self.crates.get(from).expect("from crate must exist");
408            let current_manifest = fs::read_to_string(from_dir.join("Cargo.toml")).unwrap();
409
410            let dependency_section = format!(
411                "\n[dependencies]\n{} = {{ path=\"../{}\", version=\"{}\" }}\n",
412                to, to, version
413            );
414
415            fs::write(
416                from_dir.join("Cargo.toml"),
417                current_manifest + &dependency_section,
418            )
419            .unwrap();
420
421            self
422        }
423
424        fn set_publishable(&self, crate_name: &str, publishable: bool) -> &Self {
425            let crate_dir = self.crates.get(crate_name).expect("crate must exist");
426            let manifest_path = crate_dir.join("Cargo.toml");
427            let current_manifest = fs::read_to_string(&manifest_path).unwrap();
428
429            let new_manifest = if publishable {
430                current_manifest
431            } else {
432                current_manifest + "\npublish = false\n"
433            };
434
435            fs::write(manifest_path, new_manifest).unwrap();
436            self
437        }
438
439        fn set_config(&self, content: &str) -> &Self {
440            fs::create_dir_all(self.root.join(".sampo")).unwrap();
441            fs::write(self.root.join(".sampo/config.toml"), content).unwrap();
442            self
443        }
444
445        fn run_publish(&self, dry_run: bool) -> Result<()> {
446            run_publish(&self.root, dry_run, &[])
447        }
448
449        fn assert_publishable_crates(&self, expected: &[&str]) {
450            let ws = discover_workspace(&self.root).unwrap();
451            let mut actual_publishable = Vec::new();
452            let adapter = PackageAdapter::Cargo;
453
454            for c in &ws.members {
455                let manifest = adapter.manifest_path(&c.path);
456                if adapter.is_publishable(&manifest).unwrap() {
457                    actual_publishable.push(c.name.clone());
458                }
459            }
460
461            actual_publishable.sort();
462            let mut expected_sorted: Vec<String> = expected.iter().map(|s| s.to_string()).collect();
463            expected_sorted.sort();
464
465            assert_eq!(actual_publishable, expected_sorted);
466        }
467    }
468
469    #[test]
470    fn run_publish_rejects_unconfigured_branch() {
471        let mut workspace = TestWorkspace::new();
472        workspace.add_crate("foo", "0.1.0");
473        workspace.set_publishable("foo", false);
474        workspace.set_config("[git]\nrelease_branches = [\"main\"]\n");
475
476        let _guard = EnvVarGuard::set("SAMPO_RELEASE_BRANCH", "feature");
477        let branch = current_branch().expect("branch should be readable");
478        assert_eq!(branch, "feature");
479        let err = workspace.run_publish(true).unwrap_err();
480        match err {
481            SampoError::Release(message) => {
482                assert!(
483                    message.contains("not configured for publishing"),
484                    "unexpected message: {message}"
485                );
486            }
487            other => panic!("expected Release error, got {other:?}"),
488        }
489    }
490
491    #[test]
492    fn run_publish_allows_configured_branch() {
493        let mut workspace = TestWorkspace::new();
494        workspace.add_crate("foo", "0.1.0");
495        workspace.set_publishable("foo", false);
496        workspace.set_config("[git]\nrelease_branches = [\"3.x\"]\n");
497
498        let _guard = EnvVarGuard::set("SAMPO_RELEASE_BRANCH", "3.x");
499        workspace
500            .run_publish(true)
501            .expect("publish should succeed on configured branch");
502    }
503
504    #[test]
505    fn topo_orders_deps_first() {
506        // Build a small fake graph using PackageInfo structures
507        let a = PackageInfo {
508            name: "a".into(),
509            identifier: "cargo/a".into(),
510            version: "0.1.0".into(),
511            path: PathBuf::from("/tmp/a"),
512            internal_deps: BTreeSet::new(),
513            kind: PackageKind::Cargo,
514        };
515        let mut deps_b = BTreeSet::new();
516        deps_b.insert("cargo/a".into());
517        let b = PackageInfo {
518            name: "b".into(),
519            identifier: "cargo/b".into(),
520            version: "0.1.0".into(),
521            path: PathBuf::from("/tmp/b"),
522            internal_deps: deps_b,
523            kind: PackageKind::Cargo,
524        };
525        let mut deps_c = BTreeSet::new();
526        deps_c.insert("cargo/b".into());
527        let c = PackageInfo {
528            name: "c".into(),
529            identifier: "cargo/c".into(),
530            version: "0.1.0".into(),
531            path: PathBuf::from("/tmp/c"),
532            internal_deps: deps_c,
533            kind: PackageKind::Cargo,
534        };
535
536        let mut map: BTreeMap<String, &PackageInfo> = BTreeMap::new();
537        map.insert("cargo/a".into(), &a);
538        map.insert("cargo/b".into(), &b);
539        map.insert("cargo/c".into(), &c);
540
541        let mut include = BTreeSet::new();
542        include.insert("cargo/a".into());
543        include.insert("cargo/b".into());
544        include.insert("cargo/c".into());
545
546        let order = topo_order(&map, &include).unwrap();
547        assert_eq!(order, vec!["cargo/a", "cargo/b", "cargo/c"]);
548    }
549
550    #[test]
551    fn detects_dependency_cycle() {
552        // Create a circular dependency: a -> b -> a
553        let mut deps_a = BTreeSet::new();
554        deps_a.insert("cargo/b".into());
555        let a = PackageInfo {
556            name: "a".into(),
557            identifier: "cargo/a".into(),
558            version: "0.1.0".into(),
559            path: PathBuf::from("/tmp/a"),
560            internal_deps: deps_a,
561            kind: PackageKind::Cargo,
562        };
563
564        let mut deps_b = BTreeSet::new();
565        deps_b.insert("cargo/a".into());
566        let b = PackageInfo {
567            name: "b".into(),
568            identifier: "cargo/b".into(),
569            version: "0.1.0".into(),
570            path: PathBuf::from("/tmp/b"),
571            internal_deps: deps_b,
572            kind: PackageKind::Cargo,
573        };
574
575        let mut map: BTreeMap<String, &PackageInfo> = BTreeMap::new();
576        map.insert("cargo/a".into(), &a);
577        map.insert("cargo/b".into(), &b);
578
579        let mut include = BTreeSet::new();
580        include.insert("cargo/a".into());
581        include.insert("cargo/b".into());
582
583        let result = topo_order(&map, &include);
584        assert!(result.is_err());
585        assert!(format!("{}", result.unwrap_err()).contains("dependency cycle"));
586    }
587
588    #[test]
589    fn identifies_publishable_crates() {
590        let mut workspace = TestWorkspace::new();
591        workspace
592            .add_crate("publishable", "0.1.0")
593            .add_crate("not-publishable", "0.1.0")
594            .set_publishable("not-publishable", false);
595
596        workspace.assert_publishable_crates(&["publishable"]);
597    }
598
599    #[test]
600    fn handles_empty_workspace() {
601        let workspace = TestWorkspace::new();
602
603        // Should succeed with no output
604        let result = workspace.run_publish(true);
605        assert!(result.is_ok());
606    }
607
608    #[test]
609    fn rejects_invalid_internal_dependencies() {
610        let mut workspace = TestWorkspace::new();
611        workspace
612            .add_crate("publishable", "0.1.0")
613            .add_crate("not-publishable", "0.1.0")
614            .add_dependency("publishable", "not-publishable", "0.1.0")
615            .set_publishable("not-publishable", false);
616
617        let result = workspace.run_publish(true);
618        assert!(result.is_err());
619        let error_msg = format!("{}", result.unwrap_err());
620        assert!(error_msg.contains("cannot publish due to non-publishable internal dependencies"));
621    }
622
623    #[test]
624    fn dry_run_publishes_in_dependency_order() {
625        let mut workspace = TestWorkspace::new();
626        workspace
627            .add_crate("foundation", "0.1.0")
628            .add_crate("middleware", "0.1.0")
629            .add_crate("app", "0.1.0")
630            .add_dependency("middleware", "foundation", "0.1.0")
631            .add_dependency("app", "middleware", "0.1.0");
632
633        // Dry run should succeed and show correct order
634        let result = workspace.run_publish(true);
635        assert!(result.is_ok());
636    }
637
638    #[test]
639    fn parses_manifest_publish_field_correctly() {
640        let temp_dir = tempfile::tempdir().unwrap();
641        let adapter = PackageAdapter::Cargo;
642
643        // Test publish = false
644        let manifest_false = temp_dir.path().join("false.toml");
645        fs::write(
646            &manifest_false,
647            "[package]\nname=\"test\"\nversion=\"0.1.0\"\npublish = false\n",
648        )
649        .unwrap();
650        assert!(!adapter.is_publishable(&manifest_false).unwrap());
651
652        // Test publish = ["custom-registry"] (not crates-io)
653        let manifest_custom = temp_dir.path().join("custom.toml");
654        fs::write(
655            &manifest_custom,
656            "[package]\nname=\"test\"\nversion=\"0.1.0\"\npublish = [\"custom-registry\"]\n",
657        )
658        .unwrap();
659        assert!(!adapter.is_publishable(&manifest_custom).unwrap());
660
661        // Test publish = ["crates-io"] (explicitly allowed)
662        let manifest_allowed = temp_dir.path().join("allowed.toml");
663        fs::write(
664            &manifest_allowed,
665            "[package]\nname=\"test\"\nversion=\"0.1.0\"\npublish = [\"crates-io\"]\n",
666        )
667        .unwrap();
668        assert!(adapter.is_publishable(&manifest_allowed).unwrap());
669
670        // Test default (no publish field)
671        let manifest_default = temp_dir.path().join("default.toml");
672        fs::write(
673            &manifest_default,
674            "[package]\nname=\"test\"\nversion=\"0.1.0\"\n",
675        )
676        .unwrap();
677        assert!(adapter.is_publishable(&manifest_default).unwrap());
678    }
679
680    #[test]
681    fn handles_missing_package_section() {
682        let temp_dir = tempfile::tempdir().unwrap();
683        let manifest_path = temp_dir.path().join("no-package.toml");
684        fs::write(&manifest_path, "[dependencies]\nserde = \"1.0\"\n").unwrap();
685
686        let adapter = PackageAdapter::Cargo;
687        // Should return false (not publishable) for manifests without [package]
688        assert!(!adapter.is_publishable(&manifest_path).unwrap());
689    }
690
691    #[test]
692    fn handles_malformed_toml() {
693        let temp_dir = tempfile::tempdir().unwrap();
694        let manifest_path = temp_dir.path().join("broken.toml");
695        fs::write(&manifest_path, "[package\nname=\"test\"\n").unwrap(); // Missing closing bracket
696
697        let adapter = PackageAdapter::Cargo;
698        let result = adapter.is_publishable(&manifest_path);
699        assert!(result.is_err());
700        assert!(format!("{}", result.unwrap_err()).contains("Invalid data"));
701    }
702
703    #[test]
704    fn skips_ignored_packages_during_publish() {
705        use std::collections::BTreeSet;
706
707        let temp_dir = tempfile::tempdir().unwrap();
708        let root = temp_dir.path();
709
710        // Create config that ignores examples/*
711        let config_dir = root.join(".sampo");
712        fs::create_dir_all(&config_dir).unwrap();
713        fs::write(
714            config_dir.join("config.toml"),
715            "[packages]\nignore = [\"examples/*\"]\n",
716        )
717        .unwrap();
718
719        // Create a mock workspace with packages
720        let main_pkg = root.join("main-package");
721        let examples_pkg = root.join("examples/demo");
722
723        fs::create_dir_all(&main_pkg).unwrap();
724        fs::create_dir_all(&examples_pkg).unwrap();
725
726        // Create publishable Cargo.toml files
727        let main_toml = r#"
728[package]
729name = "main-package"
730version = "1.0.0"
731edition = "2021"
732"#;
733        let examples_toml = r#"
734[package]
735name = "examples-demo"
736version = "1.0.0"
737edition = "2021"
738"#;
739
740        fs::write(main_pkg.join("Cargo.toml"), main_toml).unwrap();
741        fs::write(examples_pkg.join("Cargo.toml"), examples_toml).unwrap();
742
743        // Create a workspace with both packages
744        let workspace = Workspace {
745            root: root.to_path_buf(),
746            members: vec![
747                PackageInfo {
748                    name: "main-package".to_string(),
749                    identifier: PackageInfo::dependency_identifier(
750                        PackageKind::Cargo,
751                        "main-package",
752                    ),
753                    version: "1.0.0".to_string(),
754                    path: main_pkg,
755                    internal_deps: BTreeSet::new(),
756                    kind: PackageKind::Cargo,
757                },
758                PackageInfo {
759                    name: "examples-demo".to_string(),
760                    identifier: PackageInfo::dependency_identifier(
761                        PackageKind::Cargo,
762                        "examples-demo",
763                    ),
764                    version: "1.0.0".to_string(),
765                    path: examples_pkg,
766                    internal_deps: BTreeSet::new(),
767                    kind: PackageKind::Cargo,
768                },
769            ],
770        };
771
772        let config = crate::Config::load(&workspace.root).unwrap();
773
774        // Simulate what run_publish does for determining publishable packages
775        let mut publishable: BTreeSet<String> = BTreeSet::new();
776        let adapter = PackageAdapter::Cargo;
777        for c in &workspace.members {
778            // Skip ignored packages
779            if should_ignore_package(&config, &workspace, c).unwrap() {
780                continue;
781            }
782
783            let manifest = adapter.manifest_path(&c.path);
784            if adapter.is_publishable(&manifest).unwrap() {
785                publishable.insert(c.name.clone());
786            }
787        }
788
789        // Only main-package should be publishable, examples-demo should be ignored
790        assert_eq!(publishable.len(), 1);
791        assert!(publishable.contains("main-package"));
792        assert!(!publishable.contains("examples-demo"));
793    }
794}