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::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#[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#[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
137fn 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
247pub type HygieneFn = Box<
251 dyn Fn(&str) -> Result<Option<(u64, u64, u64, u64)>, Box<dyn std::error::Error>> + Send + Sync,
252>;
253
254pub type DaemonOps = Box<dyn Fn() -> Result<(), Box<dyn std::error::Error>> + Send + Sync>;
256
257pub struct DaemonCallbacks {
259 pub is_installed: Box<dyn Fn() -> bool + Send + Sync>,
260 pub restart: DaemonOps,
261}
262
263fn 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
273pub 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
335pub(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
362pub 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 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 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, ®.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 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, ®istry_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 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#[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}