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
22pub 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
37pub 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
46pub 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 pub fn new(dir: &Path) -> Result<Self> {
104 Self::with_confirmer(dir, |_| true)
105 }
106
107 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 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 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 pub fn config(&self) -> &Config {
155 &self.config
156 }
157
158 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 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 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 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 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 pub fn status(path: Option<&Path>) -> Result<ManifestDescription> {
260 manifest::describe_manifest(path)
261 }
262
263 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 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 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 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, Ok(n) => Some(n),
424 Err(_) => None, };
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 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 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 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 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 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 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 let d = Deferral::confirm(repo_root, ticket, reason)?;
567 Ok(d)
568 }
569
570 pub fn reject_deferral(repo_root: &Path, ticket: &str, reason: &str) -> Result<Deferral> {
573 Deferral::reject(repo_root, ticket, reason)
574 }
575
576 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#[derive(Debug)]
589pub struct PublishAllResult {
590 pub publish_order: Vec<String>,
592 pub ok: usize,
594 pub failed: Vec<String>,
596 pub blocked: Vec<String>,
598}