1use crate::config::{Config, NAME_PLACEHOLDER};
8use crate::git;
9use crate::github;
10use crate::process;
11use crate::publisher;
12use crate::release;
13use anyhow::{Context, Result, bail, ensure};
14use ctl_core::formatdoc;
15use serde::Serialize;
16use std::collections::BTreeMap;
17use std::path::Path;
18use std::time::Duration;
19
20pub const PUBLISH_TIMEOUT: Duration = Duration::from_mins(10);
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct PublishPlan {
24 pub packages: Vec<PublishEntry>,
25 pub tags: Vec<TagPlan>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct TagPlan {
31 pub tag: String,
32 pub title: String,
33 pub packages: Vec<PublishEntry>,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct PublishEntry {
38 pub name: String,
39 pub version: String,
40 pub noun: String,
41 pub label: String,
42 pub argv: Vec<String>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct PublishOutcome {
47 pub packages: Vec<PublishLine>,
48 pub releases: Vec<String>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
52pub struct PublishLine {
53 pub name: String,
54 pub version: String,
55 pub via: String,
56 pub note: Option<String>,
57}
58
59pub fn plan(config: &Config, root: &Path) -> Result<PublishPlan> {
60 let mut packages = Vec::new();
61 for spec in &config.packages {
62 let path = root.join(&spec.path);
63 let driver = spec.resolve(config, root)?;
64 let raw = std::fs::read_to_string(&path).with_context(|| path.display().to_string())?;
65 let version = driver.read(&raw)?.trim().to_owned();
66 let (label, noun, argv) = publisher::resolve(config, spec, &path)?;
67 let label = match spec.registry.as_deref() {
68 Some(registry) if !registry.is_empty() => format!("{label} {registry}"),
69 _ => label,
70 };
71 packages.push(PublishEntry {
72 name: spec.name.clone(),
73 version,
74 noun,
75 label,
76 argv,
77 });
78 }
79 let tags = tags_for(config, &packages)?;
80 Ok(PublishPlan { packages, tags })
81}
82
83pub fn run(config: &Config, root: &Path, dry_run: bool) -> Result<PublishOutcome> {
84 let planned = plan(config, root)?;
85 if dry_run {
86 let releases = planned
87 .tags
88 .iter()
89 .map(|tag| formatdoc!("would create {tag}", tag = tag.tag))
90 .collect();
91 return Ok(PublishOutcome {
92 packages: planned.packages.iter().map(PublishLine::from).collect(),
93 releases,
94 });
95 }
96 prove_version_commit(config, root, &planned)?;
97 let token = release::resolve_token()?;
98 let mut packages = Vec::new();
99 for entry in &planned.packages {
100 let note = match publish_package(entry)? {
101 status if status.ends_with(" (already)") => Some("already".into()),
102 _ => None,
103 };
104 packages.push(PublishLine {
105 name: entry.name.clone(),
106 version: entry.version.clone(),
107 via: entry.label.clone(),
108 note,
109 });
110 }
111 let repo = github::repo(root)?;
112 let sha = git::head_sha(root)?;
113 let mut releases = Vec::new();
114 for tag in &planned.tags {
115 let shipped = tag.packages.iter().any(|entry| {
116 packages
117 .iter()
118 .any(|line| line.name == entry.name && line.note.is_none())
119 });
120 let notes = release::notes_for(
121 config,
122 root,
123 tag.packages
124 .iter()
125 .map(|entry| (entry.name.as_str(), entry.version.as_str())),
126 );
127 let url =
128 github::ensure_release(&token, &repo, &tag.tag, &tag.title, ¬es, &sha, shipped)?;
129 releases.push(url);
130 }
131 Ok(PublishOutcome { packages, releases })
132}
133
134fn publish_package(entry: &PublishEntry) -> Result<String> {
135 let coord = format!("{}@{}", entry.name, entry.version);
136 match process::run_inherit(&entry.argv, PUBLISH_TIMEOUT) {
137 Ok(()) => Ok(coord),
138 Err(error) if already_published(&format!("{error:#}")) => Ok(format!("{coord} (already)")),
139 Err(error) => Err(error).context(coord),
140 }
141}
142
143impl From<&PublishEntry> for PublishLine {
144 fn from(entry: &PublishEntry) -> Self {
145 Self {
146 name: entry.name.clone(),
147 version: entry.version.clone(),
148 via: entry.label.clone(),
149 note: None,
150 }
151 }
152}
153
154fn prove_version_commit(config: &Config, root: &Path, planned: &PublishPlan) -> Result<()> {
155 release::require_changelog_versions(
156 config,
157 root,
158 planned
159 .packages
160 .iter()
161 .map(|entry| (entry.name.as_str(), entry.version.as_str())),
162 )?;
163 if let Some(branch) = git::configured_default_branch() {
164 git::ensure_origin_head(root, &branch, &git::FetchDefault::from_env())?;
165 }
166 git::require_on_default_history(root)
167}
168
169fn already_published(message: &str) -> bool {
170 let lower = message.to_ascii_lowercase();
171 lower.contains("already uploaded")
172 || lower.contains("already exists")
173 || lower.contains("cannot be republished")
174 || lower.contains("previously published")
175 || lower.contains("tolerate-republish")
176 || lower.contains("version already exists")
177}
178
179pub fn tags_for(config: &Config, packages: &[PublishEntry]) -> Result<Vec<TagPlan>> {
185 ensure!(
186 !packages.is_empty(),
187 "the config declares no packages to publish"
188 );
189 if config.tags.uses_name() {
190 return per_package_tags(config, packages);
191 }
192 one_shared_tag(config, packages)
193}
194
195fn one_shared_tag(config: &Config, packages: &[PublishEntry]) -> Result<Vec<TagPlan>> {
196 let template = &config.tags.template;
197 let mut by_version: BTreeMap<&str, Vec<&PublishEntry>> = BTreeMap::new();
198 for entry in packages {
199 by_version
200 .entry(entry.version.as_str())
201 .or_default()
202 .push(entry);
203 }
204 if by_version.len() > 1 {
205 let detail = packages
206 .iter()
207 .map(|entry| format!("{}@{}", entry.name, entry.version))
208 .collect::<Vec<_>>()
209 .join(", ");
210 bail!(
211 "tags.template {template:?} has no {NAME_PLACEHOLDER}, so it can only name one tag, but this release has more than one version: {detail}"
212 );
213 }
214 let version = packages[0].version.as_str();
215 let tag = config.tags.render(version, None)?;
216 let title = packages.first().map_or_else(
217 || tag.clone(),
218 |entry| format!("{} {}", entry.name, entry.version),
219 );
220 Ok(vec![TagPlan {
221 tag,
222 title,
223 packages: packages.to_vec(),
224 }])
225}
226
227fn per_package_tags(config: &Config, packages: &[PublishEntry]) -> Result<Vec<TagPlan>> {
228 let template = &config.tags.template;
229 let mut seen: BTreeMap<String, String> = BTreeMap::new();
230 let mut tags = Vec::with_capacity(packages.len());
231 for entry in packages {
232 let tag = config.tags.render(&entry.version, Some(&entry.name))?;
233 if let Some(earlier) = seen.insert(tag.clone(), entry.name.clone()) {
234 bail!(
235 "tags.template {template:?} renders the same tag {tag:?} for {earlier} and {}",
236 entry.name
237 );
238 }
239 tags.push(TagPlan {
240 tag,
241 title: format!("{} {}", entry.name, entry.version),
242 packages: vec![entry.clone()],
243 });
244 }
245 Ok(tags)
246}
247
248#[cfg(test)]
249mod tests {
250 use super::{PublishEntry, already_published, tags_for};
251 use crate::config::Config;
252 use indoc::indoc;
253
254 fn entry(name: &str, version: &str) -> PublishEntry {
255 PublishEntry {
256 name: name.into(),
257 version: version.into(),
258 noun: "crate".into(),
259 label: "cargo".into(),
260 argv: vec!["true".into()],
261 }
262 }
263
264 fn config(tags: &str) -> Config {
265 let body = if tags.is_empty() {
266 indoc! {"
267 packages:
268 - name: demo
269 path: Cargo.toml
270 "}
271 .to_owned()
272 } else {
273 formatdoc_config(tags)
274 };
275 Config::parse(&body).unwrap_or_else(|error| panic!("{error:#}"))
276 }
277
278 fn formatdoc_config(tags: &str) -> String {
279 use ctl_core::formatdoc;
280 formatdoc! {"
281 packages:
282 - name: demo
283 path: Cargo.toml
284
285 tags:
286 template: {tags:?}
287 "}
288 }
289
290 #[test]
291 fn already_uploaded_is_ok() {
292 assert!(already_published(
293 "crate ctl-core@0.0.1 already exists on crates.io"
294 ));
295 assert!(already_published("error: crate already uploaded"));
296 assert!(already_published("version already exists"));
297 assert!(!already_published("token is required"));
298 }
299
300 #[test]
301 fn default_template_is_one_v_version_tag() {
302 let planned = tags_for(&config(""), &[entry("demo", "1.2.3")]).unwrap();
303 assert_eq!(planned.len(), 1);
304 assert_eq!(planned[0].tag, "v1.2.3");
305 assert_eq!(planned[0].packages.len(), 1);
306 }
307
308 #[test]
309 fn shared_version_across_packages_is_one_tag() {
310 let planned = tags_for(&config(""), &[entry("a", "1.0.0"), entry("b", "1.0.0")]).unwrap();
311 assert_eq!(planned.len(), 1);
312 assert_eq!(planned[0].tag, "v1.0.0");
313 assert_eq!(planned[0].packages.len(), 2);
314 }
315
316 #[test]
317 fn differing_versions_without_name_refuse() {
318 let error = tags_for(&config(""), &[entry("a", "1.0.0"), entry("b", "2.0.0")])
319 .expect_err("must refuse");
320 let message = format!("{error:#}");
321 assert!(message.contains("{name}"), "{message}");
322 assert!(message.contains("a@1.0.0"), "{message}");
323 assert!(message.contains("b@2.0.0"), "{message}");
324 }
325
326 #[test]
327 fn per_package_template_names_each_tag() {
328 let planned = tags_for(
329 &config("{name}@{version}"),
330 &[entry("a", "1.0.0"), entry("b", "2.0.0")],
331 )
332 .unwrap();
333 assert_eq!(
334 planned
335 .iter()
336 .map(|tag| tag.tag.as_str())
337 .collect::<Vec<_>>(),
338 ["a@1.0.0", "b@2.0.0"]
339 );
340 assert!(planned.iter().all(|tag| tag.packages.len() == 1));
341 }
342
343 #[test]
344 fn same_version_with_name_still_one_tag_each() {
345 let planned = tags_for(
346 &config("{name}@{version}"),
347 &[entry("a", "1.0.0"), entry("b", "1.0.0")],
348 )
349 .unwrap();
350 assert_eq!(
351 planned
352 .iter()
353 .map(|tag| tag.tag.as_str())
354 .collect::<Vec<_>>(),
355 ["a@1.0.0", "b@1.0.0"]
356 );
357 }
358
359 #[test]
360 fn colliding_per_package_template_refuses() {
361 let error = tags_for(
362 &config("{name}@{version}"),
363 &[entry("dup", "1.0.0"), entry("dup", "1.0.0")],
364 )
365 .expect_err("same rendered tag");
366 assert!(format!("{error:#}").contains("same tag"), "{error:#}");
367 }
368}