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