use std::{fs, path::Path};
use color_eyre::eyre::{Result, WrapErr};
use toml_edit::{DocumentMut, Item, Table, value};
use super::document::expand_home;
const REPO: &str = "repo";
const SET: &str = "set";
const EXCLUDE: &str = "exclude";
const PATH: &str = "path";
const SET_PATH_ARRAYS: [&str; 2] = ["include", EXCLUDE];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Edit {
Exclude,
Unexclude,
Remove,
}
fn provenance_comment(today: &str) -> String {
format!("# ignored from Repon on {today}")
}
pub(crate) fn today() -> String {
let now = repon_core::Timestamp::now().to_string();
now.split('T').next().unwrap_or(&now).to_string()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Written {
pub(crate) changed: bool,
pub(crate) removed_repo_entry: bool,
}
pub(crate) fn write(config_file: &Path, path: &Path, edit: Edit) -> Result<Written> {
let before = match fs::read_to_string(config_file) {
Ok(text) => text,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(err) => {
return Err(err).wrap_err_with(|| format!("could not read {}", config_file.display()));
}
};
let removed_repo_entry = edit == Edit::Remove
&& before
.parse::<DocumentMut>()
.is_ok_and(|document| find_entry(&document, path).is_some());
let after = apply(&before, path, edit, &today())?;
if after == before {
return Ok(Written {
changed: false,
removed_repo_entry,
});
}
if let Some(parent) = config_file.parent() {
fs::create_dir_all(parent)
.wrap_err_with(|| format!("could not create {}", parent.display()))?;
}
fs::write(config_file, &after)
.wrap_err_with(|| format!("could not write {}", config_file.display()))?;
Ok(Written {
changed: true,
removed_repo_entry,
})
}
pub(crate) fn apply(text: &str, path: &Path, edit: Edit, today: &str) -> Result<String> {
let mut document: DocumentMut = text
.parse()
.wrap_err("could not parse the config file to edit it")?;
let existing = find_entry(&document, path);
match (edit, existing) {
(Edit::Exclude, Some(index)) => {
entries_mut(&mut document)
.and_then(|entries| entries.get_mut(index))
.expect("the index just found still resolves")[EXCLUDE] = value(true);
}
(Edit::Exclude, None) => append_entry(&mut document, path, today),
(Edit::Unexclude, Some(index)) => {
let Some(entries) = entries_mut(&mut document) else {
unreachable!("an index was found, so the array of tables is there");
};
let entry = entries
.get_mut(index)
.expect("the index just found still resolves");
entry.remove(EXCLUDE);
if carries_only_a_path(entry) {
entries.remove(index);
}
}
(Edit::Remove, index) => {
if let Some(index) = index {
let Some(entries) = entries_mut(&mut document) else {
unreachable!("an index was found, so the array of tables is there");
};
entries.remove(index);
}
remove_from_set_path_arrays(&mut document, path);
}
(Edit::Unexclude, None) => {}
}
Ok(document.to_string())
}
fn remove_from_set_path_arrays(document: &mut DocumentMut, path: &Path) {
let Some(sets) = document.get_mut(SET).and_then(Item::as_array_of_tables_mut) else {
return;
};
for set in sets.iter_mut() {
for key in SET_PATH_ARRAYS {
let Some(paths) = set.get_mut(key).and_then(Item::as_array_mut) else {
continue;
};
paths.retain(|named| {
!named
.as_str()
.is_some_and(|declared| declared_path_matches(declared, path))
});
if paths.is_empty() {
set.remove(key);
}
}
}
}
fn find_entry(document: &DocumentMut, path: &Path) -> Option<usize> {
document
.get(REPO)?
.as_array_of_tables()?
.iter()
.position(|entry| entry_path_matches(entry, path))
}
fn entry_path_matches(entry: &Table, path: &Path) -> bool {
entry
.get(PATH)
.and_then(Item::as_str)
.is_some_and(|declared| declared_path_matches(declared, path))
}
fn declared_path_matches(declared: &str, path: &Path) -> bool {
expand_home(declared) == path
}
fn entries_mut(document: &mut DocumentMut) -> Option<&mut toml_edit::ArrayOfTables> {
document.get_mut(REPO)?.as_array_of_tables_mut()
}
fn carries_only_a_path(entry: &Table) -> bool {
entry.len() == 1 && entry.contains_key(PATH)
}
fn append_entry(document: &mut DocumentMut, path: &Path, today: &str) {
let mut entry = Table::new();
entry[PATH] = value(contract_home(path));
entry[EXCLUDE] = value(true);
entry
.decor_mut()
.set_prefix(format!("\n{}\n", provenance_comment(today)));
let entries = document
.entry(REPO)
.or_insert(Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
if let Some(entries) = entries.as_array_of_tables_mut() {
entries.push(entry);
}
}
fn contract_home(path: &Path) -> String {
if let Ok(home) = etcetera::home_dir()
&& let Ok(rest) = path.strip_prefix(&home)
{
if rest.as_os_str().is_empty() {
return "~".to_string();
}
return format!("~/{}", rest.display());
}
path.display().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn commented_fixture() -> String {
std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/config/example.toml"),
)
.expect("read the shipped annotated example")
}
fn comments(text: &str) -> Vec<String> {
text.lines()
.filter_map(|line| {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix('#') {
return Some(format!("#{rest}"));
}
trimmed.find(" # ").map(|at| trimmed[at + 1..].to_string())
})
.collect()
}
fn path(declared: &str) -> std::path::PathBuf {
expand_home(declared)
}
#[test]
fn every_comment_in_the_commented_fixture_survives_every_write() {
let before = commented_fixture();
let original = comments(&before);
assert!(
original.len() > 5,
"the fixture has to actually carry comments for this to prove anything, found {original:?}"
);
let after = apply(
&before,
&path("~/dev/legacy-api"),
Edit::Exclude,
"2026-09-01",
)
.expect("the fixture parses");
assert_eq!(comments(&after), original);
let after = apply(
&after,
&path("~/dev/legacy-api"),
Edit::Unexclude,
"2026-09-01",
)
.expect("the written document parses");
assert_eq!(comments(&after), original);
let after = apply(&before, &path("~/dev/noisy"), Edit::Exclude, "2026-09-01")
.expect("the fixture parses");
let written = comments(&after);
let mut without_the_new_one = written.clone();
without_the_new_one.retain(|comment| comment != &provenance_comment("2026-09-01"));
assert_eq!(without_the_new_one, original);
assert_eq!(
written.len(),
original.len() + 1,
"an append must add its own comment once and no other"
);
let after = apply(
&before,
&path("~/dev/legacy-api"),
Edit::Remove,
"2026-09-01",
)
.expect("the fixture parses");
let lost: Vec<&String> = original
.iter()
.filter(|comment| !comments(&after).contains(comment))
.collect();
assert_eq!(
lost,
vec!["# origin/HEAD on this one still says master; pin it."],
"removing an entry must take its own leading comment and no other"
);
}
#[test]
fn a_write_reformats_nothing_outside_the_repo_entry_it_edits() {
let before = commented_fixture();
let after = apply(
&before,
&path("~/dev/legacy-api"),
Edit::Exclude,
"2026-09-01",
)
.expect("the fixture parses");
assert_eq!(
after.replacen(
"default_branch = \"main\"\nexclude = true\n",
"default_branch = \"main\"\n",
1
),
before,
"a write must touch nothing but the `[[repo]]` entry it names, got {after:?}"
);
}
#[test]
fn exclude_then_unexclude_on_a_file_with_no_repo_array_returns_it_byte_for_byte() {
let before = "# the whole file's value is its comments\ntheme = \"default\"\n\n\
[refresh]\npoll_interval = \"2s\" # a trailing comment\n\n\
# a comment at the end of the file\n";
let ignored = apply(before, &path("~/dev/noisy"), Edit::Exclude, "2026-09-01")
.expect("the document parses");
assert!(
ignored.contains("[[repo]]"),
"the ignore has to have written something for the round trip to prove anything: \
{ignored:?}"
);
let unexcluded = apply(
&ignored,
&path("~/dev/noisy"),
Edit::Unexclude,
"2026-09-01",
)
.expect("the written document parses");
assert_eq!(unexcluded, before);
}
#[test]
fn an_appended_entry_lands_after_the_last_existing_repo_entry() {
let before = commented_fixture();
let after = apply(&before, &path("~/dev/noisy"), Edit::Exclude, "2026-09-01")
.expect("the fixture parses");
let lines: Vec<&str> = after.lines().collect();
let appended = lines
.iter()
.position(|line| *line == "path = \"~/dev/noisy\"")
.expect("the appended entry is there");
let vendor_mirror = lines
.iter()
.position(|line| *line == "path = \"~/dev/vendor-mirror\"")
.expect("the last existing entry is still there");
let first_launcher = lines
.iter()
.position(|line| *line == "[[launcher]]")
.expect("the following array of tables is still there");
assert!(
vendor_mirror < appended && appended < first_launcher,
"the appended entry must land after the last `[[repo]]` and before the following \
`[[launcher]]`, got {after:?}"
);
}
#[test]
fn an_appended_entry_lands_after_a_trailing_comment_rather_than_capturing_it() {
let before = "theme = \"default\"\n\n# a comment at the end of the file\n";
let after = apply(before, &path("~/dev/noisy"), Edit::Exclude, "2026-09-01")
.expect("the document parses");
let end_of_file = after
.lines()
.position(|line| line == "# a comment at the end of the file")
.expect("the trailing comment survives");
let header = after
.lines()
.position(|line| line == "[[repo]]")
.expect("the appended entry is there");
assert!(
header < end_of_file,
"the trailing comment must stay at the end rather than being captured as the \
appended table's own leading comment: {after:?}"
);
}
#[test]
fn unexclude_on_an_entry_carrying_default_branch_removes_exclude_alone() {
let before = "# pinned, and ignored for now\n[[repo]]\npath = \"~/dev/legacy-api\"\n\
default_branch = \"main\"\nexclude = true\n";
let after = apply(
before,
&path("~/dev/legacy-api"),
Edit::Unexclude,
"2026-09-01",
)
.expect("the document parses");
assert_eq!(
after,
"# pinned, and ignored for now\n[[repo]]\npath = \"~/dev/legacy-api\"\n\
default_branch = \"main\"\n"
);
}
#[test]
fn unexclude_on_an_entry_left_with_nothing_but_path_removes_the_table_and_the_array() {
let before = "theme = \"default\"\n\n[[repo]]\npath = \"~/dev/noisy\"\nexclude = true\n";
let after = apply(before, &path("~/dev/noisy"), Edit::Unexclude, "2026-09-01")
.expect("the document parses");
assert!(
!after.contains("[[repo]]") && !after.contains("noisy"),
"an entry left with nothing but `path` must go entirely: {after:?}"
);
}
#[test]
fn an_appended_entry_carries_its_provenance_comment_on_the_line_above_it() {
let after = apply("", &path("~/dev/noisy"), Edit::Exclude, "2026-09-01")
.expect("an empty document parses");
let lines: Vec<&str> = after.lines().filter(|line| !line.is_empty()).collect();
assert_eq!(
lines,
vec![
"# ignored from Repon on 2026-09-01",
"[[repo]]",
"path = \"~/dev/noisy\"",
"exclude = true",
],
"got {after:?}"
);
}
#[test]
fn the_appended_entry_matches_repo_management_mds_own_fenced_example() {
let spec = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../docs/spec/repo-management.md"),
)
.expect("read docs/spec/repo-management.md");
let fenced = spec
.split("```toml\n")
.nth(1)
.and_then(|rest| rest.split("```").next())
.expect("repo-management.md still carries its fenced provenance example");
let date = fenced
.lines()
.next()
.and_then(|line| line.rsplit(' ').next())
.expect("the example's comment names a date");
let written =
apply("", &path("~/dev/noisy"), Edit::Exclude, date).expect("an empty document parses");
assert_eq!(written.trim_start_matches('\n'), fenced);
}
#[test]
fn an_appended_entry_is_found_again_by_the_absolute_path_it_was_written_for() {
let absolute = expand_home("~/dev/noisy");
let written =
apply("", &absolute, Edit::Exclude, "2026-09-01").expect("an empty document parses");
assert_eq!(
find_entry(&written.parse().expect("parses"), &absolute),
Some(0)
);
}
#[test]
fn removing_an_entry_also_removes_the_path_from_every_set_include_array_naming_it() {
let before = "[[set]]\nname = \"one\"\nroots = [\"~/dev\"]\n\
include = [\"~/dev/gone\", \"~/dev/kept\"]\n\n\
[[set]]\nname = \"two\"\nroots = [\"~/dev\"]\n\
include = [\"~/dev/other\", \"~/dev/gone\"]\n";
let after = apply(before, &path("~/dev/gone"), Edit::Remove, "2026-09-01")
.expect("the document parses");
assert!(
!after.contains("gone"),
"no Set may go on naming a path `delete` destroyed: {after:?}"
);
assert!(
after.contains("\"~/dev/kept\"") && after.contains("\"~/dev/other\""),
"every other path a Set names is left alone: {after:?}"
);
}
#[test]
fn removing_an_entry_also_removes_the_path_from_a_set_exclude_array_naming_it() {
let before = "[[set]]\nname = \"one\"\nroots = [\"~/dev\"]\n\
exclude = [\"~/dev/gone\", \"**/node_modules/**\"]\n";
let after = apply(before, &path("~/dev/gone"), Edit::Remove, "2026-09-01")
.expect("the document parses");
assert!(
!after.contains("gone") && after.contains("\"**/node_modules/**\""),
"the path goes and the glob beside it stays: {after:?}"
);
}
#[test]
fn removing_an_entry_leaves_a_set_glob_that_would_have_matched_the_deleted_path_alone() {
let before = commented_fixture();
let after = apply(
&before,
&path("~/dev/acme/checkout"),
Edit::Remove,
"2026-09-01",
)
.expect("the fixture parses");
assert_eq!(
after, before,
"a glob that would have matched the deleted path is not a name for it"
);
}
#[test]
fn a_set_array_left_empty_by_a_removal_goes_with_the_last_path_in_it() {
let before = "[[set]]\nname = \"one\"\nroots = [\"~/dev\"]\n\
include = [\"~/dev/gone\"]\nexclude = [\"~/dev/gone\"]\n";
let after = apply(before, &path("~/dev/gone"), Edit::Remove, "2026-09-01")
.expect("the document parses");
assert_eq!(after, "[[set]]\nname = \"one\"\nroots = [\"~/dev\"]\n");
}
#[test]
fn a_set_path_written_relative_to_home_is_found_by_the_absolute_path_it_expands_to() {
let before = "[[set]]\nname = \"one\"\nroots = [\"~/dev\"]\n\
include = [\"~/dev/gone\", \"~/dev/kept\"]\n";
let after = apply(
before,
&expand_home("~/dev/gone"),
Edit::Remove,
"2026-09-01",
)
.expect("the document parses");
assert!(!after.contains("gone"), "got {after:?}");
}
#[test]
fn exclude_and_unexclude_leave_every_set_array_untouched() {
let before = "[[set]]\nname = \"one\"\nroots = [\"~/dev\"]\n\
include = [\"~/dev/noisy\"]\n";
for edit in [Edit::Exclude, Edit::Unexclude] {
let after = apply(before, &path("~/dev/noisy"), edit, "2026-09-01")
.expect("the document parses");
assert!(
after.contains("include = [\"~/dev/noisy\"]"),
"{edit:?} must leave the Set naming it alone: {after:?}"
);
}
}
#[test]
fn removing_an_entry_that_was_never_there_changes_nothing() {
let before = commented_fixture();
let after = apply(
&before,
&path("~/dev/never-configured"),
Edit::Remove,
"2026-09-01",
)
.expect("the fixture parses");
assert_eq!(after, before);
}
#[test]
fn a_key_this_crates_schema_never_reads_survives_a_write() {
let dir = tempfile::tempdir().expect("temp dir");
let file = dir.path().join("config.toml");
std::fs::write(
&file,
"# hand-written\nsomething_repon_has_never_heard_of = 3\n",
)
.expect("write the config file");
let written = write(&file, &path("~/dev/noisy"), Edit::Exclude).expect("the write runs");
assert!(written.changed);
let after = std::fs::read_to_string(&file).expect("read it back");
assert!(
after.contains("something_repon_has_never_heard_of = 3")
&& after.contains("# hand-written"),
"a read-modify-write of the file on disk must keep what it does not understand: \
{after:?}"
);
}
#[test]
fn a_write_with_no_config_file_at_all_creates_one() {
let dir = tempfile::tempdir().expect("temp dir");
let file = dir.path().join("nested").join("config.toml");
let written = write(&file, &path("~/dev/noisy"), Edit::Exclude).expect("the write runs");
assert!(written.changed);
let after = std::fs::read_to_string(&file).expect("read it back");
assert!(after.contains("[[repo]]"), "got {after:?}");
}
#[test]
fn today_is_the_date_half_of_the_cores_own_timestamp_rendering() {
let today = today();
assert!(
repon_core::Timestamp::now().to_string().starts_with(&today),
"expected today's date to lead the core's own timestamp, got {today:?}"
);
assert_eq!(today.len(), "2026-09-01".len());
}
}