mod error;
pub use error::Error as ImportError;
use std::{
fs, io,
path::{Path, PathBuf},
};
use smol_str::{SmolStr, ToSmolStr};
use toml_edit::{Array, ArrayOfTables, DocumentMut, Item, Table, value};
use zenops_safe_relative_path::SinglePathComponent;
use crate::{
config_files::{ConfigFileDirs, ConfigFilePath},
error::Error,
line_prompter::{LinePrompter, RustylinePrompter},
output::{
AppliedAction, Event, ImportApplied, ImportFileAction, ImportMode, ImportPlan,
ImportTomlChange, ImportType, Output,
},
};
#[allow(clippy::too_many_arguments)]
pub fn run(
path: &Path,
pkg: Option<&str>,
source_override: Option<&str>,
brew: &[String],
no_install_hint: bool,
yes: bool,
dry_run: bool,
host: &crate::HostInputs<'_>,
output: &mut dyn Output,
) -> Result<(), Error> {
if yes || dry_run {
run_with_prompter(
path,
pkg,
source_override,
brew,
no_install_hint,
yes,
dry_run,
host.dirs,
output,
None,
)
} else if host.args.stdin_is_terminal {
let mut prompter = RustylinePrompter::new().map_err(crate::prompt::PromptError::Read)?;
run_with_prompter(
path,
pkg,
source_override,
brew,
no_install_hint,
yes,
dry_run,
host.dirs,
output,
Some(&mut prompter),
)
} else {
Err(ImportError::NeedsTty.into())
}
}
#[allow(clippy::too_many_arguments)]
pub fn run_with_prompter(
path: &Path,
pkg_override: Option<&str>,
source_override: Option<&str>,
brew: &[String],
no_install_hint: bool,
yes: bool,
dry_run: bool,
dirs: &ConfigFileDirs,
output: &mut dyn Output,
mut prompter: Option<&mut dyn LinePrompter>,
) -> Result<(), Error> {
if brew.iter().any(|s| s.is_empty()) {
return Err(ImportError::EmptyBrewPackage.into());
}
if let Some(over) = source_override {
let parsed = Path::new(over);
if parsed.is_absolute()
|| parsed
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(ImportError::SourceOverrideEscapesRepo(parsed.to_path_buf()).into());
}
}
let cfg_path = dirs.zenops().join("config.toml");
let cfg_text = match fs::read_to_string(&cfg_path) {
Ok(t) => t,
Err(e) if e.kind() == io::ErrorKind::NotFound => {
return Err(ImportError::ZenopsRepoMissing(cfg_path).into());
}
Err(e) => return Err(ImportError::Io(cfg_path, e).into()),
};
let mut doc: DocumentMut = cfg_text
.parse()
.map_err(|e| Error::from(ImportError::ConfigParse(cfg_path.clone(), e)))?;
let plan = build_plan(path, pkg_override, source_override, dirs, &doc)?;
if matches!(
plan.toml,
TomlPlan::ExtendExisting { .. } | TomlPlan::Reconcile { .. }
) {
let mut bad_flags: Vec<&'static str> = Vec::new();
if pkg_override.is_some() {
bad_flags.push("--pkg");
}
if source_override.is_some() {
bad_flags.push("--source");
}
if !brew.is_empty() {
bad_flags.push("--brew");
}
if no_install_hint {
bad_flags.push("--no-install-hint");
}
if !bad_flags.is_empty() {
return Err(ImportError::ExtendFlagsInvalid { flags: bad_flags }.into());
}
}
let created_pkg = !pkg_block_exists(&doc, &plan.pkg_key);
let brew_packages = resolve_brew(
&plan.pkg_key,
brew,
no_install_hint,
created_pkg,
&mut prompter,
)?;
let is_noop = plan.files.is_empty()
&& plan.removed_files.is_empty()
&& plan.renamed_files.is_empty()
&& match &plan.toml {
TomlPlan::Reconcile {
added_symlinks,
removed_symlinks,
..
} => added_symlinks.is_empty() && removed_symlinks.is_empty(),
_ => false,
};
output.push(Event::ImportPlan(plan_to_event(
&plan,
created_pkg,
no_install_hint,
&brew_packages,
)))?;
if dry_run {
return Ok(());
}
if is_noop {
output.push(Event::ImportApplied(ImportApplied {
pkg: plan.pkg_key.clone(),
is_noop: true,
}))?;
return Ok(());
}
if !yes
&& let Some(p) = prompter.as_mut()
&& !confirm(*p, "Apply this plan?")?
{
return Err(ImportError::Aborted.into());
}
apply_files(&plan, dirs, output)?;
update_doc(
&mut doc,
&plan,
created_pkg,
no_install_hint,
&brew_packages,
)?;
fs::write(&cfg_path, doc.to_string()).map_err(|e| Error::from(ImportError::Io(cfg_path, e)))?;
output.push(Event::ImportApplied(ImportApplied {
pkg: plan.pkg_key.clone(),
is_noop: false,
}))?;
Ok(())
}
struct Plan {
pkg_key: SmolStr,
r#type: ImportType,
source: PathBuf,
source_root: PathBuf,
repo_dest: PathBuf,
repo_rel: String,
files: Vec<PlannedFile>,
skipped: Vec<SkippedEntry>,
removed_files: Vec<RemovedFile>,
renamed_files: Vec<RenamedFile>,
toml: TomlPlan,
}
#[derive(Debug, Clone)]
struct SkippedEntry {
path: PathBuf,
reason: SmolStr,
}
struct PlannedFile {
rel: PathBuf,
repo_rel: PathBuf,
}
struct RemovedFile {
repo_rel: PathBuf,
}
struct RenamedFile {
from: PathBuf,
to: PathBuf,
}
enum TomlPlan {
DotConfig {
name_override: Option<String>,
symlinks: Vec<String>,
},
Home {
dir: String,
symlinks: Vec<String>,
},
ExtendExisting {
config_index: usize,
existing_symlinks: Vec<String>,
added_symlinks: Vec<String>,
},
Reconcile {
config_index: usize,
existing_symlinks: Vec<String>,
added_symlinks: Vec<String>,
removed_symlinks: Vec<String>,
},
}
fn build_plan(
raw_path: &Path,
pkg_override: Option<&str>,
source_override: Option<&str>,
dirs: &ConfigFileDirs,
doc: &DocumentMut,
) -> Result<Plan, Error> {
let cwd = std::env::current_dir().map_err(|e| ImportError::Io(PathBuf::from("."), e))?;
let joined = if raw_path.is_absolute() {
raw_path.to_path_buf()
} else {
cwd.join(raw_path)
};
let probe = match joined.symlink_metadata() {
Ok(m) => m,
Err(e) if e.kind() == io::ErrorKind::NotFound => {
return Err(ImportError::SourceMissing(joined).into());
}
Err(e) => return Err(ImportError::Io(joined, e).into()),
};
if probe.file_type().is_symlink() {
return Err(ImportError::SourceIsSymlink(joined).into());
}
let canonical_source = joined
.canonicalize()
.map_err(|e| ImportError::Io(joined.clone(), e))?;
let canonical_home = dirs
.home()
.canonicalize()
.map_err(|e| ImportError::Io(dirs.home().to_path_buf(), e))?;
let canonical_zenops = dirs
.zenops()
.canonicalize()
.map_err(|e| ImportError::Io(dirs.zenops().to_path_buf(), e))?;
if canonical_source == canonical_zenops || canonical_source.starts_with(&canonical_zenops) {
return Err(ImportError::CannotImportZenopsRepo(canonical_source).into());
}
let tail = canonical_source
.strip_prefix(&canonical_home)
.map_err(|_| ImportError::PathNotUnderHome(canonical_source.clone()))?;
if let Some(root_match) = find_managed_root(doc, tail)? {
return build_reconcile_plan(root_match, canonical_source, &canonical_home, dirs);
}
if let Some(matched) = find_matching_config(doc, tail)? {
let is_dir = canonical_source.is_dir();
if is_dir {
return Err(ImportError::ExtendDirectoryNotSupported(canonical_source).into());
}
return build_extend_plan(matched, canonical_source, &canonical_home, dirs);
}
let layout = classify(tail)?;
let is_dir = canonical_source.is_dir();
let pkg_key = derive_pkg_key(&layout, pkg_override)?;
if pkg_has_configs_entries(doc, pkg_key.as_str()) {
return Err(ImportError::PkgKeyTaken { pkg: pkg_key }.into());
}
let repo_rel = source_override
.map(str::to_string)
.unwrap_or_else(|| format!("configs/{pkg_key}"));
let repo_dest = dirs.zenops().join(&repo_rel);
let (source_root, toml_plan_kind, single_file_rel) = match (&layout, is_dir) {
(
Layout::DotConfig {
dir,
sub_path: None,
},
true,
) => {
let root = canonical_home.join(".config").join(dir);
let name_override = if dir == pkg_key.as_str() {
None
} else {
Some(dir.clone())
};
(root, TomlPlanKind::DotConfig { name_override }, None)
}
(Layout::DotConfig { sub_path: None, .. }, false) => {
return Err(ImportError::ExpectedDirectory(canonical_source).into());
}
(
Layout::DotConfig {
sub_path: Some(_), ..
},
true,
) => {
return Err(ImportError::NestedDirectoryNotSupported(canonical_source).into());
}
(
Layout::DotConfig {
dir,
sub_path: Some(rest),
},
false,
) => {
let root = canonical_home.join(".config").join(dir);
let name_override = if dir == pkg_key.as_str() {
None
} else {
Some(dir.clone())
};
(
root,
TomlPlanKind::DotConfig { name_override },
Some(rest.clone()),
)
}
(Layout::Home { name }, true) => {
let root = canonical_home.join(name);
(root, TomlPlanKind::Home { dir: name.clone() }, None)
}
(Layout::Home { name: _ }, false) => {
let root = canonical_source
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| canonical_home.clone());
let file_name = canonical_source.file_name().ok_or_else(|| {
ImportError::Io(canonical_source.clone(), io::ErrorKind::InvalidInput.into())
})?;
(
root,
TomlPlanKind::HomeSingleFile,
Some(PathBuf::from(file_name)),
)
}
};
let (collected, skipped) = match single_file_rel {
Some(rel) => (
vec![CollectedEntry {
rel: rel.clone(),
repo_rel: rel,
}],
Vec::new(),
),
None => {
let mut files = Vec::new();
let mut skipped = Vec::new();
walk_dir(&canonical_source, &mut files, &mut skipped, Path::new(""))?;
(files, skipped)
}
};
let mut files = Vec::with_capacity(collected.len());
let mut already_imported = 0usize;
for entry in collected {
let symlink_path = source_root.join(&entry.rel);
let dest_in_repo = repo_dest.join(&entry.repo_rel);
if dest_in_repo.try_exists().unwrap_or(false) {
if symlink_path
.symlink_metadata()
.is_ok_and(|m| m.is_symlink())
&& std::fs::read_link(&symlink_path).is_ok_and(|t| t == dest_in_repo)
{
already_imported += 1;
continue;
}
return Err(ImportError::DestExists(dest_in_repo).into());
}
files.push(PlannedFile {
rel: entry.rel,
repo_rel: entry.repo_rel,
});
}
if files.is_empty() && already_imported == 0 {
return Err(ImportError::SourceEmpty(canonical_source).into());
}
let toml = match toml_plan_kind {
TomlPlanKind::DotConfig { name_override } => TomlPlan::DotConfig {
name_override,
symlinks: files
.iter()
.map(|f| path_to_forward_slash(&f.repo_rel))
.collect(),
},
TomlPlanKind::Home { dir } => TomlPlan::Home {
dir,
symlinks: files
.iter()
.map(|f| path_to_forward_slash(&f.repo_rel))
.collect(),
},
TomlPlanKind::HomeSingleFile => TomlPlan::Home {
dir: String::new(),
symlinks: files
.iter()
.map(|f| path_to_forward_slash(&f.rel))
.collect(),
},
};
let r#type = match &layout {
Layout::DotConfig { .. } => ImportType::DotConfig,
Layout::Home { .. } => ImportType::Home,
};
Ok(Plan {
pkg_key,
r#type,
source: canonical_source,
source_root,
repo_dest,
repo_rel,
files,
skipped,
removed_files: Vec::new(),
renamed_files: Vec::new(),
toml,
})
}
#[derive(Debug)]
enum Layout {
DotConfig {
dir: String,
sub_path: Option<PathBuf>,
},
Home { name: String },
}
fn classify(tail: &Path) -> Result<Layout, ImportError> {
let comps: Vec<&str> = tail
.components()
.map(|c| c.as_os_str().to_str().unwrap_or(""))
.collect();
match comps.as_slice() {
[".config", dir, rest @ ..]
if !dir.is_empty() && SinglePathComponent::try_new(dir).is_ok() =>
{
let sub_path = if rest.is_empty() {
None
} else {
Some(rest.iter().collect::<PathBuf>())
};
Ok(Layout::DotConfig {
dir: (*dir).to_string(),
sub_path,
})
}
[".config"] => Err(ImportError::UnsupportedLayout(".config".to_string())),
[name]
if name.starts_with('.')
&& name.len() > 1
&& SinglePathComponent::try_new(name).is_ok() =>
{
Ok(Layout::Home {
name: (*name).to_string(),
})
}
_ => Err(ImportError::UnsupportedLayout(
tail.to_string_lossy().into_owned(),
)),
}
}
fn derive_pkg_key(layout: &Layout, pkg_override: Option<&str>) -> Result<SmolStr, ImportError> {
let candidate = match pkg_override {
Some(s) => s.to_string(),
None => match layout {
Layout::DotConfig { dir, .. } => dir.clone(),
Layout::Home { name } => name
.strip_prefix('.')
.map(str::to_string)
.unwrap_or_else(|| name.clone()),
},
};
SinglePathComponent::try_new(&candidate)
.map(|_| candidate.to_smolstr())
.map_err(|_| ImportError::NoDerivablePkgKey(candidate))
}
enum TomlPlanKind {
DotConfig { name_override: Option<String> },
Home { dir: String },
HomeSingleFile,
}
struct CollectedEntry {
rel: PathBuf,
repo_rel: PathBuf,
}
fn walk_dir(
abs: &Path,
files: &mut Vec<CollectedEntry>,
skipped: &mut Vec<SkippedEntry>,
rel_prefix: &Path,
) -> Result<(), Error> {
let entries = fs::read_dir(abs).map_err(|e| ImportError::Io(abs.to_path_buf(), e))?;
let mut sorted: Vec<_> = entries
.collect::<Result<Vec<_>, _>>()
.map_err(|e| ImportError::Io(abs.to_path_buf(), e))?;
sorted.sort_by_key(|e| e.file_name());
for entry in sorted {
let name = entry.file_name();
let rel = rel_prefix.join(&name);
let meta = entry
.file_type()
.map_err(|e| ImportError::Io(entry.path(), e))?;
if meta.is_symlink() {
skipped.push(SkippedEntry {
path: rel,
reason: SmolStr::new_static("symlink"),
});
} else if meta.is_file() {
files.push(CollectedEntry {
rel: rel.clone(),
repo_rel: rel,
});
} else if meta.is_dir() {
let name_str = name.to_str().unwrap_or("");
if matches!(name_str, ".git" | ".hg" | ".svn") {
skipped.push(SkippedEntry {
path: rel,
reason: SmolStr::new_static("vcs"),
});
continue;
}
walk_dir(&entry.path(), files, skipped, &rel)?;
} else {
skipped.push(SkippedEntry {
path: rel,
reason: SmolStr::new_static("other"),
});
}
}
Ok(())
}
fn pkg_block_exists(doc: &DocumentMut, key: &str) -> bool {
doc.get("pkg")
.and_then(|p| p.as_table())
.and_then(|t| t.get(key))
.is_some()
}
fn pkg_has_configs_entries(doc: &DocumentMut, key: &str) -> bool {
doc.get("pkg")
.and_then(|p| p.as_table())
.and_then(|t| t.get(key))
.and_then(|p| p.as_table())
.and_then(|t| t.get("configs"))
.and_then(Item::as_array_of_tables)
.is_some_and(|aot| !aot.is_empty())
}
struct ConfigMatch {
pkg_key: SmolStr,
config_index: usize,
r#type: ImportType,
on_disk_root: String,
source_rel: String,
file_rel: PathBuf,
existing_symlinks: Vec<String>,
}
struct RootMatch {
pkg_key: SmolStr,
config_index: usize,
r#type: ImportType,
on_disk_root: String,
source_rel: String,
existing_symlinks: Vec<String>,
}
struct ParsedConfigEntry {
on_disk_root: String,
import_type: ImportType,
source_rel: String,
existing_symlinks: Vec<String>,
}
fn parse_config_entry(entry: &Table, pkg_key_raw: &str) -> Option<ParsedConfigEntry> {
let typ = entry.get("type").and_then(|v| v.as_str())?;
let (on_disk_root, import_type) = match typ {
".config" => {
let dir = entry
.get("name")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.unwrap_or(pkg_key_raw);
(format!(".config/{dir}"), ImportType::DotConfig)
}
"home" => {
let dir = entry
.get("dir")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())?;
(dir.to_string(), ImportType::Home)
}
_ => return None,
};
let source_rel = entry.get("source").and_then(|v| v.as_str())?.to_string();
let existing_symlinks = entry
.get("symlinks")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
Some(ParsedConfigEntry {
on_disk_root,
import_type,
source_rel,
existing_symlinks,
})
}
fn for_each_config_entry(doc: &DocumentMut, mut visit: impl FnMut(&str, usize, &Table)) {
let Some(pkg_table) = doc.get("pkg").and_then(Item::as_table) else {
return;
};
for (pkg_key_raw, pkg_item) in pkg_table.iter() {
let Some(configs) = pkg_item
.as_table()
.and_then(|t| t.get("configs"))
.and_then(Item::as_array_of_tables)
else {
continue;
};
for (idx, entry) in configs.iter().enumerate() {
visit(pkg_key_raw, idx, entry);
}
}
}
fn find_matching_config(
doc: &DocumentMut,
home_tail: &Path,
) -> Result<Option<ConfigMatch>, ImportError> {
let tail_str = path_to_forward_slash(home_tail);
let mut matches: Vec<ConfigMatch> = Vec::new();
for_each_config_entry(doc, |pkg_key_raw, idx, entry| {
let Some(parsed) = parse_config_entry(entry, pkg_key_raw) else {
return;
};
let prefix = format!("{}/", parsed.on_disk_root);
let Some(file_rel_str) = tail_str
.strip_prefix(&prefix)
.filter(|rest| !rest.is_empty())
else {
return;
};
matches.push(ConfigMatch {
pkg_key: SmolStr::new(pkg_key_raw),
config_index: idx,
r#type: parsed.import_type,
on_disk_root: parsed.on_disk_root,
source_rel: parsed.source_rel,
file_rel: PathBuf::from(file_rel_str),
existing_symlinks: parsed.existing_symlinks,
});
});
let max_len = match matches.iter().map(|m| m.on_disk_root.len()).max() {
Some(n) => n,
None => return Ok(None),
};
let best: Vec<ConfigMatch> = matches
.into_iter()
.filter(|m| m.on_disk_root.len() == max_len)
.collect();
if best.len() > 1 {
return Err(ImportError::AmbiguousConfigMatch {
path: PathBuf::from(tail_str),
candidates: best.into_iter().map(|m| m.pkg_key).collect(),
});
}
Ok(best.into_iter().next())
}
fn find_managed_root(
doc: &DocumentMut,
home_tail: &Path,
) -> Result<Option<RootMatch>, ImportError> {
let tail_str = path_to_forward_slash(home_tail);
let mut matches: Vec<RootMatch> = Vec::new();
for_each_config_entry(doc, |pkg_key_raw, idx, entry| {
let Some(parsed) = parse_config_entry(entry, pkg_key_raw) else {
return;
};
if parsed.on_disk_root != tail_str {
return;
}
matches.push(RootMatch {
pkg_key: SmolStr::new(pkg_key_raw),
config_index: idx,
r#type: parsed.import_type,
on_disk_root: parsed.on_disk_root,
source_rel: parsed.source_rel,
existing_symlinks: parsed.existing_symlinks,
});
});
if matches.len() > 1 {
return Err(ImportError::AmbiguousConfigMatch {
path: PathBuf::from(tail_str),
candidates: matches.into_iter().map(|m| m.pkg_key).collect(),
});
}
Ok(matches.into_iter().next())
}
fn build_extend_plan(
matched: ConfigMatch,
canonical_source: PathBuf,
canonical_home: &Path,
dirs: &ConfigFileDirs,
) -> Result<Plan, Error> {
let source_root = canonical_home.join(&matched.on_disk_root);
let repo_dest = dirs.zenops().join(&matched.source_rel);
let file_rel_str = path_to_forward_slash(&matched.file_rel);
let symlink_path = source_root.join(&matched.file_rel);
let dest_in_repo = repo_dest.join(&matched.file_rel);
let mut files = Vec::new();
if dest_in_repo.try_exists().unwrap_or(false) {
let already_symlinked = symlink_path
.symlink_metadata()
.is_ok_and(|m| m.is_symlink())
&& fs::read_link(&symlink_path).is_ok_and(|t| t == dest_in_repo);
if !already_symlinked {
return Err(ImportError::DestExists(dest_in_repo).into());
}
} else {
files.push(PlannedFile {
rel: matched.file_rel.clone(),
repo_rel: matched.file_rel.clone(),
});
}
let added_symlinks = if matched.existing_symlinks.iter().any(|s| s == &file_rel_str) {
Vec::new()
} else {
vec![file_rel_str]
};
let toml = TomlPlan::ExtendExisting {
config_index: matched.config_index,
existing_symlinks: matched.existing_symlinks,
added_symlinks,
};
Ok(Plan {
pkg_key: matched.pkg_key,
r#type: matched.r#type,
source: canonical_source,
source_root,
repo_dest,
repo_rel: matched.source_rel,
files,
skipped: Vec::new(),
removed_files: Vec::new(),
renamed_files: Vec::new(),
toml,
})
}
fn build_reconcile_plan(
matched: RootMatch,
canonical_source: PathBuf,
canonical_home: &Path,
dirs: &ConfigFileDirs,
) -> Result<Plan, Error> {
if !canonical_source.is_dir() {
return Err(ImportError::UnsupportedLayout(matched.on_disk_root).into());
}
let source_root = canonical_home.join(&matched.on_disk_root);
let repo_dest = dirs.zenops().join(&matched.source_rel);
let mut walked = Vec::new();
let mut skipped = Vec::new();
walk_dir(&source_root, &mut walked, &mut skipped, Path::new(""))?;
let mut files: Vec<PlannedFile> = Vec::new();
let mut added_symlinks: Vec<String> = Vec::new();
for entry in walked {
let rel_str = path_to_forward_slash(&entry.rel);
let symlink_path = source_root.join(&entry.rel);
let dest_in_repo = repo_dest.join(&entry.repo_rel);
let already_in_array = matched.existing_symlinks.iter().any(|s| s == &rel_str);
let already_symlinked = symlink_path
.symlink_metadata()
.is_ok_and(|m| m.is_symlink())
&& fs::read_link(&symlink_path).is_ok_and(|t| t == dest_in_repo);
if already_symlinked {
if !already_in_array {
added_symlinks.push(rel_str);
}
continue;
}
if already_in_array {
skipped.push(SkippedEntry {
path: entry.rel,
reason: SmolStr::new_static("present_but_not_linked"),
});
continue;
}
if dest_in_repo.try_exists().unwrap_or(false) {
return Err(ImportError::DestExists(dest_in_repo).into());
}
files.push(PlannedFile {
rel: entry.rel.clone(),
repo_rel: entry.repo_rel,
});
added_symlinks.push(rel_str);
}
let mut classified_skipped: Vec<SkippedEntry> = Vec::with_capacity(skipped.len());
let mut renamed_files: Vec<RenamedFile> = Vec::new();
let mut rename_from_set: Vec<String> = Vec::new();
for s in skipped {
if s.reason != "symlink" {
classified_skipped.push(s);
continue;
}
let abs = source_root.join(&s.path);
let target = fs::read_link(&abs).ok();
let in_repo_at_walk = repo_dest.join(&s.path);
if target.as_deref() == Some(in_repo_at_walk.as_path()) {
continue;
}
let new_rel_str = path_to_forward_slash(&s.path);
let new_in_array = matched.existing_symlinks.iter().any(|e| e == &new_rel_str);
let detected_rename = if new_in_array {
None
} else if let Some(t) = target.as_deref()
&& let Ok(target_rel) = t.strip_prefix(&repo_dest)
{
let from_rel_str = path_to_forward_slash(target_rel);
if from_rel_str != new_rel_str
&& matched.existing_symlinks.iter().any(|e| e == &from_rel_str)
&& !rename_from_set.iter().any(|s| s == &from_rel_str)
{
Some((PathBuf::from(target_rel), from_rel_str))
} else {
None
}
} else {
None
};
match detected_rename {
Some((from_path, from_rel_str)) => {
rename_from_set.push(from_rel_str);
renamed_files.push(RenamedFile {
from: from_path,
to: s.path,
});
}
None => classified_skipped.push(SkippedEntry {
path: s.path,
reason: SmolStr::new_static("symlink_elsewhere"),
}),
}
}
let mut removed_symlinks: Vec<String> = Vec::new();
let mut removed_files: Vec<RemovedFile> = Vec::new();
for rel in &matched.existing_symlinks {
if rename_from_set.iter().any(|f| f == rel) {
continue;
}
let home_path = source_root.join(rel);
let exists = home_path.symlink_metadata().is_ok();
if !exists {
removed_symlinks.push(rel.clone());
removed_files.push(RemovedFile {
repo_rel: PathBuf::from(rel),
});
}
}
for r in &renamed_files {
added_symlinks.push(path_to_forward_slash(&r.to));
removed_symlinks.push(path_to_forward_slash(&r.from));
}
let toml = TomlPlan::Reconcile {
config_index: matched.config_index,
existing_symlinks: matched.existing_symlinks,
added_symlinks,
removed_symlinks,
};
Ok(Plan {
pkg_key: matched.pkg_key,
r#type: matched.r#type,
source: canonical_source,
source_root,
repo_dest,
repo_rel: matched.source_rel,
files,
skipped: classified_skipped,
removed_files,
renamed_files,
toml,
})
}
fn resolve_brew(
pkg_key: &SmolStr,
brew: &[String],
no_install_hint: bool,
created_pkg: bool,
prompter: &mut Option<&mut dyn LinePrompter>,
) -> Result<Vec<String>, Error> {
if !brew.is_empty() {
return Ok(brew.to_vec());
}
if no_install_hint || !created_pkg {
return Ok(Vec::new());
}
match prompter.as_deref_mut() {
Some(p) => {
match read_trimmed(p, &format!("Brew package(s) for `{pkg_key}` [{pkg_key}]: "))? {
Some(line) => Ok(line.split_whitespace().map(str::to_string).collect()),
None => Ok(vec![pkg_key.to_string()]),
}
}
None => Err(ImportError::MissingInstallHint(pkg_key.to_string()).into()),
}
}
fn read_trimmed(p: &mut dyn LinePrompter, prompt: &str) -> Result<Option<String>, Error> {
let line = crate::prompt::ask_line(p, prompt)?;
Ok(line.and_then(|s| {
let trimmed = s.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}))
}
fn confirm(p: &mut dyn LinePrompter, prompt: &str) -> Result<bool, Error> {
loop {
let answer = read_trimmed(p, &format!("{prompt} [y/N]: "))?;
match answer.as_deref().map(str::to_ascii_lowercase).as_deref() {
None | Some("n" | "no") => return Ok(false),
Some("y" | "yes") => return Ok(true),
_ => p
.writeln("Please answer y or n.")
.map_err(crate::prompt::PromptError::Read)?,
}
}
}
fn apply_files(plan: &Plan, dirs: &ConfigFileDirs, output: &mut dyn Output) -> Result<(), Error> {
let mut undo = UndoLog::default();
if !plan.repo_dest.try_exists().unwrap_or(false) {
fs::create_dir_all(&plan.repo_dest)
.map_err(|e| ImportError::Io(plan.repo_dest.clone(), e))?;
undo.dirs_created.push(plan.repo_dest.clone());
push_dir_event(output, dirs, &plan.repo_rel)?;
}
for f in &plan.files {
let src = plan.source_root.join(&f.rel);
let dst = plan.repo_dest.join(&f.repo_rel);
if let Some(parent) = dst.parent()
&& !parent.try_exists().unwrap_or(false)
{
if let Err(e) = fs::create_dir_all(parent) {
rollback(&undo);
return Err(ImportError::Io(parent.to_path_buf(), e).into());
}
undo.dirs_created.push(parent.to_path_buf());
}
if let Err(e) = fs::copy(&src, &dst) {
rollback(&undo);
return Err(ImportError::Copy {
src,
dst,
source: e,
}
.into());
}
undo.files_copied.push(dst.clone());
push_file_event(output, dirs, &plan.repo_rel, &f.repo_rel)?;
}
for f in &plan.files {
let symlink_path = plan.source_root.join(&f.rel);
let target = plan.repo_dest.join(&f.repo_rel);
if let Err(e) = fs::remove_file(&symlink_path) {
rollback(&undo);
return Err(ImportError::RemoveOriginal(symlink_path, e).into());
}
if let Err(e) = std::os::unix::fs::symlink(&target, &symlink_path) {
rollback(&undo);
return Err(ImportError::Symlink {
real: target,
symlink: symlink_path,
source: e,
}
.into());
}
undo.symlinks_created
.push((symlink_path.clone(), target.clone()));
push_symlink_event(
output,
dirs,
&plan.repo_rel,
&f.repo_rel,
&plan.source_root,
&f.rel,
)?;
}
for r in &plan.renamed_files {
let from_abs = plan.repo_dest.join(&r.from);
let to_abs = plan.repo_dest.join(&r.to);
if let Some(parent) = to_abs.parent()
&& !parent.try_exists().unwrap_or(false)
{
fs::create_dir_all(parent).map_err(|e| ImportError::Io(parent.to_path_buf(), e))?;
}
fs::rename(&from_abs, &to_abs).map_err(|e| ImportError::Io(from_abs.clone(), e))?;
push_renamed_file_event(output, dirs, &plan.repo_rel, &r.from, &r.to)?;
let home_link = plan.source_root.join(&r.to);
if let Err(e) = fs::remove_file(&home_link) {
return Err(ImportError::RemoveOriginal(home_link, e).into());
}
if let Err(e) = std::os::unix::fs::symlink(&to_abs, &home_link) {
return Err(ImportError::Symlink {
real: to_abs.clone(),
symlink: home_link,
source: e,
}
.into());
}
push_symlink_event(
output,
dirs,
&plan.repo_rel,
&r.to,
&plan.source_root,
&r.to,
)?;
let mut parent = from_abs.parent().map(Path::to_path_buf);
while let Some(p) = parent {
if p == plan.repo_dest || !p.starts_with(&plan.repo_dest) {
break;
}
match fs::read_dir(&p) {
Ok(mut iter) => {
if iter.next().is_some() {
break;
}
}
Err(_) => break,
}
if fs::remove_dir(&p).is_err() {
break;
}
push_removed_dir_event_abs(output, dirs, &p)?;
parent = p.parent().map(Path::to_path_buf);
}
}
for r in &plan.removed_files {
let target = plan.repo_dest.join(&r.repo_rel);
if target.try_exists().unwrap_or(false) {
fs::remove_file(&target).map_err(|e| ImportError::Io(target.clone(), e))?;
}
push_removed_file_event(output, dirs, &plan.repo_rel, &r.repo_rel)?;
let mut parent = target.parent().map(Path::to_path_buf);
while let Some(p) = parent {
if p == plan.repo_dest || !p.starts_with(&plan.repo_dest) {
break;
}
match fs::read_dir(&p) {
Ok(mut iter) => {
if iter.next().is_some() {
break;
}
}
Err(_) => break,
}
if fs::remove_dir(&p).is_err() {
break;
}
push_removed_dir_event_abs(output, dirs, &p)?;
parent = p.parent().map(Path::to_path_buf);
}
}
Ok(())
}
#[derive(Default)]
struct UndoLog {
dirs_created: Vec<PathBuf>,
files_copied: Vec<PathBuf>,
symlinks_created: Vec<(PathBuf, PathBuf)>,
}
fn rollback(undo: &UndoLog) {
for (symlink, target) in undo.symlinks_created.iter().rev() {
let _ = fs::remove_file(symlink);
if target.try_exists().unwrap_or(false) {
let _ = fs::copy(target, symlink);
}
}
for f in undo.files_copied.iter().rev() {
let _ = fs::remove_file(f);
}
for d in undo.dirs_created.iter().rev() {
let _ = fs::remove_dir(d);
}
}
fn push_dir_event(
output: &mut dyn Output,
dirs: &ConfigFileDirs,
repo_rel: &str,
) -> Result<(), Error> {
let path = ConfigFilePath::Zenops(
zenops_safe_relative_path::SafeRelativePath::from_relative_path(repo_rel)?.into(),
);
let resolved = crate::output::ResolvedConfigFilePath {
full: std::sync::Arc::from(path.resolved(dirs)),
path,
};
output
.push(Event::AppliedAction(AppliedAction::CreatedDir(resolved)))
.map_err(Into::into)
}
fn push_file_event(
output: &mut dyn Output,
dirs: &ConfigFileDirs,
repo_rel: &str,
file_rel: &Path,
) -> Result<(), Error> {
let joined = format!("{repo_rel}/{}", path_to_forward_slash(file_rel));
let path = ConfigFilePath::Zenops(
zenops_safe_relative_path::SafeRelativePath::from_relative_path(&joined)?.into(),
);
let resolved = crate::output::ResolvedConfigFilePath {
full: std::sync::Arc::from(path.resolved(dirs)),
path,
};
output
.push(Event::AppliedAction(AppliedAction::CreatedFile(resolved)))
.map_err(Into::into)
}
fn push_symlink_event(
output: &mut dyn Output,
dirs: &ConfigFileDirs,
repo_rel: &str,
file_rel: &Path,
home_root: &Path,
home_file_rel: &Path,
) -> Result<(), Error> {
let real_joined = format!("{repo_rel}/{}", path_to_forward_slash(file_rel));
let real_path = ConfigFilePath::Zenops(
zenops_safe_relative_path::SafeRelativePath::from_relative_path(&real_joined)?.into(),
);
let real = crate::output::ResolvedConfigFilePath {
full: std::sync::Arc::from(real_path.resolved(dirs)),
path: real_path,
};
let symlink_full = home_root.join(home_file_rel);
let home_rel = symlink_full
.strip_prefix(dirs.home())
.map(Path::to_path_buf)
.unwrap_or_else(|_| symlink_full.clone());
let home_rel_str = path_to_forward_slash(&home_rel);
let symlink_path = ConfigFilePath::Home(
zenops_safe_relative_path::SafeRelativePath::from_relative_path(&home_rel_str)?.into(),
);
let symlink = crate::output::ResolvedConfigFilePath {
full: std::sync::Arc::from(symlink_full.as_path()),
path: symlink_path,
};
output
.push(Event::AppliedAction(AppliedAction::CreatedSymlink {
real,
symlink,
}))
.map_err(Into::into)
}
fn push_removed_file_event(
output: &mut dyn Output,
dirs: &ConfigFileDirs,
repo_rel: &str,
file_rel: &Path,
) -> Result<(), Error> {
let joined = format!("{repo_rel}/{}", path_to_forward_slash(file_rel));
let path = ConfigFilePath::Zenops(
zenops_safe_relative_path::SafeRelativePath::from_relative_path(&joined)?.into(),
);
let resolved = crate::output::ResolvedConfigFilePath {
full: std::sync::Arc::from(path.resolved(dirs)),
path,
};
output
.push(Event::AppliedAction(AppliedAction::RemovedFile(resolved)))
.map_err(Into::into)
}
fn push_removed_dir_event_abs(
output: &mut dyn Output,
dirs: &ConfigFileDirs,
abs: &Path,
) -> Result<(), Error> {
let zenops_root = dirs.zenops();
let rel = match abs.strip_prefix(zenops_root) {
Ok(r) => path_to_forward_slash(r),
Err(_) => return Ok(()),
};
if rel.is_empty() {
return Ok(());
}
let path = ConfigFilePath::Zenops(
zenops_safe_relative_path::SafeRelativePath::from_relative_path(&rel)?.into(),
);
let resolved = crate::output::ResolvedConfigFilePath {
full: std::sync::Arc::from(path.resolved(dirs)),
path,
};
output
.push(Event::AppliedAction(AppliedAction::RemovedDir(resolved)))
.map_err(Into::into)
}
fn push_renamed_file_event(
output: &mut dyn Output,
dirs: &ConfigFileDirs,
repo_rel: &str,
from_rel: &Path,
to_rel: &Path,
) -> Result<(), Error> {
let from_joined = format!("{repo_rel}/{}", path_to_forward_slash(from_rel));
let to_joined = format!("{repo_rel}/{}", path_to_forward_slash(to_rel));
let from_path = ConfigFilePath::Zenops(
zenops_safe_relative_path::SafeRelativePath::from_relative_path(&from_joined)?.into(),
);
let to_path = ConfigFilePath::Zenops(
zenops_safe_relative_path::SafeRelativePath::from_relative_path(&to_joined)?.into(),
);
let from = crate::output::ResolvedConfigFilePath {
full: std::sync::Arc::from(from_path.resolved(dirs)),
path: from_path,
};
let to = crate::output::ResolvedConfigFilePath {
full: std::sync::Arc::from(to_path.resolved(dirs)),
path: to_path,
};
output
.push(Event::AppliedAction(AppliedAction::RenamedFile {
from,
to,
}))
.map_err(Into::into)
}
fn path_to_forward_slash(p: &Path) -> String {
let mut out = String::new();
for (i, c) in p.components().enumerate() {
if i > 0 {
out.push('/');
}
out.push_str(&c.as_os_str().to_string_lossy());
}
out
}
fn update_doc(
doc: &mut DocumentMut,
plan: &Plan,
created_pkg: bool,
no_install_hint: bool,
brew: &[String],
) -> Result<(), Error> {
if let TomlPlan::ExtendExisting {
config_index,
added_symlinks,
..
} = &plan.toml
{
append_symlinks_to_configs_entry(doc, &plan.pkg_key, *config_index, added_symlinks);
return Ok(());
}
if let TomlPlan::Reconcile {
config_index,
added_symlinks,
removed_symlinks,
..
} = &plan.toml
{
append_symlinks_to_configs_entry(doc, &plan.pkg_key, *config_index, added_symlinks);
remove_symlinks_from_configs_entry(doc, &plan.pkg_key, *config_index, removed_symlinks);
return Ok(());
}
let pkg_root = doc
.entry("pkg")
.or_insert_with(|| Item::Table(Table::new()))
.as_table_mut()
.expect("pkg should be a table");
pkg_root.set_implicit(true);
let key_table = pkg_root
.entry(&plan.pkg_key)
.or_insert_with(|| Item::Table(Table::new()))
.as_table_mut()
.expect("pkg.<key> should be a table");
if created_pkg {
populate_install_hint(key_table, brew, no_install_hint);
}
let configs_item = key_table
.entry("configs")
.or_insert_with(|| Item::ArrayOfTables(ArrayOfTables::new()));
let configs = configs_item
.as_array_of_tables_mut()
.expect("configs should be an array of tables");
configs.push(build_configs_entry_table(plan));
Ok(())
}
fn append_symlinks_to_configs_entry(
doc: &mut DocumentMut,
pkg_key: &str,
config_index: usize,
new_paths: &[String],
) {
if new_paths.is_empty() {
return;
}
let entry = doc
.get_mut("pkg")
.and_then(|p| p.as_table_mut())
.and_then(|t| t.get_mut(pkg_key))
.and_then(|p| p.as_table_mut())
.and_then(|t| t.get_mut("configs"))
.and_then(Item::as_array_of_tables_mut)
.and_then(|aot| aot.get_mut(config_index))
.expect("matched configs entry should still exist");
if entry.get("symlinks").is_none() {
entry["symlinks"] = value(Array::new());
}
let arr = entry["symlinks"]
.as_array_mut()
.expect("symlinks should be an array");
for path in new_paths {
arr.push(path.as_str());
}
}
fn remove_symlinks_from_configs_entry(
doc: &mut DocumentMut,
pkg_key: &str,
config_index: usize,
paths: &[String],
) {
if paths.is_empty() {
return;
}
let entry = doc
.get_mut("pkg")
.and_then(|p| p.as_table_mut())
.and_then(|t| t.get_mut(pkg_key))
.and_then(|p| p.as_table_mut())
.and_then(|t| t.get_mut("configs"))
.and_then(Item::as_array_of_tables_mut)
.and_then(|aot| aot.get_mut(config_index))
.expect("matched configs entry should still exist");
let Some(arr) = entry.get_mut("symlinks").and_then(|v| v.as_array_mut()) else {
return;
};
let mut idx = 0;
while idx < arr.len() {
let drop = arr
.get(idx)
.and_then(|v| v.as_str())
.is_some_and(|s| paths.iter().any(|p| p == s));
if drop {
arr.remove(idx);
} else {
idx += 1;
}
}
}
fn populate_install_hint(key_table: &mut Table, brew: &[String], no_install_hint: bool) {
let install_hint = key_table
.entry("install_hint")
.or_insert_with(|| Item::Table(Table::new()))
.as_table_mut()
.expect("install_hint should be a table");
let brew_t = install_hint
.entry("brew")
.or_insert_with(|| Item::Table(Table::new()))
.as_table_mut()
.expect("brew should be a table");
let mut arr = Array::new();
if !no_install_hint {
for pkg in brew {
arr.push(pkg.as_str());
}
}
brew_t["packages"] = value(arr);
let dnf5_t = install_hint
.entry("dnf5")
.or_insert_with(|| Item::Table(Table::new()))
.as_table_mut()
.expect("dnf5 should be a table");
dnf5_t["packages"] = value(Array::new());
let apt_t = install_hint
.entry("apt")
.or_insert_with(|| Item::Table(Table::new()))
.as_table_mut()
.expect("apt should be a table");
apt_t["packages"] = value(Array::new());
let pacman_t = install_hint
.entry("pacman")
.or_insert_with(|| Item::Table(Table::new()))
.as_table_mut()
.expect("pacman should be a table");
pacman_t["packages"] = value(Array::new());
let cargo_t = install_hint
.entry("cargo")
.or_insert_with(|| Item::Table(Table::new()))
.as_table_mut()
.expect("cargo should be a table");
cargo_t["packages"] = value(Array::new());
}
fn build_configs_entry_table(plan: &Plan) -> Table {
let mut entry = Table::new();
match &plan.toml {
TomlPlan::DotConfig {
name_override,
symlinks,
} => {
entry["type"] = value(".config");
if let Some(name) = name_override {
entry["name"] = value(name.as_str());
}
entry["source"] = value(plan.repo_rel.as_str());
let mut arr = Array::new();
for s in symlinks {
arr.push(s.as_str());
}
entry["symlinks"] = value(arr);
}
TomlPlan::Home { dir, symlinks } => {
entry["type"] = value("home");
entry["dir"] = value(dir.as_str());
entry["source"] = value(plan.repo_rel.as_str());
let mut arr = Array::new();
for s in symlinks {
arr.push(s.as_str());
}
entry["symlinks"] = value(arr);
}
TomlPlan::ExtendExisting { .. } | TomlPlan::Reconcile { .. } => {
unreachable!(
"extend / reconcile modes emit AppendSymlinks / TrimSymlinks, not a new configs entry"
)
}
}
entry
}
fn plan_to_event(
plan: &Plan,
created_pkg: bool,
no_install_hint: bool,
brew_packages: &[String],
) -> ImportPlan {
let mut file_actions = Vec::with_capacity(
plan.files.len() + plan.skipped.len() + plan.removed_files.len() + plan.renamed_files.len(),
);
for f in &plan.files {
file_actions.push(ImportFileAction::MoveAndSymlink { rel: f.rel.clone() });
}
for r in &plan.renamed_files {
file_actions.push(ImportFileAction::RenameInRepo {
from: r.from.clone(),
to: r.to.clone(),
});
}
for r in &plan.removed_files {
file_actions.push(ImportFileAction::RemoveFromRepo {
rel: r.repo_rel.clone(),
});
}
for s in &plan.skipped {
file_actions.push(ImportFileAction::Skip {
path: s.path.clone(),
reason: s.reason.clone(),
});
}
let mut toml_changes = Vec::new();
match &plan.toml {
TomlPlan::ExtendExisting {
config_index,
existing_symlinks,
added_symlinks,
} => {
toml_changes.push(ImportTomlChange::AppendSymlinks {
pkg: plan.pkg_key.clone(),
config_index: *config_index,
paths: added_symlinks.clone(),
array_after_preview: render_symlinks_array_preview(
existing_symlinks,
added_symlinks,
&[],
),
});
}
TomlPlan::Reconcile {
config_index,
existing_symlinks,
added_symlinks,
removed_symlinks,
} => {
let after_preview =
render_symlinks_array_preview(existing_symlinks, added_symlinks, removed_symlinks);
if !added_symlinks.is_empty() {
toml_changes.push(ImportTomlChange::AppendSymlinks {
pkg: plan.pkg_key.clone(),
config_index: *config_index,
paths: added_symlinks.clone(),
array_after_preview: after_preview.clone(),
});
}
if !removed_symlinks.is_empty() {
toml_changes.push(ImportTomlChange::TrimSymlinks {
pkg: plan.pkg_key.clone(),
config_index: *config_index,
paths: removed_symlinks.clone(),
array_after_preview: after_preview,
});
}
}
TomlPlan::DotConfig { .. } | TomlPlan::Home { .. } => {
if created_pkg {
toml_changes.push(ImportTomlChange::CreatePkg {
pkg: plan.pkg_key.clone(),
brew_packages: brew_packages.to_vec(),
block_preview: render_pkg_block_preview(
&plan.pkg_key,
brew_packages,
no_install_hint,
),
});
}
toml_changes.push(ImportTomlChange::AppendConfigsEntry {
pkg: plan.pkg_key.clone(),
entry_preview: render_configs_entry_preview(plan),
});
}
}
let mode = match &plan.toml {
TomlPlan::Reconcile { .. } => ImportMode::Reconcile,
TomlPlan::ExtendExisting { .. } => ImportMode::Extend,
TomlPlan::DotConfig { .. } | TomlPlan::Home { .. } => {
if created_pkg {
ImportMode::NewPkg
} else {
ImportMode::Extend
}
}
};
ImportPlan {
pkg: plan.pkg_key.clone(),
created_pkg,
mode,
r#type: plan.r#type,
source: plan.source.clone(),
repo_dest: plan.repo_dest.clone(),
file_actions,
toml_changes,
}
}
fn render_symlinks_array_preview(
existing: &[String],
added: &[String],
removed: &[String],
) -> String {
let mut arr = Array::new();
for s in existing {
if removed.iter().any(|r| r == s) {
continue;
}
arr.push(s.as_str());
}
for s in added {
arr.push(s.as_str());
}
format!("symlinks = {arr}")
}
fn render_pkg_block_preview(pkg_key: &str, brew: &[String], no_install_hint: bool) -> String {
let mut doc = DocumentMut::new();
let pkg_root = doc
.entry("pkg")
.or_insert_with(|| Item::Table(Table::new()))
.as_table_mut()
.expect("pkg should be a table");
pkg_root.set_implicit(true);
let key_table = pkg_root
.entry(pkg_key)
.or_insert_with(|| Item::Table(Table::new()))
.as_table_mut()
.expect("pkg.<key> should be a table");
populate_install_hint(key_table, brew, no_install_hint);
if let Some(ih) = key_table
.get_mut("install_hint")
.and_then(Item::as_table_mut)
{
ih.set_implicit(true);
if let Some(brew_t) = ih.get_mut("brew").and_then(Item::as_table_mut) {
brew_t.set_implicit(true);
}
}
doc.to_string().trim_end_matches('\n').to_string()
}
fn render_configs_entry_preview(plan: &Plan) -> String {
let entry = build_configs_entry_table(plan);
entry.to_string().trim_end_matches('\n').to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn classify_dot_config() {
let layout = classify(Path::new(".config/helix")).unwrap();
assert!(matches!(
&layout,
Layout::DotConfig { dir, sub_path: None } if dir == "helix",
));
}
#[test]
fn classify_home_dotfile() {
let layout = classify(Path::new(".zshrc")).unwrap();
assert!(matches!(&layout, Layout::Home { name } if name == ".zshrc"));
}
#[test]
fn classify_home_dotdir() {
let layout = classify(Path::new(".ssh")).unwrap();
assert!(matches!(&layout, Layout::Home { name } if name == ".ssh"));
}
#[test]
fn classify_dot_config_with_nested_file() {
let layout = classify(Path::new(".config/helix/themes/onedark.toml")).unwrap();
match layout {
Layout::DotConfig { dir, sub_path } => {
assert_eq!(dir, "helix");
assert_eq!(sub_path, Some(PathBuf::from("themes/onedark.toml")));
}
other => panic!("expected DotConfig with sub_path, got {other:?}"),
}
}
#[test]
fn classify_dot_config_with_single_nested_component() {
let layout = classify(Path::new(".config/myapp/config.toml")).unwrap();
match layout {
Layout::DotConfig { dir, sub_path } => {
assert_eq!(dir, "myapp");
assert_eq!(sub_path, Some(PathBuf::from("config.toml")));
}
other => panic!("expected DotConfig with sub_path, got {other:?}"),
}
}
#[test]
fn classify_rejects_non_dot_home() {
let err = classify(Path::new("dotfiles")).unwrap_err();
assert!(matches!(err, ImportError::UnsupportedLayout(_)));
}
#[test]
fn classify_rejects_nested_home_dotdir() {
let err = classify(Path::new(".ssh/config")).unwrap_err();
assert!(matches!(err, ImportError::UnsupportedLayout(_)));
}
#[test]
fn derive_pkg_key_strips_leading_dot_for_home() {
let layout = Layout::Home {
name: ".zshrc".into(),
};
let key = derive_pkg_key(&layout, None).unwrap();
assert_eq!(key.as_str(), "zshrc");
}
#[test]
fn derive_pkg_key_uses_dir_for_dotconfig() {
let layout = Layout::DotConfig {
dir: "helix".into(),
sub_path: None,
};
let key = derive_pkg_key(&layout, None).unwrap();
assert_eq!(key.as_str(), "helix");
}
#[test]
fn derive_pkg_key_ignores_sub_path_for_dotconfig() {
let layout = Layout::DotConfig {
dir: "some-app".into(),
sub_path: Some(PathBuf::from("dir/file.json")),
};
let key = derive_pkg_key(&layout, None).unwrap();
assert_eq!(key.as_str(), "some-app");
}
#[test]
fn derive_pkg_key_honors_override() {
let layout = Layout::DotConfig {
dir: "nvim".into(),
sub_path: None,
};
let key = derive_pkg_key(&layout, Some("neovim")).unwrap();
assert_eq!(key.as_str(), "neovim");
}
use crate::line_prompter::BufReadPrompter;
#[test]
fn resolve_brew_uses_explicit_flag() {
let mut prompter: Option<&mut dyn LinePrompter> = None;
let pkg = SmolStr::new_static("foo");
let got =
resolve_brew(&pkg, &["a".into(), "b".into()], false, true, &mut prompter).unwrap();
assert_eq!(got, vec!["a".to_string(), "b".to_string()]);
}
#[test]
fn resolve_brew_no_install_hint_returns_empty() {
let mut prompter: Option<&mut dyn LinePrompter> = None;
let pkg = SmolStr::new_static("foo");
let got = resolve_brew(&pkg, &[], true, true, &mut prompter).unwrap();
assert!(got.is_empty());
}
#[test]
fn resolve_brew_existing_pkg_returns_empty() {
let mut prompter: Option<&mut dyn LinePrompter> = None;
let pkg = SmolStr::new_static("foo");
let got = resolve_brew(&pkg, &[], false, false, &mut prompter).unwrap();
assert!(got.is_empty());
}
#[test]
fn resolve_brew_new_pkg_no_prompter_errors() {
let mut prompter: Option<&mut dyn LinePrompter> = None;
let pkg = SmolStr::new_static("foo");
let err = resolve_brew(&pkg, &[], false, true, &mut prompter).unwrap_err();
match err {
Error::Import(ImportError::MissingInstallHint(k)) => assert_eq!(k, "foo"),
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn resolve_brew_prompts_uses_default_on_blank() {
let input = b"\n";
let mut p = BufReadPrompter::new(&input[..], Vec::<u8>::new());
let mut handle: Option<&mut dyn LinePrompter> = Some(&mut p);
let pkg = SmolStr::new_static("foo");
let got = resolve_brew(&pkg, &[], false, true, &mut handle).unwrap();
assert_eq!(got, vec!["foo".to_string()]);
}
#[test]
fn resolve_brew_prompts_splits_whitespace() {
let input = b"a b c\n";
let mut p = BufReadPrompter::new(&input[..], Vec::<u8>::new());
let mut handle: Option<&mut dyn LinePrompter> = Some(&mut p);
let pkg = SmolStr::new_static("foo");
let got = resolve_brew(&pkg, &[], false, true, &mut handle).unwrap();
assert_eq!(got, vec!["a".to_string(), "b".to_string(), "c".to_string()],);
}
#[test]
fn confirm_yes_then_no() {
let mut p = BufReadPrompter::new(&b"y\n"[..], Vec::<u8>::new());
assert!(confirm(&mut p, "Apply?").unwrap());
let mut p = BufReadPrompter::new(&b"n\n"[..], Vec::<u8>::new());
assert!(!confirm(&mut p, "Apply?").unwrap());
let mut p = BufReadPrompter::new(&b"\n"[..], Vec::<u8>::new());
assert!(!confirm(&mut p, "Apply?").unwrap());
}
}