1pub mod applier;
9pub mod checker;
10pub mod config;
11pub mod constants;
12pub mod detection;
13pub mod errors;
14pub mod planner;
15pub mod platform;
16pub mod types;
17pub mod utils;
18pub mod ux;
19
20#[cfg(test)]
21pub(crate) mod test_utils;
22
23pub use config::{BaseConfig, Hooks, ResolvedConfig, UpdateKitConfig, VersionSourceConfig};
25pub use errors::UpdateKitError;
26pub use types::{
27 ApplyResult, AssetInfo, Channel, CheckMode, Confidence, DelegateMode, Evidence,
28 InstallDetection, PlanKind, PostAction, UpdatePlan, UpdateStatus,
29};
30
31use std::path::PathBuf;
32
33use crate::applier::delegate::{apply_delegate_update, DelegateApplyOptions};
34use crate::applier::native::apply_native_update;
35use crate::applier::types::ApplyOptions;
36use crate::checker::infer_sources::{infer_source_configs, order_sources_by_channel};
37use crate::checker::sources::{
38 create_version_source, FetchVersionsOptions, VersionListResult, VersionSource,
39};
40use crate::checker::{check_update, CheckUpdateOptions};
41use crate::detection::{detect_install, DetectionConfig};
42use crate::planner::{plan_update, PlanUpdateOptions};
43use crate::platform::paths::get_default_cache_dir;
44use crate::ux::banner::render_banner;
45
46pub struct UpdateKit {
51 config: ResolvedConfig,
52 sources: Option<Vec<Box<dyn VersionSource>>>,
53 hooks: Hooks,
54 executable_path: Option<String>,
55}
56
57impl std::fmt::Debug for UpdateKit {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 f.debug_struct("UpdateKit")
60 .field("config", &self.config)
61 .field("sources", &self.sources.as_ref().map(|s| s.len()))
62 .field("hooks", &self.hooks)
63 .field("executable_path", &self.executable_path)
64 .finish()
65 }
66}
67
68impl UpdateKit {
69 pub fn new(config: UpdateKitConfig) -> Result<Self, UpdateKitError> {
74 Self::with_hooks(config, Hooks::default(), None)
75 }
76
77 pub fn with_hooks(
79 config: UpdateKitConfig,
80 hooks: Hooks,
81 executable_path: Option<String>,
82 ) -> Result<Self, UpdateKitError> {
83 let resolved = ResolvedConfig::try_from(config)?;
84
85 let sources = resolved.base.sources.as_ref().map(|source_configs| {
87 source_configs
88 .iter()
89 .cloned()
90 .map(create_version_source)
91 .collect::<Vec<_>>()
92 });
93
94 Ok(Self {
95 config: resolved,
96 sources,
97 hooks,
98 executable_path,
99 })
100 }
101
102 pub async fn detect_install(&self) -> Result<InstallDetection, UpdateKitError> {
104 let exec_path = self.resolve_exec_path()?;
105 let cmd = crate::utils::process::TokioCommandRunner;
106 let detection_config = DetectionConfig {
107 app_name: &self.config.app_name,
108 brew_cask_name: self.config.base.brew_cask_name.as_deref(),
109 custom_detectors: &[],
110 receipt_dir: None,
111 };
112 Ok(detect_install(&exec_path, &detection_config, &cmd).await)
113 }
114
115 pub async fn check_update(&self, mode: CheckMode) -> Result<UpdateStatus, UpdateKitError> {
120 if let Some(ref hook) = self.hooks.before_check {
122 hook().await?;
123 }
124
125 let sources = self.get_effective_sources(None);
126 let cache_dir = self.get_cache_dir();
127
128 let options = CheckUpdateOptions {
129 app_name: self.config.app_name.clone(),
130 current_version: self.config.current_version.to_string(),
131 sources,
132 cache_dir,
133 check_interval: self.config.base.check_interval_ms,
134 };
135
136 check_update(&options, mode).await
137 }
138
139 pub fn plan_update(
141 &self,
142 status: &UpdateStatus,
143 detection: &InstallDetection,
144 ) -> Option<UpdatePlan> {
145 plan_update(status, detection, &self.config, None)
146 }
147
148 pub fn plan_update_with_options(
150 &self,
151 status: &UpdateStatus,
152 detection: &InstallDetection,
153 options: PlanUpdateOptions,
154 ) -> Option<UpdatePlan> {
155 plan_update(status, detection, &self.config, Some(options))
156 }
157
158 pub async fn apply_update(
163 &self,
164 plan: &UpdatePlan,
165 options: Option<ApplyOptions>,
166 ) -> ApplyResult {
167 if let Some(ref hook) = self.hooks.before_apply {
169 match hook(plan).await {
170 Ok(false) => {
171 return ApplyResult::Failed {
172 error: Box::new(UpdateKitError::ApplyFailed(
173 "Aborted by before_apply hook".into(),
174 )),
175 rollback_succeeded: false,
176 };
177 }
178 Err(e) => {
179 if let Some(ref on_err) = self.hooks.on_error {
180 on_err(&e);
181 }
182 return ApplyResult::Failed {
183 error: Box::new(e),
184 rollback_succeeded: false,
185 };
186 }
187 Ok(true) => {}
188 }
189 }
190
191 let result = match &plan.kind {
192 PlanKind::NativeInPlace { .. } => {
193 let target_path = match self.resolve_exec_path() {
194 Ok(p) => p,
195 Err(e) => {
196 return ApplyResult::Failed {
197 error: Box::new(e),
198 rollback_succeeded: false,
199 };
200 }
201 };
202 apply_native_update(plan, &target_path, options.as_ref()).await
203 }
204 PlanKind::DelegateCommand { mode, .. } => {
205 let delegate_opts = DelegateApplyOptions {
206 mode: Some(*mode),
207 timeout_ms: self.config.base.delegate_timeout_ms,
208 on_progress: None,
209 cmd: None,
210 };
211 apply_delegate_update(plan, Some(delegate_opts)).await
212 }
213 PlanKind::ManualInstall {
214 instructions,
215 download_url,
216 ..
217 } => {
218 let msg = if let Some(url) = download_url {
219 format!("{}\nDownload: {}", instructions, url)
220 } else {
221 instructions.clone()
222 };
223 ApplyResult::NeedsRestart { message: msg }
224 }
225 };
226
227 if let Some(ref hook) = self.hooks.after_apply {
229 let _ = hook(&result).await;
230 }
231
232 if let ApplyResult::Failed { ref error, .. } = result {
234 if let Some(ref on_err) = self.hooks.on_error {
235 on_err(error);
236 }
237 }
238
239 result
240 }
241
242 pub async fn check_and_notify(&self) -> Result<Option<String>, UpdateKitError> {
248 let detection = match self.detect_install().await {
249 Ok(d) => d,
250 Err(_) => return Ok(None),
251 };
252
253 let status = match self.check_update(CheckMode::NonBlocking).await {
254 Ok(s) => s,
255 Err(_) => return Ok(None),
256 };
257
258 Ok(render_banner(&status, &detection))
259 }
260
261 pub async fn auto_update(&self, options: Option<ApplyOptions>) -> ApplyResult {
265 let detection = match self.detect_install().await {
266 Ok(d) => d,
267 Err(e) => {
268 return ApplyResult::Failed {
269 error: Box::new(e),
270 rollback_succeeded: false,
271 }
272 }
273 };
274
275 let status = match self.check_update(CheckMode::Blocking).await {
276 Ok(s) => s,
277 Err(e) => {
278 return ApplyResult::Failed {
279 error: Box::new(e),
280 rollback_succeeded: false,
281 }
282 }
283 };
284
285 let plan = match self.plan_update(&status, &detection) {
286 Some(p) => p,
287 None => {
288 let current = match &status {
289 UpdateStatus::UpToDate { current } => current.clone(),
290 UpdateStatus::Available { current, .. } => current.clone(),
291 UpdateStatus::Unknown { .. } => self.config.current_version.to_string(),
292 };
293 return ApplyResult::UpToDate { current };
294 }
295 };
296
297 self.apply_update(&plan, options).await
298 }
299
300 pub async fn list_versions(
302 &self,
303 options: Option<FetchVersionsOptions>,
304 ) -> Result<VersionListResult, UpdateKitError> {
305 let sources = self.get_effective_sources(None);
306 let opts = options.unwrap_or_default();
307
308 for source in &sources {
309 match source.fetch_versions(opts.clone()).await {
310 Ok(result) => return Ok(result),
311 Err(UpdateKitError::UnsupportedOperation(_)) => continue,
312 Err(e) => return Err(e),
313 }
314 }
315
316 Err(UpdateKitError::UnsupportedOperation(
317 "No source supports fetch_versions".into(),
318 ))
319 }
320
321 pub async fn switch_version(
323 &self,
324 target: &str,
325 options: Option<ApplyOptions>,
326 ) -> ApplyResult {
327 let detection = match self.detect_install().await {
328 Ok(d) => d,
329 Err(e) => {
330 return ApplyResult::Failed {
331 error: Box::new(e),
332 rollback_succeeded: false,
333 }
334 }
335 };
336
337 let current = self.config.current_version.to_string();
339 let status = UpdateStatus::Available {
340 current: current.clone(),
341 latest: target.to_string(),
342 release_url: None,
343 release_notes: None,
344 assets: None,
345 };
346
347 let plan_opts = PlanUpdateOptions {
348 target_version: Some(target.to_string()),
349 assets: None,
350 };
351
352 let plan = match self.plan_update_with_options(&status, &detection, plan_opts) {
353 Some(p) => p,
354 None => {
355 return ApplyResult::UpToDate { current };
356 }
357 };
358
359 self.apply_update(&plan, options).await
360 }
361
362 pub fn resolved_config(&self) -> &ResolvedConfig {
364 &self.config
365 }
366
367 fn resolve_exec_path(&self) -> Result<String, UpdateKitError> {
370 if let Some(ref path) = self.executable_path {
371 return Ok(path.clone());
372 }
373 std::env::current_exe()
374 .map(|p| p.to_string_lossy().to_string())
375 .map_err(|e| {
376 UpdateKitError::DetectionFailed(format!("Cannot determine executable path: {}", e))
377 })
378 }
379
380 fn get_cache_dir(&self) -> PathBuf {
381 get_default_cache_dir().join("update-kit")
382 }
383
384 fn get_effective_sources(
387 &self,
388 channel: Option<&crate::types::Channel>,
389 ) -> Vec<Box<dyn VersionSource>> {
390 if let Some(ref _sources) = self.sources {
391 if let Some(ref source_configs) = self.config.base.sources {
393 return source_configs
394 .iter()
395 .cloned()
396 .map(create_version_source)
397 .collect();
398 }
399 return vec![];
402 }
403
404 let mut configs = infer_source_configs(&self.config);
406 if let Some(ch) = channel {
407 configs = order_sources_by_channel(configs, ch);
408 }
409 configs.into_iter().map(create_version_source).collect()
410 }
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416 use crate::config::BaseConfig;
417
418 #[test]
419 fn create_with_explicit_config_succeeds() {
420 let config = UpdateKitConfig::Explicit {
421 app_name: "my-app".into(),
422 current_version: "1.0.0".into(),
423 base: BaseConfig::default(),
424 };
425 let kit = UpdateKit::new(config);
426 assert!(kit.is_ok());
427 let kit = kit.unwrap();
428 assert_eq!(kit.resolved_config().app_name, "my-app");
429 assert_eq!(
430 kit.resolved_config().current_version,
431 semver::Version::new(1, 0, 0)
432 );
433 }
434
435 #[test]
436 fn create_with_invalid_semver_fails() {
437 let config = UpdateKitConfig::Explicit {
438 app_name: "my-app".into(),
439 current_version: "not-valid".into(),
440 base: BaseConfig::default(),
441 };
442 let err = UpdateKit::new(config).unwrap_err();
443 assert_eq!(err.code(), "VERSION_PARSE");
444 }
445
446 #[test]
447 fn create_with_empty_app_name_fails() {
448 let config = UpdateKitConfig::Explicit {
449 app_name: "".into(),
450 current_version: "1.0.0".into(),
451 base: BaseConfig::default(),
452 };
453 let err = UpdateKit::new(config).unwrap_err();
454 assert_eq!(err.code(), "VERSION_PARSE");
455 }
456
457 #[test]
458 fn plan_update_delegates_to_planner() {
459 let config = UpdateKitConfig::Explicit {
460 app_name: "my-app".into(),
461 current_version: "1.0.0".into(),
462 base: BaseConfig::default(),
463 };
464 let kit = UpdateKit::new(config).unwrap();
465
466 let status = UpdateStatus::UpToDate {
468 current: "1.0.0".into(),
469 };
470 let detection = InstallDetection {
471 channel: crate::types::Channel::Native,
472 confidence: crate::types::Confidence::High,
473 evidence: vec![],
474 };
475 assert!(kit.plan_update(&status, &detection).is_none());
476
477 let status = UpdateStatus::Available {
479 current: "1.0.0".into(),
480 latest: "2.0.0".into(),
481 release_url: None,
482 release_notes: None,
483 assets: None,
484 };
485 let plan = kit.plan_update(&status, &detection);
486 assert!(plan.is_some());
487 let plan = plan.unwrap();
488 assert_eq!(plan.from_version, "1.0.0");
489 assert_eq!(plan.to_version, "2.0.0");
490 }
491
492 #[test]
493 fn resolved_config_returns_reference() {
494 let config = UpdateKitConfig::Explicit {
495 app_name: "test-app".into(),
496 current_version: "0.1.0".into(),
497 base: BaseConfig {
498 repository: Some("https://github.com/user/repo".into()),
499 ..Default::default()
500 },
501 };
502 let kit = UpdateKit::new(config).unwrap();
503 assert_eq!(kit.resolved_config().app_name, "test-app");
504 assert_eq!(
505 kit.resolved_config().base.repository.as_deref(),
506 Some("https://github.com/user/repo")
507 );
508 }
509
510 #[test]
511 fn create_with_explicit_sources() {
512 let config = UpdateKitConfig::Explicit {
513 app_name: "my-app".into(),
514 current_version: "1.0.0".into(),
515 base: BaseConfig {
516 sources: Some(vec![crate::config::VersionSourceConfig::Npm {
517 package_name: "my-app".into(),
518 registry_url: None,
519 }]),
520 ..Default::default()
521 },
522 };
523 let kit = UpdateKit::new(config).unwrap();
524 assert!(kit.sources.is_some());
526 }
527}