mod update_binary;
mod update_mechanic;
mod update_oauth;
mod update_providers;
mod update_skills;
use std::collections::HashMap;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use roboticus_core::home_dir;
use super::{colors, heading, icons};
use crate::cli::{CRT_DRAW_MS, theme};
pub(crate) const DEFAULT_REGISTRY_URL: &str = "https://roboticus.ai/registry/manifest.json";
const CRATES_IO_API: &str = "https://crates.io/api/v1/crates/roboticus";
const CRATE_NAME: &str = "roboticus";
const RELEASE_BASE_URL: &str = "https://github.com/robot-accomplice/roboticus/releases/download";
const GITHUB_RELEASES_API: &str =
"https://api.github.com/repos/robot-accomplice/roboticus/releases?per_page=100";
pub(crate) use update_binary::check_binary_version;
pub use update_binary::cleanup_old_binary;
pub use update_binary::cmd_update_binary;
pub use update_providers::cmd_update_providers;
pub(crate) use update_skills::apply_multi_registry_skills_update;
pub use update_skills::cmd_update_skills;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistryManifest {
pub version: String,
pub packs: Packs,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Packs {
pub providers: ProviderPack,
pub skills: SkillPack,
#[serde(default)]
pub plugins: Option<roboticus_plugin_sdk::catalog::PluginCatalog>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderPack {
pub sha256: String,
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillPack {
pub sha256: Option<String>,
pub path: String,
pub files: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UpdateState {
pub binary_version: String,
pub last_check: String,
pub registry_url: String,
pub installed_content: InstalledContent,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct InstalledContent {
pub providers: Option<ContentRecord>,
pub skills: Option<SkillsRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentRecord {
pub version: String,
pub sha256: String,
pub installed_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillsRecord {
pub version: String,
pub files: HashMap<String, String>,
pub installed_at: String,
}
impl UpdateState {
pub fn load() -> Self {
let path = state_path();
if path.exists() {
match std::fs::read_to_string(&path) {
Ok(content) => serde_json::from_str(&content)
.inspect_err(|e| tracing::warn!(error = %e, "corrupted update state file, resetting to default"))
.unwrap_or_default(),
Err(e) => {
tracing::warn!(error = %e, "failed to read update state file, resetting to default");
Self::default()
}
}
} else {
Self::default()
}
}
pub fn save(&self) -> io::Result<()> {
let path = state_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(self).map_err(io::Error::other)?;
std::fs::write(&path, json)
}
}
fn state_path() -> PathBuf {
home_dir().join(".roboticus").join("update_state.json")
}
fn roboticus_home() -> PathBuf {
home_dir().join(".roboticus")
}
fn now_iso() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
fn is_safe_skill_path(base_dir: &Path, filename: &str) -> bool {
if filename.contains("..") || Path::new(filename).is_absolute() {
return false;
}
let effective_base = base_dir.canonicalize().unwrap_or_else(|_| {
base_dir.components().fold(PathBuf::new(), |mut acc, c| {
acc.push(c);
acc
})
});
let joined = effective_base.join(filename);
let normalized: PathBuf = joined.components().fold(PathBuf::new(), |mut acc, c| {
match c {
std::path::Component::ParentDir => {
acc.pop();
}
other => acc.push(other),
}
acc
});
normalized.starts_with(&effective_base)
}
pub fn file_sha256(path: &Path) -> io::Result<String> {
let bytes = std::fs::read(path)?;
let hash = Sha256::digest(&bytes);
Ok(hex::encode(hash))
}
pub fn bytes_sha256(data: &[u8]) -> String {
let hash = Sha256::digest(data);
hex::encode(hash)
}
pub(crate) fn resolve_registry_url(cli_override: Option<&str>, config_path: &str) -> String {
if let Some(url) = cli_override {
return url.to_string();
}
if let Ok(val) = std::env::var("ROBOTICUS_REGISTRY_URL")
&& !val.is_empty()
{
return val;
}
if let Ok(content) = std::fs::read_to_string(config_path)
&& let Ok(config) = content.parse::<toml::Value>()
&& let Some(url) = config
.get("update")
.and_then(|u| u.get("registry_url"))
.and_then(|v| v.as_str())
&& !url.is_empty()
{
return url.to_string();
}
DEFAULT_REGISTRY_URL.to_string()
}
pub(crate) fn registry_base_url(manifest_url: &str) -> String {
if let Some(pos) = manifest_url.rfind('/') {
manifest_url[..pos].to_string()
} else {
manifest_url.to_string()
}
}
fn confirm_action(prompt: &str, default_yes: bool) -> bool {
let hint = if default_yes { "[Y/n]" } else { "[y/N]" };
print!(" {prompt} {hint} ");
io::stdout().flush().ok();
let mut input = String::new();
if io::stdin().read_line(&mut input).is_err() {
return default_yes;
}
let answer = input.trim().to_lowercase();
if answer.is_empty() {
return default_yes;
}
matches!(answer.as_str(), "y" | "yes")
}
fn confirm_overwrite(filename: &str) -> OverwriteChoice {
let (_, _, _, _, YELLOW, _, _, RESET, _) = colors();
print!(" Overwrite {YELLOW}{filename}{RESET}? [y/N/backup] ");
io::stdout().flush().ok();
let mut input = String::new();
if io::stdin().read_line(&mut input).is_err() {
return OverwriteChoice::Skip;
}
match input.trim().to_lowercase().as_str() {
"y" | "yes" => OverwriteChoice::Overwrite,
"b" | "backup" => OverwriteChoice::Backup,
_ => OverwriteChoice::Skip,
}
}
#[derive(Debug, PartialEq)]
enum OverwriteChoice {
Overwrite,
Backup,
Skip,
}
fn http_client() -> Result<reqwest::Client, Box<dyn std::error::Error>> {
Ok(reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.user_agent(format!("roboticus/{}", env!("CARGO_PKG_VERSION")))
.build()?)
}
pub type HygieneFn = Box<
dyn Fn(&str) -> Result<Option<(u64, u64, u64, u64)>, Box<dyn std::error::Error>> + Send + Sync,
>;
pub type DaemonOps = Box<dyn Fn() -> Result<(), Box<dyn std::error::Error>> + Send + Sync>;
pub struct DaemonCallbacks {
pub is_installed: Box<dyn Fn() -> bool + Send + Sync>,
pub restart: DaemonOps,
}
fn run_oauth_storage_maintenance() {
update_oauth::run_oauth_storage_maintenance();
}
fn run_mechanic_checks_maintenance(config_path: &str, hygiene_fn: Option<&HygieneFn>) {
update_mechanic::run_mechanic_checks_maintenance(config_path, hygiene_fn);
}
pub fn diff_lines(old: &str, new: &str) -> Vec<DiffLine> {
let old_lines: Vec<&str> = old.lines().collect();
let new_lines: Vec<&str> = new.lines().collect();
let mut result = Vec::new();
let max = old_lines.len().max(new_lines.len());
for i in 0..max {
match (old_lines.get(i), new_lines.get(i)) {
(Some(o), Some(n)) if o == n => {
result.push(DiffLine::Same((*o).to_string()));
}
(Some(o), Some(n)) => {
result.push(DiffLine::Removed((*o).to_string()));
result.push(DiffLine::Added((*n).to_string()));
}
(Some(o), None) => {
result.push(DiffLine::Removed((*o).to_string()));
}
(None, Some(n)) => {
result.push(DiffLine::Added((*n).to_string()));
}
(None, None) => {}
}
}
result
}
#[derive(Debug, PartialEq)]
pub enum DiffLine {
Same(String),
Added(String),
Removed(String),
}
fn print_diff(old: &str, new: &str) {
let (DIM, _, _, GREEN, _, RED, _, RESET, _) = colors();
let lines = diff_lines(old, new);
let changes: Vec<&DiffLine> = lines
.iter()
.filter(|l| !matches!(l, DiffLine::Same(_)))
.collect();
if changes.is_empty() {
println!(" {DIM}(no changes){RESET}");
return;
}
for line in &changes {
match line {
DiffLine::Removed(s) => println!(" {RED}- {s}{RESET}"),
DiffLine::Added(s) => println!(" {GREEN}+ {s}{RESET}"),
DiffLine::Same(_) => {}
}
}
}
pub(crate) fn is_newer(remote: &str, local: &str) -> bool {
update_binary::parse_semver(remote) > update_binary::parse_semver(local)
}
pub(crate) async fn fetch_manifest(
client: &reqwest::Client,
registry_url: &str,
) -> Result<RegistryManifest, Box<dyn std::error::Error>> {
let resp = client.get(registry_url).send().await?;
if !resp.status().is_success() {
return Err(format!("Registry returned HTTP {}", resp.status()).into());
}
let manifest: RegistryManifest = resp.json().await?;
Ok(manifest)
}
async fn fetch_file(
client: &reqwest::Client,
base_url: &str,
relative_path: &str,
) -> Result<String, Box<dyn std::error::Error>> {
let url = format!("{base_url}/{relative_path}");
let resp = client.get(&url).send().await?;
if !resp.status().is_success() {
return Err(format!("Failed to fetch {relative_path}: HTTP {}", resp.status()).into());
}
Ok(resp.text().await?)
}
pub async fn cmd_update_check(
channel: &str,
registry_url_override: Option<&str>,
config_path: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let (DIM, BOLD, _, GREEN, _, _, _, RESET, MONO) = colors();
let (OK, _, WARN, _, _) = icons();
heading("Update Check");
let current = env!("CARGO_PKG_VERSION");
let client = http_client()?;
println!("\n {BOLD}Binary{RESET}");
println!(" Current: {MONO}v{current}{RESET}");
println!(" Channel: {DIM}{channel}{RESET}");
match check_binary_version(&client).await? {
Some(latest) => {
if is_newer(&latest, current) {
println!(" Latest: {GREEN}v{latest}{RESET} (update available)");
} else {
println!(" {OK} Up to date (v{current})");
}
}
None => println!(" {WARN} Could not check crates.io"),
}
let registries: Vec<roboticus_core::config::RegistrySource> =
if let Some(url) = registry_url_override {
vec![roboticus_core::config::RegistrySource {
name: "cli-override".into(),
url: url.to_string(),
priority: 100,
enabled: true,
}]
} else {
std::fs::read_to_string(config_path)
.ok()
.and_then(|raw| {
let table: toml::Value = toml::from_str(&raw).ok()?;
let update_val = table.get("update")?.clone();
let update_cfg: roboticus_core::config::UpdateConfig =
update_val.try_into().ok()?;
Some(update_cfg.resolve_registries())
})
.unwrap_or_else(|| {
let url = resolve_registry_url(None, config_path);
vec![roboticus_core::config::RegistrySource {
name: "default".into(),
url,
priority: 50,
enabled: true,
}]
})
};
let enabled: Vec<_> = registries.iter().filter(|r| r.enabled).collect();
println!("\n {BOLD}Content Packs{RESET}");
if enabled.len() == 1 {
println!(" Registry: {DIM}{}{RESET}", enabled[0].url);
} else {
for reg in &enabled {
println!(" Registry: {DIM}{}{RESET} ({})", reg.url, reg.name);
}
}
let primary_url = enabled
.first()
.map(|r| r.url.as_str())
.unwrap_or(DEFAULT_REGISTRY_URL);
match fetch_manifest(&client, primary_url).await {
Ok(manifest) => {
let state = UpdateState::load();
println!(" Pack version: {MONO}v{}{RESET}", manifest.version);
let providers_path = update_providers::providers_local_path(config_path);
if providers_path.exists() {
let local_hash = file_sha256(&providers_path).unwrap_or_default();
if local_hash == manifest.packs.providers.sha256 {
println!(" {OK} Providers: up to date");
} else {
println!(" {GREEN}\u{25b6}{RESET} Providers: update available");
}
} else {
println!(" {GREEN}+{RESET} Providers: new (not yet installed locally)");
}
let skills_dir = update_skills::skills_local_dir(config_path);
let mut skills_new = 0u32;
let mut skills_changed = 0u32;
let mut skills_ok = 0u32;
for (filename, remote_hash) in &manifest.packs.skills.files {
let local_file = skills_dir.join(filename);
if !local_file.exists() {
skills_new += 1;
} else {
let local_hash = file_sha256(&local_file).unwrap_or_default();
if local_hash == *remote_hash {
skills_ok += 1;
} else {
skills_changed += 1;
}
}
}
if skills_new == 0 && skills_changed == 0 {
println!(" {OK} Skills: up to date ({skills_ok} files)");
} else {
println!(
" {GREEN}\u{25b6}{RESET} Skills: {skills_new} new, {skills_changed} changed, {skills_ok} current"
);
}
for reg in enabled.iter().skip(1) {
match fetch_manifest(&client, ®.url).await {
Ok(m) => println!(" {OK} {}: reachable (v{})", reg.name, m.version),
Err(e) => println!(" {WARN} {}: unreachable ({e})", reg.name),
}
}
if let Some(ref providers) = state.installed_content.providers {
println!(
"\n {DIM}Last content update: {}{RESET}",
providers.installed_at
);
}
}
Err(e) => {
println!(" {WARN} Could not reach registry: {e}");
}
}
println!();
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn cmd_update_all(
channel: &str,
yes: bool,
no_restart: bool,
force: bool,
registry_url_override: Option<&str>,
config_path: &str,
hygiene_fn: Option<&HygieneFn>,
daemon_cbs: Option<&DaemonCallbacks>,
) -> Result<(), Box<dyn std::error::Error>> {
let (_, BOLD, _, _, _, _, _, RESET, _) = colors();
let (OK, _, WARN, DETAIL, _) = icons();
heading("Roboticus Update");
println!();
println!(" {BOLD}IMPORTANT — PLEASE READ{RESET}");
println!();
println!(" Roboticus is an autonomous AI agent that can execute actions,");
println!(" interact with external services, and manage digital assets");
println!(" including cryptocurrency wallets and on-chain transactions.");
println!();
println!(" THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND.");
println!(" The developers and contributors bear {BOLD}no responsibility{RESET} for:");
println!();
println!(" - Actions taken by the agent, whether intended or unintended");
println!(" - Loss of funds, income, cryptocurrency, or other digital assets");
println!(" - Security vulnerabilities, compromises, or unauthorized access");
println!(" - Damages arising from the agent's use, misuse, or malfunction");
println!(" - Any financial, legal, or operational consequences whatsoever");
println!();
println!(" By proceeding, you acknowledge that you use Roboticus entirely");
println!(" at your own risk and accept full responsibility for its operation.");
println!();
if !yes && !confirm_action("I understand and accept these terms", true) {
println!("\n Update cancelled.\n");
return Ok(());
}
let binary_updated = update_binary::apply_binary_update(yes, "download", force).await?;
let registry_url = resolve_registry_url(registry_url_override, config_path);
update_providers::apply_providers_update(yes, ®istry_url, config_path).await?;
apply_multi_registry_skills_update(yes, registry_url_override, config_path).await?;
run_oauth_storage_maintenance();
run_mechanic_checks_maintenance(config_path, hygiene_fn);
if let Err(e) = update_mechanic::apply_removed_legacy_config_migration(config_path) {
println!(" {WARN} Legacy config migration skipped: {e}");
}
if let Err(e) = update_mechanic::apply_security_config_migration(config_path) {
println!(" {WARN} Security config migration skipped: {e}");
}
let workspace_path = std::path::Path::new(config_path)
.parent()
.unwrap_or(std::path::Path::new("."))
.join("workspace");
if workspace_path.exists()
&& let Err(e) = update_mechanic::migrate_firmware_rules(&workspace_path)
{
println!(" {WARN} FIRMWARE.toml rules migration skipped: {e}");
}
if let Some(daemon) = daemon_cbs {
if binary_updated && !no_restart && (daemon.is_installed)() {
println!("\n Restarting daemon to apply update...");
match (daemon.restart)() {
Ok(()) => println!(" {OK} Daemon restarted"),
Err(e) => {
println!(" {WARN} Could not restart daemon: {e}");
println!(" {DETAIL} Run `roboticus daemon restart` manually.");
}
}
} else if binary_updated && no_restart {
println!("\n {DETAIL} Skipping daemon restart (--no-restart).");
println!(" {DETAIL} Run `roboticus daemon restart` to apply the update.");
}
}
println!("\n {BOLD}Update complete.{RESET}\n");
Ok(())
}
#[cfg(test)]
pub(crate) mod tests_support {
use super::bytes_sha256;
use axum::{Json, Router, extract::State, routing::get};
use tokio::net::TcpListener;
#[derive(Clone)]
pub(crate) struct MockRegistry {
manifest: String,
providers: String,
skill_payload: String,
}
pub(crate) async fn start_mock_registry(
providers: String,
skill_draft: String,
) -> (String, tokio::task::JoinHandle<()>) {
let providers_hash = bytes_sha256(providers.as_bytes());
let draft_hash = bytes_sha256(skill_draft.as_bytes());
let manifest = serde_json::json!({
"version": "0.8.0",
"packs": {
"providers": {
"sha256": providers_hash,
"path": "registry/providers.toml"
},
"skills": {
"sha256": null,
"path": "registry/skills/",
"files": {
"draft.md": draft_hash
}
}
}
})
.to_string();
let state = MockRegistry {
manifest,
providers,
skill_payload: skill_draft,
};
async fn manifest_h(State(st): State<MockRegistry>) -> Json<serde_json::Value> {
Json(serde_json::from_str(&st.manifest).unwrap())
}
async fn providers_h(State(st): State<MockRegistry>) -> String {
st.providers
}
async fn skill_h(State(st): State<MockRegistry>) -> String {
st.skill_payload
}
let app = Router::new()
.route("/manifest.json", get(manifest_h))
.route("/registry/providers.toml", get(providers_h))
.route("/registry/skills/draft.md", get(skill_h))
.with_state(state);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
(
format!("http://{}:{}/manifest.json", addr.ip(), addr.port()),
handle,
)
}
pub(crate) async fn start_namespaced_mock_registry(
registry_name: &str,
skill_filename: &str,
skill_content: String,
) -> (String, tokio::task::JoinHandle<()>) {
let content_hash = bytes_sha256(skill_content.as_bytes());
let manifest = serde_json::json!({
"version": "1.0.0",
"packs": {
"providers": {
"sha256": "unused",
"path": "registry/providers.toml"
},
"skills": {
"sha256": null,
"path": format!("registry/{registry_name}/"),
"files": {
skill_filename: content_hash
}
}
}
})
.to_string();
let skill_route = format!("/registry/{registry_name}/{skill_filename}");
let state = MockRegistry {
manifest,
providers: String::new(),
skill_payload: skill_content,
};
async fn manifest_h(State(st): State<MockRegistry>) -> Json<serde_json::Value> {
Json(serde_json::from_str(&st.manifest).unwrap())
}
async fn providers_h(State(st): State<MockRegistry>) -> String {
st.providers.clone()
}
async fn skill_h(State(st): State<MockRegistry>) -> String {
st.skill_payload.clone()
}
let app = Router::new()
.route("/manifest.json", get(manifest_h))
.route("/registry/providers.toml", get(providers_h))
.route(&skill_route, get(skill_h))
.with_state(state);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
(
format!("http://{}:{}/manifest.json", addr.ip(), addr.port()),
handle,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn update_state_serde_roundtrip() {
let state = UpdateState {
binary_version: "0.2.0".into(),
last_check: "2026-02-20T00:00:00Z".into(),
registry_url: DEFAULT_REGISTRY_URL.into(),
installed_content: InstalledContent {
providers: Some(ContentRecord {
version: "0.2.0".into(),
sha256: "abc123".into(),
installed_at: "2026-02-20T00:00:00Z".into(),
}),
skills: Some(SkillsRecord {
version: "0.2.0".into(),
files: {
let mut m = HashMap::new();
m.insert("draft.md".into(), "hash1".into());
m.insert("rust.md".into(), "hash2".into());
m
},
installed_at: "2026-02-20T00:00:00Z".into(),
}),
},
};
let json = serde_json::to_string_pretty(&state).unwrap();
let parsed: UpdateState = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.binary_version, "0.2.0");
assert_eq!(
parsed.installed_content.providers.as_ref().unwrap().sha256,
"abc123"
);
assert_eq!(
parsed
.installed_content
.skills
.as_ref()
.unwrap()
.files
.len(),
2
);
}
#[test]
fn update_state_default_is_empty() {
let state = UpdateState::default();
assert_eq!(state.binary_version, "");
assert!(state.installed_content.providers.is_none());
assert!(state.installed_content.skills.is_none());
}
#[test]
fn file_sha256_computes_correctly() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.txt");
std::fs::write(&path, "hello world\n").unwrap();
let hash = file_sha256(&path).unwrap();
assert_eq!(hash.len(), 64);
let expected = bytes_sha256(b"hello world\n");
assert_eq!(hash, expected);
}
#[test]
fn file_sha256_error_on_missing() {
let result = file_sha256(Path::new("/nonexistent/file.txt"));
assert!(result.is_err());
}
#[test]
fn bytes_sha256_deterministic() {
let h1 = bytes_sha256(b"test data");
let h2 = bytes_sha256(b"test data");
assert_eq!(h1, h2);
assert_ne!(bytes_sha256(b"different"), h1);
}
#[test]
fn modification_detection_unmodified() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("providers.toml");
let content = "[providers.openai]\nurl = \"https://api.openai.com\"\n";
std::fs::write(&path, content).unwrap();
let installed_hash = bytes_sha256(content.as_bytes());
let current_hash = file_sha256(&path).unwrap();
assert_eq!(current_hash, installed_hash);
}
#[test]
fn modification_detection_modified() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("providers.toml");
let original = "[providers.openai]\nurl = \"https://api.openai.com\"\n";
let modified = "[providers.openai]\nurl = \"https://custom.endpoint.com\"\n";
let installed_hash = bytes_sha256(original.as_bytes());
std::fs::write(&path, modified).unwrap();
let current_hash = file_sha256(&path).unwrap();
assert_ne!(current_hash, installed_hash);
}
#[test]
fn manifest_parse() {
let json = r#"{
"version": "0.2.0",
"packs": {
"providers": { "sha256": "abc123", "path": "registry/providers.toml" },
"skills": {
"sha256": null,
"path": "registry/skills/",
"files": {
"draft.md": "hash1",
"rust.md": "hash2"
}
}
}
}"#;
let manifest: RegistryManifest = serde_json::from_str(json).unwrap();
assert_eq!(manifest.version, "0.2.0");
assert_eq!(manifest.packs.providers.sha256, "abc123");
assert_eq!(manifest.packs.skills.files.len(), 2);
assert_eq!(manifest.packs.skills.files["draft.md"], "hash1");
}
#[test]
fn diff_lines_identical() {
let result = diff_lines("a\nb\nc", "a\nb\nc");
assert!(result.iter().all(|l| matches!(l, DiffLine::Same(_))));
}
#[test]
fn diff_lines_changed() {
let result = diff_lines("a\nb\nc", "a\nB\nc");
assert_eq!(result.len(), 4);
assert_eq!(result[0], DiffLine::Same("a".into()));
assert_eq!(result[1], DiffLine::Removed("b".into()));
assert_eq!(result[2], DiffLine::Added("B".into()));
assert_eq!(result[3], DiffLine::Same("c".into()));
}
#[test]
fn diff_lines_added() {
let result = diff_lines("a\nb", "a\nb\nc");
assert_eq!(result.len(), 3);
assert_eq!(result[2], DiffLine::Added("c".into()));
}
#[test]
fn diff_lines_removed() {
let result = diff_lines("a\nb\nc", "a\nb");
assert_eq!(result.len(), 3);
assert_eq!(result[2], DiffLine::Removed("c".into()));
}
#[test]
fn diff_lines_empty_to_content() {
let result = diff_lines("", "a\nb");
assert!(result.iter().any(|l| matches!(l, DiffLine::Added(_))));
}
#[test]
fn registry_base_url_strips_filename() {
let url = "https://roboticus.ai/registry/manifest.json";
assert_eq!(registry_base_url(url), "https://roboticus.ai/registry");
}
#[test]
fn resolve_registry_url_cli_override() {
let result = resolve_registry_url(
Some("https://custom.registry/manifest.json"),
"nonexistent.toml",
);
assert_eq!(result, "https://custom.registry/manifest.json");
}
#[test]
fn resolve_registry_url_default() {
let result = resolve_registry_url(None, "nonexistent.toml");
assert_eq!(result, DEFAULT_REGISTRY_URL);
}
#[test]
fn resolve_registry_url_from_config() {
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("roboticus.toml");
std::fs::write(
&config,
"[update]\nregistry_url = \"https://my.registry/manifest.json\"\n",
)
.unwrap();
let result = resolve_registry_url(None, config.to_str().unwrap());
assert_eq!(result, "https://my.registry/manifest.json");
}
#[test]
fn update_state_save_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("update_state.json");
let state = UpdateState {
binary_version: "0.3.0".into(),
last_check: "2026-03-01T12:00:00Z".into(),
registry_url: "https://example.com/manifest.json".into(),
installed_content: InstalledContent::default(),
};
let json = serde_json::to_string_pretty(&state).unwrap();
std::fs::write(&path, &json).unwrap();
let loaded: UpdateState =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(loaded.binary_version, "0.3.0");
assert_eq!(loaded.registry_url, "https://example.com/manifest.json");
}
#[test]
fn bytes_sha256_empty_input() {
let hash = bytes_sha256(b"");
assert_eq!(hash.len(), 64);
assert_eq!(
hash,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn diff_lines_both_empty() {
let result = diff_lines("", "");
assert!(result.is_empty() || result.iter().all(|l| matches!(l, DiffLine::Same(_))));
}
#[test]
fn diff_lines_content_to_empty() {
let result = diff_lines("a\nb", "");
assert!(result.iter().any(|l| matches!(l, DiffLine::Removed(_))));
}
#[test]
fn registry_base_url_no_slash() {
assert_eq!(registry_base_url("manifest.json"), "manifest.json");
}
#[test]
fn registry_base_url_nested() {
assert_eq!(
registry_base_url("https://cdn.example.com/v1/registry/manifest.json"),
"https://cdn.example.com/v1/registry"
);
}
#[test]
fn installed_content_default_is_empty() {
let ic = InstalledContent::default();
assert!(ic.skills.is_none());
assert!(ic.providers.is_none());
}
}