Skip to main content

cargo_promote/
lib.rs

1pub mod config;
2pub mod domain;
3pub mod infra;
4
5use std::collections::{HashMap, HashSet};
6use std::path::Path;
7
8use anyhow::{Context, Result};
9
10use config::Config;
11use domain::deferral::{Deferral, DeferralKind, DeferralStatus};
12use domain::depgraph;
13use domain::manifest::{self, ManifestDescription};
14use domain::pipeline::PipelineEngine;
15use domain::traits::{Forge, NoopForge, Notifier, PipelineRunner, RegistryQuery};
16use domain::version;
17use domain::{CrateInfo, CrateRef, Pipeline, PublishOpts, Stage};
18use infra::cargo::CargoPublisher;
19use infra::git::gitea::GiteaRegistry;
20use infra::token::CargoTokenResolver;
21
22/// If autobump is configured, bump the manifest version and return an
23/// updated CrateRef.
24pub fn maybe_autobump(krate: CrateRef, cfg: &Config) -> Result<CrateRef> {
25    let per_pkg = cfg.package_override(&krate.name).and_then(|o| o.autobump);
26    let Some(level) = per_pkg.or(cfg.autobump) else {
27        return Ok(krate);
28    };
29    let (old, new) = version::bump_manifest_version(&krate.manifest_path, level)?;
30    eprintln!("=> autobump: {} v{old} -> v{new}", krate.name);
31    Ok(CrateRef {
32        version: new.to_string(),
33        ..krate
34    })
35}
36
37/// Library API for driving promotion pipelines programmatically.
38pub struct Api {
39    config: Config,
40    engine: Box<dyn PipelineRunner>,
41    registry_query: Box<dyn RegistryQuery>,
42    notifier: Box<dyn Notifier>,
43    forge: Box<dyn Forge>,
44}
45
46/// Builder for `Api` with injectable dependencies.
47pub struct ApiBuilder {
48    config: Option<Config>,
49    engine: Option<Box<dyn PipelineRunner>>,
50    registry_query: Option<Box<dyn RegistryQuery>>,
51    notifier: Option<Box<dyn Notifier>>,
52    forge: Option<Box<dyn Forge>>,
53}
54
55impl ApiBuilder {
56    pub fn config(mut self, config: Config) -> Self {
57        self.config = Some(config);
58        self
59    }
60
61    pub fn engine(mut self, engine: Box<dyn PipelineRunner>) -> Self {
62        self.engine = Some(engine);
63        self
64    }
65
66    pub fn registry_query(mut self, query: Box<dyn RegistryQuery>) -> Self {
67        self.registry_query = Some(query);
68        self
69    }
70
71    pub fn notifier(mut self, notifier: Box<dyn Notifier>) -> Self {
72        self.notifier = Some(notifier);
73        self
74    }
75
76    pub fn forge(mut self, forge: Box<dyn Forge>) -> Self {
77        self.forge = Some(forge);
78        self
79    }
80
81    pub fn build(self) -> Result<Api> {
82        Ok(Api {
83            config: self
84                .config
85                .ok_or_else(|| anyhow::anyhow!("config required"))?,
86            engine: self
87                .engine
88                .ok_or_else(|| anyhow::anyhow!("engine required"))?,
89            registry_query: self
90                .registry_query
91                .ok_or_else(|| anyhow::anyhow!("registry_query required"))?,
92            notifier: self
93                .notifier
94                .ok_or_else(|| anyhow::anyhow!("notifier required"))?,
95            forge: self.forge.unwrap_or_else(|| Box::new(NoopForge)),
96        })
97    }
98}
99
100impl Api {
101    /// Build with default adapters (CargoPublisher, GiteaRegistry,
102    /// NoopNotifier) and auto-accepting confirmer.
103    pub fn new(dir: &Path) -> Result<Self> {
104        Self::with_confirmer(dir, |_| true)
105    }
106
107    /// Build with default adapters and a custom confirmer.
108    pub fn with_confirmer(dir: &Path, confirmer: impl Fn(&str) -> bool + 'static) -> Result<Self> {
109        let config = Config::load(dir)?;
110        let engine = PipelineEngine::new(CargoPublisher, confirmer);
111        Ok(Self {
112            config,
113            engine: Box::new(engine),
114            registry_query: Box::new(GiteaRegistry::new(std::sync::Arc::new(
115                CargoTokenResolver::new(),
116            ))),
117            notifier: Box::new(infra::notify::NoopNotifier),
118            forge: Box::new(NoopForge),
119        })
120    }
121
122    /// Build with default adapters, custom confirmer, and a
123    /// notification command.
124    pub fn with_notifier(
125        dir: &Path,
126        confirmer: impl Fn(&str) -> bool + 'static,
127        command: Vec<String>,
128    ) -> Result<Self> {
129        let config = Config::load(dir)?;
130        let engine = PipelineEngine::new(CargoPublisher, confirmer);
131        Ok(Self {
132            config,
133            engine: Box::new(engine),
134            registry_query: Box::new(GiteaRegistry::new(std::sync::Arc::new(
135                CargoTokenResolver::new(),
136            ))),
137            notifier: Box::new(infra::notify::SpawnNotifier { command }),
138            forge: Box::new(NoopForge),
139        })
140    }
141
142    /// Return a builder for full dependency injection.
143    pub fn builder() -> ApiBuilder {
144        ApiBuilder {
145            config: None,
146            engine: None,
147            registry_query: None,
148            notifier: None,
149            forge: None,
150        }
151    }
152
153    /// Access the loaded configuration.
154    pub fn config(&self) -> &Config {
155        &self.config
156    }
157
158    // -- pipeline helpers --
159
160    fn resolve_pipeline(&self, name: Option<&str>) -> Result<&Pipeline> {
161        self.config
162            .pipeline(name)
163            .ok_or_else(|| anyhow::anyhow!("pipeline '{}' not found", name.unwrap_or("default")))
164    }
165
166    /// Publish a crate to the first stage of a pipeline (or a named
167    /// registry).
168    pub fn publish(
169        &self,
170        path: Option<&Path>,
171        package: Option<&str>,
172        allow_dirty: bool,
173        force: bool,
174        pipeline: Option<&str>,
175        registry: Option<&str>,
176    ) -> Result<()> {
177        let krate = manifest::resolve_crate(path, package)?;
178        let krate = maybe_autobump(krate, &self.config)?;
179        let opts = PublishOpts {
180            allow_dirty,
181            force,
182            ..Default::default()
183        };
184
185        if let Some(reg_name) = registry {
186            let reg = self
187                .config
188                .registry(reg_name)
189                .ok_or_else(|| anyhow::anyhow!("unknown registry '{reg_name}'"))?;
190            let stage = Stage {
191                registry: reg.clone(),
192            };
193            self.engine.run_stage(&krate, &stage, &opts)?;
194        } else {
195            let pl = self.resolve_pipeline(pipeline)?;
196            let first = pl.stages.first().context("pipeline has no stages")?;
197            self.engine.run_stage(&krate, first, &opts)?;
198        }
199        Ok(())
200    }
201
202    /// Promote a crate from one pipeline stage to the next.
203    pub fn promote(
204        &self,
205        path: Option<&Path>,
206        package: Option<&str>,
207        yes: bool,
208        dry_run: bool,
209        pipeline: Option<&str>,
210        from: Option<&str>,
211    ) -> Result<()> {
212        let krate = manifest::resolve_crate(path, package)?;
213        let opts = PublishOpts {
214            skip_confirm: yes,
215            dry_run,
216            ..Default::default()
217        };
218        let pl = self.resolve_pipeline(pipeline)?;
219        let from_stage = from.unwrap_or_else(|| &pl.stages[0].registry.name);
220        self.engine.promote_next(&krate, pl, from_stage, &opts)?;
221        Ok(())
222    }
223
224    /// Run all stages of a pipeline sequentially.
225    pub fn ship(
226        &self,
227        path: Option<&Path>,
228        package: Option<&str>,
229        allow_dirty: bool,
230        yes: bool,
231        force: bool,
232        pipeline: Option<&str>,
233    ) -> Result<()> {
234        let krate = manifest::resolve_crate(path, package)?;
235        let krate = maybe_autobump(krate, &self.config)?;
236        let opts = PublishOpts {
237            allow_dirty,
238            skip_confirm: yes,
239            force,
240            ..Default::default()
241        };
242        let pl = self.resolve_pipeline(pipeline)?;
243        self.engine.run_full(&krate, pl, &opts)?;
244        Ok(())
245    }
246
247    /// List crates in a registry.
248    pub fn list(&self, registry: Option<&str>) -> Result<Vec<CrateInfo>> {
249        let reg_name = registry.unwrap_or("cratebox");
250        let reg = self
251            .config
252            .registry(reg_name)
253            .ok_or_else(|| anyhow::anyhow!("unknown registry '{reg_name}'"))?;
254        let crates = self.registry_query.list_crates(reg)?;
255        Ok(crates)
256    }
257
258    /// Describe local crate versions.
259    pub fn status(path: Option<&Path>) -> Result<ManifestDescription> {
260        manifest::describe_manifest(path)
261    }
262
263    /// Publish all crates under a directory in dependency order.
264    pub fn publish_all(
265        &self,
266        root: &Path,
267        allow_dirty: bool,
268        dry_run: bool,
269        force: bool,
270        registry: Option<&str>,
271        skip: &[&str],
272    ) -> Result<PublishAllResult> {
273        let nodes = depgraph::scan_workspace_tree(root, skip)?;
274        let publishable: Vec<_> = nodes.iter().filter(|n| !n.unpublishable).collect();
275        let order =
276            depgraph::topo_sort(&publishable.iter().map(|n| (*n).clone()).collect::<Vec<_>>())?;
277
278        let blocked: Vec<_> = publishable
279            .iter()
280            .filter(|n| !n.path_only_deps.is_empty())
281            .collect();
282
283        let publishable_names: HashSet<&str> = publishable
284            .iter()
285            .filter(|n| n.path_only_deps.is_empty())
286            .filter(|n| {
287                self.config
288                    .package_override(&n.name)
289                    .and_then(|o| o.publish)
290                    != Some(false)
291            })
292            .map(|n| n.name.as_str())
293            .collect();
294
295        let publish_order: Vec<String> = order
296            .iter()
297            .filter(|name| publishable_names.contains(name.as_str()))
298            .cloned()
299            .collect();
300
301        let blocked_names: Vec<String> = blocked.iter().map(|n| n.name.clone()).collect();
302
303        if dry_run {
304            return Ok(PublishAllResult {
305                publish_order,
306                ok: 0,
307                failed: vec![],
308                blocked: blocked_names,
309            });
310        }
311
312        let reg_name = registry.unwrap_or("cratebox");
313        let reg = self
314            .config
315            .registry(reg_name)
316            .ok_or_else(|| anyhow::anyhow!("unknown registry '{reg_name}'"))?;
317        let stage = Stage {
318            registry: reg.clone(),
319        };
320        let opts = PublishOpts {
321            allow_dirty,
322            skip_confirm: true,
323            force,
324            ..Default::default()
325        };
326
327        let node_map: HashMap<&str, &depgraph::CrateNode> =
328            nodes.iter().map(|n| (n.name.as_str(), n)).collect();
329
330        let mut ok = 0usize;
331        let mut failed = Vec::new();
332        for name in &publish_order {
333            let node = node_map[name.as_str()];
334            let krate = CrateRef {
335                name: node.name.clone(),
336                version: node.version.clone(),
337                manifest_path: node.manifest_path.clone(),
338            };
339            match self.engine.run_stage(&krate, &stage, &opts) {
340                Ok(()) => ok += 1,
341                Err(e) => {
342                    eprintln!("  FAIL: {} -- {}", name, e);
343                    failed.push(name.clone());
344                }
345            }
346        }
347
348        Ok(PublishAllResult {
349            publish_order,
350            ok,
351            failed,
352            blocked: blocked_names,
353        })
354    }
355
356    /// Bump version and create promote.lock.
357    pub fn bump(&self, path: Option<&Path>, package: Option<&str>, cwd: &Path) -> Result<()> {
358        let krate = manifest::resolve_crate(path, package)?;
359        let branch_cfg = self
360            .config
361            .branch_pipeline
362            .as_ref()
363            .ok_or_else(|| anyhow::anyhow!("branch pipeline not configured in promote.toml"))?;
364        let repo_path = path.unwrap_or(cwd);
365        let git = infra::git::local::LocalGit::new(repo_path.to_path_buf());
366        domain::pipeline::BranchPipeline::bump(&krate, &branch_cfg.stages, repo_path, &git)?;
367        Ok(())
368    }
369
370    /// Branch from one stage to the next.
371    pub fn branch(&self, path: Option<&Path>, from: &str, cwd: &Path) -> Result<()> {
372        let branch_cfg = self
373            .config
374            .branch_pipeline
375            .as_ref()
376            .ok_or_else(|| anyhow::anyhow!("branch pipeline not configured in promote.toml"))?;
377        let repo_root = path.unwrap_or(cwd);
378        let git = infra::git::local::LocalGit::new(repo_root.to_path_buf());
379        domain::pipeline::BranchPipeline::branch(&branch_cfg.stages, from, &git, &git, repo_root)?;
380        Ok(())
381    }
382
383    /// Defer a crate's promotion to the next pipeline stage.
384    ///
385    /// Creates a pending deferral ticket and fires a notification.
386    /// The promotion is provisional until confirmed or rejected.
387    pub fn defer_to(
388        &self,
389        path: Option<&Path>,
390        package: Option<&str>,
391        from: &str,
392        pipeline: Option<&str>,
393        repo_root: &Path,
394    ) -> Result<Deferral> {
395        let krate = manifest::resolve_crate(path, package)?;
396        let pl = self.resolve_pipeline(pipeline)?;
397
398        let from_idx = pl
399            .stages
400            .iter()
401            .position(|s| s.registry.name == from)
402            .ok_or_else(|| anyhow::anyhow!("unknown stage '{from}' in pipeline"))?;
403        let to_stage = pl
404            .stages
405            .get(from_idx + 1)
406            .ok_or_else(|| anyhow::anyhow!("no next stage after '{from}'"))?;
407
408        let source_hash = domain::promote_lock::PromoteLock::compute_source_hash(repo_root)?;
409
410        let ticket = Deferral::ticket_id(&krate.name);
411        let now = chrono::Local::now();
412
413        let pr_number = match self.forge.create_pr(
414            &format!(
415                "promote: {} v{} {} -> {}",
416                krate.name, krate.version, from, to_stage.registry.name
417            ),
418            &format!("Deferred promotion ticket: {ticket}"),
419            from,
420            &to_stage.registry.name,
421        ) {
422            Ok(0) => None, // NoopForge returns 0
423            Ok(n) => Some(n),
424            Err(_) => None, // Best-effort; don't fail defer on forge error
425        };
426
427        let deferral = Deferral {
428            ticket: ticket.clone(),
429            crate_name: krate.name.clone(),
430            version: krate.version.clone(),
431            from_stage: from.to_string(),
432            to_stage: to_stage.registry.name.clone(),
433            status: DeferralStatus::Pending,
434            kind: DeferralKind::Registry,
435            deferred_at: now.format("%Y%m%d.%H%M%S").to_string(),
436            source_hash,
437            command: vec![],
438            reason: String::new(),
439            pr_number,
440        };
441
442        deferral.write(repo_root)?;
443        self.notifier.on_deferred(&deferral)?;
444        Ok(deferral)
445    }
446
447    /// Defer a branch promotion (merge from one stage branch to the
448    /// next). Verifies the promote.lock hash before creating the
449    /// ticket.
450    // qual:allow(iosp) reason: "integration root — orchestrates validation + deferral"
451    pub fn defer_branch(
452        &self,
453        path: Option<&Path>,
454        package: Option<&str>,
455        from: &str,
456        repo_root: &Path,
457    ) -> Result<Deferral> {
458        let krate = manifest::resolve_crate(path, package)?;
459        let branch_cfg = self
460            .config
461            .branch_pipeline
462            .as_ref()
463            .ok_or_else(|| anyhow::anyhow!("branch pipeline not configured in promote.toml"))?;
464
465        let from_idx = branch_cfg
466            .stages
467            .iter()
468            .position(|s| s == from)
469            .ok_or_else(|| anyhow::anyhow!("unknown branch stage '{from}'"))?;
470        let to_stage = branch_cfg
471            .stages
472            .get(from_idx + 1)
473            .ok_or_else(|| anyhow::anyhow!("no next stage after '{from}'"))?;
474
475        // Verify promote.lock hash before deferring.
476        let lock = domain::promote_lock::PromoteLock::read(repo_root)?;
477        lock.verify_hash(repo_root)?;
478
479        let ticket = Deferral::ticket_id(&krate.name);
480        let now = chrono::Local::now();
481
482        let pr_number = match self.forge.create_pr(
483            &format!(
484                "promote: {} v{} branch {} -> {}",
485                krate.name, krate.version, from, to_stage
486            ),
487            &format!("Deferred branch promotion ticket: {ticket}"),
488            from,
489            to_stage,
490        ) {
491            Ok(0) => None,
492            Ok(n) => Some(n),
493            Err(_) => None,
494        };
495
496        let deferral = Deferral {
497            ticket,
498            crate_name: krate.name.clone(),
499            version: krate.version.clone(),
500            from_stage: from.to_string(),
501            to_stage: to_stage.clone(),
502            status: DeferralStatus::Pending,
503            kind: DeferralKind::Branch,
504            deferred_at: now.format("%Y%m%d.%H%M%S").to_string(),
505            source_hash: lock.source_hash.clone(),
506            command: vec![],
507            reason: String::new(),
508            pr_number,
509        };
510
511        deferral.write(repo_root)?;
512        self.notifier.on_deferred(&deferral)?;
513        Ok(deferral)
514    }
515
516    /// Confirm a pending deferral. For branch deferrals, this
517    /// automatically executes the merge and push. The ticket is
518    /// only marked confirmed after the merge succeeds — if the
519    /// merge fails, the ticket remains pending.
520    // qual:allow(iosp) reason: "integration root — orchestrates validation + merge + confirm"
521    pub fn confirm_deferral(
522        &self,
523        repo_root: &Path,
524        ticket: &str,
525        reason: &str,
526    ) -> Result<Deferral> {
527        let d = Deferral::read(repo_root, ticket)?;
528        if d.status != DeferralStatus::Pending {
529            anyhow::bail!("deferral '{}' is already {:?}", ticket, d.status,);
530        }
531
532        if d.kind == DeferralKind::Branch {
533            let branch_cfg =
534                self.config.branch_pipeline.as_ref().ok_or_else(|| {
535                    anyhow::anyhow!("branch pipeline not configured in promote.toml")
536                })?;
537
538            // Re-verify hash before merging.
539            let lock = domain::promote_lock::PromoteLock::read(repo_root)?;
540            lock.verify_hash(repo_root)?;
541
542            let git = infra::git::local::LocalGit::new(repo_root.to_path_buf());
543
544            // Merge first — only mark confirmed if this succeeds.
545            domain::pipeline::BranchPipeline::branch(
546                &branch_cfg.stages,
547                &d.from_stage,
548                &git,
549                &git,
550                repo_root,
551            )?;
552
553            eprintln!(
554                "=> branch merge complete: '{}' -> '{}'",
555                d.from_stage, d.to_stage,
556            );
557        }
558
559        // Close associated PR if one exists (best-effort).
560        if let Some(pr) = d.pr_number {
561            let _ = self.forge.comment_pr(pr, &format!("Confirmed: {reason}"));
562            let _ = self.forge.close_pr(pr);
563        }
564
565        // Status update happens after side effects succeed.
566        let d = Deferral::confirm(repo_root, ticket, reason)?;
567        Ok(d)
568    }
569
570    /// Reject a pending deferral. No side effects beyond status
571    /// update.
572    pub fn reject_deferral(repo_root: &Path, ticket: &str, reason: &str) -> Result<Deferral> {
573        Deferral::reject(repo_root, ticket, reason)
574    }
575
576    /// List all deferrals (optionally filtered to pending only).
577    // qual:allow(iosp) reason: "thin delegation with filter flag"
578    pub fn deferrals(repo_root: &Path, pending_only: bool) -> Result<Vec<Deferral>> {
579        if pending_only {
580            Deferral::list_pending(repo_root)
581        } else {
582            Deferral::list(repo_root)
583        }
584    }
585}
586
587/// Result of a `publish_all` operation.
588#[derive(Debug)]
589pub struct PublishAllResult {
590    /// Crates in topological publish order.
591    pub publish_order: Vec<String>,
592    /// Number of successfully published crates.
593    pub ok: usize,
594    /// Names of crates that failed to publish.
595    pub failed: Vec<String>,
596    /// Names of crates blocked by path-only dependencies.
597    pub blocked: Vec<String>,
598}