use std::{
collections::{BTreeMap, HashMap},
env, fs, io,
ops::Range,
path::{Path, PathBuf},
time::Duration,
};
use color_eyre::eyre::{Result, WrapErr, eyre};
use serde::Deserialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Glyphs {
#[default]
Full,
Ascii,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct RefreshConfig {
#[serde(with = "humantime_serde")]
pub poll_interval: Duration,
#[serde(with = "humantime_serde")]
pub status_stale_after: Duration,
pub on_focus: bool,
}
impl Default for RefreshConfig {
fn default() -> Self {
Self {
poll_interval: Duration::from_secs(2),
status_stale_after: Duration::from_secs(5 * 60),
on_focus: true,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct FetchConfig {
pub enabled: bool,
#[serde(with = "humantime_serde")]
pub interval: Duration,
pub concurrency: u32,
}
impl Default for FetchConfig {
fn default() -> Self {
Self {
enabled: false,
interval: Duration::from_secs(5 * 60),
concurrency: 4,
}
}
}
#[derive(Debug, Clone, Copy, Default, Deserialize)]
#[serde(default)]
pub struct AutoUpdateConfig {
pub enabled: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SetConfig {
pub name: toml::Spanned<String>,
pub roots: Vec<String>,
#[serde(default)]
pub include: Option<Vec<String>>,
#[serde(default)]
pub exclude: Option<Vec<String>>,
#[serde(default)]
pub on_refresh: Option<String>,
#[serde(default)]
pub before_sync: Option<String>,
#[serde(default)]
pub after_sync: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RepoConfig {
pub path: toml::Spanned<String>,
#[serde(default)]
pub default_branch: Option<String>,
#[serde(default)]
pub exclude: bool,
}
pub fn repo_overrides(document: &Document) -> Vec<repon_core::RepoOverride> {
document
.repos
.iter()
.map(|repo| repon_core::RepoOverride {
path: expand_home(repo.path.get_ref()),
default_branch: repo.default_branch.clone(),
excluded: repo.exclude,
})
.collect()
}
#[derive(Debug, Clone, Deserialize)]
pub struct LauncherConfig {
pub name: toml::Spanned<String>,
#[serde(default)]
pub args: Option<Vec<String>>,
#[serde(default)]
pub from_env: Option<String>,
#[serde(default)]
pub shell: bool,
#[serde(default)]
pub interactive: bool,
#[serde(default = "default_launcher_takes_terminal")]
pub takes_terminal: bool,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default)]
pub disabled: bool,
}
fn default_launcher_takes_terminal() -> bool {
true
}
#[derive(Debug, Clone, Deserialize)]
pub struct StepConfig {
pub args: Vec<String>,
#[serde(default)]
pub shell: bool,
#[serde(default)]
pub interactive: bool,
#[serde(default)]
pub env: BTreeMap<String, String>,
}
fn default_action_confirm() -> bool {
true
}
fn default_action_concurrency() -> u32 {
4
}
#[derive(Debug, Clone, Deserialize)]
pub struct ActionConfig {
pub name: toml::Spanned<String>,
#[serde(default)]
pub description: Option<String>,
pub steps: Vec<StepConfig>,
#[serde(default = "default_action_confirm")]
pub confirm: bool,
#[serde(default = "default_action_concurrency")]
pub concurrency: u32,
#[serde(default)]
pub when: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct Document {
pub theme: String,
pub glyphs: Glyphs,
pub show_worktrees: bool,
pub show_submodules: bool,
pub advance_on_toggle: bool,
#[serde(with = "humantime_serde")]
pub notice_timeout: Duration,
pub on_refresh: Option<String>,
pub before_sync: Option<String>,
pub after_sync: Option<String>,
pub refresh: RefreshConfig,
pub fetch: FetchConfig,
pub auto_update: AutoUpdateConfig,
#[serde(rename = "set")]
pub sets: Vec<SetConfig>,
#[serde(rename = "repo")]
pub repos: Vec<RepoConfig>,
#[serde(rename = "launcher")]
pub launchers: Vec<LauncherConfig>,
#[serde(rename = "action")]
pub actions: Vec<ActionConfig>,
pub keys: toml::Table,
}
impl Default for Document {
fn default() -> Self {
Self {
theme: "default".to_string(),
glyphs: Glyphs::default(),
show_worktrees: true,
show_submodules: false,
advance_on_toggle: false,
notice_timeout: Duration::from_secs(3),
on_refresh: None,
before_sync: None,
after_sync: None,
refresh: RefreshConfig::default(),
fetch: FetchConfig::default(),
auto_update: AutoUpdateConfig::default(),
sets: Vec::new(),
repos: Vec::new(),
launchers: Vec::new(),
actions: Vec::new(),
keys: toml::Table::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Warning {
UnknownKey(String),
SetNamedAll,
SetGlobMatchesNothing { set: String, glob: String },
RepoPathMatchesNothing { path: String },
AutoUpdateWithoutFetch,
OnRefreshNamesNoAction { name: String },
SetOnRefreshNamesNoAction { set: String, name: String },
BeforeSyncNamesNoAction { name: String },
SetBeforeSyncNamesNoAction { set: String, name: String },
AfterSyncNamesNoAction { name: String },
SetAfterSyncNamesNoAction { set: String, name: String },
}
impl std::fmt::Display for Warning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Warning::UnknownKey(path) => write!(f, "unknown config key `{path}`"),
Warning::SetNamedAll => write!(
f,
"a [[set]] is named `all`, shadowing the implicit Set; the declaration wins"
),
Warning::SetGlobMatchesNothing { set, glob } => {
write!(
f,
"set `{set}`'s glob `{glob}` matches nothing under its roots"
)
}
Warning::RepoPathMatchesNothing { path } => {
write!(f, "[[repo]] path `{path}` matches no discovered entity")
}
Warning::AutoUpdateWithoutFetch => write!(
f,
"auto_update.enabled is true but fetch.enabled is false, so auto-update can never fire"
),
Warning::OnRefreshNamesNoAction { name } => write!(
f,
"on_refresh names `{name}`, which no [[action]] declares, so nothing runs after a refresh"
),
Warning::SetOnRefreshNamesNoAction { set, name } => write!(
f,
"set `{set}`'s on_refresh names `{name}`, which no [[action]] declares, so \
nothing runs after a refresh while `{set}` is active"
),
Warning::BeforeSyncNamesNoAction { name } => write!(
f,
"before_sync names `{name}`, which no [[action]] declares, so sync runs with \
no pre-hook"
),
Warning::SetBeforeSyncNamesNoAction { set, name } => write!(
f,
"set `{set}`'s before_sync names `{name}`, which no [[action]] declares, so \
sync runs with no pre-hook while `{set}` is active"
),
Warning::AfterSyncNamesNoAction { name } => write!(
f,
"after_sync names `{name}`, which no [[action]] declares, so sync runs with \
no post-hook"
),
Warning::SetAfterSyncNamesNoAction { set, name } => write!(
f,
"set `{set}`'s after_sync names `{name}`, which no [[action]] declares, so \
sync runs with no post-hook while `{set}` is active"
),
}
}
}
#[derive(Debug)]
pub struct Loaded {
pub document: Document,
pub warnings: Vec<Warning>,
pub zero_config: bool,
}
const EXAMPLE_CONFIG: &str = include_str!("example.toml");
pub fn annotated_example() -> &'static str {
EXAMPLE_CONFIG
}
pub fn load(path: &Path) -> Result<Loaded> {
load_with_term(path, term_signal().as_deref())
}
fn term_signal() -> Option<String> {
env::var("TERM").ok()
}
fn load_with_term(path: &Path, term: Option<&str>) -> Result<Loaded> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(err) if err.kind() == io::ErrorKind::NotFound => {
let mut document = Document {
glyphs: conditional_glyphs_default(term),
..Document::default()
};
document.sets.push(implicit_all_set(working_directory()));
return Ok(Loaded {
document,
warnings: Vec::new(),
zero_config: true,
});
}
Err(err) => {
return Err(err).wrap_err_with(|| format!("could not read {}", path.display()));
}
};
parse_with_term(&text, path, term)
}
#[cfg(test)]
fn parse(text: &str, path: &Path) -> Result<Loaded> {
parse_with_term(text, path, None)
}
fn conditional_glyphs_default(term: Option<&str>) -> Glyphs {
if term == Some("linux") {
Glyphs::Ascii
} else {
Glyphs::Full
}
}
fn glyphs_key_declared(text: &str) -> bool {
text.parse::<toml::Table>()
.map(|table| table.contains_key("glyphs"))
.unwrap_or(false)
}
fn parse_with_term(text: &str, path: &Path, term: Option<&str>) -> Result<Loaded> {
let deserializer =
toml::de::Deserializer::parse(text).map_err(|err| render_error(path, text, &err))?;
let mut unknown_paths = Vec::new();
let mut document: Document = serde_ignored::deserialize(deserializer, |ignored| {
unknown_paths.push(ignored.to_string())
})
.map_err(|err| render_error(path, text, &err))?;
reject_duplicate_names(&document, text, path)?;
reject_reserved_action_names(&document, text, path)?;
reject_launchers_declaring_both_argv_forms(&document, text, path)?;
reject_interactive_without_shell(&document, text, path)?;
if !glyphs_key_declared(text) {
document.glyphs = conditional_glyphs_default(term);
}
let mut warnings: Vec<Warning> = unknown_paths.into_iter().map(Warning::UnknownKey).collect();
warnings.extend(cross_key_warnings(&document));
if document.sets.is_empty() {
document.sets.push(implicit_all_set(working_directory()));
}
Ok(Loaded {
document,
warnings,
zero_config: false,
})
}
pub(crate) fn working_directory() -> PathBuf {
env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
fn implicit_all_set(root: PathBuf) -> SetConfig {
SetConfig {
name: toml::Spanned::new(0..0, "all".to_string()),
roots: vec![root.to_string_lossy().into_owned()],
include: None,
exclude: None,
on_refresh: None,
before_sync: None,
after_sync: None,
}
}
fn render_error(path: &Path, input: &str, err: &toml::de::Error) -> color_eyre::eyre::Error {
parse_error(path, input, err.message(), err.span())
}
fn parse_error(
path: &Path,
input: &str,
message: &str,
span: Option<Range<usize>>,
) -> color_eyre::eyre::Error {
match span.map(|span| line_col(input, span.start)) {
Some((line, column)) => eyre!(
"could not parse {}: {message} at line {line}, column {column}",
path.display()
),
None => eyre!("could not parse {}: {message}", path.display()),
}
}
fn line_col(input: &str, offset: usize) -> (usize, usize) {
let mut offset = offset.min(input.len());
while offset > 0 && !input.is_char_boundary(offset) {
offset -= 1;
}
let mut line = 1;
let mut column = 1;
for ch in input[..offset].chars() {
if ch == '\n' {
line += 1;
column = 1;
} else {
column += 1;
}
}
(line, column)
}
fn reject_duplicate_names(document: &Document, input: &str, path: &Path) -> Result<()> {
if let Some((value, span)) = duplicate(&document.sets, |set| &set.name) {
return Err(parse_error(
path,
input,
&duplicate_message("set name", &value),
Some(span),
));
}
if let Some((value, span)) = duplicate(&document.repos, |repo| &repo.path) {
return Err(parse_error(
path,
input,
&duplicate_message("repo path", &value),
Some(span),
));
}
if let Some((value, span)) = duplicate(&document.launchers, |launcher| &launcher.name) {
return Err(parse_error(
path,
input,
&duplicate_message("launcher name", &value),
Some(span),
));
}
if let Some((value, span)) = duplicate(&document.actions, |action| &action.name) {
return Err(parse_error(
path,
input,
&duplicate_message("action name", &value),
Some(span),
));
}
Ok(())
}
fn duplicate_message(what: &str, value: &str) -> String {
format!("duplicate {what} `{value}`")
}
fn reject_reserved_action_names(document: &Document, input: &str, path: &Path) -> Result<()> {
for action in &document.actions {
let name = action.name.get_ref();
if crate::management::Operation::from_name(name).is_some() {
return Err(parse_error(
path,
input,
&duplicate_message("action name", name),
Some(action.name.span()),
));
}
}
Ok(())
}
fn reject_launchers_declaring_both_argv_forms(
document: &Document,
input: &str,
path: &Path,
) -> Result<()> {
for launcher in &document.launchers {
if launcher.args.is_some() && launcher.from_env.is_some() {
return Err(parse_error(
path,
input,
&format!(
"launcher `{}` declares both `args` and `from_env`, which are mutually exclusive",
launcher.name.get_ref()
),
Some(launcher.name.span()),
));
}
}
Ok(())
}
fn reject_interactive_without_shell(document: &Document, input: &str, path: &Path) -> Result<()> {
for launcher in &document.launchers {
if launcher.interactive && !launcher.shell {
return Err(parse_error(
path,
input,
&format!(
"launcher `{}` declares `interactive = true` without `shell = true`",
launcher.name.get_ref()
),
Some(launcher.name.span()),
));
}
}
for action in &document.actions {
for step in &action.steps {
if step.interactive && !step.shell {
return Err(parse_error(
path,
input,
&format!(
"action `{}`'s step declares `interactive = true` without `shell = true`",
action.name.get_ref()
),
Some(action.name.span()),
));
}
}
}
Ok(())
}
fn duplicate<'a, T>(
items: &'a [T],
key: impl Fn(&'a T) -> &'a toml::Spanned<String>,
) -> Option<(String, Range<usize>)> {
let mut seen: HashMap<&str, ()> = HashMap::new();
for item in items {
let spanned = key(item);
let value = spanned.get_ref().as_str();
if seen.insert(value, ()).is_some() {
return Some((value.to_string(), spanned.span()));
}
}
None
}
fn resolve_hook_name<'a>(
document: &'a Document,
active_set_name: &str,
set_field: impl Fn(&'a SetConfig) -> Option<&'a str>,
document_field: Option<&'a str>,
) -> Option<&'a str> {
document
.sets
.iter()
.find(|set| set.name.get_ref() == active_set_name)
.and_then(set_field)
.or(document_field)
}
pub(crate) fn resolve_on_refresh_name<'a>(
document: &'a Document,
active_set_name: &str,
) -> Option<&'a str> {
resolve_hook_name(
document,
active_set_name,
|set| set.on_refresh.as_deref(),
document.on_refresh.as_deref(),
)
}
pub(crate) fn resolve_before_sync_name<'a>(
document: &'a Document,
active_set_name: &str,
) -> Option<&'a str> {
resolve_hook_name(
document,
active_set_name,
|set| set.before_sync.as_deref(),
document.before_sync.as_deref(),
)
}
pub(crate) fn resolve_after_sync_name<'a>(
document: &'a Document,
active_set_name: &str,
) -> Option<&'a str> {
resolve_hook_name(
document,
active_set_name,
|set| set.after_sync.as_deref(),
document.after_sync.as_deref(),
)
}
fn cross_key_warnings(document: &Document) -> Vec<Warning> {
let mut warnings = Vec::new();
if document.auto_update.enabled && !document.fetch.enabled {
warnings.push(Warning::AutoUpdateWithoutFetch);
}
if let Some(name) = document.on_refresh.as_ref().filter(|name| {
!document
.actions
.iter()
.any(|action| action.name.get_ref() == *name)
}) {
warnings.push(Warning::OnRefreshNamesNoAction { name: name.clone() });
}
if let Some(name) = document.before_sync.as_ref().filter(|name| {
!document
.actions
.iter()
.any(|action| action.name.get_ref() == *name)
}) {
warnings.push(Warning::BeforeSyncNamesNoAction { name: name.clone() });
}
if let Some(name) = document.after_sync.as_ref().filter(|name| {
!document
.actions
.iter()
.any(|action| action.name.get_ref() == *name)
}) {
warnings.push(Warning::AfterSyncNamesNoAction { name: name.clone() });
}
for set in &document.sets {
let name = set.name.get_ref();
if name == "all" {
warnings.push(Warning::SetNamedAll);
}
if let Some(on_refresh) = set.on_refresh.as_ref().filter(|on_refresh| {
!document
.actions
.iter()
.any(|action| action.name.get_ref().as_str() == on_refresh.as_str())
}) {
warnings.push(Warning::SetOnRefreshNamesNoAction {
set: name.clone(),
name: on_refresh.clone(),
});
}
if let Some(before_sync) = set.before_sync.as_ref().filter(|before_sync| {
!document
.actions
.iter()
.any(|action| action.name.get_ref().as_str() == before_sync.as_str())
}) {
warnings.push(Warning::SetBeforeSyncNamesNoAction {
set: name.clone(),
name: before_sync.clone(),
});
}
if let Some(after_sync) = set.after_sync.as_ref().filter(|after_sync| {
!document
.actions
.iter()
.any(|action| action.name.get_ref().as_str() == after_sync.as_str())
}) {
warnings.push(Warning::SetAfterSyncNamesNoAction {
set: name.clone(),
name: after_sync.clone(),
});
}
for glob in set.include.iter().chain(&set.exclude).flatten() {
if !set_glob_matches_something(set, glob) {
warnings.push(Warning::SetGlobMatchesNothing {
set: name.clone(),
glob: glob.clone(),
});
}
}
}
for repo in &document.repos {
let path = repo.path.get_ref();
if !expand_home(path).exists() {
warnings.push(Warning::RepoPathMatchesNothing { path: path.clone() });
}
}
warnings
}
pub(crate) fn expand_home(path: &str) -> PathBuf {
if let Some(rest) = path.strip_prefix("~/") {
if let Ok(home) = etcetera::home_dir() {
return home.join(rest);
}
} else if path == "~"
&& let Ok(home) = etcetera::home_dir()
{
return home;
}
PathBuf::from(path)
}
const MATCH_PROBE_ENTRY_CAP: usize = 20_000;
fn set_glob_matches_something(set: &SetConfig, pattern: &str) -> bool {
let Ok(glob) = globset::Glob::new(pattern) else {
return false;
};
let matcher = glob.compile_matcher();
set.roots
.iter()
.any(|root| walk_matches(&expand_home(root), &matcher))
}
fn walk_matches(root: &Path, matcher: &globset::GlobMatcher) -> bool {
let mut stack = vec![root.to_path_buf()];
let mut visited = 0usize;
while let Some(dir) = stack.pop() {
if matcher.is_match(&dir) {
return true;
}
if dir.join(".git").exists() {
continue;
}
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
visited += 1;
if visited > MATCH_PROBE_ENTRY_CAP {
return false;
}
let path = entry.path();
if matcher.is_match(&path) {
return true;
}
if path.is_dir() {
stack.push(path);
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_ok(text: &str) -> Loaded {
parse(text, Path::new("config.toml")).expect("expected the document to parse")
}
fn parse_err(text: &str) -> String {
parse(text, Path::new("config.toml"))
.expect_err("expected the document to fail to parse")
.to_string()
}
fn parse_ok_with_term(text: &str, term: Option<&str>) -> Loaded {
parse_with_term(text, Path::new("config.toml"), term)
.expect("expected the document to parse")
}
#[test]
fn an_empty_file_carries_the_stated_top_level_defaults() {
let loaded = parse_ok("");
assert_eq!(loaded.document.theme, "default");
assert_eq!(loaded.document.glyphs, Glyphs::Full);
assert!(loaded.document.show_worktrees);
assert!(!loaded.document.show_submodules);
assert_eq!(loaded.document.notice_timeout, Duration::from_secs(3));
}
#[test]
fn an_absent_glyphs_key_defaults_to_ascii_on_term_linux_and_full_otherwise() {
assert_eq!(
parse_ok_with_term("", Some("linux")).document.glyphs,
Glyphs::Ascii
);
for term in [
None,
Some(""),
Some("xterm-256color"),
Some("screen"),
Some("tmux-256color"),
Some("linux-256color"),
Some("LINUX"),
] {
assert_eq!(
parse_ok_with_term("", term).document.glyphs,
Glyphs::Full,
"expected full for TERM={term:?}"
);
}
}
#[test]
fn an_explicit_glyphs_key_pins_the_value_against_the_conditional_default_either_way() {
let pinned_full = parse_ok_with_term("glyphs = \"full\"\n", Some("linux"));
assert_eq!(pinned_full.document.glyphs, Glyphs::Full);
let pinned_ascii = parse_ok_with_term("glyphs = \"ascii\"\n", None);
assert_eq!(pinned_ascii.document.glyphs, Glyphs::Ascii);
}
#[test]
fn a_missing_file_still_applies_the_conditional_glyphs_default() {
let missing = Path::new("/does/not/exist/repon-glyphs-default-test/config.toml");
assert_eq!(
load_with_term(missing, Some("linux"))
.expect("a missing file is not an error")
.document
.glyphs,
Glyphs::Ascii
);
assert_eq!(
load_with_term(missing, None)
.expect("a missing file is not an error")
.document
.glyphs,
Glyphs::Full
);
}
#[test]
fn glyphs_default_reads_exactly_one_term_value_and_no_other_variable() {
use crate::test_support::{
all_lines_where, blocks_opened_by, production_source_at, workspace_crate_src_dirs,
};
let dirs = workspace_crate_src_dirs();
let term_reads = all_lines_where(&dirs, |line| {
line.contains("env::var(\"TERM\")") || line.contains("env::var_os(\"TERM\")")
});
assert_eq!(
term_reads.len(),
1,
"expected exactly one live `TERM` read across the workspace, found: {:?}",
term_reads
.iter()
.map(|line| format!("{}:{}", line.path.display(), line.number))
.collect::<Vec<_>>()
);
assert!(
term_reads[0].path.ends_with("config/document.rs"),
"expected the one TERM read to live in config/document.rs, found: {}",
term_reads[0].path.display()
);
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let source = production_source_at(&manifest_dir.join("src/config/document.rs"));
let decision_blocks = blocks_opened_by(&source, "fn conditional_glyphs_default");
assert_eq!(
decision_blocks.len(),
1,
"expected exactly one conditional_glyphs_default function"
);
let decision_body = &decision_blocks[0];
assert!(
!decision_body.contains("env::"),
"conditional_glyphs_default must stay a pure function of its `term` argument, but \
its body reads the environment directly: {decision_body}"
);
let significant_values = decision_body.matches("Some(\"").count();
assert_eq!(
significant_values, 1,
"expected exactly one TERM value to matter to the decision, got: {decision_body}"
);
assert!(
decision_body.contains("Some(\"linux\")"),
"expected the one significant value to be \"linux\", got: {decision_body}"
);
}
#[test]
fn notice_timeout_parses_as_humantime_and_zero_seconds_is_a_valid_value() {
let loaded = parse_ok("notice_timeout = \"10s\"\n");
assert_eq!(loaded.document.notice_timeout, Duration::from_secs(10));
let loaded = parse_ok("notice_timeout = \"0s\"\n");
assert_eq!(loaded.document.notice_timeout, Duration::ZERO);
}
#[test]
fn a_bare_integer_notice_timeout_is_a_bad_value() {
let message = parse_err("notice_timeout = 3\n");
assert!(
message.contains("duration"),
"expected a duration type error, got: {message}"
);
}
#[test]
fn the_six_refresh_fetch_and_auto_update_keys_carry_their_stated_defaults() {
let loaded = parse_ok("");
let refresh = &loaded.document.refresh;
assert_eq!(refresh.poll_interval, Duration::from_secs(2));
assert_eq!(refresh.status_stale_after, Duration::from_secs(5 * 60));
assert!(refresh.on_focus);
let fetch = &loaded.document.fetch;
assert!(!fetch.enabled);
assert_eq!(fetch.interval, Duration::from_secs(5 * 60));
assert_eq!(fetch.concurrency, 4);
assert!(!loaded.document.auto_update.enabled);
}
#[test]
fn a_bare_integer_duration_is_a_bad_value_in_a_known_key() {
let message = parse_err("[refresh]\npoll_interval = 2\n");
assert!(
message.contains("duration"),
"expected a duration type error, got: {message}"
);
assert!(
message.contains("line 2, column 17"),
"expected the offending value's position, got: {message}"
);
}
#[test]
fn a_zero_second_string_disables_the_poll() {
let loaded = parse_ok("[refresh]\npoll_interval = \"0s\"\n");
assert_eq!(loaded.document.refresh.poll_interval, Duration::ZERO);
}
#[test]
fn a_partial_refresh_table_merges_over_the_defaults_for_the_fields_it_omits() {
let loaded = parse_ok("[refresh]\npoll_interval = \"10s\"\n");
let refresh = &loaded.document.refresh;
assert_eq!(refresh.poll_interval, Duration::from_secs(10));
assert_eq!(refresh.status_stale_after, Duration::from_secs(5 * 60));
assert!(refresh.on_focus);
}
#[test]
fn a_missing_file_resolves_to_the_implicit_all_set() {
let loaded = load(Path::new("/does/not/exist/config.toml")).expect("not an error");
assert_eq!(loaded.document.sets.len(), 1);
assert_eq!(loaded.document.sets[0].name.get_ref(), "all");
assert_eq!(
loaded.document.sets[0].roots,
vec![working_directory().to_string_lossy().into_owned()]
);
assert!(loaded.warnings.is_empty());
assert!(
loaded.zero_config,
"a missing file must report zero_config, since state.toml's own scope key reads \
this to decide between the active Set's name and the working directory"
);
}
#[test]
fn a_real_file_declaring_no_set_is_not_reported_as_zero_config() {
let loaded = parse_ok("");
assert_eq!(loaded.document.sets[0].name.get_ref(), "all");
assert!(
!loaded.zero_config,
"a file that was actually read must never report zero_config, even when it \
declares no [[set]] of its own"
);
}
#[test]
fn malformed_toml_reports_line_and_column_from_the_api() {
let message = parse_err("this is not = = valid toml [[[\n");
assert!(message.contains("could not parse"));
assert!(
message.contains("line 1, column 6"),
"expected the parser's own position, got: {message}"
);
}
#[test]
fn every_unknown_key_is_reported_in_one_pass() {
let loaded = parse_ok(
"typo_one = true\n\n[refresh]\ntypo_two = 1\n\n[[set]]\nname = \"dev\"\nroots = [\"~/dev\"]\ntypo_three = 1\n",
);
let unknown: Vec<&str> = loaded
.warnings
.iter()
.filter_map(|warning| match warning {
Warning::UnknownKey(path) => Some(path.as_str()),
_ => None,
})
.collect();
assert_eq!(
unknown.len(),
3,
"expected all three typos, got: {unknown:?}"
);
assert!(unknown.contains(&"typo_one"));
assert!(unknown.contains(&"refresh.typo_two"));
assert!(unknown.iter().any(|path| path.ends_with("typo_three")));
}
#[test]
fn an_appearance_key_is_not_part_of_the_schema_and_warns_as_unknown() {
let loaded = parse_ok("appearance = \"dark\"\n");
assert!(
loaded.warnings.iter().any(
|warning| matches!(warning, Warning::UnknownKey(path) if path == "appearance")
),
"expected `appearance` to warn as an unknown key, got: {:?}",
loaded.warnings
);
}
#[test]
fn a_duplicate_set_name_is_rejected_with_a_line_number() {
let message = parse_err(
"[[set]]\nname = \"dev\"\nroots = [\"~/dev\"]\n\n[[set]]\nname = \"dev\"\nroots = [\"~/other\"]\n",
);
assert!(message.contains("duplicate set name"));
assert!(
message.contains("line 6"),
"expected the second declaration's line, got: {message}"
);
}
#[test]
fn a_duplicate_repo_path_is_rejected_with_a_line_number() {
let message =
parse_err("[[repo]]\npath = \"~/dev/one\"\n\n[[repo]]\npath = \"~/dev/one\"\n");
assert!(message.contains("duplicate repo path"));
assert!(message.contains("line 5"), "got: {message}");
}
#[test]
fn a_duplicate_launcher_name_is_rejected_with_a_line_number() {
let message = parse_err(
"[[launcher]]\nname = \"lazygit\"\nargs = [\"lazygit\"]\n\n[[launcher]]\nname = \"lazygit\"\nargs = [\"lg\"]\n",
);
assert!(message.contains("duplicate launcher name"));
assert!(
message.contains("line 6"),
"expected the second declaration's line, got: {message}"
);
}
#[test]
fn set_declaration_order_is_preserved() {
let loaded = parse_ok(
"[[set]]\nname = \"zeta\"\nroots = [\"~/dev\"]\n\n[[set]]\nname = \"alpha\"\nroots = [\"~/dev\"]\n",
);
let names: Vec<&str> = loaded
.document
.sets
.iter()
.map(|set| set.name.get_ref().as_str())
.collect();
assert_eq!(names, vec!["zeta", "alpha"]);
}
#[test]
fn a_launcher_entrys_full_field_schema_parses_with_no_unknown_keys() {
let text = "[[launcher]]\n\
name = \"lazygit\"\n\
args = [\"lazygit\"]\n\
shell = false\n\
takes_terminal = true\n\
disabled = false\n\
[launcher.env]\n\
FOO = \"bar\"\n\
\n\
[[launcher]]\n\
name = \"editor\"\n\
from_env = \"EDITOR\"\n\
takes_terminal = false\n";
let loaded = parse_ok(text);
assert!(
!loaded
.warnings
.iter()
.any(|warning| matches!(warning, Warning::UnknownKey(_))),
"expected no unknown-key warnings, got: {:?}",
loaded.warnings
);
let lazygit = &loaded.document.launchers[0];
assert_eq!(
lazygit.args.as_deref(),
Some(["lazygit".to_string()].as_slice())
);
assert_eq!(lazygit.from_env, None);
assert!(!lazygit.shell);
assert!(lazygit.takes_terminal);
assert!(!lazygit.disabled);
assert_eq!(lazygit.env.get("FOO").map(String::as_str), Some("bar"));
let editor = &loaded.document.launchers[1];
assert_eq!(editor.from_env.as_deref(), Some("EDITOR"));
assert_eq!(editor.args, None);
assert!(
!editor.takes_terminal,
"an entry declaring `takes_terminal = false` must keep it, whichever argv form it \
uses"
);
}
#[test]
fn a_launcher_entrys_actually_unknown_key_still_warns() {
let text = "[[launcher]]\nname = \"lazygit\"\ntypo_field = 1\n";
let loaded = parse_ok(text);
assert!(
loaded.warnings.iter().any(|warning| matches!(
warning,
Warning::UnknownKey(path) if path.ends_with("typo_field")
)),
"expected an unknown-key warning for the stray field, got: {:?}",
loaded.warnings
);
}
#[test]
fn a_working_directory_key_on_a_launcher_entry_is_not_part_of_the_schema_and_warns_as_unknown()
{
let text = "[[launcher]]\nname = \"lazygit\"\ncwd = \"/tmp\"\n";
let loaded = parse_ok(text);
assert!(
loaded.warnings.iter().any(|warning| matches!(
warning,
Warning::UnknownKey(path) if path.ends_with("cwd")
)),
"expected `cwd` to warn as an unknown key, got: {:?}",
loaded.warnings
);
}
fn spec_launcher_field_rows(spec: &str) -> Vec<(String, String)> {
const ANCHOR: &str = "## Launchers";
let after = spec
.split(ANCHOR)
.nth(1)
.expect("the Launchers section is present");
after
.lines()
.skip_while(|line| !line.starts_with('|'))
.take_while(|line| line.starts_with('|'))
.filter(|line| !line.starts_with("| ---"))
.filter_map(|line| {
let cells: Vec<&str> = line.split('|').map(str::trim).collect();
let (field, kind) = (cells[1].trim_matches('`'), cells[2]);
(field != "field").then(|| (field.to_string(), kind.to_string()))
})
.collect()
}
fn spec_declared_bool_default(kind: &str) -> Option<bool> {
let stated = kind.strip_prefix("bool, default ")?;
match stated.trim_matches('`') {
"true" => Some(true),
"false" => Some(false),
other => panic!("unexpected bool default {other:?} in the Launchers field table"),
}
}
#[test]
fn every_bool_launcher_key_defaults_to_what_the_spec_states_when_an_entry_omits_it() {
let spec = read_config_spec();
let mut loaded = parse_ok("[[launcher]]\nname = \"lazygit\"\nargs = [\"lazygit\"]\n");
let launcher = loaded
.document
.launchers
.pop()
.expect("one parsed [[launcher]] entry");
let mut checked = Vec::new();
for (field, kind) in spec_launcher_field_rows(&spec) {
let Some(expected) = spec_declared_bool_default(&kind) else {
continue;
};
let actual = match field.as_str() {
"shell" => launcher.shell,
"interactive" => launcher.interactive,
"takes_terminal" => launcher.takes_terminal,
"disabled" => launcher.disabled,
other => panic!("no `LauncherConfig` field is wired to the spec's `{other}`"),
};
assert_eq!(
actual, expected,
"`{field}` must default to the spec's own stated default"
);
checked.push(field);
}
assert_eq!(
checked,
vec!["shell", "interactive", "takes_terminal", "disabled"],
"the Launchers table's bool rows, in its own order; a parse that stops finding \
them would otherwise leave this test asserting nothing"
);
}
#[test]
fn launcher_config_carries_no_working_directory_field_every_launcher_uses_its_entitys_own_cwd()
{
let loaded = parse_ok("[[launcher]]\nname = \"lazygit\"\nargs = [\"lazygit\"]\n");
let LauncherConfig {
name: _,
args: _,
from_env: _,
shell: _,
interactive: _,
takes_terminal: _,
env: _,
disabled: _,
} = loaded
.document
.launchers
.into_iter()
.next()
.expect("one parsed [[launcher]] entry");
}
#[test]
fn a_launcher_declaring_both_args_and_from_env_is_rejected_at_load() {
let message =
parse_err("[[launcher]]\nname = \"editor\"\nargs = [\"vi\"]\nfrom_env = \"EDITOR\"\n");
assert!(
message.contains("editor") && message.contains("mutually exclusive"),
"expected a mutual-exclusion error naming the launcher, got: {message}"
);
}
#[test]
fn a_launcher_declaring_only_args_or_only_from_env_is_accepted() {
let with_args = parse_ok("[[launcher]]\nname = \"lazygit\"\nargs = [\"lazygit\"]\n");
assert_eq!(with_args.document.launchers[0].from_env, None);
let with_from_env = parse_ok("[[launcher]]\nname = \"editor\"\nfrom_env = \"EDITOR\"\n");
assert_eq!(with_from_env.document.launchers[0].args, None);
}
#[test]
fn a_launcher_declaring_interactive_without_shell_is_rejected_at_load() {
let message =
parse_err("[[launcher]]\nname = \"log\"\nargs = [\"true\"]\ninteractive = true\n");
assert!(
message.contains("log") && message.contains("interactive") && message.contains("shell"),
"expected an error naming both the launcher and both keys, got: {message}"
);
}
#[test]
fn an_action_step_declaring_interactive_without_shell_is_rejected_at_load() {
let message = parse_err(
"[[action]]\nname = \"deploy\"\n\n\
[[action.steps]]\nargs = [\"true\"]\ninteractive = true\n",
);
assert!(
message.contains("deploy")
&& message.contains("interactive")
&& message.contains("shell"),
"expected an error naming both the action and both keys, got: {message}"
);
}
#[test]
fn interactive_alongside_shell_is_accepted_on_a_launcher_and_a_step() {
let launcher = parse_ok(
"[[launcher]]\nname = \"log\"\nargs = [\"true\"]\nshell = true\ninteractive = true\n",
);
assert!(launcher.document.launchers[0].interactive);
let step = parse_ok(
"[[action]]\nname = \"deploy\"\n\n\
[[action.steps]]\nargs = [\"true\"]\nshell = true\ninteractive = true\n",
);
assert!(step.document.actions[0].steps[0].interactive);
}
#[test]
fn a_repo_entrys_default_branch_and_exclude_parse_with_excludes_stated_default() {
let dir = tempfile::tempdir().expect("tempdir");
let text = format!(
"[[repo]]\npath = \"{}\"\ndefault_branch = \"main\"\nexclude = true\n\n[[repo]]\npath = \"{}\"\n",
dir.path().display(),
dir.path().join("other").display()
);
let loaded = parse_ok(&text);
assert_eq!(
loaded.document.repos[0].default_branch.as_deref(),
Some("main")
);
assert!(loaded.document.repos[0].exclude);
assert_eq!(loaded.document.repos[1].default_branch, None);
assert!(!loaded.document.repos[1].exclude);
assert!(
!loaded
.warnings
.iter()
.any(|warning| matches!(warning, Warning::UnknownKey(_))),
"expected no unknown-key warnings, got: {:?}",
loaded.warnings
);
}
#[test]
fn a_repo_entrys_actually_unknown_key_still_warns() {
let dir = tempfile::tempdir().expect("tempdir");
let text = format!(
"[[repo]]\npath = \"{}\"\ntypo_field = 1\n",
dir.path().display()
);
let loaded = parse_ok(&text);
assert!(
loaded
.warnings
.iter()
.any(|warning| matches!(warning, Warning::UnknownKey(path) if path.ends_with("typo_field"))),
"expected an unknown-key warning for the stray field, got: {:?}",
loaded.warnings
);
}
#[test]
fn repo_overrides_tilde_expands_the_path_and_carries_default_branch_and_exclude() {
let loaded = parse_ok(
"[[repo]]\npath = \"~/dev/legacy-api\"\ndefault_branch = \"main\"\n\n[[repo]]\npath = \"/absolute/vendor-mirror\"\nexclude = true\n",
);
let overrides = repo_overrides(&loaded.document);
assert_eq!(overrides.len(), 2);
assert_eq!(
overrides[0].path,
expand_home("~/dev/legacy-api"),
"a `~`-prefixed path must expand the same way every other path in this file does"
);
assert_eq!(overrides[0].default_branch.as_deref(), Some("main"));
assert!(!overrides[0].excluded);
assert_eq!(overrides[1].path, PathBuf::from("/absolute/vendor-mirror"));
assert_eq!(overrides[1].default_branch, None);
assert!(overrides[1].excluded);
}
#[test]
fn auto_update_without_fetch_warns() {
let loaded = parse_ok("[fetch]\nenabled = false\n\n[auto_update]\nenabled = true\n");
assert!(loaded.warnings.contains(&Warning::AutoUpdateWithoutFetch));
}
#[test]
fn auto_update_with_fetch_does_not_warn() {
let loaded = parse_ok("[fetch]\nenabled = true\n\n[auto_update]\nenabled = true\n");
assert!(!loaded.warnings.contains(&Warning::AutoUpdateWithoutFetch));
}
#[test]
fn on_refresh_naming_an_undeclared_action_warns_rather_than_failing_the_load() {
let loaded = parse_ok("on_refresh = \"sync\"\n");
assert!(
loaded.warnings.contains(&Warning::OnRefreshNamesNoAction {
name: "sync".to_string(),
}),
"got: {:?}",
loaded.warnings
);
assert_eq!(loaded.document.on_refresh.as_deref(), Some("sync"));
}
#[test]
fn on_refresh_naming_a_declared_action_does_not_warn() {
let loaded = parse_ok(
"on_refresh = \"hook\"\n\n[[action]]\nname = \"hook\"\nsteps = [{ args = [\"true\"] }]\n",
);
assert!(
!loaded
.warnings
.iter()
.any(|warning| matches!(warning, Warning::OnRefreshNamesNoAction { name: _ })),
"got: {:?}",
loaded.warnings
);
}
#[test]
fn an_absent_on_refresh_key_warns_about_nothing_and_defaults_to_none() {
let loaded = parse_ok("theme = \"default\"\n");
assert_eq!(loaded.document.on_refresh, None);
assert!(
!loaded
.warnings
.iter()
.any(|warning| matches!(warning, Warning::OnRefreshNamesNoAction { name: _ })),
"got: {:?}",
loaded.warnings
);
}
#[test]
fn a_set_on_refresh_key_parses() {
let loaded = parse_ok(
"[[set]]\nname = \"work\"\nroots = [\"~/dev\"]\non_refresh = \"hook\"\n\n\
[[action]]\nname = \"hook\"\nsteps = [{ args = [\"true\"] }]\n",
);
assert_eq!(loaded.document.sets[0].on_refresh.as_deref(), Some("hook"));
}
#[test]
fn an_unknown_key_inside_a_set_that_also_declares_on_refresh_still_warns() {
let loaded = parse_ok(
"[[set]]\nname = \"work\"\nroots = [\"~/dev\"]\non_refresh = \"sync\"\ntypo = 1\n",
);
assert!(
loaded.warnings.iter().any(
|warning| matches!(warning, Warning::UnknownKey(path) if path.ends_with("typo"))
),
"expected the stray key to still warn beside a real on_refresh, got: {:?}",
loaded.warnings
);
}
#[test]
fn a_set_on_refresh_naming_no_declared_action_warns_and_names_the_set() {
let loaded = parse_ok(
"[[set]]\nname = \"work\"\nroots = [\"~/dev\"]\non_refresh = \"nothing-declares-this\"\n",
);
assert!(
loaded
.warnings
.contains(&Warning::SetOnRefreshNamesNoAction {
set: "work".to_string(),
name: "nothing-declares-this".to_string(),
}),
"got: {:?}",
loaded.warnings
);
}
#[test]
fn two_sets_with_the_same_bad_on_refresh_name_produce_two_distinguishable_warnings() {
let loaded = parse_ok(
"[[set]]\nname = \"work\"\nroots = [\"~/dev\"]\non_refresh = \"nothing-declares-this\"\n\n\
[[set]]\nname = \"personal\"\nroots = [\"~/dev-misc\"]\non_refresh = \"nothing-declares-this\"\n",
);
let set_names: Vec<&str> = loaded
.warnings
.iter()
.filter_map(|warning| match warning {
Warning::SetOnRefreshNamesNoAction { set, .. } => Some(set.as_str()),
_ => None,
})
.collect();
assert_eq!(
set_names,
vec!["work", "personal"],
"each Set's own bad on_refresh must warn on its own, naming that Set, got: {:?}",
loaded.warnings
);
}
#[test]
fn a_set_on_refresh_naming_a_declared_action_does_not_warn() {
let loaded = parse_ok(
"[[set]]\nname = \"work\"\nroots = [\"~/dev\"]\non_refresh = \"hook\"\n\n\
[[action]]\nname = \"hook\"\nsteps = [{ args = [\"true\"] }]\n",
);
assert!(
!loaded
.warnings
.iter()
.any(|warning| matches!(warning, Warning::SetOnRefreshNamesNoAction { .. })),
"got: {:?}",
loaded.warnings
);
}
#[test]
fn before_sync_naming_an_undeclared_action_warns_rather_than_failing_the_load() {
let loaded = parse_ok("before_sync = \"tidy\"\n");
assert!(
loaded.warnings.contains(&Warning::BeforeSyncNamesNoAction {
name: "tidy".to_string(),
}),
"got: {:?}",
loaded.warnings
);
assert_eq!(loaded.document.before_sync.as_deref(), Some("tidy"));
}
#[test]
fn after_sync_naming_an_undeclared_action_warns_rather_than_failing_the_load() {
let loaded = parse_ok("after_sync = \"tidy\"\n");
assert!(
loaded.warnings.contains(&Warning::AfterSyncNamesNoAction {
name: "tidy".to_string(),
}),
"got: {:?}",
loaded.warnings
);
assert_eq!(loaded.document.after_sync.as_deref(), Some("tidy"));
}
#[test]
fn before_sync_and_after_sync_naming_a_declared_action_do_not_warn() {
let loaded = parse_ok(
"before_sync = \"hook\"\nafter_sync = \"hook\"\n\n\
[[action]]\nname = \"hook\"\nsteps = [{ args = [\"true\"] }]\n",
);
assert!(
!loaded.warnings.iter().any(|warning| matches!(
warning,
Warning::BeforeSyncNamesNoAction { .. } | Warning::AfterSyncNamesNoAction { .. }
)),
"got: {:?}",
loaded.warnings
);
}
#[test]
fn absent_before_sync_and_after_sync_keys_warn_about_nothing_and_default_to_none() {
let loaded = parse_ok("theme = \"default\"\n");
assert_eq!(loaded.document.before_sync, None);
assert_eq!(loaded.document.after_sync, None);
assert!(
!loaded.warnings.iter().any(|warning| matches!(
warning,
Warning::BeforeSyncNamesNoAction { .. } | Warning::AfterSyncNamesNoAction { .. }
)),
"got: {:?}",
loaded.warnings
);
}
#[test]
fn a_set_before_sync_and_after_sync_key_parse() {
let loaded = parse_ok(
"[[set]]\nname = \"work\"\nroots = [\"~/dev\"]\n\
before_sync = \"pre\"\nafter_sync = \"post\"\n\n\
[[action]]\nname = \"pre\"\nsteps = [{ args = [\"true\"] }]\n\n\
[[action]]\nname = \"post\"\nsteps = [{ args = [\"true\"] }]\n",
);
assert_eq!(loaded.document.sets[0].before_sync.as_deref(), Some("pre"));
assert_eq!(loaded.document.sets[0].after_sync.as_deref(), Some("post"));
}
#[test]
fn a_set_before_sync_naming_no_declared_action_warns_and_names_the_set() {
let loaded = parse_ok(
"[[set]]\nname = \"work\"\nroots = [\"~/dev\"]\nbefore_sync = \"nothing-declares-this\"\n",
);
assert!(
loaded
.warnings
.contains(&Warning::SetBeforeSyncNamesNoAction {
set: "work".to_string(),
name: "nothing-declares-this".to_string(),
}),
"got: {:?}",
loaded.warnings
);
}
#[test]
fn a_set_after_sync_naming_no_declared_action_warns_and_names_the_set() {
let loaded = parse_ok(
"[[set]]\nname = \"work\"\nroots = [\"~/dev\"]\nafter_sync = \"nothing-declares-this\"\n",
);
assert!(
loaded
.warnings
.contains(&Warning::SetAfterSyncNamesNoAction {
set: "work".to_string(),
name: "nothing-declares-this".to_string(),
}),
"got: {:?}",
loaded.warnings
);
}
#[test]
fn before_sync_and_after_sync_resolve_over_all_three_rungs_from_one_document() {
let loaded = parse_ok(
"before_sync = \"top-level-pre\"\nafter_sync = \"top-level-post\"\n\n\
[[set]]\nname = \"own-hooks\"\nroots = [\"~/dev\"]\n\
before_sync = \"set-pre\"\nafter_sync = \"set-post\"\n\n\
[[set]]\nname = \"falls-through\"\nroots = [\"~/dev\"]\n\n\
[[action]]\nname = \"set-pre\"\nsteps = [{ args = [\"true\"] }]\n\n\
[[action]]\nname = \"set-post\"\nsteps = [{ args = [\"true\"] }]\n\n\
[[action]]\nname = \"top-level-pre\"\nsteps = [{ args = [\"true\"] }]\n\n\
[[action]]\nname = \"top-level-post\"\nsteps = [{ args = [\"true\"] }]\n",
);
assert_eq!(
resolve_before_sync_name(&loaded.document, "own-hooks"),
Some("set-pre"),
"a Set with its own before_sync must resolve to it, ahead of the top-level key"
);
assert_eq!(
resolve_after_sync_name(&loaded.document, "own-hooks"),
Some("set-post"),
"a Set with its own after_sync must resolve to it, ahead of the top-level key"
);
assert_eq!(
resolve_before_sync_name(&loaded.document, "falls-through"),
Some("top-level-pre"),
"a Set with no before_sync of its own must fall through to the top-level key"
);
assert_eq!(
resolve_after_sync_name(&loaded.document, "falls-through"),
Some("top-level-post"),
"a Set with no after_sync of its own must fall through to the top-level key"
);
}
#[test]
fn on_refresh_resolves_over_all_three_rungs_from_one_document() {
let loaded = parse_ok(
"on_refresh = \"top-level\"\n\n\
[[set]]\nname = \"own-hook\"\nroots = [\"~/dev\"]\non_refresh = \"set-scoped\"\n\n\
[[set]]\nname = \"falls-through\"\nroots = [\"~/dev\"]\n\n\
[[set]]\nname = \"third-set-has-one\"\nroots = [\"~/dev\"]\non_refresh = \"set-scoped\"\n\n\
[[action]]\nname = \"set-scoped\"\nsteps = [{ args = [\"true\"] }]\n\n\
[[action]]\nname = \"top-level\"\nsteps = [{ args = [\"true\"] }]\n",
);
assert_eq!(
resolve_on_refresh_name(&loaded.document, "own-hook"),
Some("set-scoped"),
"a Set with its own hook must resolve to it, ahead of the top-level key"
);
assert_eq!(
resolve_on_refresh_name(&loaded.document, "falls-through"),
Some("top-level"),
"a Set with no on_refresh of its own must fall through to the top-level key"
);
assert_eq!(
resolve_on_refresh_name(&loaded.document, "third-set-has-one"),
Some("set-scoped"),
"a third Set's own hook must resolve independently of what another Set declares"
);
}
#[test]
fn on_refresh_resolves_to_none_when_neither_the_set_nor_the_top_level_declares_one() {
let loaded = parse_ok("[[set]]\nname = \"quiet\"\nroots = [\"~/dev\"]\n");
assert_eq!(resolve_on_refresh_name(&loaded.document, "quiet"), None);
}
#[test]
fn a_set_named_all_warns_and_its_declaration_wins() {
let loaded = parse_ok("[[set]]\nname = \"all\"\nroots = [\"~/dev\"]\n");
assert!(loaded.warnings.contains(&Warning::SetNamedAll));
assert_eq!(loaded.document.sets.len(), 1);
assert_eq!(loaded.document.sets[0].roots, vec!["~/dev".to_string()]);
}
#[test]
fn a_repo_path_that_does_not_exist_warns() {
let loaded = parse_ok("[[repo]]\npath = \"/does/not/exist/anywhere\"\n");
assert!(loaded.warnings.iter().any(|warning| matches!(
warning,
Warning::RepoPathMatchesNothing { path } if path == "/does/not/exist/anywhere"
)));
}
#[test]
fn a_repo_path_that_exists_does_not_warn() {
let dir = tempfile::tempdir().expect("tempdir");
let text = format!("[[repo]]\npath = \"{}\"\n", dir.path().display());
let loaded = parse_ok(&text);
assert!(loaded.warnings.is_empty(), "got: {:?}", loaded.warnings);
}
#[test]
fn a_set_glob_matching_nothing_under_its_roots_warns() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(dir.path().join("kept")).expect("create dir");
let text = format!(
"[[set]]\nname = \"dev\"\nroots = [\"{}\"]\ninclude = [\"**/nonexistent-glob-target/**\"]\n",
dir.path().display()
);
let loaded = parse_ok(&text);
assert!(loaded.warnings.iter().any(|warning| matches!(
warning,
Warning::SetGlobMatchesNothing { glob, .. } if glob == "**/nonexistent-glob-target/**"
)));
}
#[test]
fn a_set_glob_matching_something_under_its_roots_does_not_warn() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(dir.path().join("kept")).expect("create dir");
let text = format!(
"[[set]]\nname = \"dev\"\nroots = [\"{}\"]\ninclude = [\"**/kept\"]\n",
dir.path().display()
);
let loaded = parse_ok(&text);
assert!(loaded.warnings.is_empty(), "got: {:?}", loaded.warnings);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TemplateSection {
TopLevel,
Refresh,
Fetch,
AutoUpdate,
Set,
Repo,
Launcher,
Action,
ActionSteps,
}
fn sectioned_lines(template: &str) -> Vec<(TemplateSection, &str)> {
let mut section = TemplateSection::TopLevel;
template
.lines()
.map(|line| {
let trimmed = line.trim();
if trimmed.starts_with("[[action.steps]]") {
section = TemplateSection::ActionSteps;
} else if trimmed.starts_with("[[set]]") {
section = TemplateSection::Set;
} else if trimmed.starts_with("[[repo]]") {
section = TemplateSection::Repo;
} else if trimmed.starts_with("[[launcher]]") {
section = TemplateSection::Launcher;
} else if trimmed.starts_with("[[action]]") {
section = TemplateSection::Action;
} else if trimmed.starts_with("[refresh]") {
section = TemplateSection::Refresh;
} else if trimmed.starts_with("[fetch]") {
section = TemplateSection::Fetch;
} else if trimmed.starts_with("[auto_update]") {
section = TemplateSection::AutoUpdate;
}
(section, line)
})
.collect()
}
fn line_declares_key(line: &str, key: &str) -> bool {
let line = line.trim();
let line = line.strip_prefix('#').map(str::trim).unwrap_or(line);
line.strip_prefix(key)
.map(|rest| rest.trim_start().starts_with('='))
.unwrap_or(false)
}
fn section_declares_key(
lines: &[(TemplateSection, &str)],
section: TemplateSection,
key: &str,
) -> bool {
lines
.iter()
.filter(|(line_section, _)| *line_section == section)
.any(|(_, line)| line_declares_key(line, key))
}
fn document_field_names() -> (&'static [&'static str], &'static [&'static str]) {
let Document {
theme: _,
glyphs: _,
show_worktrees: _,
show_submodules: _,
advance_on_toggle: _,
notice_timeout: _,
on_refresh: _,
before_sync: _,
after_sync: _,
refresh: _,
fetch: _,
auto_update: _,
sets: _,
repos: _,
launchers: _,
actions: _,
keys: _,
} = Document::default();
(
&[
"theme",
"glyphs",
"show_worktrees",
"show_submodules",
"advance_on_toggle",
"notice_timeout",
"on_refresh",
"before_sync",
"after_sync",
],
&[
"[refresh]",
"[fetch]",
"[auto_update]",
"[keys",
"[[set]]",
"[[repo]]",
"[[launcher]]",
"[[action]]",
],
)
}
fn refresh_config_field_names() -> &'static [&'static str] {
let RefreshConfig {
poll_interval: _,
status_stale_after: _,
on_focus: _,
} = RefreshConfig::default();
&["poll_interval", "status_stale_after", "on_focus"]
}
fn fetch_config_field_names() -> &'static [&'static str] {
let FetchConfig {
enabled: _,
interval: _,
concurrency: _,
} = FetchConfig::default();
&["enabled", "interval", "concurrency"]
}
fn auto_update_config_field_names() -> &'static [&'static str] {
let AutoUpdateConfig { enabled: _ } = AutoUpdateConfig::default();
&["enabled"]
}
fn set_config_field_names() -> &'static [&'static str] {
let SetConfig {
name: _,
roots: _,
include: _,
exclude: _,
on_refresh: _,
before_sync: _,
after_sync: _,
} = toml::from_str::<SetConfig>("name = \"x\"\nroots = []\n").expect("minimal SetConfig");
&[
"name",
"roots",
"include",
"exclude",
"on_refresh",
"before_sync",
"after_sync",
]
}
fn repo_config_field_names() -> &'static [&'static str] {
let RepoConfig {
path: _,
default_branch: _,
exclude: _,
} = toml::from_str::<RepoConfig>("path = \"x\"\n").expect("minimal RepoConfig");
&["path", "default_branch", "exclude"]
}
fn launcher_config_field_names() -> &'static [&'static str] {
let LauncherConfig {
name: _,
args: _,
from_env: _,
shell: _,
interactive: _,
takes_terminal: _,
env: _,
disabled: _,
} = toml::from_str::<LauncherConfig>("name = \"x\"\n").expect("minimal LauncherConfig");
&[
"name",
"args",
"from_env",
"shell",
"interactive",
"takes_terminal",
"env",
"disabled",
]
}
fn action_config_field_names() -> &'static [&'static str] {
let ActionConfig {
name: _,
description: _,
steps: _,
confirm: _,
concurrency: _,
when: _,
} = toml::from_str::<ActionConfig>("name = \"x\"\nsteps = []\n")
.expect("minimal ActionConfig");
&["name", "description", "confirm", "concurrency", "when"]
}
fn step_config_field_names() -> &'static [&'static str] {
let StepConfig {
args: _,
shell: _,
interactive: _,
env: _,
} = toml::from_str::<StepConfig>("args = []\n").expect("minimal StepConfig");
&["args", "shell", "interactive", "env"]
}
#[test]
fn every_schema_field_appears_somewhere_in_the_shipped_template() {
let template = annotated_example();
let lines = sectioned_lines(template);
let (top_level, headers) = document_field_names();
let mut missing = Vec::new();
for key in top_level {
if !section_declares_key(&lines, TemplateSection::TopLevel, key) {
missing.push(format!("Document.{key}"));
}
}
for header in headers {
if !template.contains(header) {
missing.push(format!("Document.{header}"));
}
}
let sections: &[(&str, TemplateSection, &[&str])] = &[
(
"RefreshConfig",
TemplateSection::Refresh,
refresh_config_field_names(),
),
(
"FetchConfig",
TemplateSection::Fetch,
fetch_config_field_names(),
),
(
"AutoUpdateConfig",
TemplateSection::AutoUpdate,
auto_update_config_field_names(),
),
("SetConfig", TemplateSection::Set, set_config_field_names()),
(
"RepoConfig",
TemplateSection::Repo,
repo_config_field_names(),
),
(
"LauncherConfig",
TemplateSection::Launcher,
launcher_config_field_names(),
),
(
"ActionConfig",
TemplateSection::Action,
action_config_field_names(),
),
(
"StepConfig",
TemplateSection::ActionSteps,
step_config_field_names(),
),
];
for (struct_name, section, fields) in sections {
for key in *fields {
if !section_declares_key(&lines, *section, key) {
missing.push(format!("{struct_name}.{key}"));
}
}
}
if !template.contains("[[action.steps]]") {
missing.push("ActionConfig.steps".to_string());
}
assert!(
missing.is_empty(),
"example.toml omits these schema fields entirely: {missing:?}"
);
}
fn uncomment_defaults(template: &str) -> String {
template
.lines()
.map(|line| {
let trimmed = line.trim_start();
let Some(rest) = trimmed.strip_prefix('#') else {
return line.to_string();
};
let candidate = rest.trim_start();
let key_end = candidate
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '.'))
.unwrap_or(candidate.len());
let is_key_value =
key_end > 0 && candidate[key_end..].trim_start().starts_with('=');
if is_key_value {
let indent = &line[..line.len() - trimmed.len()];
format!("{indent}{candidate}")
} else {
line.to_string()
}
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn the_annotated_example_parses_against_the_real_schema() {
let example = annotated_example();
assert!(
example.starts_with("# This terminal draws braille"),
"expected the extracted block to start with the spec's own comment, got: {example:?}"
);
let loaded = parse_ok(example);
let unknown: Vec<&Warning> = loaded
.warnings
.iter()
.filter(|warning| matches!(warning, Warning::UnknownKey(_)))
.collect();
assert!(
unknown.is_empty(),
"expected no unknown-key warnings, got: {unknown:?}"
);
}
#[test]
fn the_annotated_examples_keys_block_merges_cleanly_and_does_what_its_comments_say() {
let loaded = parse_ok(annotated_example());
let (bindings, warnings) =
crate::keys::merge(&loaded.document.keys).expect("expected the keys block to merge");
assert!(
warnings.is_empty(),
"expected the shipped example's [keys] block to raise no warning, got: {warnings:?}"
);
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
assert_eq!(
bindings.dispatch(
crate::keys::Context::Global,
KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)
),
Some(crate::keys::Action::RefreshAll)
);
assert_eq!(
bindings.dispatch(
crate::keys::Context::Global,
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)
),
None
);
assert_eq!(
bindings.dispatch(
crate::keys::Context::List,
KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)
),
None
);
}
#[test]
fn every_default_valued_field_the_example_shows_could_be_deleted() {
let uncommented = uncomment_defaults(annotated_example());
let loaded = parse_ok(&uncommented);
let unknown: Vec<&Warning> = loaded
.warnings
.iter()
.filter(|warning| matches!(warning, Warning::UnknownKey(_)))
.collect();
assert!(
unknown.is_empty(),
"uncommenting the whole template must raise no unknown-key warning, got: {unknown:?}"
);
let document = &loaded.document;
assert_eq!(document.theme, Document::default().theme);
assert_eq!(document.glyphs, Glyphs::default());
assert_eq!(document.show_worktrees, Document::default().show_worktrees);
assert_eq!(
document.show_submodules,
Document::default().show_submodules
);
assert_eq!(document.notice_timeout, Document::default().notice_timeout);
assert_eq!(
document.refresh.poll_interval,
RefreshConfig::default().poll_interval
);
assert_eq!(
document.refresh.status_stale_after,
RefreshConfig::default().status_stale_after
);
assert_eq!(document.refresh.on_focus, RefreshConfig::default().on_focus);
assert_eq!(document.fetch.interval, FetchConfig::default().interval);
assert_eq!(
document.fetch.concurrency,
FetchConfig::default().concurrency
);
let editor = document
.launchers
.iter()
.find(|launcher| launcher.name.get_ref() == "editor")
.expect("the example's editor launcher");
assert_eq!(editor.env, BTreeMap::new());
assert!(!editor.disabled);
let reinstall = document
.actions
.iter()
.find(|action| action.name.get_ref() == "reinstall")
.expect("the example's reinstall action");
assert_eq!(reinstall.confirm, default_action_confirm());
let rm_step = reinstall.steps.first().expect("reinstall's first step");
assert!(!rm_step.shell);
assert_eq!(rm_step.env, BTreeMap::new());
assert_ne!(document.fetch.enabled, FetchConfig::default().enabled);
assert_ne!(
document.auto_update.enabled,
AutoUpdateConfig::default().enabled
);
assert!(reinstall.when.is_some());
}
fn extract_fenced_example(spec: &str) -> &str {
const HEADING: &str = "## An annotated example";
const FENCE_OPEN: &str = "```toml\n";
const FENCE_CLOSE: &str = "\n```";
let after_heading = &spec[spec
.find(HEADING)
.expect("config.md must contain the annotated example section")..];
let body = &after_heading[after_heading
.find(FENCE_OPEN)
.expect("the annotated example section must open a ```toml fence")
+ FENCE_OPEN.len()..];
let fence_close = body
.find(FENCE_CLOSE)
.expect("the annotated example section must close its ```toml fence");
&body[..=fence_close]
}
fn read_config_spec() -> String {
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
std::fs::read_to_string(manifest_dir.join("../../docs/spec/config.md"))
.expect("read the config specification")
}
fn spec_action_field_default(spec: &str, field: &str) -> String {
let anchor = format!("| `{field}` |");
let row = spec
.lines()
.find(|line| line.contains(&anchor))
.unwrap_or_else(|| panic!("no `{field}` row in the Actions field table"));
let after = row
.split("default `")
.nth(1)
.unwrap_or_else(|| panic!("`{field}`'s row names no stated default: {row}"));
after
.split('`')
.next()
.unwrap_or_else(|| {
panic!("`{field}`'s default value is not backtick-terminated: {row}")
})
.to_string()
}
fn comment_block_immediately_above<'a>(example: &'a str, header: &str) -> &'a str {
let marker = format!("\n\n{header}");
let header_at = example
.find(&marker)
.unwrap_or_else(|| panic!("example has no {header} header"));
let before = &example[..header_at];
let block_start = before.rfind("\n\n").map_or(0, |i| i + 2);
&before[block_start..]
}
fn strip_link_lines(block: &str) -> String {
block
.lines()
.filter(|line| !line.contains("http"))
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn the_refresh_fetch_and_auto_update_block_is_preceded_by_a_comment_naming_all_four_terms() {
let prose = strip_link_lines(comment_block_immediately_above(
annotated_example(),
"[refresh]",
));
for term in ["refresh", "fetch", "auto_update", "sync"] {
assert!(
prose.contains(term),
"the comment above [refresh] does not mention `{term}` outside its link: {prose}"
);
}
}
#[test]
fn the_comment_distinguishes_the_sync_action_from_the_sync_cell() {
let prose = strip_link_lines(comment_block_immediately_above(
annotated_example(),
"[refresh]",
));
assert!(
prose.matches("sync").count() >= 2,
"the comment names `sync` only once, not both its senses: {prose}"
);
assert!(
prose.contains("action"),
"the comment does not name the sync action: {prose}"
);
assert!(
prose.contains("Cell"),
"the comment does not name the sync Cell as a separate thing: {prose}"
);
}
#[test]
fn the_refresh_fetch_and_auto_update_comment_links_to_its_own_spec_section() {
let spec = read_config_spec();
assert!(
spec.contains("## Refresh, fetch and auto-update"),
"config.md must keep its \"Refresh, fetch and auto-update\" heading"
);
let block = comment_block_immediately_above(annotated_example(), "[refresh]");
assert!(
block.contains(
"https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#refresh-fetch-and-auto-update"
),
"the comment above [refresh] does not link to its own spec section: {block}"
);
}
#[test]
fn the_shipped_example_matches_the_specs_fenced_block() {
let spec = read_config_spec();
let expected = extract_fenced_example(&spec);
assert_eq!(
annotated_example(),
expected,
"config/example.toml has drifted from docs/spec/config.md's annotated example"
);
}
fn read_llms_txt() -> String {
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
std::fs::read_to_string(manifest_dir.join("../../llms.txt")).expect("read llms.txt")
}
fn markdown_link_urls(text: &str) -> Vec<String> {
let mut urls = Vec::new();
let mut rest = text;
while let Some(start) = rest.find("](") {
let after = &rest[start + 2..];
let end = after.find(')').expect("an opened markdown link must close");
urls.push(after[..end].to_string());
rest = &after[end + 1..];
}
urls
}
#[test]
fn llms_txt_links_all_resolve_and_all_specs_are_listed() {
const RAW_PREFIX: &str = "https://raw.githubusercontent.com/paulchiu/repon/main/";
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let repo_root = manifest_dir.join("../..");
let llms_txt = read_llms_txt();
for url in markdown_link_urls(&llms_txt) {
let relative = url
.strip_prefix(RAW_PREFIX)
.unwrap_or_else(|| panic!("llms.txt link is not rooted at {RAW_PREFIX}: {url}"));
assert!(
repo_root.join(relative).exists(),
"llms.txt references missing file: {relative}"
);
}
let spec_names = fs::read_dir(repo_root.join("docs/spec"))
.expect("read docs/spec")
.map(|entry| {
entry
.expect("read a docs/spec entry")
.file_name()
.to_string_lossy()
.into_owned()
})
.filter(|name| name.ends_with(".md"));
for name in spec_names {
assert!(
llms_txt.contains(&format!("docs/spec/{name}")),
"docs/spec/{name} is not referenced in llms.txt"
);
}
}
fn the_applicability_key_config_md_names() -> String {
let spec = read_config_spec();
let actions = spec
.split("## Actions")
.nth(1)
.expect("config.md must carry an Actions section");
let row = actions
.lines()
.take_while(|line| !line.starts_with("## "))
.find(|line| line.starts_with('|') && line.contains("Filter grammar"))
.expect("config.md's Actions table must carry a row naming the Filter grammar");
row.split('`')
.nth(1)
.expect("that row must name its key in backticks")
.to_string()
}
#[test]
fn an_action_parses_the_applicability_predicate_key_config_md_names() {
let key = the_applicability_key_config_md_names();
let loaded = parse_ok(&format!(
"[[action]]\nname = \"reinstall\"\n{key} = \"kind:repo\"\n\n\
[[action.steps]]\nargs = [\"true\"]\n"
));
assert!(
!loaded
.warnings
.iter()
.any(|warning| matches!(warning, Warning::UnknownKey(path) if path.contains(&key))),
"`{key}` is a key of the schema, not an unknown one: {:?}",
loaded.warnings
);
assert_eq!(
loaded.document.actions[0].when.as_deref(),
Some("kind:repo")
);
}
#[test]
fn a_when_naming_nothing_the_grammar_knows_is_never_a_load_error() {
let key = the_applicability_key_config_md_names();
for predicate in ["is:banana", ":", "kimd:repo"] {
let loaded = parse_ok(&format!(
"[[action]]\nname = \"reinstall\"\n{key} = \"{predicate}\"\n\n\
[[action.steps]]\nargs = [\"true\"]\n"
));
assert_eq!(
loaded.document.actions[0].when.as_deref(),
Some(predicate),
"the text must reach the schema unaltered, since nothing here judges it"
);
assert!(
loaded.warnings.is_empty(),
"a predicate matching nothing is not a condition to warn about: {:?}",
loaded.warnings
);
}
}
#[test]
fn action_config_carries_no_pty_width_field_the_pty_is_a_fixed_constant_never_a_config_key() {
let loaded =
parse_ok("[[action]]\nname = \"reinstall\"\n\n[[action.steps]]\nargs = [\"true\"]\n");
let ActionConfig {
name: _,
description: _,
steps: _,
confirm: _,
concurrency: _,
when: _,
} = loaded
.document
.actions
.into_iter()
.next()
.expect("one parsed [[action]] entry");
}
#[test]
fn an_action_entry_parses_every_field_the_spec_names() {
let loaded = parse_ok(
"[[action]]\n\
name = \"reinstall\"\n\
description = \"Reinstall dependencies from scratch\"\n\
confirm = false\n\
concurrency = 8\n\n\
[[action.steps]]\n\
args = [\"rm -rf node_modules && pnpm install\"]\n\
shell = true\n\
env = { FOO = \"bar\" }\n",
);
let action = &loaded.document.actions[0];
assert_eq!(action.name.get_ref(), "reinstall");
assert_eq!(
action.description.as_deref(),
Some("Reinstall dependencies from scratch")
);
assert!(!action.confirm);
assert_eq!(action.concurrency, 8);
assert_eq!(action.steps.len(), 1);
assert_eq!(
action.steps[0].args,
vec!["rm -rf node_modules && pnpm install"]
);
assert!(action.steps[0].shell);
assert_eq!(
action.steps[0].env.get("FOO").map(String::as_str),
Some("bar")
);
}
#[test]
fn an_action_with_no_steps_field_at_all_fails_to_parse() {
parse_err("[[action]]\nname = \"reinstall\"\n");
}
#[test]
fn action_confirm_defaults_to_the_specs_own_stated_value() {
let spec = read_config_spec();
let expected: bool = spec_action_field_default(&spec, "confirm")
.parse()
.expect("confirm's stated default parses as a bool");
let loaded =
parse_ok("[[action]]\nname = \"reinstall\"\n\n[[action.steps]]\nargs = [\"true\"]\n");
assert_eq!(loaded.document.actions[0].confirm, expected);
}
#[test]
fn action_concurrency_defaults_to_the_specs_own_stated_value() {
let spec = read_config_spec();
let expected: u32 = spec_action_field_default(&spec, "concurrency")
.parse()
.expect("concurrency's stated default parses as an integer");
let loaded =
parse_ok("[[action]]\nname = \"reinstall\"\n\n[[action.steps]]\nargs = [\"true\"]\n");
assert_eq!(loaded.document.actions[0].concurrency, expected);
}
#[test]
fn action_concurrency_has_no_schema_maximum() {
let loaded = parse_ok(
"[[action]]\nname = \"reinstall\"\nconcurrency = 999999999\n\n\
[[action.steps]]\nargs = [\"true\"]\n",
);
assert_eq!(loaded.document.actions[0].concurrency, 999_999_999);
}
#[test]
fn a_duplicate_action_name_is_rejected_with_a_line_number() {
let message = parse_err(
"[[action]]\nname = \"reinstall\"\n\n[[action.steps]]\nargs = [\"true\"]\n\n\
[[action]]\nname = \"reinstall\"\n\n[[action.steps]]\nargs = [\"true\"]\n",
);
assert!(message.contains("duplicate action name"));
assert!(
message.contains("line 8"),
"expected the second declaration's line, got: {message}"
);
}
#[test]
fn a_config_action_taking_a_reserved_name_fails_with_the_duplicate_name_message_shape() {
let steps = "\n[[action.steps]]\nargs = [\"true\"]\n";
for operation in crate::management::OPERATIONS {
let name = operation.name();
let reserved = parse_err(&format!("[[action]]\nname = \"{name}\"{steps}"));
let genuine_duplicate = parse_err(&format!(
"[[action]]\nname = \"not-reserved\"{steps}\n\
[[action]]\nname = \"not-reserved\"{steps}"
));
let shape = |message: &str| {
message
.split(" at line")
.next()
.expect("a message")
.to_string()
};
assert_eq!(
shape(&reserved),
shape(&genuine_duplicate).replace("not-reserved", name),
"a reserved name must fail with the same grade and wording a duplicate does"
);
assert!(
reserved.contains("line 2"),
"expected the offending declaration's own line, got: {reserved}"
);
}
}
#[test]
fn a_config_action_may_take_the_name_unignore() {
let loaded =
parse_ok("[[action]]\nname = \"unignore\"\n\n[[action.steps]]\nargs = [\"true\"]\n");
assert_eq!(loaded.document.actions[0].name.get_ref(), "unignore");
}
#[test]
fn an_action_name_that_merely_contains_a_reserved_one_still_loads() {
let loaded = parse_ok(
"[[action]]\nname = \"ignore-vendored\"\n\n[[action.steps]]\nargs = [\"true\"]\n",
);
assert_eq!(loaded.document.actions[0].name.get_ref(), "ignore-vendored");
}
#[test]
fn refresh_config_carries_no_scoping_field_scope_is_never_a_config_toggle() {
let RefreshConfig {
poll_interval: _,
status_stale_after: _,
on_focus: _,
} = RefreshConfig::default();
}
}