/ src / custom.rs
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  }