Skip to main content

roboticus_cli/cli/update/
mod.rs

1//! Update subsystem: binary self-update, provider/skill content updates,
2//! OAuth maintenance, and mechanic health checks.
3
4mod update_binary;
5mod update_mechanic;
6mod update_oauth;
7mod update_providers;
8mod update_skills;
9
10use std::collections::HashMap;
11use std::io::{self, Write};
12use std::path::{Path, PathBuf};
13
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16
17use roboticus_core::home_dir;
18
19use super::{colors, heading, icons};
20use crate::cli::{CRT_DRAW_MS, theme};
21
22pub(crate) const DEFAULT_REGISTRY_URL: &str = "https://roboticus.ai/registry/manifest.json";
23const CRATES_IO_API: &str = "https://crates.io/api/v1/crates/roboticus";
24const CRATE_NAME: &str = "roboticus";
25const RELEASE_BASE_URL: &str = "https://github.com/robot-accomplice/roboticus/releases/download";
26const GITHUB_RELEASES_API: &str =
27    "https://api.github.com/repos/robot-accomplice/roboticus/releases?per_page=100";
28
29// ── Re-exports (public API) ──────────────────────────────────
30
31pub(crate) use update_binary::check_binary_version;
32pub use update_binary::cmd_update_binary;
33pub use update_providers::cmd_update_providers;
34pub(crate) use update_skills::apply_multi_registry_skills_update;
35pub use update_skills::cmd_update_skills;
36
37// ── Registry manifest (remote) ───────────────────────────────
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct RegistryManifest {
41    pub version: String,
42    pub packs: Packs,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct Packs {
47    pub providers: ProviderPack,
48    pub skills: SkillPack,
49    #[serde(default)]
50    pub plugins: Option<roboticus_plugin_sdk::catalog::PluginCatalog>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct ProviderPack {
55    pub sha256: String,
56    pub path: String,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct SkillPack {
61    pub sha256: Option<String>,
62    pub path: String,
63    pub files: HashMap<String, String>,
64}
65
66// ── Local update state ───────────────────────────────────────
67
68#[derive(Debug, Clone, Serialize, Deserialize, Default)]
69pub struct UpdateState {
70    pub binary_version: String,
71    pub last_check: String,
72    pub registry_url: String,
73    pub installed_content: InstalledContent,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize, Default)]
77pub struct InstalledContent {
78    pub providers: Option<ContentRecord>,
79    pub skills: Option<SkillsRecord>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct ContentRecord {
84    pub version: String,
85    pub sha256: String,
86    pub installed_at: String,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct SkillsRecord {
91    pub version: String,
92    pub files: HashMap<String, String>,
93    pub installed_at: String,
94}
95
96impl UpdateState {
97    pub fn load() -> Self {
98        let path = state_path();
99        if path.exists() {
100            match std::fs::read_to_string(&path) {
101                Ok(content) => serde_json::from_str(&content)
102                    .inspect_err(|e| tracing::warn!(error = %e, "corrupted update state file, resetting to default"))
103                    .unwrap_or_default(),
104                Err(e) => {
105                    tracing::warn!(error = %e, "failed to read update state file, resetting to default");
106                    Self::default()
107                }
108            }
109        } else {
110            Self::default()
111        }
112    }
113
114    pub fn save(&self) -> io::Result<()> {
115        let path = state_path();
116        if let Some(parent) = path.parent() {
117            std::fs::create_dir_all(parent)?;
118        }
119        let json = serde_json::to_string_pretty(self).map_err(io::Error::other)?;
120        std::fs::write(&path, json)
121    }
122}
123
124fn state_path() -> PathBuf {
125    home_dir().join(".roboticus").join("update_state.json")
126}
127
128fn roboticus_home() -> PathBuf {
129    home_dir().join(".roboticus")
130}
131
132fn now_iso() -> String {
133    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
134}
135
136// ── Helpers ──────────────────────────────────────────────────
137
138fn is_safe_skill_path(base_dir: &Path, filename: &str) -> bool {
139    if filename.contains("..") || Path::new(filename).is_absolute() {
140        return false;
141    }
142    let effective_base = base_dir.canonicalize().unwrap_or_else(|_| {
143        base_dir.components().fold(PathBuf::new(), |mut acc, c| {
144            acc.push(c);
145            acc
146        })
147    });
148    let joined = effective_base.join(filename);
149    let normalized: PathBuf = joined.components().fold(PathBuf::new(), |mut acc, c| {
150        match c {
151            std::path::Component::ParentDir => {
152                acc.pop();
153            }
154            other => acc.push(other),
155        }
156        acc
157    });
158    normalized.starts_with(&effective_base)
159}
160
161pub fn file_sha256(path: &Path) -> io::Result<String> {
162    let bytes = std::fs::read(path)?;
163    let hash = Sha256::digest(&bytes);
164    Ok(hex::encode(hash))
165}
166
167pub fn bytes_sha256(data: &[u8]) -> String {
168    let hash = Sha256::digest(data);
169    hex::encode(hash)
170}
171
172pub(crate) fn resolve_registry_url(cli_override: Option<&str>, config_path: &str) -> String {
173    if let Some(url) = cli_override {
174        return url.to_string();
175    }
176    if let Ok(val) = std::env::var("ROBOTICUS_REGISTRY_URL")
177        && !val.is_empty()
178    {
179        return val;
180    }
181    if let Ok(content) = std::fs::read_to_string(config_path)
182        && let Ok(config) = content.parse::<toml::Value>()
183        && let Some(url) = config
184            .get("update")
185            .and_then(|u| u.get("registry_url"))
186            .and_then(|v| v.as_str())
187        && !url.is_empty()
188    {
189        return url.to_string();
190    }
191    DEFAULT_REGISTRY_URL.to_string()
192}
193
194pub(crate) fn registry_base_url(manifest_url: &str) -> String {
195    if let Some(pos) = manifest_url.rfind('/') {
196        manifest_url[..pos].to_string()
197    } else {
198        manifest_url.to_string()
199    }
200}
201
202fn confirm_action(prompt: &str, default_yes: bool) -> bool {
203    let hint = if default_yes { "[Y/n]" } else { "[y/N]" };
204    print!("    {prompt} {hint} ");
205    io::stdout().flush().ok();
206    let mut input = String::new();
207    if io::stdin().read_line(&mut input).is_err() {
208        return default_yes;
209    }
210    let answer = input.trim().to_lowercase();
211    if answer.is_empty() {
212        return default_yes;
213    }
214    matches!(answer.as_str(), "y" | "yes")
215}
216
217fn confirm_overwrite(filename: &str) -> OverwriteChoice {
218    let (_, _, _, _, YELLOW, _, _, RESET, _) = colors();
219    print!("    Overwrite {YELLOW}{filename}{RESET}? [y/N/backup] ");
220    io::stdout().flush().ok();
221    let mut input = String::new();
222    if io::stdin().read_line(&mut input).is_err() {
223        return OverwriteChoice::Skip;
224    }
225    match input.trim().to_lowercase().as_str() {
226        "y" | "yes" => OverwriteChoice::Overwrite,
227        "b" | "backup" => OverwriteChoice::Backup,
228        _ => OverwriteChoice::Skip,
229    }
230}
231
232#[derive(Debug, PartialEq)]
233enum OverwriteChoice {
234    Overwrite,
235    Backup,
236    Skip,
237}
238
239fn http_client() -> Result<reqwest::Client, Box<dyn std::error::Error>> {
240    Ok(reqwest::Client::builder()
241        .timeout(std::time::Duration::from_secs(15))
242        .user_agent(format!("roboticus/{}", env!("CARGO_PKG_VERSION")))
243        .build()?)
244}
245
246// ── Callback types ───────────────────────────────────────────
247
248/// Callback type for state hygiene.
249pub type HygieneFn = Box<
250    dyn Fn(&str) -> Result<Option<(u64, u64, u64, u64)>, Box<dyn std::error::Error>> + Send + Sync,
251>;
252
253/// Callback type for daemon operations (restart after update).
254pub type DaemonOps = Box<dyn Fn() -> Result<(), Box<dyn std::error::Error>> + Send + Sync>;
255
256/// Result of the daemon installed check + restart.
257pub struct DaemonCallbacks {
258    pub is_installed: Box<dyn Fn() -> bool + Send + Sync>,
259    pub restart: DaemonOps,
260}
261
262// ── Delegated maintenance (used by sub-modules) ──────────────
263
264fn run_oauth_storage_maintenance() {
265    update_oauth::run_oauth_storage_maintenance();
266}
267
268fn run_mechanic_checks_maintenance(config_path: &str, hygiene_fn: Option<&HygieneFn>) {
269    update_mechanic::run_mechanic_checks_maintenance(config_path, hygiene_fn);
270}
271
272// ── TOML diff ────────────────────────────────────────────────
273
274pub fn diff_lines(old: &str, new: &str) -> Vec<DiffLine> {
275    let old_lines: Vec<&str> = old.lines().collect();
276    let new_lines: Vec<&str> = new.lines().collect();
277    let mut result = Vec::new();
278
279    let max = old_lines.len().max(new_lines.len());
280    for i in 0..max {
281        match (old_lines.get(i), new_lines.get(i)) {
282            (Some(o), Some(n)) if o == n => {
283                result.push(DiffLine::Same((*o).to_string()));
284            }
285            (Some(o), Some(n)) => {
286                result.push(DiffLine::Removed((*o).to_string()));
287                result.push(DiffLine::Added((*n).to_string()));
288            }
289            (Some(o), None) => {
290                result.push(DiffLine::Removed((*o).to_string()));
291            }
292            (None, Some(n)) => {
293                result.push(DiffLine::Added((*n).to_string()));
294            }
295            (None, None) => {}
296        }
297    }
298    result
299}
300
301#[derive(Debug, PartialEq)]
302pub enum DiffLine {
303    Same(String),
304    Added(String),
305    Removed(String),
306}
307
308fn print_diff(old: &str, new: &str) {
309    let (DIM, _, _, GREEN, _, RED, _, RESET, _) = colors();
310    let lines = diff_lines(old, new);
311    let changes: Vec<&DiffLine> = lines
312        .iter()
313        .filter(|l| !matches!(l, DiffLine::Same(_)))
314        .collect();
315
316    if changes.is_empty() {
317        println!("      {DIM}(no changes){RESET}");
318        return;
319    }
320
321    for line in &changes {
322        match line {
323            DiffLine::Removed(s) => println!("      {RED}- {s}{RESET}"),
324            DiffLine::Added(s) => println!("      {GREEN}+ {s}{RESET}"),
325            DiffLine::Same(_) => {}
326        }
327    }
328}
329
330pub(crate) fn is_newer(remote: &str, local: &str) -> bool {
331    update_binary::parse_semver(remote) > update_binary::parse_semver(local)
332}
333
334// ── Manifest fetching (shared) ───────────────────────────────
335
336pub(crate) async fn fetch_manifest(
337    client: &reqwest::Client,
338    registry_url: &str,
339) -> Result<RegistryManifest, Box<dyn std::error::Error>> {
340    let resp = client.get(registry_url).send().await?;
341    if !resp.status().is_success() {
342        return Err(format!("Registry returned HTTP {}", resp.status()).into());
343    }
344    let manifest: RegistryManifest = resp.json().await?;
345    Ok(manifest)
346}
347
348async fn fetch_file(
349    client: &reqwest::Client,
350    base_url: &str,
351    relative_path: &str,
352) -> Result<String, Box<dyn std::error::Error>> {
353    let url = format!("{base_url}/{relative_path}");
354    let resp = client.get(&url).send().await?;
355    if !resp.status().is_success() {
356        return Err(format!("Failed to fetch {relative_path}: HTTP {}", resp.status()).into());
357    }
358    Ok(resp.text().await?)
359}
360
361// ── Public CLI entry points ──────────────────────────────────
362
363pub async fn cmd_update_check(
364    channel: &str,
365    registry_url_override: Option<&str>,
366    config_path: &str,
367) -> Result<(), Box<dyn std::error::Error>> {
368    let (DIM, BOLD, _, GREEN, _, _, _, RESET, MONO) = colors();
369    let (OK, _, WARN, _, _) = icons();
370
371    heading("Update Check");
372    let current = env!("CARGO_PKG_VERSION");
373    let client = http_client()?;
374
375    // Binary
376    println!("\n  {BOLD}Binary{RESET}");
377    println!("    Current: {MONO}v{current}{RESET}");
378    println!("    Channel: {DIM}{channel}{RESET}");
379
380    match check_binary_version(&client).await? {
381        Some(latest) => {
382            if is_newer(&latest, current) {
383                println!("    Latest:  {GREEN}v{latest}{RESET} (update available)");
384            } else {
385                println!("    {OK} Up to date (v{current})");
386            }
387        }
388        None => println!("    {WARN} Could not check crates.io"),
389    }
390
391    // Content packs
392    let registries: Vec<roboticus_core::config::RegistrySource> =
393        if let Some(url) = registry_url_override {
394            vec![roboticus_core::config::RegistrySource {
395                name: "cli-override".into(),
396                url: url.to_string(),
397                priority: 100,
398                enabled: true,
399            }]
400        } else {
401            std::fs::read_to_string(config_path)
402                .ok()
403                .and_then(|raw| {
404                    let table: toml::Value = toml::from_str(&raw).ok()?;
405                    let update_val = table.get("update")?.clone();
406                    let update_cfg: roboticus_core::config::UpdateConfig =
407                        update_val.try_into().ok()?;
408                    Some(update_cfg.resolve_registries())
409                })
410                .unwrap_or_else(|| {
411                    let url = resolve_registry_url(None, config_path);
412                    vec![roboticus_core::config::RegistrySource {
413                        name: "default".into(),
414                        url,
415                        priority: 50,
416                        enabled: true,
417                    }]
418                })
419        };
420
421    let enabled: Vec<_> = registries.iter().filter(|r| r.enabled).collect();
422
423    println!("\n  {BOLD}Content Packs{RESET}");
424    if enabled.len() == 1 {
425        println!("    Registry: {DIM}{}{RESET}", enabled[0].url);
426    } else {
427        for reg in &enabled {
428            println!("    Registry: {DIM}{}{RESET} ({})", reg.url, reg.name);
429        }
430    }
431
432    let primary_url = enabled
433        .first()
434        .map(|r| r.url.as_str())
435        .unwrap_or(DEFAULT_REGISTRY_URL);
436
437    match fetch_manifest(&client, primary_url).await {
438        Ok(manifest) => {
439            let state = UpdateState::load();
440            println!("    Pack version: {MONO}v{}{RESET}", manifest.version);
441
442            let providers_path = update_providers::providers_local_path(config_path);
443            if providers_path.exists() {
444                let local_hash = file_sha256(&providers_path).unwrap_or_default();
445                if local_hash == manifest.packs.providers.sha256 {
446                    println!("    {OK} Providers: up to date");
447                } else {
448                    println!("    {GREEN}\u{25b6}{RESET} Providers: update available");
449                }
450            } else {
451                println!("    {GREEN}+{RESET} Providers: new (not yet installed locally)");
452            }
453
454            let skills_dir = update_skills::skills_local_dir(config_path);
455            let mut skills_new = 0u32;
456            let mut skills_changed = 0u32;
457            let mut skills_ok = 0u32;
458            for (filename, remote_hash) in &manifest.packs.skills.files {
459                let local_file = skills_dir.join(filename);
460                if !local_file.exists() {
461                    skills_new += 1;
462                } else {
463                    let local_hash = file_sha256(&local_file).unwrap_or_default();
464                    if local_hash == *remote_hash {
465                        skills_ok += 1;
466                    } else {
467                        skills_changed += 1;
468                    }
469                }
470            }
471
472            if skills_new == 0 && skills_changed == 0 {
473                println!("    {OK} Skills: up to date ({skills_ok} files)");
474            } else {
475                println!(
476                    "    {GREEN}\u{25b6}{RESET} Skills: {skills_new} new, {skills_changed} changed, {skills_ok} current"
477                );
478            }
479
480            for reg in enabled.iter().skip(1) {
481                match fetch_manifest(&client, &reg.url).await {
482                    Ok(m) => println!("    {OK} {}: reachable (v{})", reg.name, m.version),
483                    Err(e) => println!("    {WARN} {}: unreachable ({e})", reg.name),
484                }
485            }
486
487            if let Some(ref providers) = state.installed_content.providers {
488                println!(
489                    "\n    {DIM}Last content update: {}{RESET}",
490                    providers.installed_at
491                );
492            }
493        }
494        Err(e) => {
495            println!("    {WARN} Could not reach registry: {e}");
496        }
497    }
498
499    println!();
500    Ok(())
501}
502
503#[allow(clippy::too_many_arguments)]
504pub async fn cmd_update_all(
505    channel: &str,
506    yes: bool,
507    no_restart: bool,
508    force: bool,
509    registry_url_override: Option<&str>,
510    config_path: &str,
511    hygiene_fn: Option<&HygieneFn>,
512    daemon_cbs: Option<&DaemonCallbacks>,
513) -> Result<(), Box<dyn std::error::Error>> {
514    let (_, BOLD, _, _, _, _, _, RESET, _) = colors();
515    let (OK, _, WARN, DETAIL, _) = icons();
516    heading("Roboticus Update");
517
518    // ── Liability Waiver ──────────────────────────────────────────
519    println!();
520    println!("    {BOLD}IMPORTANT — PLEASE READ{RESET}");
521    println!();
522    println!("    Roboticus is an autonomous AI agent that can execute actions,");
523    println!("    interact with external services, and manage digital assets");
524    println!("    including cryptocurrency wallets and on-chain transactions.");
525    println!();
526    println!("    THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND.");
527    println!("    The developers and contributors bear {BOLD}no responsibility{RESET} for:");
528    println!();
529    println!("      - Actions taken by the agent, whether intended or unintended");
530    println!("      - Loss of funds, income, cryptocurrency, or other digital assets");
531    println!("      - Security vulnerabilities, compromises, or unauthorized access");
532    println!("      - Damages arising from the agent's use, misuse, or malfunction");
533    println!("      - Any financial, legal, or operational consequences whatsoever");
534    println!();
535    println!("    By proceeding, you acknowledge that you use Roboticus entirely");
536    println!("    at your own risk and accept full responsibility for its operation.");
537    println!();
538    if !yes && !confirm_action("I understand and accept these terms", true) {
539        println!("\n    Update cancelled.\n");
540        return Ok(());
541    }
542
543    let binary_updated = update_binary::apply_binary_update(yes, "download", force).await?;
544
545    let registry_url = resolve_registry_url(registry_url_override, config_path);
546    update_providers::apply_providers_update(yes, &registry_url, config_path).await?;
547    apply_multi_registry_skills_update(yes, registry_url_override, config_path).await?;
548    run_oauth_storage_maintenance();
549    run_mechanic_checks_maintenance(config_path, hygiene_fn);
550    if let Err(e) = update_mechanic::apply_removed_legacy_config_migration(config_path) {
551        println!("    {WARN} Legacy config migration skipped: {e}");
552    }
553
554    if let Err(e) = update_mechanic::apply_security_config_migration(config_path) {
555        println!("    {WARN} Security config migration skipped: {e}");
556    }
557
558    // Migrate FIRMWARE.toml rules from legacy [[rules]] array to [rules] table
559    let workspace_path = std::path::Path::new(config_path)
560        .parent()
561        .unwrap_or(std::path::Path::new("."))
562        .join("workspace");
563    if workspace_path.exists()
564        && let Err(e) = update_mechanic::migrate_firmware_rules(&workspace_path)
565    {
566        println!("    {WARN} FIRMWARE.toml rules migration skipped: {e}");
567    }
568
569    if let Some(daemon) = daemon_cbs {
570        if binary_updated && !no_restart && (daemon.is_installed)() {
571            println!("\n    Restarting daemon to apply update...");
572            match (daemon.restart)() {
573                Ok(()) => println!("    {OK} Daemon restarted"),
574                Err(e) => {
575                    println!("    {WARN} Could not restart daemon: {e}");
576                    println!("    {DETAIL} Run `roboticus daemon restart` manually.");
577                }
578            }
579        } else if binary_updated && no_restart {
580            println!("\n    {DETAIL} Skipping daemon restart (--no-restart).");
581            println!("    {DETAIL} Run `roboticus daemon restart` to apply the update.");
582        }
583    }
584
585    println!("\n  {BOLD}Update complete.{RESET}\n");
586    Ok(())
587}
588
589// ── Test support ─────────────────────────────────────────────
590
591#[cfg(test)]
592pub(crate) mod tests_support {
593    use super::bytes_sha256;
594    use axum::{Json, Router, extract::State, routing::get};
595    use tokio::net::TcpListener;
596
597    #[derive(Clone)]
598    pub(crate) struct MockRegistry {
599        manifest: String,
600        providers: String,
601        skill_payload: String,
602    }
603
604    pub(crate) async fn start_mock_registry(
605        providers: String,
606        skill_draft: String,
607    ) -> (String, tokio::task::JoinHandle<()>) {
608        let providers_hash = bytes_sha256(providers.as_bytes());
609        let draft_hash = bytes_sha256(skill_draft.as_bytes());
610        let manifest = serde_json::json!({
611            "version": "0.8.0",
612            "packs": {
613                "providers": {
614                    "sha256": providers_hash,
615                    "path": "registry/providers.toml"
616                },
617                "skills": {
618                    "sha256": null,
619                    "path": "registry/skills/",
620                    "files": {
621                        "draft.md": draft_hash
622                    }
623                }
624            }
625        })
626        .to_string();
627
628        let state = MockRegistry {
629            manifest,
630            providers,
631            skill_payload: skill_draft,
632        };
633
634        async fn manifest_h(State(st): State<MockRegistry>) -> Json<serde_json::Value> {
635            Json(serde_json::from_str(&st.manifest).unwrap())
636        }
637        async fn providers_h(State(st): State<MockRegistry>) -> String {
638            st.providers
639        }
640        async fn skill_h(State(st): State<MockRegistry>) -> String {
641            st.skill_payload
642        }
643
644        let app = Router::new()
645            .route("/manifest.json", get(manifest_h))
646            .route("/registry/providers.toml", get(providers_h))
647            .route("/registry/skills/draft.md", get(skill_h))
648            .with_state(state);
649        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
650        let addr = listener.local_addr().unwrap();
651        let handle = tokio::spawn(async move {
652            axum::serve(listener, app).await.unwrap();
653        });
654        (
655            format!("http://{}:{}/manifest.json", addr.ip(), addr.port()),
656            handle,
657        )
658    }
659
660    pub(crate) async fn start_namespaced_mock_registry(
661        registry_name: &str,
662        skill_filename: &str,
663        skill_content: String,
664    ) -> (String, tokio::task::JoinHandle<()>) {
665        let content_hash = bytes_sha256(skill_content.as_bytes());
666        let manifest = serde_json::json!({
667            "version": "1.0.0",
668            "packs": {
669                "providers": {
670                    "sha256": "unused",
671                    "path": "registry/providers.toml"
672                },
673                "skills": {
674                    "sha256": null,
675                    "path": format!("registry/{registry_name}/"),
676                    "files": {
677                        skill_filename: content_hash
678                    }
679                }
680            }
681        })
682        .to_string();
683
684        let skill_route = format!("/registry/{registry_name}/{skill_filename}");
685
686        let state = MockRegistry {
687            manifest,
688            providers: String::new(),
689            skill_payload: skill_content,
690        };
691
692        async fn manifest_h(State(st): State<MockRegistry>) -> Json<serde_json::Value> {
693            Json(serde_json::from_str(&st.manifest).unwrap())
694        }
695        async fn providers_h(State(st): State<MockRegistry>) -> String {
696            st.providers.clone()
697        }
698        async fn skill_h(State(st): State<MockRegistry>) -> String {
699            st.skill_payload.clone()
700        }
701
702        let app = Router::new()
703            .route("/manifest.json", get(manifest_h))
704            .route("/registry/providers.toml", get(providers_h))
705            .route(&skill_route, get(skill_h))
706            .with_state(state);
707        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
708        let addr = listener.local_addr().unwrap();
709        let handle = tokio::spawn(async move {
710            axum::serve(listener, app).await.unwrap();
711        });
712        (
713            format!("http://{}:{}/manifest.json", addr.ip(), addr.port()),
714            handle,
715        )
716    }
717}
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722
723    #[test]
724    fn update_state_serde_roundtrip() {
725        let state = UpdateState {
726            binary_version: "0.2.0".into(),
727            last_check: "2026-02-20T00:00:00Z".into(),
728            registry_url: DEFAULT_REGISTRY_URL.into(),
729            installed_content: InstalledContent {
730                providers: Some(ContentRecord {
731                    version: "0.2.0".into(),
732                    sha256: "abc123".into(),
733                    installed_at: "2026-02-20T00:00:00Z".into(),
734                }),
735                skills: Some(SkillsRecord {
736                    version: "0.2.0".into(),
737                    files: {
738                        let mut m = HashMap::new();
739                        m.insert("draft.md".into(), "hash1".into());
740                        m.insert("rust.md".into(), "hash2".into());
741                        m
742                    },
743                    installed_at: "2026-02-20T00:00:00Z".into(),
744                }),
745            },
746        };
747
748        let json = serde_json::to_string_pretty(&state).unwrap();
749        let parsed: UpdateState = serde_json::from_str(&json).unwrap();
750        assert_eq!(parsed.binary_version, "0.2.0");
751        assert_eq!(
752            parsed.installed_content.providers.as_ref().unwrap().sha256,
753            "abc123"
754        );
755        assert_eq!(
756            parsed
757                .installed_content
758                .skills
759                .as_ref()
760                .unwrap()
761                .files
762                .len(),
763            2
764        );
765    }
766
767    #[test]
768    fn update_state_default_is_empty() {
769        let state = UpdateState::default();
770        assert_eq!(state.binary_version, "");
771        assert!(state.installed_content.providers.is_none());
772        assert!(state.installed_content.skills.is_none());
773    }
774
775    #[test]
776    fn file_sha256_computes_correctly() {
777        let dir = tempfile::tempdir().unwrap();
778        let path = dir.path().join("test.txt");
779        std::fs::write(&path, "hello world\n").unwrap();
780
781        let hash = file_sha256(&path).unwrap();
782        assert_eq!(hash.len(), 64);
783
784        let expected = bytes_sha256(b"hello world\n");
785        assert_eq!(hash, expected);
786    }
787
788    #[test]
789    fn file_sha256_error_on_missing() {
790        let result = file_sha256(Path::new("/nonexistent/file.txt"));
791        assert!(result.is_err());
792    }
793
794    #[test]
795    fn bytes_sha256_deterministic() {
796        let h1 = bytes_sha256(b"test data");
797        let h2 = bytes_sha256(b"test data");
798        assert_eq!(h1, h2);
799        assert_ne!(bytes_sha256(b"different"), h1);
800    }
801
802    #[test]
803    fn modification_detection_unmodified() {
804        let dir = tempfile::tempdir().unwrap();
805        let path = dir.path().join("providers.toml");
806        let content = "[providers.openai]\nurl = \"https://api.openai.com\"\n";
807        std::fs::write(&path, content).unwrap();
808
809        let installed_hash = bytes_sha256(content.as_bytes());
810        let current_hash = file_sha256(&path).unwrap();
811        assert_eq!(current_hash, installed_hash);
812    }
813
814    #[test]
815    fn modification_detection_modified() {
816        let dir = tempfile::tempdir().unwrap();
817        let path = dir.path().join("providers.toml");
818        let original = "[providers.openai]\nurl = \"https://api.openai.com\"\n";
819        let modified = "[providers.openai]\nurl = \"https://custom.endpoint.com\"\n";
820
821        let installed_hash = bytes_sha256(original.as_bytes());
822        std::fs::write(&path, modified).unwrap();
823
824        let current_hash = file_sha256(&path).unwrap();
825        assert_ne!(current_hash, installed_hash);
826    }
827
828    #[test]
829    fn manifest_parse() {
830        let json = r#"{
831            "version": "0.2.0",
832            "packs": {
833                "providers": { "sha256": "abc123", "path": "registry/providers.toml" },
834                "skills": {
835                    "sha256": null,
836                    "path": "registry/skills/",
837                    "files": {
838                        "draft.md": "hash1",
839                        "rust.md": "hash2"
840                    }
841                }
842            }
843        }"#;
844        let manifest: RegistryManifest = serde_json::from_str(json).unwrap();
845        assert_eq!(manifest.version, "0.2.0");
846        assert_eq!(manifest.packs.providers.sha256, "abc123");
847        assert_eq!(manifest.packs.skills.files.len(), 2);
848        assert_eq!(manifest.packs.skills.files["draft.md"], "hash1");
849    }
850
851    #[test]
852    fn diff_lines_identical() {
853        let result = diff_lines("a\nb\nc", "a\nb\nc");
854        assert!(result.iter().all(|l| matches!(l, DiffLine::Same(_))));
855    }
856
857    #[test]
858    fn diff_lines_changed() {
859        let result = diff_lines("a\nb\nc", "a\nB\nc");
860        assert_eq!(result.len(), 4);
861        assert_eq!(result[0], DiffLine::Same("a".into()));
862        assert_eq!(result[1], DiffLine::Removed("b".into()));
863        assert_eq!(result[2], DiffLine::Added("B".into()));
864        assert_eq!(result[3], DiffLine::Same("c".into()));
865    }
866
867    #[test]
868    fn diff_lines_added() {
869        let result = diff_lines("a\nb", "a\nb\nc");
870        assert_eq!(result.len(), 3);
871        assert_eq!(result[2], DiffLine::Added("c".into()));
872    }
873
874    #[test]
875    fn diff_lines_removed() {
876        let result = diff_lines("a\nb\nc", "a\nb");
877        assert_eq!(result.len(), 3);
878        assert_eq!(result[2], DiffLine::Removed("c".into()));
879    }
880
881    #[test]
882    fn diff_lines_empty_to_content() {
883        let result = diff_lines("", "a\nb");
884        assert!(result.iter().any(|l| matches!(l, DiffLine::Added(_))));
885    }
886
887    #[test]
888    fn registry_base_url_strips_filename() {
889        let url = "https://roboticus.ai/registry/manifest.json";
890        assert_eq!(registry_base_url(url), "https://roboticus.ai/registry");
891    }
892
893    #[test]
894    fn resolve_registry_url_cli_override() {
895        let result = resolve_registry_url(
896            Some("https://custom.registry/manifest.json"),
897            "nonexistent.toml",
898        );
899        assert_eq!(result, "https://custom.registry/manifest.json");
900    }
901
902    #[test]
903    fn resolve_registry_url_default() {
904        let result = resolve_registry_url(None, "nonexistent.toml");
905        assert_eq!(result, DEFAULT_REGISTRY_URL);
906    }
907
908    #[test]
909    fn resolve_registry_url_from_config() {
910        let dir = tempfile::tempdir().unwrap();
911        let config = dir.path().join("roboticus.toml");
912        std::fs::write(
913            &config,
914            "[update]\nregistry_url = \"https://my.registry/manifest.json\"\n",
915        )
916        .unwrap();
917
918        let result = resolve_registry_url(None, config.to_str().unwrap());
919        assert_eq!(result, "https://my.registry/manifest.json");
920    }
921
922    #[test]
923    fn update_state_save_load_roundtrip() {
924        let dir = tempfile::tempdir().unwrap();
925        let path = dir.path().join("update_state.json");
926
927        let state = UpdateState {
928            binary_version: "0.3.0".into(),
929            last_check: "2026-03-01T12:00:00Z".into(),
930            registry_url: "https://example.com/manifest.json".into(),
931            installed_content: InstalledContent::default(),
932        };
933
934        let json = serde_json::to_string_pretty(&state).unwrap();
935        std::fs::write(&path, &json).unwrap();
936
937        let loaded: UpdateState =
938            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
939        assert_eq!(loaded.binary_version, "0.3.0");
940        assert_eq!(loaded.registry_url, "https://example.com/manifest.json");
941    }
942
943    #[test]
944    fn bytes_sha256_empty_input() {
945        let hash = bytes_sha256(b"");
946        assert_eq!(hash.len(), 64);
947        assert_eq!(
948            hash,
949            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
950        );
951    }
952
953    #[test]
954    fn diff_lines_both_empty() {
955        let result = diff_lines("", "");
956        assert!(result.is_empty() || result.iter().all(|l| matches!(l, DiffLine::Same(_))));
957    }
958
959    #[test]
960    fn diff_lines_content_to_empty() {
961        let result = diff_lines("a\nb", "");
962        assert!(result.iter().any(|l| matches!(l, DiffLine::Removed(_))));
963    }
964
965    #[test]
966    fn registry_base_url_no_slash() {
967        assert_eq!(registry_base_url("manifest.json"), "manifest.json");
968    }
969
970    #[test]
971    fn registry_base_url_nested() {
972        assert_eq!(
973            registry_base_url("https://cdn.example.com/v1/registry/manifest.json"),
974            "https://cdn.example.com/v1/registry"
975        );
976    }
977
978    #[test]
979    fn installed_content_default_is_empty() {
980        let ic = InstalledContent::default();
981        assert!(ic.skills.is_none());
982        assert!(ic.providers.is_none());
983    }
984}