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