custom.rs
1 use crate::{Provider, UpdateSummary}; 2 use anyhow::{Context, Result}; 3 use regex::Regex; 4 use serde::Deserialize; 5 use std::path::PathBuf; 6 use std::sync::LazyLock; 7 8 pub fn providers_dir() -> Result<PathBuf> { 9 let config = dirs::config_dir().context("Could not determine config directory")?; 10 Ok(config.join("bupdate").join("providers")) 11 } 12 13 pub fn init_providers_dir() -> Result<PathBuf> { 14 let dir = providers_dir()?; 15 std::fs::create_dir_all(&dir)?; 16 17 let readme = dir.join("README.md"); 18 if !readme.exists() { 19 std::fs::write(&readme, EXAMPLE_README)?; 20 } 21 22 let example_toml = dir.join("example.toml.disabled"); 23 if !example_toml.exists() { 24 std::fs::write(&example_toml, EXAMPLE_TOML)?; 25 } 26 27 let example_sh = dir.join("example.sh.disabled"); 28 if !example_sh.exists() { 29 std::fs::write(&example_sh, EXAMPLE_SHELL)?; 30 } 31 32 let example_rs = dir.join("example.rs.disabled"); 33 if !example_rs.exists() { 34 std::fs::write(&example_rs, EXAMPLE_RUST)?; 35 } 36 37 Ok(dir) 38 } 39 40 const EXAMPLE_TOML: &str = r#"name = "my-package-manager" 41 command = "my-package-manager upgrade --all --yes" 42 needs_root = false 43 check = "my-package-manager --version" 44 45 [[progress]] 46 contains = "Downloading" 47 message = "downloading" 48 49 [[progress]] 50 regex = '(\d+)/(\d+)\s+(\S+)' 51 message = "[{1}/{2}] {3}" 52 53 [summary] 54 upgraded = 'upgraded (\d+)' 55 installed = 'installed (\d+)' 56 removed = 'removed (\d+)' 57 "#; 58 59 const EXAMPLE_SHELL: &str = r#"#!/bin/bash 60 # bupdate:name = my-script 61 # bupdate:needs_root = false 62 # bupdate:check = which my-tool 63 64 set -euo pipefail 65 66 my-tool sync 67 my-tool upgrade --all 68 "#; 69 70 const EXAMPLE_RUST: &str = r#"use crate::{Provider, UpdateSummary}; 71 use regex::Regex; 72 use std::sync::LazyLock; 73 74 static RE_OUTDATED: LazyLock<Regex> = 75 LazyLock::new(|| Regex::new(r"^(\S+)\s+\S+\s+(\S+)\s").unwrap()); 76 77 pub struct MyTool; 78 79 impl Provider for MyTool { 80 fn name(&self) -> &str { "my-tool" } 81 82 fn is_available(&self) -> bool { 83 std::process::Command::new("my-tool") 84 .arg("--version") 85 .stdout(std::process::Stdio::null()) 86 .stderr(std::process::Stdio::null()) 87 .status() 88 .map(|s| s.success()) 89 .unwrap_or(false) 90 } 91 92 fn needs_root(&self) -> bool { false } 93 94 fn get_command(&self) -> String { 95 "my-tool upgrade --all -y".into() 96 } 97 98 fn parse_progress(&self, line: &str) -> Option<String> { 99 if let Some(caps) = RE_OUTDATED.captures(line) { 100 return Some(format!("{} -> {}", &caps[1], &caps[2])); 101 } 102 None 103 } 104 105 fn parse_summary(&self, log: &str) -> UpdateSummary { 106 let upgraded = log.lines().filter(|l| RE_OUTDATED.is_match(l)).count(); 107 UpdateSummary { upgraded, ..Default::default() } 108 } 109 } 110 "#; 111 112 const EXAMPLE_README: &str = r#"# Custom Providers 113 114 Place custom provider files in this directory. 115 bupdate loads them automatically at startup alongside built-in providers. 116 117 Three formats are supported: TOML, Shell and Rust. 118 119 ## 1. TOML (declarative) 120 121 Create a `.toml` file: 122 123 name = "mypkg" 124 command = "mypkg update --yes" 125 needs_root = false 126 check = "mypkg --version" 127 128 [[progress]] 129 contains = "Downloading" 130 message = "downloading" 131 132 [[progress]] 133 regex = '(\d+)/(\d+)\s+(\S+)' 134 message = "[{1}/{2}] {3}" 135 136 [summary] 137 upgraded = 'upgraded (\d+)' 138 installed = 'installed (\d+)' 139 removed = 'removed (\d+)' 140 141 ## 2. Shell / Bash (scripted) 142 143 Create a `.sh` or `.bash` file: 144 145 #!/bin/bash 146 # bupdate:name = my-updater 147 # bupdate:needs_root = false 148 # bupdate:check = which my-tool 149 150 set -euo pipefail 151 my-tool sync 152 my-tool upgrade --all 153 154 ## 3. Rust (native format) 155 156 Create a `.rs` file using the same format as built-in providers: 157 158 use crate::{Provider, UpdateSummary}; 159 use regex::Regex; 160 use std::sync::LazyLock; 161 162 static RE_UPGRADED: LazyLock<Regex> = 163 LazyLock::new(|| Regex::new(r"^Upgrading (\S+)").unwrap()); 164 165 pub struct MyTool; 166 167 impl Provider for MyTool { 168 fn name(&self) -> &str { "my-tool" } 169 fn is_available(&self) -> bool { ... } 170 fn needs_root(&self) -> bool { false } 171 fn get_command(&self) -> String { "my-tool upgrade -y".into() } 172 fn parse_progress(&self, line: &str) -> Option<String> { ... } 173 fn parse_summary(&self, log: &str) -> UpdateSummary { ... } 174 } 175 176 bupdate parses the .rs source at startup — no compilation needed. 177 See the example.*.disabled files for complete examples. 178 Rename them (remove .disabled) to activate. 179 "#; 180 181 pub fn load_custom_providers() -> Vec<Box<dyn Provider>> { 182 let dir = match providers_dir() { 183 Ok(d) => d, 184 Err(_) => return vec![], 185 }; 186 187 if !dir.is_dir() { 188 return vec![]; 189 } 190 191 let mut providers: Vec<Box<dyn Provider>> = Vec::new(); 192 let mut entries: Vec<_> = match std::fs::read_dir(&dir) { 193 Ok(rd) => rd.filter_map(|e| e.ok()).collect(), 194 Err(_) => return vec![], 195 }; 196 entries.sort_by_key(|e| e.path()); 197 198 for entry in entries { 199 let path = entry.path(); 200 let ext = path.extension().and_then(|s| s.to_str()).unwrap_or(""); 201 match ext { 202 "toml" => { 203 if let Some(p) = TomlProvider::load(&path) { 204 providers.push(Box::new(p)); 205 } 206 } 207 "sh" | "bash" => { 208 if let Some(p) = ShellProvider::load(&path) { 209 providers.push(Box::new(p)); 210 } 211 } 212 "rs" => { 213 if let Some(p) = RustScriptProvider::load(&path) { 214 providers.push(Box::new(p)); 215 } 216 } 217 _ => {} 218 } 219 } 220 221 providers 222 } 223 224 #[derive(Deserialize)] 225 struct TomlDef { 226 name: String, 227 command: String, 228 #[serde(default = "default_true")] 229 needs_root: bool, 230 #[serde(default)] 231 check: String, 232 #[serde(default)] 233 progress: Vec<TomlPattern>, 234 #[serde(default)] 235 summary: TomlSummary, 236 } 237 238 fn default_true() -> bool { true } 239 240 #[derive(Deserialize, Default)] 241 struct TomlPattern { 242 #[serde(default)] 243 contains: String, 244 #[serde(default)] 245 regex: String, 246 #[serde(default)] 247 message: String, 248 } 249 250 #[derive(Deserialize, Default)] 251 struct TomlSummary { 252 #[serde(default)] 253 upgraded: String, 254 #[serde(default)] 255 installed: String, 256 #[serde(default)] 257 removed: String, 258 } 259 260 pub struct TomlProvider { 261 def: TomlDef, 262 progress_re: Vec<(Option<Regex>, String, String)>, 263 upgraded_re: Option<Regex>, 264 installed_re: Option<Regex>, 265 removed_re: Option<Regex>, 266 } 267 268 impl TomlProvider { 269 fn load(path: &std::path::Path) -> Option<Self> { 270 let content = std::fs::read_to_string(path).ok()?; 271 let def: TomlDef = toml::from_str(&content).ok()?; 272 273 let progress_re: Vec<(Option<Regex>, String, String)> = def 274 .progress 275 .iter() 276 .map(|p| { 277 let re = if p.regex.is_empty() { 278 None 279 } else { 280 Regex::new(&p.regex).ok() 281 }; 282 (re, p.contains.clone(), p.message.clone()) 283 }) 284 .collect(); 285 286 let upgraded_re = compile_opt(&def.summary.upgraded); 287 let installed_re = compile_opt(&def.summary.installed); 288 let removed_re = compile_opt(&def.summary.removed); 289 290 Some(Self { def, progress_re, upgraded_re, installed_re, removed_re }) 291 } 292 } 293 294 fn compile_opt(pattern: &str) -> Option<Regex> { 295 if pattern.is_empty() { 296 None 297 } else { 298 Regex::new(pattern).ok() 299 } 300 } 301 302 fn extract_count(re: &Option<Regex>, log: &str) -> usize { 303 let Some(re) = re else { return 0 }; 304 log.lines() 305 .filter_map(|l| re.captures(l)) 306 .filter_map(|c| c.get(1)) 307 .filter_map(|m| m.as_str().parse::<usize>().ok()) 308 .sum() 309 } 310 311 impl Provider for TomlProvider { 312 fn name(&self) -> &str { &self.def.name } 313 314 fn needs_root(&self) -> bool { self.def.needs_root } 315 316 fn is_available(&self) -> bool { 317 if self.def.check.is_empty() { 318 let bin = self.def.command.split_whitespace().next().unwrap_or(""); 319 return cmd_exists(bin); 320 } 321 std::process::Command::new("sh") 322 .args(["-c", &self.def.check]) 323 .stdout(std::process::Stdio::null()) 324 .stderr(std::process::Stdio::null()) 325 .status() 326 .map(|s| s.success()) 327 .unwrap_or(false) 328 } 329 330 fn get_command(&self) -> String { self.def.command.clone() } 331 332 fn parse_progress(&self, line: &str) -> Option<String> { 333 for (re, contains, message) in &self.progress_re { 334 if !contains.is_empty() && line.contains(contains.as_str()) { 335 return Some(message.clone()); 336 } 337 if let Some(re) = re { 338 if let Some(caps) = re.captures(line) { 339 let mut out = message.clone(); 340 for i in 1..caps.len() { 341 if let Some(m) = caps.get(i) { 342 out = out.replace(&format!("{{{}}}", i), m.as_str()); 343 } 344 } 345 return Some(out); 346 } 347 } 348 } 349 None 350 } 351 352 fn parse_summary(&self, log: &str) -> UpdateSummary { 353 UpdateSummary { 354 upgraded: extract_count(&self.upgraded_re, log), 355 installed: extract_count(&self.installed_re, log), 356 removed: extract_count(&self.removed_re, log), 357 ..Default::default() 358 } 359 } 360 } 361 362 pub struct ShellProvider { 363 provider_name: String, 364 script_path: String, 365 root: bool, 366 check_cmd: String, 367 } 368 369 impl ShellProvider { 370 fn load(path: &std::path::Path) -> Option<Self> { 371 let content = std::fs::read_to_string(path).ok()?; 372 let mut name = path 373 .file_stem() 374 .and_then(|s| s.to_str()) 375 .unwrap_or("custom") 376 .to_string(); 377 let mut root = false; 378 let mut check_cmd = String::new(); 379 380 for line in content.lines() { 381 let trimmed = line.trim(); 382 if let Some(val) = strip_header(trimmed, "name") { 383 name = val; 384 } else if let Some(val) = strip_header(trimmed, "needs_root") { 385 root = val == "true"; 386 } else if let Some(val) = strip_header(trimmed, "check") { 387 check_cmd = val; 388 } 389 } 390 391 let script_path = path.to_string_lossy().into_owned(); 392 Some(Self { provider_name: name, script_path, root, check_cmd }) 393 } 394 } 395 396 fn strip_header(line: &str, key: &str) -> Option<String> { 397 let tag = line.strip_prefix('#')?.trim(); 398 let tag = tag.strip_prefix("bupdate:")?.trim(); 399 let rest = tag.strip_prefix(key)?.trim(); 400 let rest = rest.strip_prefix('=')?.trim(); 401 Some(rest.to_string()) 402 } 403 404 impl Provider for ShellProvider { 405 fn name(&self) -> &str { &self.provider_name } 406 407 fn needs_root(&self) -> bool { self.root } 408 409 fn is_available(&self) -> bool { 410 if self.check_cmd.is_empty() { 411 return std::path::Path::new(&self.script_path).exists(); 412 } 413 std::process::Command::new("sh") 414 .args(["-c", &self.check_cmd]) 415 .stdout(std::process::Stdio::null()) 416 .stderr(std::process::Stdio::null()) 417 .status() 418 .map(|s| s.success()) 419 .unwrap_or(false) 420 } 421 422 fn get_command(&self) -> String { 423 format!("bash {}", self.script_path) 424 } 425 } 426 427 pub struct RustScriptProvider { 428 provider_name: String, 429 command: String, 430 root: bool, 431 check_bin: String, 432 progress_patterns: Vec<(Regex, String)>, 433 summary_upgraded_re: Option<Regex>, 434 summary_installed_re: Option<Regex>, 435 summary_removed_re: Option<Regex>, 436 } 437 438 impl RustScriptProvider { 439 fn load(path: &std::path::Path) -> Option<Self> { 440 let src = std::fs::read_to_string(path).ok()?; 441 442 let name = extract_fn_str_return(&src, "name")?; 443 let command = extract_fn_str_return(&src, "get_command")?; 444 let root = extract_fn_bool_return(&src, "needs_root").unwrap_or(true); 445 let check_bin = extract_is_available_bin(&src).unwrap_or_default(); 446 447 let progress_patterns = extract_progress_patterns(&src); 448 let (up_re, inst_re, rem_re) = extract_summary_patterns(&src); 449 450 Some(Self { 451 provider_name: name, 452 command, 453 root, 454 check_bin, 455 progress_patterns, 456 summary_upgraded_re: up_re, 457 summary_installed_re: inst_re, 458 summary_removed_re: rem_re, 459 }) 460 } 461 } 462 463 impl Provider for RustScriptProvider { 464 fn name(&self) -> &str { &self.provider_name } 465 466 fn needs_root(&self) -> bool { self.root } 467 468 fn is_available(&self) -> bool { 469 if self.check_bin.is_empty() { 470 let bin = self.command.split_whitespace().next().unwrap_or(""); 471 return cmd_exists(bin); 472 } 473 cmd_exists(&self.check_bin) 474 } 475 476 fn get_command(&self) -> String { self.command.clone() } 477 478 fn parse_progress(&self, line: &str) -> Option<String> { 479 for (re, template) in &self.progress_patterns { 480 if let Some(caps) = re.captures(line) { 481 let mut out = template.clone(); 482 for i in 1..caps.len() { 483 if let Some(m) = caps.get(i) { 484 out = out.replace(&format!("{{{}}}", i), m.as_str()); 485 } 486 } 487 return Some(out); 488 } 489 } 490 None 491 } 492 493 fn parse_summary(&self, log: &str) -> UpdateSummary { 494 UpdateSummary { 495 upgraded: extract_count(&self.summary_upgraded_re, log), 496 installed: extract_count(&self.summary_installed_re, log), 497 removed: extract_count(&self.summary_removed_re, log), 498 ..Default::default() 499 } 500 } 501 } 502 503 fn extract_fn_str_return(src: &str, fn_name: &str) -> Option<String> { 504 static RE_FN: LazyLock<Regex> = LazyLock::new(|| { 505 Regex::new(r#"fn\s+(\w+)\s*\([^)]*\)\s*->[^{]*\{\s*"([^"]+)""#).unwrap() 506 }); 507 static RE_FN_INTO: LazyLock<Regex> = LazyLock::new(|| { 508 Regex::new(r#"fn\s+(\w+)\s*\([^)]*\)\s*->[^{]*\{\s*(?:return\s+)?"([^"]+)"\.into\(\)"#).unwrap() 509 }); 510 for re in [&*RE_FN, &*RE_FN_INTO] { 511 for caps in re.captures_iter(src) { 512 if &caps[1] == fn_name { 513 return Some(caps[2].to_string()); 514 } 515 } 516 } 517 None 518 } 519 520 fn extract_fn_bool_return(src: &str, fn_name: &str) -> Option<bool> { 521 static RE_BOOL: LazyLock<Regex> = LazyLock::new(|| { 522 Regex::new(r#"fn\s+(\w+)\s*\([^)]*\)\s*->[^{]*\{\s*(true|false)\s*\}"#).unwrap() 523 }); 524 for caps in RE_BOOL.captures_iter(src) { 525 if &caps[1] == fn_name { 526 return Some(&caps[2] == "true"); 527 } 528 } 529 None 530 } 531 532 fn extract_is_available_bin(src: &str) -> Option<String> { 533 static RE_BIN: LazyLock<Regex> = LazyLock::new(|| { 534 Regex::new(r#"fn\s+is_available\b[^{]*\{[^}]*Command::new\("([^"]+)"\)"#).unwrap() 535 }); 536 RE_BIN.captures(src).map(|c| c[1].to_string()) 537 } 538 539 fn extract_progress_patterns(src: &str) -> Vec<(Regex, String)> { 540 static RE_PROGRESS_USE: LazyLock<Regex> = LazyLock::new(|| { 541 Regex::new(r#"(\w+)\.captures\(line\)\s*\)\s*\{[^}]*format!\("([^"]+)""#).unwrap() 542 }); 543 544 let statics = extract_statics(src); 545 546 let mut result = Vec::new(); 547 for caps in RE_PROGRESS_USE.captures_iter(src) { 548 let var_name = &caps[1]; 549 let fmt_template = caps[2].to_string(); 550 if let Some((_, pattern)) = statics.iter().find(|(n, _)| n == var_name) { 551 if let Ok(re) = Regex::new(pattern) { 552 let template = fmt_template 553 .replace("{}", "{1}") 554 .replace("&caps[1]", "{1}") 555 .replace("&caps[2]", "{2}") 556 .replace("&caps[3]", "{3}"); 557 result.push((re, template)); 558 } 559 } 560 } 561 result 562 } 563 564 fn extract_summary_patterns(src: &str) -> (Option<Regex>, Option<Regex>, Option<Regex>) { 565 let statics = extract_statics(src); 566 567 let find_re = |keywords: &[&str]| -> Option<Regex> { 568 for (name, pattern) in &statics { 569 let lower = name.to_lowercase(); 570 if keywords.iter().any(|k| lower.contains(k)) { 571 return Regex::new(pattern).ok(); 572 } 573 } 574 None 575 }; 576 577 let upgraded = find_re(&["upgrad", "updated", "refreshed"]); 578 let installed = find_re(&["install", "added"]); 579 let removed = find_re(&["remov", "delet", "uninstall"]); 580 581 (upgraded, installed, removed) 582 } 583 584 fn extract_statics(src: &str) -> Vec<(String, String)> { 585 static RE_STATIC: LazyLock<Regex> = LazyLock::new(|| { 586 Regex::new(r#"static\s+(\w+)\s*:.*Regex.*Regex::new\(r"([^"]+)"\)"#).unwrap() 587 }); 588 RE_STATIC 589 .captures_iter(src) 590 .map(|c| (c[1].to_string(), c[2].to_string())) 591 .collect() 592 } 593 594 fn cmd_exists(bin: &str) -> bool { 595 if bin.is_empty() { 596 return false; 597 } 598 std::process::Command::new("which") 599 .arg(bin) 600 .stdout(std::process::Stdio::null()) 601 .stderr(std::process::Stdio::null()) 602 .status() 603 .map(|s| s.success()) 604 .unwrap_or(false) 605 }