1mod 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
29pub(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#[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#[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
136fn 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
246pub type HygieneFn = Box<
250 dyn Fn(&str) -> Result<Option<(u64, u64, u64, u64)>, Box<dyn std::error::Error>> + Send + Sync,
251>;
252
253pub type DaemonOps = Box<dyn Fn() -> Result<(), Box<dyn std::error::Error>> + Send + Sync>;
255
256pub struct DaemonCallbacks {
258 pub is_installed: Box<dyn Fn() -> bool + Send + Sync>,
259 pub restart: DaemonOps,
260}
261
262fn 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
272pub 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
334pub(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
361pub 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 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 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, ®.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 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, ®istry_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 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#[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}