use crate::packages::Kind;
use anyhow::Result;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Requirement {
pub kind: Kind,
pub name: String,
pub source: String,
}
pub fn scan(root: &Path) -> Result<Vec<Requirement>> {
let mut reqs = Vec::new();
git_config(root, &mut reqs)?;
alacritty(root, &mut reqs)?;
zed(root, &mut reqs)?;
zsh(root, &mut reqs)?;
config_dirs(root, &mut reqs)?;
annotations(root, &mut reqs)?;
secret_templates(root, &mut reqs)?;
reqs.sort_by(|a, b| (a.kind.label(), &a.name).cmp(&(b.kind.label(), &b.name)));
reqs.dedup_by(|a, b| a.kind == b.kind && a.name == b.name);
Ok(reqs)
}
fn read(root: &Path, rel: &str) -> Option<String> {
std::fs::read_to_string(root.join(rel))
.or_else(|_| std::fs::read_to_string(root.join(format!("{rel}.tmpl"))))
.ok()
}
fn command_name(value: &str) -> Option<String> {
let v = value.trim().trim_start_matches('!').trim();
let first = v.split_whitespace().next()?;
let name = first.rsplit('/').next()?;
if name.is_empty() {
None
} else {
Some(name.to_string())
}
}
fn git_config(root: &Path, out: &mut Vec<Requirement>) -> Result<()> {
let Some(text) = read(root, ".config/git/config") else {
return Ok(());
};
for line in text.lines() {
let line = line.trim();
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim();
if !matches!(key, "pager" | "diffFilter" | "helper") {
continue;
}
if let Some(name) = command_name(value) {
out.push(Requirement {
kind: Kind::Command,
name,
source: format!(".config/git/config ({key})"),
});
}
}
Ok(())
}
fn alacritty(root: &Path, out: &mut Vec<Requirement>) -> Result<()> {
let Some(text) = read(root, ".config/alacritty/alacritty.toml") else {
return Ok(());
};
for cap in find_all(&text, "family = \"") {
out.push(Requirement {
kind: Kind::Font,
name: cap,
source: ".config/alacritty/alacritty.toml (font.family)".into(),
});
}
Ok(())
}
fn zed(root: &Path, out: &mut Vec<Requirement>) -> Result<()> {
let Some(text) = read(root, ".config/zed/settings.json") else {
return Ok(());
};
for key in ["\"buffer_font_family\": \"", "\"font_family\": \""] {
for cap in find_all(&text, key) {
out.push(Requirement {
kind: Kind::Font,
name: cap,
source: ".config/zed/settings.json (font_family)".into(),
});
}
}
if let Some(start) = text.find("\"auto_install_extensions\"") {
if let Some(open) = text[start..].find('{') {
let rest = &text[start + open..];
if let Some(close) = rest.find('}') {
for cap in find_all(&rest[..close], "\"") {
if !cap.is_empty() {
out.push(Requirement {
kind: Kind::Extension,
name: cap,
source: ".config/zed/settings.json (auto_install_extensions)".into(),
});
}
}
}
}
}
Ok(())
}
fn zsh(root: &Path, out: &mut Vec<Requirement>) -> Result<()> {
let mut files: Vec<std::path::PathBuf> = vec![root.join(".zshenv")];
if let Ok(dir) = std::fs::read_dir(root.join(".config/zsh/rc")) {
files.extend(dir.filter_map(|e| e.ok()).map(|e| e.path()));
}
files.push(root.join(".config/zsh/.zshrc"));
for path in files {
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let rel = path
.strip_prefix(root)
.unwrap_or(&path)
.display()
.to_string();
for cap in find_all(&text, "eval \"$(") {
if let Some(name) = command_name(&cap) {
out.push(Requirement {
kind: Kind::Command,
name,
source: format!("{rel} (eval)"),
});
}
}
for cap in find_all(&text, "${+commands[") {
let name = cap.trim_end_matches(']').to_string();
if !name.is_empty() {
out.push(Requirement {
kind: Kind::Command,
name,
source: format!("{rel} (commands[])"),
});
}
}
}
Ok(())
}
fn secret_templates(root: &Path, out: &mut Vec<Requirement>) -> Result<()> {
let mut files = Vec::new();
walk(root, &mut files, 0);
for path in files {
if path.extension().is_none_or(|e| e != "tmpl") {
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
if !crate::render::needs_secrets(&text) {
continue;
}
out.push(Requirement {
kind: Kind::Command,
name: "op".to_string(),
source: format!(
"{} (op:// reference)",
path.strip_prefix(root).unwrap_or(&path).display()
),
});
}
Ok(())
}
fn annotations(root: &Path, out: &mut Vec<Requirement>) -> Result<()> {
let mut files = Vec::new();
walk(&root.join(".config"), &mut files, 0);
files.push(root.join(".zshenv"));
for path in files {
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
if bytes.len() > 512 * 1024 {
continue;
}
let text = String::from_utf8_lossy(&bytes);
let rel = path
.strip_prefix(root)
.unwrap_or(&path)
.display()
.to_string();
for line in text.lines() {
let Some(idx) = line.find("sennit: requires ") else {
continue;
};
let rest = line[idx + "sennit: requires ".len()..].trim();
let (kind, value) = match rest.split_once(char::is_whitespace) {
Some(("command", v)) => (Kind::Command, v),
Some(("font", v)) => (Kind::Font, v),
Some(("extension", v)) => (Kind::Extension, v),
_ => continue,
};
let value = value.trim().trim_matches('"').trim();
if value.is_empty() {
continue;
}
out.push(Requirement {
kind,
name: value.to_string(),
source: format!("{rel} (annotation)"),
});
}
}
Ok(())
}
fn walk(dir: &Path, out: &mut Vec<std::path::PathBuf>, depth: usize) {
if depth > 6 {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.filter_map(|e| e.ok()) {
let p = e.path();
let name = e.file_name();
let name = name.to_string_lossy();
if name == ".git" || name == "target" || name == "node_modules" {
continue;
}
if p.is_dir() {
walk(&p, out, depth + 1);
} else {
out.push(p);
}
}
}
fn config_dirs(root: &Path, out: &mut Vec<Requirement>) -> Result<()> {
let Ok(dir) = std::fs::read_dir(root.join(".config")) else {
return Ok(());
};
for entry in dir.filter_map(|e| e.ok()) {
let name = entry.file_name().to_string_lossy().to_string();
let name = match name.strip_suffix(".tmpl") {
Some(base) => {
if entry.path().with_file_name(base).exists() {
continue;
}
base.to_string()
}
None => name,
};
let name = name.strip_suffix(".toml").unwrap_or(&name).to_string();
if name.starts_with('.') {
continue;
}
out.push(Requirement {
kind: Kind::Command,
name,
source: ".config/ (directory exists)".into(),
});
}
Ok(())
}
fn find_all(text: &str, prefix: &str) -> Vec<String> {
let close = match prefix.chars().last() {
Some('"') => '"',
Some('(') => ')',
Some('[') => ']',
_ => return Vec::new(),
};
let mut out = Vec::new();
let mut rest = text;
while let Some(i) = rest.find(prefix) {
let after = &rest[i + prefix.len()..];
if let Some(j) = after.find(close) {
out.push(after[..j].to_string());
rest = &after[j..];
} else {
break;
}
}
out
}