Skip to main content

update_kit/
lib.rs

1//! Channel-aware self-update toolkit for CLI applications.
2//!
3//! `update-kit` detects how an application was installed and manages self-updates
4//! through a pipeline: **Detection -> Check -> Plan -> Apply**.
5//!
6//! The main entry point is [`UpdateKit`], which orchestrates the full pipeline.
7
8pub 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
23// Re-export key types at the crate root for convenience.
24pub 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
46/// The main orchestrator for the update-kit pipeline.
47///
48/// Coordinates detection, checking, planning, and applying updates
49/// through a channel-based policy engine.
50pub 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    /// Create a new UpdateKit instance from the given configuration.
70    ///
71    /// Resolves and validates the config (including semver parsing).
72    /// If explicit sources are provided in config, they are materialized here.
73    pub fn new(config: UpdateKitConfig) -> Result<Self, UpdateKitError> {
74        Self::with_hooks(config, Hooks::default(), None)
75    }
76
77    /// Create a new UpdateKit instance with lifecycle hooks and an optional executable path.
78    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        // Pre-build sources if explicitly configured
86        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    /// Detect how the application was installed.
103    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    /// Check for available updates.
116    ///
117    /// In `Blocking` mode, fetches from sources immediately.
118    /// In `NonBlocking` mode, reads cache and spawns background check if stale.
119    pub async fn check_update(&self, mode: CheckMode) -> Result<UpdateStatus, UpdateKitError> {
120        // Run before_check hook
121        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    /// Create an update plan based on the current status and detection result.
140    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    /// Create an update plan with additional options (e.g., target version).
149    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    /// Apply an update plan.
159    ///
160    /// Dispatches to native, delegate, or manual handler based on the plan kind.
161    /// Runs before_apply and after_apply hooks, and on_error on failure.
162    pub async fn apply_update(
163        &self,
164        plan: &UpdatePlan,
165        options: Option<ApplyOptions>,
166    ) -> ApplyResult {
167        // Run before_apply hook
168        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        // Run after_apply hook
228        if let Some(ref hook) = self.hooks.after_apply {
229            let _ = hook(&result).await;
230        }
231
232        // Run on_error hook if failed
233        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    /// One-liner for app startup: detect, non-blocking check, render banner.
243    ///
244    /// Returns `Ok(Some(banner))` if an update is available, `Ok(None)` if
245    /// up-to-date or check is still pending, or quietly returns `Ok(None)` on
246    /// any error.
247    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    /// Full auto-update pipeline: detect -> blocking check -> plan -> apply.
262    ///
263    /// Never panics. All errors are wrapped in `ApplyResult::Failed`.
264    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    /// Fetch a list of available versions from the first source that supports it.
301    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    /// Switch to a specific version: detect -> create synthetic status -> plan -> apply.
322    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        // Create a synthetic Available status for the target version
338        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    /// Return a reference to the resolved configuration.
363    pub fn resolved_config(&self) -> &ResolvedConfig {
364        &self.config
365    }
366
367    // ── Private helpers ──
368
369    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    /// Get effective sources: explicit if configured, otherwise inferred and
385    /// optionally ordered by channel.
386    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            // Rebuild from config since trait objects can't be cloned
392            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            // Fallback: this shouldn't happen since self.sources is only Some when
400            // config has explicit sources, but handle gracefully
401            return vec![];
402        }
403
404        // Infer sources from config
405        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        // UpToDate should produce None
467        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        // Available should produce Some
478        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        // sources field should be Some since we provided explicit sources
525        assert!(kit.sources.is_some());
526    }
527}