use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use eyre::{Result, bail};
use toml_edit::{Array, DocumentMut, InlineTable, Item, Value};
pub(crate) use crate::config::edit::{declaration_file, read_document};
use crate::config::{Config, Settings};
use crate::file::{self, display_path};
use crate::path::PathExt;
use crate::system::files::{FileMode, FileRequest};
use crate::system::history::checkpoint::{Draft, Outcome, Store};
use crate::system::history::select::Variant;
use crate::system::history::store::Trigger;
use crate::system::history::tracked::{
CREDENTIAL_REASON, TrackedEntry, TrackedSet, normalize_target,
};
#[derive(Debug, usage_rs::Args)]
#[usage(verbatim_doc_comment, after_long_help = AFTER_LONG_HELP)]
pub(crate) struct DotfilesTrack {
#[usage(value_name = "PATH", required = true)]
targets: Vec<String>,
#[usage(long, value_name = "OS")]
os: Option<String>,
#[usage(long, value_name = "PROFILE")]
profile: Option<String>,
#[usage(long)]
no_autosave: bool,
#[usage(long)]
encrypt: bool,
#[usage(long, short)]
yes: bool,
#[usage(long, short = 'n')]
dry_run: bool,
}
const DRY_RUN_LINES: usize = 20;
impl DotfilesTrack {
pub(crate) async fn run(self) -> Result<()> {
let _declarations = if self.dry_run {
None
} else {
Some(declaration_lock()?)
};
let config = Config::get().await?;
if self.encrypt && !Settings::get().history.enabled {
bail!("dotfiles: cannot enroll encrypted paths while history is disabled");
}
if self.encrypt && inside_capture()? {
bail!(
"dotfiles: cannot enroll encrypted paths inside an active history capture; run `mise dot track --encrypt` separately so its baseline can be verified"
);
}
let managed = crate::system::files::composed_files_from_config(&config)?;
let global = declaration_file(false)?;
let mut edits: BTreeMap<PathBuf, DeclarationEdit> = BTreeMap::new();
let mut locations = BTreeMap::new();
let mut declared: Vec<(String, PathBuf)> = vec![];
let mut manual = vec![];
let exclude = crate::system::history::config::exclude_globs()?;
let effective =
match crate::system::history::shadow::HistoryRepo::path_in(&crate::dirs::STATE)
.join("HEAD")
.is_file()
{
true => TrackedSet::effective().await.unwrap_or_else(|err| {
debug!("dotfiles: previewing from declarations alone: {err:#}");
TrackedSet::default()
}),
false => TrackedSet::default(),
};
let mut preview_set = TrackedSet {
exclude: exclude.clone(),
..Default::default()
};
let mut resolved: Vec<(PathBuf, PathBuf)> = vec![];
for target_raw in &self.targets {
let target = crate::system::files::resolve_target_arg(target_raw)
.components()
.collect::<PathBuf>();
if target.is_relative() {
bail!("{target_raw}: target must be absolute or start with ~/");
}
crate::system::history::tracked::ensure_portable_ancestors(&target)?;
if let Some(invalid) = crate::system::files::invalid_declarations()
.into_iter()
.find(|invalid| {
invalid.cause == crate::system::files::Ignored::Unreadable
&& crate::system::files::resolve_target_arg(&invalid.target)
.components()
.collect::<PathBuf>()
== target
})
{
bail!(
"{target_raw} is already declared in {}, and that declaration cannot be read: {}. Fix it there, or remove it, before tracking this path again — re-tracking would replace it and lose what it says",
display_path(&invalid.config),
invalid.reason
);
}
let existing = managed
.iter()
.find(|req| req.target == target && req.mode == FileMode::Track);
let normalized = normalize_target(&target);
let mut entry = TrackedEntry::new(normalized.clone(), "track", self.policy(existing));
let saved = effective
.entry_for(&entry.path)
.filter(|resolved| resolved.path == entry.path);
entry.exclude = existing
.filter(|existing| existing.policy.explicit.exclude)
.map(|existing| {
existing
.exclude
.iter()
.map(|pattern| pattern.as_str().to_owned())
.collect()
})
.or_else(|| saved.and_then(|resolved| resolved.exclude.clone()));
entry.include = existing
.and_then(|existing| existing.include.as_ref())
.map(|patterns| {
patterns
.iter()
.map(|pattern| pattern.as_str().to_owned())
.collect()
})
.or_else(|| saved.and_then(|resolved| resolved.include.clone()));
preview_set.push(entry);
resolved.push((target, normalized));
}
for entry in TrackedSet::from_config(&config)?.entries {
preview_set.push(entry);
}
let targets: Vec<usize> = resolved
.iter()
.filter_map(|(_, normalized)| preview_set.entry_index_for(normalized))
.collect();
let preview_walk = preview_set.walk_selected(&targets)?;
preview_walk.report_warnings();
let mut previews: Vec<String> = vec![];
for (target, normalized) in resolved {
let target_key = normalized_target(&target);
let present = target.exists() || target.is_symlink();
if !present {
warn!(
"dotfiles: {} does not exist yet; it is captured once it does",
target.display_user()
);
}
let existing = managed
.iter()
.find(|req| req.target == target && req.mode == FileMode::Track);
let config_path = if let Some(existing) = existing
&& crate::config::is_global_config(&existing.origin.config)
&& !crate::config::is_system_config(&existing.origin.config)
{
existing.origin.config.clone()
} else if managed
.iter()
.any(|req| req.target == target && req.mode != FileMode::Track)
{
global
.parent()
.unwrap_or(Path::new("."))
.join("conf.d/dotfiles-tracking.toml")
} else {
global.clone()
};
let edit = match edits.entry(config_path.clone()) {
std::collections::btree_map::Entry::Occupied(entry) => entry.into_mut(),
std::collections::btree_map::Entry::Vacant(entry) => {
entry.insert(DeclarationEdit::read(&config_path)?)
}
};
let doc = &mut edit.document;
let declaration_key = existing
.filter(|req| req.origin.config == config_path)
.map_or(target_key.as_str(), |req| req.target_raw.as_str());
locations.insert(target_key.clone(), config_path);
let policy = self.policy(existing);
if !policy.autosave {
manual.push(target_key.clone());
}
let set = &preview_set;
let entry_index = set
.entry_index_for(&normalized)
.expect("every target is an entry of the preview set");
if !target.is_dir() {
let owner = &set.entries[entry_index];
if let Some(reason) = owner.capture_exclusion(&target) {
let advice = if reason == CREDENTIAL_REASON {
"; `mise dot track --encrypt` saves it encrypted"
} else {
""
};
if present {
warn!(
"dotfiles: {target_key} will be omitted from every save ({reason}){advice}"
);
} else {
warn!(
"dotfiles: {target_key} is omitted from every save if it is created as a file, never as a directory ({reason}){advice}"
);
}
}
}
let preview = preview_walk.preview_of(set, entry_index);
let summary = preview.summary();
if self.dry_run {
miseprintln!("{target_key}: {summary}");
for glob in &set.exclude {
miseprintln!(" exclude: {glob}");
}
for glob in set.entries[entry_index].exclude.iter().flatten() {
miseprintln!(" exclude ({target_key}): {glob}");
}
for glob in set.entries[entry_index].include.iter().flatten() {
miseprintln!(" include ({target_key}): {glob}");
}
if let Some(considered) = preview_walk.considered.get(&entry_index) {
if preview_walk.skipped.contains(&entry_index) {
miseprintln!(
" {target_key}: {} files (include list)",
crate::system::history::tracked::with_separators(preview.files),
);
} else {
miseprintln!(
" {target_key}: {} of {} files (include list)",
crate::system::history::tracked::with_separators(preview.files),
crate::system::history::tracked::with_separators(*considered as usize)
);
}
}
let lines: Vec<String> =
preview
.plaintext
.iter()
.map(|plaintext| {
format!("plaintext: {} ({})", plaintext.path, plaintext.reason)
})
.chain(preview.omitted.iter().map(|omitted| {
format!("omitted: {} ({})", omitted.path, omitted.reason)
}))
.chain(
preview.nested.iter().map(|nested| {
format!("nested: {} ({})", nested.path, nested.reason)
}),
)
.collect();
for line in lines.iter().take(DRY_RUN_LINES) {
miseprintln!(" {line}");
}
if lines.len() > DRY_RUN_LINES {
miseprintln!(
" ... {} more; `mise dot paths` lists them all once the path is tracked",
lines.len() - DRY_RUN_LINES
);
}
for incomplete in &preview.incomplete {
miseprintln!(" incomplete: {} ({})", incomplete.path, incomplete.reason);
}
}
if !self.dry_run {
for incomplete in &preview.incomplete {
warn!(
"dotfiles: {}: {}; the rest would not be captured either",
incomplete.path, incomplete.reason
);
}
}
if preview.is_large() {
warn!(
"dotfiles: {target_key} is a large tree ({summary}); exclude what does not belong in history, for example `mise dot exclude '{target_key}/<subdir>/**'`, or track its files individually"
);
}
previews.push(summary);
let previous_table = doc
.get("dotfiles")
.and_then(|dotfiles| dotfiles.get(declaration_key))
.and_then(Item::as_table_like);
let previous: Vec<String> = previous_table
.map(|table| table.iter().map(|(key, _)| key.to_string()).collect())
.unwrap_or_default();
let previous_exclude = previous_table
.and_then(|table| table.get("exclude"))
.and_then(Item::as_array)
.cloned();
let previous_include = previous_table
.and_then(|table| table.get("include"))
.and_then(Item::as_array)
.cloned();
let entry = self.entry(existing, &previous, previous_exclude, previous_include);
let dotfiles = doc
.entry("dotfiles")
.or_insert(Item::Table(toml_edit::Table::new()));
if let Some(table) = dotfiles.as_table_mut() {
table.set_implicit(false);
table.insert(declaration_key, Item::Value(Value::InlineTable(entry)));
} else {
doc["dotfiles"][declaration_key] = Item::Value(Value::InlineTable(entry));
}
declared.push((target_key, target));
}
if self.dry_run {
info!("dotfiles: dry run; nothing was tracked");
return Ok(());
}
if !self.yes && !Settings::get().yes && console::user_attended_stderr() {
let list = declared
.iter()
.zip(&previews)
.map(|((key, _), summary)| format!("{key} ({summary})"))
.collect::<Vec<_>>()
.join(", ");
if !crate::ui::prompt::confirm(format!("dotfiles: track {list}?"))?.is_yes() {
info!("dotfiles: skipped");
return Ok(());
}
}
let result = async {
for (path, edit) in &mut edits {
edit.write(path)?;
}
activate_and_baseline(&declared).await
}
.await;
if let Err(error) = result {
for (path, edit) in edits.iter().rev() {
if let Err(recovery) = edit.restore(path) {
warn!(
"dotfiles: could not restore {}: {recovery:#}",
display_path(path)
);
}
}
return Err(error);
}
for ((key, _), summary) in declared.iter().zip(&previews) {
info!(
"dotfiles: tracking {key} ({summary}; declared in {})",
display_path(&locations[key])
);
}
if !manual.is_empty() {
info!(
"history: manual saving selected for {}; run `mise dot save <path>` after editing",
manual.join(", ")
);
}
if manual.len() < declared.len() {
crate::cli::dotfiles::capture_health::report().await;
}
Ok(())
}
fn policy(&self, existing: Option<&FileRequest>) -> crate::system::files::FilePolicy {
let mut policy = existing
.map(|req| req.policy)
.unwrap_or_else(|| crate::system::files::FilePolicy::for_mode(FileMode::Track));
if self.no_autosave {
policy.autosave = false;
}
if self.encrypt {
policy.encrypt = true;
}
policy
}
fn entry(
&self,
existing: Option<&FileRequest>,
previous: &[String],
previous_exclude: Option<Array>,
previous_include: Option<Array>,
) -> InlineTable {
let mut table = InlineTable::new();
table.insert("mode", string("track"));
let policy = self.policy(existing);
let written = |key: &str| previous.iter().any(|written| written == key);
if self.encrypt || written("encrypt") {
table.insert(
"encrypt",
Value::Boolean(toml_edit::Formatted::new(policy.encrypt)),
);
}
if self.no_autosave || written("autosave") {
table.insert(
"autosave",
Value::Boolean(toml_edit::Formatted::new(policy.autosave)),
);
}
if existing.is_some() && written("exclude") {
let list = previous_exclude.unwrap_or_else(|| {
let mut list = Array::new();
for pattern in existing.iter().flat_map(|req| &req.exclude) {
list.push(string(pattern.as_str()));
}
list
});
table.insert("exclude", Value::Array(list));
}
if existing.is_some() && written("include") {
let list = previous_include.unwrap_or_else(|| {
let mut list = Array::new();
for pattern in existing
.iter()
.filter_map(|req| req.include.as_ref())
.flatten()
{
list.push(string(pattern.as_str()));
}
list
});
table.insert("include", Value::Array(list));
}
let mut variants: Vec<Variant> =
existing.map(|req| req.variants.clone()).unwrap_or_default();
if self.os.is_some() || self.profile.is_some() {
if existing.is_some() && variants.is_empty() {
variants.push(Variant {
os: vec![],
profile: None,
default: true,
});
}
let variant = Variant {
os: self.os.iter().cloned().collect(),
profile: self.profile.clone(),
default: false,
};
if !variants.iter().any(|existing| {
existing.os == variant.os
&& existing.profile == variant.profile
&& existing.default == variant.default
}) {
variants.push(variant);
}
}
if !variants.is_empty() {
let mut array = Array::new();
for variant in &variants {
let mut item = InlineTable::new();
match variant.os.as_slice() {
[] => {}
[os] => {
item.insert("os", string(os));
}
many => {
let mut list = Array::new();
for os in many {
list.push(string(os));
}
item.insert("os", Value::Array(list));
}
}
if let Some(profile) = &variant.profile {
item.insert("profile", string(profile));
}
if variant.default {
item.insert("default", Value::Boolean(toml_edit::Formatted::new(true)));
}
array.push(Value::InlineTable(item));
}
table.insert("variants", Value::Array(array));
}
table
}
}
struct DeclarationEdit {
document: DocumentMut,
original: Option<String>,
written: Option<String>,
}
impl DeclarationEdit {
fn read(path: &Path) -> Result<Self> {
let original = match std::fs::read_to_string(path) {
Ok(body) => Some(body),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(error.into()),
};
Ok(Self {
document: original.as_deref().unwrap_or("").parse()?,
original,
written: None,
})
}
fn write(&mut self, path: &Path) -> Result<()> {
if let Some(table) = self.document["dotfiles"].as_table_mut() {
table.sort_values();
}
let body = self.document.to_string();
if let Some(parent) = path.parent() {
file::create_dir_all(parent)?;
}
let prepared = file::prepare_atomic_write(path, &body)?;
commit_declaration(path, self.original.as_deref(), prepared)?;
self.written = Some(body);
Ok(())
}
fn restore(&self, path: &Path) -> Result<()> {
let Some(written) = &self.written else {
return Ok(());
};
match &self.original {
Some(original) => {
let prepared = file::prepare_atomic_write(path, original)?;
commit_declaration(path, Some(written), prepared)
}
None => {
check_declaration(path, Some(written))?;
Ok(std::fs::remove_file(path)?)
}
}
}
}
pub(super) fn declaration_lock() -> Result<fslock::LockFile> {
declaration_lock_for(&crate::config::global_shared_config_path())
}
fn declaration_lock_for(config: &Path) -> Result<fslock::LockFile> {
crate::lock_file::LockFile::new(&config.with_extension("dotfiles-declarations.lock"))
.try_lock()?
.ok_or_else(|| {
eyre::eyre!("another tracking declaration command is running; retry shortly")
})
}
fn check_declaration(path: &Path, expected: Option<&str>) -> Result<()> {
let current = match std::fs::read_to_string(path) {
Ok(body) => Some(body),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(error.into()),
};
if current.as_deref() != expected {
bail!(
"{} changed while preparing enrollment; concurrent declaration edit preserved; retry",
display_path(path)
);
}
Ok(())
}
fn commit_declaration(
path: &Path,
expected: Option<&str>,
prepared: file::PreparedAtomicWrite,
) -> Result<()> {
check_declaration(path, expected)?;
prepared.commit()
}
async fn activate_and_baseline(declared: &[(String, PathBuf)]) -> Result<()> {
let config = Config::reset().await?;
let tracked = TrackedSet::from_config(&config)?;
for (key, target) in declared {
let path = normalize_target(target);
let active = tracked
.entry_for(&path)
.is_some_and(|entry| entry.path == path);
if !active {
let reason = tracked
.invalid
.iter()
.find(|invalid| invalid.path == display_path(&path))
.map(|invalid| invalid.reason.clone())
.unwrap_or_else(|| "the declaration was not loaded".into());
bail!("dotfiles: {key} could not be tracked: {reason}");
}
}
baseline(&tracked, declared).await?;
for (key, target) in declared {
if target.is_symlink() {
let source = match resolve_symlink_source(target) {
Ok(source) => source,
Err(error) => {
warn!(
"dotfiles: {key} is a symlink; history saves and syncs the link, not its contents. Could not resolve its source: {error}"
);
continue;
}
};
if !tracked.would_capture(&source)? {
warn!(
"dotfiles: {key} is a symlink; history saves and syncs the link, not its contents. Its source {} is not tracked for capture; track the source with `mise dot track {}` (and check any exclusions) to include its contents",
display_path(&source),
shell_words::quote(&source.to_string_lossy()),
);
}
}
}
Ok(())
}
fn resolve_symlink_source(target: &Path) -> Result<PathBuf> {
file::atomic_write_target(target).map(|source| normalize_target(&source))
}
async fn baseline(tracked: &TrackedSet, declared: &[(String, PathBuf)]) -> Result<()> {
if !crate::config::Settings::get().history.enabled {
warn!("dotfiles: history is disabled (history.enabled = false); no baseline saved");
return Ok(());
}
let store = Store::open()?;
if let Some(reason) = store.unavailable() {
bail!("dotfiles: cannot save the baseline: {reason}");
}
if inside_capture()? {
info!(
"dotfiles: enrolled; the enclosing capture will save the baseline when the command finishes"
);
return Ok(());
}
let names = declared
.iter()
.map(|(key, _)| key.as_str())
.collect::<Vec<_>>()
.join(", ");
let mut draft = Draft::new(Trigger::Baseline);
draft.explicit_paths = declared
.iter()
.map(|(_, path)| normalize_target(path))
.collect();
draft.description = Some(format!("tracked {names}"));
let tracked = tracked.clone();
tokio::task::spawn_blocking(move || {
let _operation = crate::system::history::scope::take_operation_lock(&store, &tracked)?;
match store.attempt(&tracked, draft)? {
Outcome::Created(entry) => {
info!("history: saved baseline checkpoint {}", entry.id);
Ok(())
}
Outcome::Unchanged => Ok(()),
Outcome::Unavailable(reason) => bail!("dotfiles: cannot save the baseline: {reason}"),
}
})
.await?
}
fn inside_capture() -> Result<bool> {
let Some(parent) = std::env::var_os(crate::system::history::scope::ENV_VAR) else {
return Ok(false);
};
Ok(
crate::system::history::store::read_marker_in(&crate::dirs::STATE)?.is_some_and(|marker| {
marker.kind == crate::system::history::store::OperationKind::Capture
&& parent == std::ffi::OsStr::new(&marker.uuid)
}),
)
}
pub(crate) fn normalized_target(target: &Path) -> String {
match target.strip_prefix(*crate::dirs::HOME) {
Ok(rel) if !rel.as_os_str().is_empty() => {
let rel = rel.to_string_lossy();
let rel = if cfg!(windows) {
rel.replace('\\', "/")
} else {
rel.into_owned()
};
format!("~/{rel}")
}
Ok(_) => "~".to_string(),
Err(_) => target.to_string_lossy().to_string(),
}
}
fn string(text: &str) -> Value {
Value::String(toml_edit::Formatted::new(text.to_string()))
}
static AFTER_LONG_HELP: &str = color_print::cstr!(
r#"<bold><underline>Examples:</underline></bold>
$ <bold>mise dot track ~/.zshrc ~/.config/hypr</bold>
$ <bold>mise dot track --dry-run ~/.codex</bold>
$ <bold>mise dot track ~/.zshrc --os macos</bold>
$ <bold>mise dot track ~/.config/app/credentials --encrypt</bold>
$ <bold>mise dot track ~/.config/app/state.json --no-autosave</bold>
"#
);
pub(crate) fn exclude_rule_for_path(key: &str, directory: bool) -> String {
let escaped = globset::escape(key);
match directory {
true => format!("{escaped}/**"),
false => escaped,
}
}
pub(crate) struct ExcludeEdit {
pub changed: bool,
pub still_excluding: Vec<String>,
}
pub(crate) fn edit_exclude(glob: &str, add: bool) -> Result<ExcludeEdit> {
use toml_edit::{Item, Value};
if add
&& let Some(reason) = crate::system::history::tracked::unusable_pattern(
glob.strip_prefix('!').unwrap_or(glob),
)
{
bail!("{glob}: {reason}");
}
let global = crate::config::global_shared_config_path();
let mut doc = read_document(&global)?;
let history = doc
.entry("history")
.or_insert(Item::Table(toml_edit::Table::new()));
let Some(table) = history.as_table_mut() else {
eyre::bail!("[history] in {} is not a table", display_path(&global));
};
table.set_implicit(false);
let exclude = table
.entry("exclude")
.or_insert(Item::Value(Value::Array(toml_edit::Array::new())));
let Some(array) = exclude.as_array_mut() else {
eyre::bail!(
"[history] exclude in {} is not an array",
display_path(&global)
);
};
let (changed, still_excluding) = if add {
(append_rule(array, glob), vec![])
} else {
let changed = remove_argument(array, glob);
(changed, rules_still_held(array, glob))
};
if changed {
crate::file::write(&global, doc.to_string())?;
}
Ok(ExcludeEdit {
changed,
still_excluding,
})
}
fn rules_still_held(array: &toml_edit::Array, argument: &str) -> Vec<String> {
let held: Vec<String> = list_entries(array).into_iter().flatten().collect();
let mut candidates = vec![argument.to_string()];
candidates.extend(path_rules_for_argument(argument));
candidates
.into_iter()
.filter(|rule| held.contains(rule))
.collect()
}
fn list_entries(array: &toml_edit::Array) -> Vec<Option<String>> {
array
.iter()
.map(|value| value.as_str().map(str::to_string))
.collect()
}
fn append_rule(array: &mut toml_edit::Array, glob: &str) -> bool {
let bare = |pattern: &str| pattern.strip_prefix('!').unwrap_or(pattern).to_string();
let subject = bare(glob);
let before = list_entries(array);
array.retain(|value| match value.as_str() {
Some(entry) => bare(entry) != subject,
None => true,
});
array.push(string(glob));
list_entries(array) != before
}
fn drop_glob(array: &mut toml_edit::Array, rule: &str) -> bool {
let before = list_entries(array);
array.retain(|value| value.as_str() != Some(rule));
list_entries(array) != before
}
fn remove_argument(array: &mut toml_edit::Array, argument: &str) -> bool {
if drop_glob(array, argument) {
return true;
}
let mut changed = false;
for rule in path_rules_for_argument(argument) {
changed |= drop_glob(array, &rule);
}
changed
}
fn path_rules_for_argument(argument: &str) -> Vec<String> {
let target = crate::system::files::resolve_target_arg(argument);
if !target.is_absolute() {
return vec![];
}
let key = normalized_target(&target);
let mut rules = vec![];
for directory in [false, true] {
let rule = exclude_rule_for_path(&key, directory);
if rule != argument && !rules.contains(&rule) {
rules.push(rule);
}
}
rules
}
#[cfg(test)]
mod exclude_list_tests {
use super::*;
#[test]
fn a_rule_replaces_its_own_negation_either_way_round() {
for (existing, appended, expected) in [
(vec!["foo"], "!foo", vec!["!foo"]),
(vec!["!foo"], "foo", vec!["foo"]),
(vec!["foo", "bar"], "!foo", vec!["bar", "!foo"]),
(vec!["!foo", "bar"], "!foo", vec!["bar", "!foo"]),
(vec!["bar"], "!foo", vec!["bar", "!foo"]),
] {
let mut list = array(&existing);
append_rule(&mut list, appended);
assert_eq!(entries(&list), expected, "{existing:?} + {appended:?}");
}
}
#[test]
fn a_rooted_glob_does_not_take_the_literal_rule_with_it() {
let glob = "~/.codex/foo*";
let literal = exclude_rule_for_path(glob, false);
assert_eq!(literal, "~/.codex/foo[*]", "escaping changed spelling");
let mut list = array(&[glob, &literal]);
assert!(remove_argument(&mut list, glob));
assert_eq!(
entries(&list),
vec![literal.clone()],
"taking back a rooted glob removed the rule for a file named `foo*`"
);
let mut list = array(&[&literal]);
assert!(remove_argument(&mut list, glob));
assert!(
entries(&list).is_empty(),
"the untrack undo stopped working once nothing spelled the argument"
);
let directory = exclude_rule_for_path("~/.codex/logs[a]", true);
assert_eq!(directory, "~/.codex/logs[[]a[]]/**");
let mut list = array(&["~/.codex/logs[a]", &directory]);
assert!(remove_argument(&mut list, "~/.codex/logs[a]"));
assert_eq!(entries(&list), vec![directory.clone()]);
let mut list = array(&[&directory]);
assert!(remove_argument(&mut list, "~/.codex/logs[a]"));
assert!(entries(&list).is_empty());
}
fn array(entries: &[&str]) -> toml_edit::Array {
let mut array = toml_edit::Array::new();
for entry in entries {
array.push(string(entry));
}
array
}
fn entries(array: &toml_edit::Array) -> Vec<String> {
array
.iter()
.filter_map(|value| value.as_str().map(str::to_string))
.collect()
}
#[test]
fn an_edit_appends_its_rule_and_reports_a_change_only_when_the_list_moves() {
let mut list = array(&["foo"]);
assert!(!append_rule(&mut list, "foo"));
assert_eq!(entries(&list), ["foo"]);
let mut list = array(&["foo", "!foo*"]);
assert!(append_rule(&mut list, "foo"));
assert_eq!(entries(&list), ["!foo*", "foo"]);
let mut list = array(&["foo", "!foo"]);
assert!(append_rule(&mut list, "foo"));
assert_eq!(entries(&list), ["foo"]);
let mut list = array(&["foo", "*.key", "!foo", "sessions/**"]);
assert!(append_rule(&mut list, "foo"));
assert_eq!(entries(&list), ["*.key", "sessions/**", "foo"]);
}
#[test]
fn an_include_removes_the_glob_rather_than_negating_it() {
let mut list = array(&["*.log", "cache"]);
assert!(drop_glob(&mut list, "cache"));
assert_eq!(entries(&list), ["*.log"]);
assert!(!drop_glob(&mut list, "cache"));
let mut list = array(&["*.log", "!important.log"]);
assert!(!drop_glob(&mut list, "important.log"));
assert_eq!(entries(&list), ["*.log", "!important.log"]);
}
}
#[cfg(test)]
mod declaration_tests {
use super::*;
#[test]
fn rewritten_declarations_keep_their_own_explicit_policies_only() {
use crate::system::files::{ExplicitFields, FilePolicy};
use crate::system::resources::ResourceOrigin;
let command = DotfilesTrack {
targets: vec![],
os: None,
profile: None,
no_autosave: false,
encrypt: false,
yes: true,
dry_run: false,
};
let mut policy = FilePolicy::for_mode(FileMode::Track);
policy.explicit = ExplicitFields {
autosave: true,
encrypt: true,
..Default::default()
};
let existing = FileRequest {
target_raw: "~/.zshrc".into(),
target: PathBuf::from("/home/test/.zshrc"),
source: PathBuf::new(),
content: None,
mode: FileMode::Track,
exclude: vec![],
include: None,
manifest: None,
permissions: None,
base: PathBuf::from("/home/test"),
origin: ResourceOrigin {
config: PathBuf::from("/home/test/.config/mise/config.toml"),
config_root: PathBuf::from("/home/test/.config/mise"),
environment: vec![],
source: None,
},
policy,
variants: vec![],
enabled: true,
remove_empty: false,
dot_prefix: false,
relative: false,
};
let previous = ["mode", "autosave", "encrypt"].map(String::from);
let table = command.entry(Some(&existing), &previous, None, None);
assert_eq!(table.get("autosave").and_then(Value::as_bool), Some(true));
assert_eq!(table.get("encrypt").and_then(Value::as_bool), Some(false));
let table = command.entry(Some(&existing), &["mode".to_string()], None, None);
assert!(table.get("autosave").is_none());
assert!(table.get("encrypt").is_none());
let table = command.entry(None, &[], None, None);
assert!(table.get("autosave").is_none());
assert!(table.get("encrypt").is_none());
let mut inherited = existing.clone();
inherited.policy.autosave = false;
inherited.policy.encrypt = true;
let table = command.entry(Some(&inherited), &["mode".to_string()], None, None);
assert!(table.get("autosave").is_none());
assert!(table.get("encrypt").is_none());
let flagged = DotfilesTrack {
no_autosave: true,
..command
};
let table = flagged.entry(Some(&inherited), &["mode".to_string()], None, None);
assert_eq!(table.get("autosave").and_then(Value::as_bool), Some(false));
assert!(table.get("encrypt").is_none());
let mut listed = existing.clone();
listed.exclude = vec![glob::Pattern::new("sessions").unwrap()];
let table = flagged.entry(Some(&listed), &["mode".to_string()], None, None);
assert!(table.get("exclude").is_none());
let table = flagged.entry(
Some(&listed),
&["mode".to_string(), "exclude".to_string()],
None,
None,
);
assert_eq!(
table
.get("exclude")
.and_then(Value::as_array)
.map(|a| a.len()),
Some(1)
);
let raw: Array = "[\"sessions\", \"[\"]"
.parse::<Value>()
.unwrap()
.as_array()
.cloned()
.unwrap();
let table = flagged.entry(
Some(&listed),
&["mode".to_string(), "exclude".to_string()],
Some(raw),
None,
);
assert_eq!(
table
.get("exclude")
.and_then(Value::as_array)
.map(|a| a.len()),
Some(2)
);
let mut cleared = existing.clone();
cleared.exclude = vec![];
let table = flagged.entry(
Some(&cleared),
&["mode".to_string(), "exclude".to_string()],
None,
None,
);
assert_eq!(
table
.get("exclude")
.and_then(Value::as_array)
.map(|a| a.len()),
Some(0)
);
for text in [
"[dotfiles]\n\"~/.zshrc\" = { mode = \"track\", autosave = true }\n",
"[dotfiles.\"~/.zshrc\"]\nmode = \"track\"\nautosave = true\n",
] {
let doc: DocumentMut = text.parse().unwrap();
let keys: Vec<String> = doc
.get("dotfiles")
.and_then(|dotfiles| dotfiles.get("~/.zshrc"))
.and_then(Item::as_table_like)
.map(|table| table.iter().map(|(key, _)| key.to_string()).collect())
.unwrap_or_default();
assert_eq!(keys, ["mode", "autosave"].map(String::from));
}
}
#[test]
fn resolved_sources_use_tracking_path_representation() {
let temporary = tempfile::tempdir().unwrap();
let source = temporary.path().join("source");
std::fs::write(&source, "contents").unwrap();
let tracked = normalize_target(&source);
let canonical = source.canonicalize().unwrap();
assert_eq!(resolve_symlink_source(&canonical).unwrap(), tracked);
}
#[test]
fn declaration_commands_fail_promptly_on_contention() {
let temporary = tempfile::tempdir().unwrap();
let path = temporary.path().join("config.toml");
let first = declaration_lock_for(&path).unwrap();
assert!(declaration_lock_for(&path).is_err());
drop(first);
assert!(declaration_lock_for(&path).is_ok());
}
#[test]
fn edits_during_replacement_preparation_are_preserved() {
let temporary = tempfile::tempdir().unwrap();
let path = temporary.path().join("config.toml");
std::fs::write(&path, "# original\n").unwrap();
let prepared = file::prepare_atomic_write(&path, "# mise replacement\n").unwrap();
std::fs::write(&path, "# external editor\n").unwrap();
assert!(commit_declaration(&path, Some("# original\n"), prepared).is_err());
assert_eq!(
std::fs::read_to_string(path).unwrap(),
"# external editor\n"
);
}
#[test]
fn failed_enrollment_restores_only_its_own_declaration_version() {
let temporary = tempfile::tempdir().unwrap();
let path = temporary.path().join("conf.d/dotfiles-tracking.toml");
let mut edit = DeclarationEdit::read(&path).unwrap();
edit.document = "[dotfiles]\n\"~/.zshrc\" = { mode = \"track\" }\n"
.parse()
.unwrap();
edit.write(&path).unwrap();
assert!(path.exists());
std::fs::write(&path, "# concurrent user edit\n").unwrap();
assert!(edit.restore(&path).is_err());
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"# concurrent user edit\n"
);
}
#[test]
fn concurrent_edit_before_enrollment_is_not_overwritten() {
let temporary = tempfile::tempdir().unwrap();
let path = temporary.path().join("config.toml");
let mut edit = DeclarationEdit::read(&path).unwrap();
edit.document = "[dotfiles]\n".parse().unwrap();
std::fs::write(&path, "# newly created by user\n").unwrap();
assert!(edit.write(&path).is_err());
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"# newly created by user\n"
);
}
}