Begin Implementing V2 features

This commit is contained in:
Breadway 2026-05-11 20:56:10 +08:00
parent 55d103b3cf
commit 5adcfb3854
18 changed files with 3433 additions and 121 deletions

124
bread-sync/src/config.rs Normal file
View file

@ -0,0 +1,124 @@
use std::path::PathBuf;
use anyhow::Result;
use serde::{Deserialize, Serialize};
/// Top-level sync configuration stored in `~/.config/bread/sync.toml`.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SyncConfig {
#[serde(default)]
pub remote: RemoteConfig,
#[serde(default)]
pub machine: MachineConfig,
#[serde(default)]
pub packages: PackagesConfig,
#[serde(default)]
pub delegates: DelegatesConfig,
}
/// Git remote configuration.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RemoteConfig {
pub url: Option<String>,
#[serde(default = "default_branch")]
pub branch: String,
}
fn default_branch() -> String {
"main".to_string()
}
/// Machine identity — name comes from here, falls back to hostname.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MachineConfig {
pub name: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
}
/// Which package managers to snapshot.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackagesConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_managers")]
pub managers: Vec<String>,
}
impl Default for PackagesConfig {
fn default() -> Self {
Self {
enabled: true,
managers: default_managers(),
}
}
}
fn default_true() -> bool {
true
}
fn default_managers() -> Vec<String> {
vec!["pacman".to_string(), "pip".to_string(), "npm".to_string()]
}
/// Config file delegation — which extra paths to include in the sync repo.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DelegatesConfig {
/// Absolute or `~`-prefixed paths to copy into `configs/<basename>/`.
#[serde(default)]
pub include: Vec<String>,
/// Glob patterns to exclude when copying.
#[serde(default)]
pub exclude: Vec<String>,
}
impl SyncConfig {
/// Load from `~/.config/bread/sync.toml`, returning `Default` if not present.
pub fn load() -> Result<Self> {
let path = config_path()?;
if !path.exists() {
return Ok(Self::default());
}
let raw = std::fs::read_to_string(&path)?;
let cfg: Self = toml::from_str(&raw)?;
Ok(cfg)
}
/// Write to `~/.config/bread/sync.toml`, creating parent dirs as needed.
pub fn save(&self) -> Result<()> {
let path = config_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let raw = toml::to_string_pretty(self)?;
std::fs::write(&path, raw)?;
Ok(())
}
/// Returns `true` if `~/.config/bread/sync.toml` exists on disk.
pub fn is_initialized() -> Result<bool> {
Ok(config_path()?.exists())
}
}
/// Path to `~/.config/bread/sync.toml`.
pub fn config_path() -> Result<PathBuf> {
let config_dir = dirs::config_dir()
.ok_or_else(|| anyhow::anyhow!("cannot determine config directory"))?;
Ok(config_dir.join("bread").join("sync.toml"))
}
/// Path to `~/.local/share/bread/sync-repo/`.
pub fn sync_repo_path() -> Result<PathBuf> {
let data_dir = dirs::data_local_dir()
.ok_or_else(|| anyhow::anyhow!("cannot determine data directory"))?;
Ok(data_dir.join("bread").join("sync-repo"))
}
/// Path to `~/.config/bread/`.
pub fn bread_config_dir() -> Result<PathBuf> {
let config_dir = dirs::config_dir()
.ok_or_else(|| anyhow::anyhow!("cannot determine config directory"))?;
Ok(config_dir.join("bread"))
}

205
bread-sync/src/delegates.rs Normal file
View file

@ -0,0 +1,205 @@
use std::path::{Path, PathBuf};
use anyhow::Result;
use crate::config::DelegatesConfig;
/// Expand `~` in a path string to the user's home directory.
pub fn expand_tilde(path: &str) -> PathBuf {
if let Some(rest) = path.strip_prefix("~/") {
dirs::home_dir()
.map(|h| h.join(rest))
.unwrap_or_else(|| PathBuf::from(path))
} else if path == "~" {
dirs::home_dir().unwrap_or_else(|| PathBuf::from(path))
} else {
PathBuf::from(path)
}
}
/// Returns `true` if `path` (relative to `base`) matches any of the `exclude` globs.
fn is_excluded(base: &Path, path: &Path, excludes: &[String]) -> bool {
let rel = path.strip_prefix(base).unwrap_or(path);
let rel_str = rel.to_string_lossy();
for pattern in excludes {
if glob_matches(pattern, &rel_str) {
return true;
}
}
false
}
/// Copy all files under `src` dir to `dest` dir, honouring `excludes`.
/// Creates `dest` if it doesn't exist. Deletes files in `dest` that are
/// absent in `src` (rsync `--delete` behaviour).
pub fn sync_dir(src: &Path, dest: &Path, excludes: &[String]) -> Result<()> {
std::fs::create_dir_all(dest)?;
copy_recursive(src, src, dest, excludes)?;
delete_extra(src, dest)?;
Ok(())
}
fn copy_recursive(root: &Path, src: &Path, dest: &Path, excludes: &[String]) -> Result<()> {
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
if is_excluded(root, &src_path, excludes) {
continue;
}
let file_name = entry.file_name();
let dest_path = dest.join(&file_name);
if src_path.is_dir() {
std::fs::create_dir_all(&dest_path)?;
copy_recursive(root, &src_path, &dest_path, excludes)?;
} else {
std::fs::copy(&src_path, &dest_path)?;
}
}
Ok(())
}
/// Remove files/dirs from `dest` that don't exist in `src`.
fn delete_extra(src: &Path, dest: &Path) -> Result<()> {
if !dest.exists() {
return Ok(());
}
for entry in std::fs::read_dir(dest)? {
let entry = entry?;
let dest_path = entry.path();
let file_name = entry.file_name();
let src_path = src.join(&file_name);
if !src_path.exists() {
if dest_path.is_dir() {
std::fs::remove_dir_all(&dest_path)?;
} else {
std::fs::remove_file(&dest_path)?;
}
}
}
Ok(())
}
/// Copy each `include` path into `<repo_root>/configs/<basename>/`.
pub fn copy_delegates_to_repo(
cfg: &DelegatesConfig,
repo_root: &Path,
) -> Result<()> {
let configs_dir = repo_root.join("configs");
std::fs::create_dir_all(&configs_dir)?;
for raw_path in &cfg.include {
let src = expand_tilde(raw_path);
if !src.exists() {
tracing_warn(&format!(
"delegate path does not exist, skipping: {}",
src.display()
));
continue;
}
let basename = src
.file_name()
.ok_or_else(|| anyhow::anyhow!("delegate path has no filename: {}", src.display()))?;
let dest = configs_dir.join(basename);
if src.is_dir() {
sync_dir(&src, &dest, &cfg.exclude)?;
} else {
std::fs::copy(&src, &dest)?;
}
}
Ok(())
}
/// Restore each delegate path from `<repo_root>/configs/<basename>/` to its original location.
pub fn restore_delegates_from_repo(
cfg: &DelegatesConfig,
repo_root: &Path,
) -> Result<()> {
let configs_dir = repo_root.join("configs");
for raw_path in &cfg.include {
let dest = expand_tilde(raw_path);
let basename = match dest.file_name() {
Some(n) => n.to_os_string(),
None => continue,
};
let src = configs_dir.join(&basename);
if !src.exists() {
continue;
}
if src.is_dir() {
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
sync_dir(&src, &dest, &[])?;
} else {
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(&src, &dest)?;
}
}
Ok(())
}
/// Simple glob match for `**` and `*` patterns against a path string.
fn glob_matches(pattern: &str, path: &str) -> bool {
glob_match_bytes(pattern.as_bytes(), path.as_bytes())
}
fn glob_match_bytes(pattern: &[u8], text: &[u8]) -> bool {
if pattern.is_empty() {
return text.is_empty();
}
// `**` matches any sequence including path separators
if pattern.starts_with(b"**") {
let rest = &pattern[2..];
if rest.is_empty() {
return true;
}
// skip leading separator in rest
let rest = if rest.starts_with(b"/") { &rest[1..] } else { rest };
for offset in 0..=text.len() {
if glob_match_bytes(rest, &text[offset..]) {
return true;
}
}
return false;
}
match pattern[0] {
b'*' => {
let mut offset = 0;
loop {
if glob_match_bytes(&pattern[1..], &text[offset..]) {
return true;
}
if offset == text.len() {
break;
}
offset += 1;
}
false
}
b'?' => {
if text.is_empty() {
return false;
}
glob_match_bytes(&pattern[1..], &text[1..])
}
ch => {
if text.first().copied() != Some(ch) {
return false;
}
glob_match_bytes(&pattern[1..], &text[1..])
}
}
}
fn tracing_warn(msg: &str) {
// Use eprintln since tracing may not be configured in library context
eprintln!("warn: {msg}");
}

227
bread-sync/src/git.rs Normal file
View file

@ -0,0 +1,227 @@
use std::path::Path;
use anyhow::{anyhow, Result};
/// Open an existing repo or initialise a new one at `path`.
pub fn init_or_open(path: &Path) -> Result<git2::Repository> {
if path.join(".git").exists() || is_bare(path) {
Ok(git2::Repository::open(path)?)
} else {
std::fs::create_dir_all(path)?;
Ok(git2::Repository::init(path)?)
}
}
/// Clone `url` to `path` if `path` is not already a repo, otherwise open it.
pub fn clone_or_open(url: &str, path: &Path) -> Result<git2::Repository> {
if path.join(".git").exists() || is_bare(path) {
return Ok(git2::Repository::open(path)?);
}
let mut builder = git2::build::RepoBuilder::new();
let mut fetch_opts = git2::FetchOptions::new();
fetch_opts.remote_callbacks(make_callbacks());
builder.fetch_options(fetch_opts);
std::fs::create_dir_all(path)?;
Ok(builder.clone(url, path)?)
}
/// Stage every tracked and untracked change (equivalent to `git add -A`).
pub fn stage_all(repo: &git2::Repository) -> Result<()> {
let mut index = repo.index()?;
index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?;
// Remove entries for deleted files
index.update_all(["*"].iter(), None)?;
index.write()?;
Ok(())
}
/// Returns `true` if the index has staged changes compared to HEAD (or repo is new).
pub fn has_changes(repo: &git2::Repository) -> Result<bool> {
let mut index = repo.index()?;
index.read(false)?;
// New repo with no commits yet
if repo.head().is_err() {
return Ok(index.len() > 0);
}
let head = repo.head()?.peel_to_tree()?;
let diff = repo.diff_tree_to_index(Some(&head), Some(&index), None)?;
Ok(diff.deltas().count() > 0)
}
/// Commit all staged changes with `message`. Returns the new commit OID.
pub fn commit(repo: &git2::Repository, message: &str) -> Result<git2::Oid> {
let mut index = repo.index()?;
let tree_id = index.write_tree()?;
let tree = repo.find_tree(tree_id)?;
let sig = repo.signature().unwrap_or_else(|_| {
git2::Signature::now("bread", "bread@localhost").expect("signature")
});
let oid = if let Ok(head) = repo.head() {
let parent = head.peel_to_commit()?;
repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])?
} else {
repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])?
};
Ok(oid)
}
/// Push `branch` to `remote_name` (defaults to "origin").
pub fn push(repo: &git2::Repository, remote_name: &str, branch: &str) -> Result<()> {
let mut remote = repo.find_remote(remote_name)?;
let mut opts = git2::PushOptions::new();
opts.remote_callbacks(make_callbacks());
remote.push(
&[&format!("refs/heads/{branch}:refs/heads/{branch}")],
Some(&mut opts),
)?;
Ok(())
}
/// Fetch from `remote_name` without merging.
pub fn fetch(repo: &git2::Repository, remote_name: &str) -> Result<()> {
let mut remote = repo.find_remote(remote_name)?;
let mut opts = git2::FetchOptions::new();
opts.remote_callbacks(make_callbacks());
remote.fetch(&[] as &[&str], Some(&mut opts), None)?;
Ok(())
}
/// Fetch and fast-forward merge from `remote_name/branch`. Errors on conflict.
pub fn pull(repo: &git2::Repository, remote_name: &str, branch: &str) -> Result<()> {
fetch(repo, remote_name)?;
let fetch_head = repo
.find_reference(&format!("refs/remotes/{remote_name}/{branch}"))
.map_err(|_| anyhow!("remote branch {remote_name}/{branch} not found after fetch"))?;
let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?;
let analysis = repo.merge_analysis(&[&fetch_commit])?;
if analysis.0.is_up_to_date() {
return Ok(());
}
if !analysis.0.is_fast_forward() {
return Err(anyhow!(
"sync conflict — resolve manually in {}",
repo.workdir()
.unwrap_or_else(|| Path::new("?"))
.display()
));
}
// Fast-forward: update HEAD and checkout
let head_ref = repo.find_reference("HEAD")?;
let resolved = head_ref.resolve()?;
let refname = resolved.name().unwrap_or("HEAD").to_string();
repo.find_reference(&refname)?
.set_target(fetch_commit.id(), "fast-forward")?;
repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))?;
Ok(())
}
/// Add a remote named `name` pointing at `url`, or update it if it already exists.
pub fn set_remote(repo: &git2::Repository, name: &str, url: &str) -> Result<()> {
if repo.find_remote(name).is_ok() {
repo.remote_set_url(name, url)?;
} else {
repo.remote(name, url)?;
}
Ok(())
}
/// Return working-tree diff against HEAD as a unified diff string.
pub fn diff_workdir(repo: &git2::Repository) -> Result<String> {
let mut buf = Vec::new();
if let Ok(head_tree) = repo.head().and_then(|h| h.peel_to_tree()) {
let diff = repo.diff_tree_to_workdir_with_index(Some(&head_tree), None)?;
diff.print(git2::DiffFormat::Patch, |_, _, line| {
buf.extend_from_slice(line.content());
true
})?;
}
Ok(String::from_utf8_lossy(&buf).into_owned())
}
/// Return diff between HEAD and `remote/branch` as a unified diff string.
pub fn diff_remote(repo: &git2::Repository, remote_name: &str, branch: &str) -> Result<String> {
let remote_ref = format!("refs/remotes/{remote_name}/{branch}");
let remote_tree = repo
.find_reference(&remote_ref)
.map_err(|_| anyhow!("remote ref {remote_ref} not found — try fetching first"))?
.peel_to_tree()?;
let local_tree = repo.head()?.peel_to_tree()?;
let diff = repo.diff_tree_to_tree(Some(&local_tree), Some(&remote_tree), None)?;
let mut buf = Vec::new();
diff.print(git2::DiffFormat::Patch, |_, _, line| {
buf.extend_from_slice(line.content());
true
})?;
Ok(String::from_utf8_lossy(&buf).into_owned())
}
/// Return a list of `(status_char, path)` for the working tree.
pub fn status_lines(repo: &git2::Repository) -> Result<Vec<(char, String)>> {
let statuses = repo.statuses(None)?;
let mut out = Vec::new();
for entry in statuses.iter() {
let path = entry.path().unwrap_or("?").to_string();
let flag = entry.status();
let ch = if flag.contains(git2::Status::INDEX_NEW) || flag.contains(git2::Status::WT_NEW) {
'A'
} else if flag.contains(git2::Status::INDEX_DELETED) || flag.contains(git2::Status::WT_DELETED) {
'D'
} else {
'M'
};
out.push((ch, path));
}
Ok(out)
}
/// Returns true if the local HEAD is behind the remote.
pub fn remote_has_changes(repo: &git2::Repository, remote_name: &str, branch: &str) -> bool {
let remote_ref = format!("refs/remotes/{remote_name}/{branch}");
let Ok(remote_ref) = repo.find_reference(&remote_ref) else {
return false;
};
let Ok(remote_commit) = remote_ref.peel_to_commit() else {
return false;
};
let Ok(local_commit) = repo.head().and_then(|h| h.peel_to_commit()) else {
return false;
};
remote_commit.id() != local_commit.id()
}
/// Timestamp of the HEAD commit (or "never").
pub fn last_commit_time(repo: &git2::Repository) -> String {
let Ok(commit) = repo.head().and_then(|h| h.peel_to_commit()) else {
return "never".to_string();
};
let ts = commit.time().seconds();
let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(ts, 0)
.unwrap_or_else(chrono::Utc::now);
dt.format("%Y-%m-%d %H:%M:%S").to_string()
}
fn is_bare(path: &Path) -> bool {
path.join("HEAD").exists() && path.join("objects").exists()
}
fn make_callbacks<'a>() -> git2::RemoteCallbacks<'a> {
let mut callbacks = git2::RemoteCallbacks::new();
callbacks.credentials(|_url, username_from_url, allowed_types| {
if allowed_types.contains(git2::CredentialType::SSH_KEY) {
git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
} else if allowed_types.contains(git2::CredentialType::DEFAULT) {
git2::Cred::default()
} else {
Err(git2::Error::from_str(
"no supported credential type (SSH agent or default)",
))
}
});
callbacks
}

10
bread-sync/src/lib.rs Normal file
View file

@ -0,0 +1,10 @@
pub mod config;
pub mod delegates;
pub mod git;
pub mod machine;
pub mod packages;
pub use config::{
bread_config_dir, config_path, sync_repo_path, DelegatesConfig, MachineConfig, PackagesConfig,
RemoteConfig, SyncConfig,
};

102
bread-sync/src/machine.rs Normal file
View file

@ -0,0 +1,102 @@
use std::path::Path;
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::config::SyncConfig;
/// Machine profile persisted to `<repo>/machines/<name>.toml`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MachineProfile {
pub name: String,
pub hostname: String,
pub tags: Vec<String>,
pub last_sync: String, // RFC 3339
}
impl MachineProfile {
pub fn new(cfg: &SyncConfig) -> Result<Self> {
let host = hostname()?;
let name = cfg.machine.name.clone().unwrap_or_else(|| host.clone());
Ok(Self {
name,
hostname: host,
tags: cfg.machine.tags.clone(),
last_sync: Utc::now().to_rfc3339(),
})
}
/// Write profile to `<repo>/machines/<name>.toml`.
pub fn write_to_repo(&self, repo_root: &Path) -> Result<()> {
let machines_dir = repo_root.join("machines");
std::fs::create_dir_all(&machines_dir)?;
let path = machines_dir.join(format!("{}.toml", self.name));
let raw = toml::to_string_pretty(self)?;
std::fs::write(&path, raw)?;
Ok(())
}
/// Load from `<repo>/machines/<name>.toml`.
pub fn load_from_repo(repo_root: &Path, name: &str) -> Result<Self> {
let path = repo_root.join("machines").join(format!("{name}.toml"));
let raw = std::fs::read_to_string(&path)?;
Ok(toml::from_str(&raw)?)
}
}
/// List all machine profiles in `<repo>/machines/`.
pub fn list_machines(repo_root: &Path) -> Vec<MachineProfile> {
let machines_dir = repo_root.join("machines");
let Ok(entries) = std::fs::read_dir(&machines_dir) else {
return Vec::new();
};
entries
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("toml"))
.filter_map(|e| {
std::fs::read_to_string(e.path())
.ok()
.and_then(|raw| toml::from_str::<MachineProfile>(&raw).ok())
})
.collect()
}
/// Returns the machine name from sync.toml, falling back to hostname.
pub fn machine_name(cfg: &SyncConfig) -> Result<String> {
if let Some(name) = cfg.machine.name.as_deref() {
return Ok(name.to_string());
}
hostname()
}
/// Returns the machine tags from sync.toml.
pub fn machine_tags(cfg: &SyncConfig) -> Vec<String> {
cfg.machine.tags.clone()
}
/// Returns true if `tag` is in the machine's tag list.
pub fn machine_has_tag(cfg: &SyncConfig, tag: &str) -> bool {
cfg.machine.tags.iter().any(|t| t == tag)
}
fn hostname() -> Result<String> {
// Try /etc/hostname first (no subprocess)
if let Ok(raw) = std::fs::read_to_string("/etc/hostname") {
let trimmed = raw.trim().to_string();
if !trimmed.is_empty() {
return Ok(trimmed);
}
}
// Fall back to hostname(1)
let out = std::process::Command::new("hostname")
.output()
.map_err(anyhow::Error::from)?;
let s = String::from_utf8(out.stdout).map_err(anyhow::Error::from)?;
Ok(s.trim().to_string())
}
#[allow(dead_code)]
fn format_last_sync(dt: &DateTime<Utc>) -> String {
dt.format("%Y-%m-%d %H:%M").to_string()
}

137
bread-sync/src/packages.rs Normal file
View file

@ -0,0 +1,137 @@
use std::path::Path;
use std::process::Command;
use anyhow::Result;
/// Write package manifests to `<repo>/packages/`.
/// Skips package managers that are not installed (warns instead of erroring).
pub fn snapshot_packages(managers: &[String], repo_root: &Path) -> Result<()> {
let pkg_dir = repo_root.join("packages");
std::fs::create_dir_all(&pkg_dir)?;
for mgr in managers {
match mgr.as_str() {
"pacman" => {
if let Some(content) = run_pacman() {
std::fs::write(pkg_dir.join("pacman.txt"), content)?;
} else {
eprintln!("warn: pacman not found, skipping package snapshot");
}
}
"pip" => {
if let Some(content) = run_pip() {
std::fs::write(pkg_dir.join("pip.txt"), content)?;
} else {
eprintln!("warn: pip not found, skipping package snapshot");
}
}
"npm" => {
if let Some(content) = run_npm() {
std::fs::write(pkg_dir.join("npm.txt"), content)?;
} else {
eprintln!("warn: npm not found, skipping package snapshot");
}
}
"cargo" => {
if let Some(content) = run_cargo() {
std::fs::write(pkg_dir.join("cargo.txt"), content)?;
} else {
eprintln!("warn: cargo not found, skipping package snapshot");
}
}
other => {
eprintln!("warn: unknown package manager '{other}', skipping");
}
}
}
Ok(())
}
/// Parse a `pacman.txt` snapshot into a list of package names.
pub fn parse_pacman(content: &str) -> Vec<String> {
content.lines().map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect()
}
/// Parse a `pip.txt` (freeze format) snapshot into package names.
pub fn parse_pip(content: &str) -> Vec<String> {
content
.lines()
.filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
.filter_map(|l| l.split("==").next().map(|s| s.trim().to_string()))
.collect()
}
/// Parse an `npm.txt` (parseable) snapshot into package names.
pub fn parse_npm(content: &str) -> Vec<String> {
content
.lines()
.skip(1) // first line is the npm global prefix path
.filter(|l| !l.trim().is_empty())
.filter_map(|l| {
Path::new(l.trim())
.file_name()
.and_then(|n| n.to_str())
.map(ToString::to_string)
})
.collect()
}
/// Parse `cargo install --list` output into `name version` lines.
pub fn parse_cargo(content: &str) -> Vec<String> {
content
.lines()
.filter(|l| !l.starts_with(' ') && !l.trim().is_empty())
.filter_map(|l| {
// Format: `name v1.2.3 (...):` or `name v1.2.3:`
let parts: Vec<&str> = l.splitn(2, ' ').collect();
if parts.len() == 2 {
let name = parts[0];
let version = parts[1].trim_start_matches('v').split_whitespace().next().unwrap_or("?").trim_end_matches(':');
Some(format!("{name} {version}"))
} else {
None
}
})
.collect()
}
fn run_pacman() -> Option<String> {
let output = Command::new("pacman").args(["-Qe"]).output().ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout).ok()
}
fn run_pip() -> Option<String> {
let output = Command::new("pip")
.args(["list", "--user", "--format=freeze"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout).ok()
}
fn run_npm() -> Option<String> {
let output = Command::new("npm")
.args(["list", "-g", "--depth=0", "--parseable"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout).ok()
}
fn run_cargo() -> Option<String> {
let output = Command::new("cargo")
.args(["install", "--list"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout).ok()
}