feat: add bread-sync module for snapshot and restore functionality

- Introduced `bread-sync` module with core functionalities for syncing system state via Git.
- Implemented `MachineProfile` struct for managing machine profiles, including methods for reading and writing profiles.
- Added package management support with snapshot capabilities for `pacman`, `pip`, `npm`, and `cargo`.
- Created comprehensive tests for sync operations, package parsing, and machine profile management.
- Enhanced `udev` adapter to include vendor and product IDs for scanned devices.
- Updated state engine to handle module clearing commands.
- Introduced Lua integration for accessing machine information and file system operations.
- Improved packaging documentation for Arch Linux and systemd service setup.
This commit is contained in:
Breadway 2026-05-12 00:20:45 +08:00
parent 96e42bc370
commit e39b168398
25 changed files with 3930 additions and 92 deletions

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

@ -0,0 +1,144 @@
use anyhow::Result;
use std::fs;
use std::path::Path;
use std::process::Command;
/// Snapshot a package manager's installed packages and write to `dest`.
/// Returns true if the snapshot was written, false if the package manager
/// is not installed (warns instead of failing).
pub fn snapshot(manager: &str, dest: &Path) -> Result<bool> {
let content = match manager {
"pacman" => run_pacman()?,
"pip" => run_pip()?,
"npm" => run_npm()?,
"cargo" => run_cargo()?,
other => {
eprintln!("bread: unknown package manager '{}', skipping", other);
return Ok(false);
}
};
let Some(content) = content else {
eprintln!(
"bread: package manager '{}' not found, skipping",
manager
);
return Ok(false);
};
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
fs::write(dest, content)?;
Ok(true)
}
/// Parse a pacman snapshot (one "name version" per line, space-separated) and
/// return a list of package names.
pub fn parse_pacman(content: &str) -> Vec<String> {
content
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| l.split_whitespace().next().unwrap_or(l).to_string())
.collect()
}
/// Parse a pip freeze snapshot and return package names.
pub fn parse_pip(content: &str) -> Vec<String> {
content
.lines()
.filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
.map(|l| {
l.split("==")
.next()
.unwrap_or(l)
.split(">=")
.next()
.unwrap_or(l)
.trim()
.to_string()
})
.collect()
}
/// Parse npm global packages list (parseable format, one path per line).
pub fn parse_npm(content: &str) -> Vec<String> {
content
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| {
// `npm list -g --parseable` outputs paths like /usr/lib/node_modules/pkg
let name = Path::new(l)
.file_name()
.map(|n| n.to_string_lossy().to_string())?;
// Skip npm itself and the root node_modules
if name == "node_modules" {
return None;
}
Some(name)
})
.collect()
}
/// Parse cargo install list.
/// Format: "crate v1.2.3 (some-path):\n binary\n..."
pub fn parse_cargo(content: &str) -> Vec<String> {
content
.lines()
.filter(|l| !l.starts_with(' ') && !l.trim().is_empty())
.map(|l| {
l.split_whitespace()
.next()
.unwrap_or(l)
.to_string()
})
.collect()
}
fn run_pacman() -> Result<Option<String>> {
match Command::new("pacman").arg("-Qe").output() {
Ok(out) if out.status.success() => Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())),
Ok(_) => Ok(None),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
fn run_pip() -> Result<Option<String>> {
// Try pip3 first, then pip
for cmd in ["pip3", "pip"] {
match Command::new(cmd)
.args(["list", "--user", "--format=freeze"])
.output()
{
Ok(out) if out.status.success() => {
return Ok(Some(String::from_utf8_lossy(&out.stdout).to_string()))
}
Ok(_) => continue,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(e.into()),
}
}
Ok(None)
}
fn run_npm() -> Result<Option<String>> {
match Command::new("npm")
.args(["list", "-g", "--depth=0", "--parseable"])
.output()
{
Ok(out) if out.status.success() => Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())),
Ok(_) => Ok(None),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
fn run_cargo() -> Result<Option<String>> {
match Command::new("cargo").args(["install", "--list"]).output() {
Ok(out) if out.status.success() => Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())),
Ok(_) => Ok(None),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}