sampo_core/
publish.rs

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