sampo_core/
publish.rs

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