sampo_core/
publish.rs

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