use std::collections::BTreeMap;
use std::path::Path;
use onetaskgraph_core::config::{
self, ConfigError, Layer, Origin, PROJECT_DOCUMENT_NAME, SECRETS_FILE_VARIABLE,
SECRETS_RELATIVE_PATH, Setting, SettingPath, USER_DOCUMENT_RELATIVE_PATH, documents, merge,
read_optional, secrets_path, unflatten, user_document_path, value_from_text, variable_for,
};
use onetaskgraph_core::{
Config, Environment, OutputFormat, PluginKind, Secrets, plugin_for, resolve,
};
use onetaskgraph_plugin_api::{SecretResolver, SourceName};
use serde_json::{Value, json};
use tempfile::TempDir;
fn path(dotted: &str) -> SettingPath {
SettingPath::parse(dotted).expect("a literal path")
}
fn flag(dotted: &str, value: Value) -> Setting {
Setting {
key: path(dotted),
value,
origin: Origin::Flag {
flag: format!("--set {dotted}"),
},
}
}
fn document(name: &str, document: Value) -> Layer {
Layer::from_document(Path::new(name).to_path_buf(), &document).expect("a mapping")
}
#[test]
fn a_later_layer_replaces_a_setting_an_earlier_one_made() {
let merged = merge(&[
document("a.yaml", json!({"page_size": 25})),
Layer::new(vec![flag("page_size", json!(9))]),
]);
let page_size = &merged[&path("page_size")];
assert_eq!(page_size.value, json!(9));
assert_eq!(
page_size.origin,
Origin::Flag {
flag: "--set page_size".to_owned()
}
);
assert_eq!(merged.len(), 1, "one setting, not two");
}
#[test]
fn a_later_layer_setting_a_leaf_replaces_an_earlier_layers_whole_subtree() {
let merged = merge(&[
Layer::new(vec![flag(
"sources.work.config",
json!({"root": "/one", "depth": 2}),
)]),
Layer::new(vec![flag("sources.work.config.root", json!("/two"))]),
]);
assert_eq!(
merged.keys().cloned().collect::<Vec<_>>(),
vec![path("sources.work.config.root")],
"a leaf below a replaced subtree does not survive beside it"
);
}
#[test]
fn a_later_layer_setting_a_subtree_replaces_an_earlier_layers_leaves_below_it() {
let merged = merge(&[
Layer::new(vec![flag("sources.work.config.root", json!("/one"))]),
Layer::new(vec![flag("sources.work.config", json!({"root": "/two"}))]),
]);
assert_eq!(
merged.keys().cloned().collect::<Vec<_>>(),
vec![path("sources.work.config")]
);
}
#[test]
fn merging_and_unflattening_rebuild_the_document_the_layers_describe() {
let merged = merge(&[
document("a.yaml", json!({"page_size": 25})),
Layer::new(vec![
flag("sources.work.plugin", json!("in-memory")),
flag("sources.work.config.root", json!("/notes")),
]),
]);
assert_eq!(
unflatten(&merged),
json!({
"page_size": 25,
"sources": { "work": { "plugin": "in-memory", "config": { "root": "/notes" } } }
})
);
}
#[test]
fn an_empty_plugin_block_survives_flattening_as_a_deliberate_setting() {
let merged = merge(&[document(
"a.yaml",
json!({"sources": {"work": {"plugin": "linear", "config": {}}}}),
)]);
assert_eq!(merged[&path("sources.work.config")].value, json!({}));
}
#[test]
fn an_empty_document_contributes_nothing() {
let layer = Layer::from_document(Path::new("a.yaml").to_path_buf(), &Value::Null)
.expect("an empty document");
assert!(layer.settings().is_empty());
}
#[test]
fn a_document_that_is_not_a_mapping_is_refused() {
let error = Layer::from_document(Path::new("a.yaml").to_path_buf(), &json!([1, 2]))
.expect_err("a list is not a document");
assert!(error.to_string().contains("a list"), "{error}");
}
#[test]
fn a_setting_path_with_an_empty_segment_addresses_nothing() {
let error = SettingPath::parse("sources..plugin").expect_err("an empty segment");
assert_eq!(error.key(), Some("sources..plugin"));
}
#[test]
fn a_textual_value_is_typed_as_far_as_it_reads() {
assert_eq!(value_from_text("100"), json!(100));
assert_eq!(value_from_text("-3"), json!(-3));
assert_eq!(value_from_text("1.5"), json!(1.5));
assert_eq!(value_from_text("true"), json!(true));
assert_eq!(value_from_text("false"), json!(false));
assert_eq!(value_from_text("/tmp/tasks"), json!("/tmp/tasks"));
assert_eq!(
value_from_text("github-projects"),
json!("github-projects"),
"a hyphen does not make a value arithmetic"
);
assert_eq!(
value_from_text("inf"),
json!("inf"),
"a float that has no JSON number stays the text it was"
);
}
#[test]
fn a_textual_value_containing_a_comma_is_a_list() {
assert_eq!(value_from_text("work,notes"), json!(["work", "notes"]));
assert_eq!(value_from_text("1, 2"), json!([1, 2]));
}
#[test]
fn the_variable_for_a_setting_is_the_one_the_contract_documents() {
assert_eq!(variable_for(&path("page_size")), "ONETASKGRAPH_PAGE_SIZE");
assert_eq!(
variable_for(&path("default_sources")),
"ONETASKGRAPH_DEFAULT_SOURCES"
);
assert_eq!(
variable_for(&path("sources.work.config.root")),
"ONETASKGRAPH_SOURCES__WORK__CONFIG__ROOT"
);
assert_eq!(
variable_for(&path("sources.gh-main.plugin")),
"ONETASKGRAPH_SOURCES__GH_MAIN__PLUGIN",
"a hyphen in a source name is an underscore in the variable"
);
assert_eq!(config::ENVIRONMENT_PREFIX, "ONETASKGRAPH_");
}
#[test]
fn a_variable_that_names_no_path_is_refused_rather_than_ignored() {
let sandbox = TempDir::new().expect("a temporary directory");
let environment = Environment::from_pairs([
("HOME", sandbox.path().to_string_lossy().to_string()),
("ONETASKGRAPH_SOURCES__", "in-memory".to_owned()),
]);
let error = config::load(sandbox.path(), &environment, &Layer::default())
.expect_err("a variable naming no setting");
assert_eq!(error.key(), Some("ONETASKGRAPH_SOURCES__"));
}
struct Host {
root: TempDir,
}
impl Host {
fn new() -> Self {
let root = TempDir::new().expect("a temporary directory");
std::fs::create_dir_all(root.path().join("home/onetaskgraph")).expect("a config home");
std::fs::create_dir_all(root.path().join("project/deep/deeper")).expect("a project tree");
Self { root }
}
fn environment(&self) -> Environment {
Environment::from_pairs([(
"XDG_CONFIG_HOME",
self.root.path().join("home").to_string_lossy().to_string(),
)])
}
fn write(&self, relative: &str, text: &str) -> std::path::PathBuf {
let path = self.root.path().join(relative);
std::fs::create_dir_all(path.parent().expect("a parent")).expect("the directory");
std::fs::write(&path, text).expect("written");
path
}
}
#[test]
fn documents_are_the_user_level_one_then_the_nearest_one_above_the_working_directory() {
let host = Host::new();
let user = host.write(
&format!("home/{USER_DOCUMENT_RELATIVE_PATH}"),
"page_size: 11\n",
);
let project = host.write(
&format!("project/{PROJECT_DOCUMENT_NAME}"),
"page_size: 25\n",
);
let found = documents(
&host.root.path().join("project/deep/deeper"),
&host.environment(),
)
.expect("both documents read");
assert_eq!(
found.iter().map(|d| d.path.clone()).collect::<Vec<_>>(),
vec![user, project],
"lowest precedence first"
);
}
#[test]
fn a_nearer_document_hides_one_further_up_rather_than_stacking_with_it() {
let host = Host::new();
host.write(
&format!("project/{PROJECT_DOCUMENT_NAME}"),
"page_size: 25\n",
);
let nearer = host.write(
&format!("project/deep/{PROJECT_DOCUMENT_NAME}"),
"page_size: 7\n",
);
let found = documents(
&host.root.path().join("project/deep/deeper"),
&host.environment(),
)
.expect("one document read");
assert_eq!(
found.iter().map(|d| d.path.clone()).collect::<Vec<_>>(),
vec![nearer]
);
}
#[test]
fn no_documents_at_all_is_the_ordinary_case_rather_than_an_error() {
let host = Host::new();
let found =
documents(&host.root.path().join("project"), &host.environment()).expect("nothing to read");
assert!(found.is_empty());
}
#[test]
fn a_document_that_cannot_be_read_stops_the_run_rather_than_being_skipped() {
let host = Host::new();
std::fs::create_dir_all(
host.root
.path()
.join(format!("home/{USER_DOCUMENT_RELATIVE_PATH}")),
)
.expect("a directory in the document's place");
let error = documents(&host.root.path().join("project"), &host.environment())
.expect_err("a document that cannot be read");
assert!(matches!(error, ConfigError::Read { .. }), "{error}");
assert!(error.to_string().contains("config.yaml"), "{error}");
assert_eq!(error.key(), None);
}
#[test]
fn the_configuration_home_falls_back_to_home_when_xdg_is_unset_or_empty() {
let from_home = Environment::from_pairs([("HOME", "/home/someone")]);
assert_eq!(
user_document_path(&from_home),
Some(Path::new("/home/someone/.config").join(USER_DOCUMENT_RELATIVE_PATH))
);
let empty_xdg = Environment::from_pairs([("XDG_CONFIG_HOME", ""), ("HOME", "/home/someone")]);
assert_eq!(
user_document_path(&empty_xdg),
user_document_path(&from_home)
);
assert_eq!(
user_document_path(&Environment::default()),
None,
"a host that says where nothing lives has no user-level document"
);
}
#[test]
fn the_credentials_path_is_the_override_when_one_is_set_and_the_default_otherwise() {
let overridden = Environment::from_pairs([
(SECRETS_FILE_VARIABLE, "/tmp/elsewhere.env"),
("HOME", "/home/someone"),
]);
assert_eq!(
secrets_path(&overridden),
Some(Path::new("/tmp/elsewhere.env").to_path_buf())
);
let default = Environment::from_pairs([("XDG_CONFIG_HOME", "/cfg")]);
assert_eq!(
secrets_path(&default),
Some(Path::new("/cfg").join(SECRETS_RELATIVE_PATH))
);
assert_eq!(secrets_path(&Environment::default()), None);
}
#[test]
fn reading_a_file_that_is_not_there_is_nothing_rather_than_an_error() {
let host = Host::new();
assert_eq!(
read_optional(&host.root.path().join("nothing-here")).expect("not an error"),
None
);
}
#[test]
fn the_environment_snapshot_never_debug_prints_a_value() {
let environment = Environment::from_pairs([("LINEAR_API_KEY", "lin_api_CANARY")]);
let rendered = format!("{environment:?}");
assert!(!rendered.contains("lin_api_CANARY"), "{rendered}");
assert!(rendered.contains("LINEAR_API_KEY"), "{rendered}");
assert!(rendered.contains("<redacted>"), "{rendered}");
}
#[test]
fn the_environment_snapshot_reads_this_process() {
let captured = Environment::from_process();
assert_eq!(
captured.names().count(),
std::env::vars().count(),
"every variable this process has is captured"
);
assert!(captured.non_empty("").is_none());
}
fn secrets(host: &Host, text: &str, exported: &[(&str, &str)]) -> Result<Secrets, ConfigError> {
host.write(&format!("home/{SECRETS_RELATIVE_PATH}"), text);
let mut pairs: Vec<(String, String)> = vec![(
"XDG_CONFIG_HOME".to_owned(),
host.root.path().join("home").to_string_lossy().to_string(),
)];
for (name, value) in exported {
pairs.push(((*name).to_owned(), (*value).to_owned()));
}
Secrets::load(Environment::from_pairs(pairs))
}
#[test]
fn the_credentials_file_answers_the_names_it_defines() {
let host = Host::new();
let resolved = secrets(
&host,
"# a comment\n\nLINEAR_API_KEY=lin_api_one\nexport GH_PROJECTS_TOKEN=\"ghp_two\"\n",
&[],
)
.expect("the file parses");
assert!(resolved.get("LINEAR_API_KEY").is_some());
assert!(
resolved.get("GH_PROJECTS_TOKEN").is_some(),
"an `export ` prefix and surrounding quotes are both read"
);
assert!(resolved.get("NOTHING_DEFINES_THIS").is_none());
}
#[test]
fn the_process_environment_beats_the_credentials_file_for_a_name_it_defines() {
let host = Host::new();
let resolved = secrets(
&host,
"LINEAR_API_KEY=from-the-file\n",
&[("LINEAR_API_KEY", "from-the-shell")],
)
.expect("the file parses");
let report = resolved.report();
assert_eq!(report.variables.len(), 1);
assert_eq!(
report.variables[0].resolved_from,
onetaskgraph_core::CredentialLayer::Environment
);
}
#[test]
fn a_credentials_file_line_that_is_not_an_assignment_names_its_line_and_not_its_value() {
let host = Host::new();
let error = secrets(
&host,
"LINEAR_API_KEY=canary-value\nnot an assignment\n",
&[],
)
.expect_err("a malformed line");
let message = error.to_string();
assert!(message.contains(":2"), "{message}");
assert!(!message.contains("canary-value"), "{message}");
}
#[test]
fn a_credentials_file_key_that_could_not_be_exported_is_refused() {
let host = Host::new();
let error = secrets(&host, "not a name=value\n", &[]).expect_err("an unusable name");
assert!(error.to_string().contains("not a name"), "{error}");
}
#[test]
fn the_resolver_never_debug_prints_a_value() {
let host = Host::new();
let resolved = secrets(&host, "LINEAR_API_KEY=lin_api_CANARY\n", &[]).expect("parses");
let rendered = format!("{resolved:?}");
assert!(!rendered.contains("lin_api_CANARY"), "{rendered}");
assert!(rendered.contains("LINEAR_API_KEY"), "{rendered}");
assert!(rendered.contains("<redacted>"), "{rendered}");
}
#[test]
fn a_host_that_says_where_nothing_lives_resolves_nothing_and_does_not_fail() {
let resolved = Secrets::load(Environment::default()).expect("nothing to read");
assert_eq!(resolved.report().path, None);
assert!(resolved.report().variables.is_empty());
assert!(resolved.get("LINEAR_API_KEY").is_none());
}
#[test]
fn a_document_with_nothing_in_it_carries_the_built_in_values() {
let config = Config::from_document(json!({})).expect("an empty document is a configuration");
assert_eq!(config.page_size().get(), 50);
assert_eq!(config.output(), OutputFormat::Text);
assert!(config.sources().is_empty());
assert_eq!(config.default_sources(), None);
assert!(config.selected_sources().is_empty());
}
#[test]
fn omitting_default_sources_selects_every_configured_source_in_name_order() {
let config = Config::from_document(json!({
"sources": {
"work": {"plugin": "in-memory"},
"notes": {"plugin": "in-memory"},
}
}))
.expect("a configuration");
assert_eq!(
config
.selected_sources()
.iter()
.map(SourceName::as_str)
.collect::<Vec<_>>(),
vec!["notes", "work"]
);
}
#[test]
fn one_default_source_named_alone_is_read_as_a_list_of_one() {
let config = Config::from_document(json!({
"default_sources": "work",
"sources": {"work": {"plugin": "in-memory"}},
}))
.expect("a configuration");
assert_eq!(
config.selected_sources(),
vec![SourceName::new("work").expect("a name")]
);
}
#[test]
fn an_unknown_field_names_the_key_it_was_written_at() {
let error = Config::from_document(json!({"sources": {"work": {"plugn": "in-memory"}}}))
.expect_err("a mistyped field");
assert_eq!(error.key(), Some("sources.work.plugn"));
}
#[test]
fn a_default_source_naming_something_unconfigured_is_refused() {
let error = Config::from_document(json!({"default_sources": ["nope"]}))
.expect_err("an unconfigured source");
assert_eq!(error.key(), Some("default_sources"));
assert!(error.to_string().contains("none are"), "{error}");
}
#[test]
fn a_default_source_that_is_not_a_usable_name_is_refused() {
let error = Config::from_document(json!({"default_sources": ["Work_1"]}))
.expect_err("an unusable name");
assert_eq!(error.key(), Some("default_sources"));
}
fn one_source(kind: &str, block: Value) -> Config {
source_document(kind, block).expect("a configuration")
}
fn source_document(kind: &str, block: Value) -> Result<Config, ConfigError> {
Config::from_document(json!({"sources": {"work": {"plugin": kind, "config": block}}}))
}
fn no_secrets() -> Secrets {
Secrets::load(Environment::default()).expect("nothing to read")
}
fn minimal_block(kind: &str) -> Value {
match kind {
"subprocess" => json!({"command": "onetaskgraph-source"}),
"local-md" => json!({"root": "."}),
_ => json!({}),
}
}
#[test]
fn every_registered_plugin_declares_a_schema_that_compiles_and_accepts_a_valid_block() {
for kind in onetaskgraph_core::plugin_kinds() {
let plugin = plugin_for(kind).expect("the registry names it");
let config = one_source(plugin.kind(), minimal_block(kind));
let outcome = onetaskgraph_core::validate_sources(&config);
assert!(
outcome.is_ok(),
"{kind} refuses its own minimal block against its own schema: {outcome:?}"
);
}
}
#[test]
fn resolving_builds_the_sources_a_configuration_names_in_name_order() {
let config = Config::from_document(json!({
"sources": {
"work": {"plugin": "in-memory", "config": {}},
"archive": {"plugin": "in-memory", "config": {}},
}
}))
.expect("a configuration");
let resolved = resolve(&config, &no_secrets()).expect("both sources build");
assert_eq!(
resolved
.iter()
.map(|source| source.name().as_str())
.collect::<Vec<_>>(),
vec!["archive", "work"]
);
assert_eq!(resolved[0].kind(), "in-memory");
assert_eq!(resolved[0].kind(), resolved[0].source().kind());
}
#[test]
fn a_plugin_this_build_does_not_have_is_refused_by_the_key_that_names_it() {
let error =
Config::from_document(json!({"sources": {"work": {"plugin": "jira", "config": {}}}}))
.expect_err("no such plugin");
assert_eq!(error.key(), Some("sources.work.plugin"));
assert!(error.to_string().contains("in-memory"), "{error}");
}
#[cfg(unix)]
#[test]
fn a_snapshot_sorts_what_this_build_can_read_from_what_it_cannot() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let not_utf8 = OsString::from_vec(vec![0x66, 0xff, 0x6f]);
let environment = Environment::from_os_pairs([
(
OsString::from("ONETASKGRAPH_PAGE_SIZE"),
OsString::from("70"),
),
(OsString::from("ONETASKGRAPH_OUTPUT"), not_utf8.clone()),
(not_utf8, OsString::from("json")),
]);
assert_eq!(environment.get("ONETASKGRAPH_PAGE_SIZE"), Some("70"));
assert_eq!(
environment.unusable().collect::<Vec<_>>(),
vec!["ONETASKGRAPH_OUTPUT"],
"a value that is not Unicode keeps its name, so the layer can refuse it by name"
);
assert_eq!(
environment.names().collect::<Vec<_>>(),
vec!["ONETASKGRAPH_PAGE_SIZE"],
"a name that is not Unicode is dropped: nothing in this product could spell it"
);
let rendered = format!("{environment:?}");
assert!(rendered.contains("ONETASKGRAPH_OUTPUT"), "{rendered}");
assert!(
!rendered.contains("70"),
"a Debug carries no value: {rendered}"
);
}
#[cfg(unix)]
#[test]
fn a_reserved_variable_whose_value_is_not_unicode_is_not_a_setting_either() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let not_utf8 = OsString::from_vec(vec![0x66, 0xff, 0x6f]);
let environment = Environment::from_os_pairs([
(OsString::from("ONETASKGRAPH_LIVE_SEAT_DIR"), not_utf8),
(
OsString::from("ONETASKGRAPH_PAGE_SIZE"),
OsString::from("70"),
),
]);
let config = config::load(Path::new("/nowhere"), &environment, &Layer::default())
.expect("a reserved variable is not a setting this build must be able to read");
assert_eq!(
config.config.page_size().get(),
70,
"the settings beside it are still read"
);
}
#[cfg(unix)]
#[test]
fn a_setting_variable_whose_value_is_not_unicode_is_refused_by_name() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let not_utf8 = OsString::from_vec(vec![0x66, 0xff, 0x6f]);
let environment =
Environment::from_os_pairs([(OsString::from("ONETASKGRAPH_PAGE_SIZE"), not_utf8.clone())]);
let error = config::load(Path::new("/nowhere"), &environment, &Layer::default())
.expect_err("a setting this build cannot read");
assert_eq!(error.key(), Some("ONETASKGRAPH_PAGE_SIZE"));
let ignored = Environment::from_os_pairs([(OsString::from("SOMETHING_ELSE"), not_utf8)]);
config::load(Path::new("/nowhere"), &ignored, &Layer::default())
.expect("a variable this build does not read is not a setting it must refuse");
}
#[test]
fn every_plugin_kind_names_the_kind_its_own_plugin_reports() {
for kind in PluginKind::ALL {
assert_eq!(
kind.as_str(),
kind.plugin().kind(),
"{kind:?} names itself differently from the plugin it builds"
);
assert_eq!(PluginKind::parse(kind.as_str()), Some(kind));
}
assert_eq!(PluginKind::parse("jira"), None);
assert_eq!(
PluginKind::ALL.map(PluginKind::as_str).to_vec(),
onetaskgraph_core::plugin_kinds()
);
}
#[test]
fn a_blocks_own_field_is_checked_against_the_plugins_schema_before_the_source_is_built() {
let reached_build = resolve(&one_source("linear", json!({})), &no_secrets())
.expect_err("the credential is deliberately absent");
assert!(
reached_build.to_string().contains("LINEAR_API_KEY"),
"a valid block reaches the factory: {reached_build}"
);
let refused = source_document("linear", json!({"api_key_env": 7}))
.expect_err("a field this plugin does not declare");
assert_eq!(refused.key(), Some("sources.work.config.api_key_env"));
assert!(
!refused.to_string().contains("LINEAR_API_KEY"),
"the block is checked before the factory is called: {refused}"
);
}
#[test]
fn a_block_a_plugin_refuses_names_the_source_it_belongs_to() {
let config = one_source(
"in-memory",
json!({"tasks": [{"id": "T-1", "title": "one",
"status": {"category": "todo", "name": "Todo"}, "labels": [], "project": "P-9"}]}),
);
let error = resolve(&config, &no_secrets()).expect_err("a task under a project that is absent");
assert_eq!(error.key(), Some("sources.work"));
assert!(error.to_string().contains("P-9"), "{error}");
}
#[test]
fn loading_layers_the_documents_the_environment_and_the_flags_in_that_order() {
let host = Host::new();
host.write(
&format!("home/{USER_DOCUMENT_RELATIVE_PATH}"),
"page_size: 11\noutput: json\n",
);
host.write(
&format!("project/{PROJECT_DOCUMENT_NAME}"),
"page_size: 25\nsources:\n work:\n plugin: in-memory\n",
);
let mut pairs: Vec<(String, String)> = vec![
(
"XDG_CONFIG_HOME".to_owned(),
host.root.path().join("home").to_string_lossy().to_string(),
),
("ONETASKGRAPH_PAGE_SIZE".to_owned(), "70".to_owned()),
];
pairs.push((
"ONETASKGRAPH_SOURCES__WORK__CONFIG__CAPABILITIES__MAX_PAGE_SIZE".to_owned(),
"5".to_owned(),
));
let loaded = config::load(
&host.root.path().join("project/deep"),
&Environment::from_pairs(pairs),
&Layer::new(vec![flag("page_size", json!(9))]),
)
.expect("the stack loads");
assert_eq!(loaded.config.page_size().get(), 9, "the flag is on top");
assert_eq!(
loaded.config.output(),
OutputFormat::Json,
"a setting only the user-level document mentions still lands"
);
let origins: BTreeMap<String, String> = loaded
.effective
.settings
.iter()
.map(|setting| (setting.key.to_string(), setting.origin.to_string()))
.collect();
assert_eq!(origins["page_size"], "flag --set page_size");
assert_eq!(
origins["sources.work.config.capabilities.max_page_size"],
"environment ONETASKGRAPH_SOURCES__WORK__CONFIG__CAPABILITIES__MAX_PAGE_SIZE"
);
assert!(origins["sources.work.plugin"].starts_with("file "));
assert_eq!(origins["default_sources"], "default");
}
#[test]
fn a_document_that_will_not_parse_names_the_file_and_the_position() {
let host = Host::new();
host.write(
&format!("project/{PROJECT_DOCUMENT_NAME}"),
"page_size: [\n",
);
let error = config::load(
&host.root.path().join("project"),
&host.environment(),
&Layer::default(),
)
.expect_err("a malformed document");
assert!(matches!(error, ConfigError::Syntax { .. }), "{error}");
assert!(error.to_string().contains(PROJECT_DOCUMENT_NAME), "{error}");
}
#[test]
fn the_effective_configuration_renders_a_line_per_setting_naming_its_layer() {
let host = Host::new();
host.write(
&format!("project/{PROJECT_DOCUMENT_NAME}"),
"page_size: 25\n",
);
host.write(
&format!("home/{SECRETS_RELATIVE_PATH}"),
"LINEAR_API_KEY=x\n",
);
let loaded = config::load(
&host.root.path().join("project"),
&host.environment(),
&Layer::default(),
)
.expect("the stack loads");
let rendered = loaded.effective.render_text();
assert!(rendered.contains("page_size"), "{rendered}");
assert!(rendered.contains(PROJECT_DOCUMENT_NAME), "{rendered}");
assert!(rendered.contains("output"), "{rendered}");
assert!(rendered.contains("default"), "{rendered}");
assert!(
rendered.contains("LINEAR_API_KEY resolved from the secrets file"),
"{rendered}"
);
assert!(!rendered.contains("=x"), "no value reaches the rendering");
}
#[test]
fn the_effective_configuration_says_so_when_there_is_no_credentials_file_to_look_in() {
let host = Host::new();
let loaded = config::load(
&host.root.path().join("project"),
&host.environment(),
&Layer::default(),
)
.expect("the stack loads");
assert!(
loaded
.effective
.render_text()
.contains("(it defines no variables, or is not there)")
);
}
#[test]
fn a_host_with_no_configuration_home_still_loads() {
let host = Host::new();
let loaded = config::load(
&host.root.path().join("project"),
&Environment::default(),
&Layer::default(),
)
.expect("nothing configured is a configuration");
assert_eq!(loaded.config.page_size().get(), 50);
assert!(
loaded
.effective
.render_text()
.contains("neither XDG_CONFIG_HOME nor HOME is set")
);
}
#[test]
fn a_document_root_of_any_other_shape_is_refused_by_what_it_is() {
for (root, described) in [
(json!(true), "a boolean"),
(json!(7), "a number"),
(json!("page_size: 25"), "a string"),
(json!([1, 2]), "a list"),
] {
let error = Layer::from_document(Path::new("a.yaml").to_path_buf(), &root)
.expect_err("only a mapping is a document");
assert!(error.to_string().contains(described), "{error}");
}
}
#[test]
fn a_setting_serializes_with_its_key_as_the_dotted_path_a_user_typed() {
let rendered = serde_json::to_value(flag("sources.work.config.root", json!("/notes")))
.expect("a setting renders");
assert_eq!(rendered["key"], "sources.work.config.root");
assert_eq!(rendered["origin"]["layer"], "flag");
}
#[test]
fn a_number_too_large_for_a_signed_integer_is_still_a_number() {
assert_eq!(
value_from_text("9223372036854775808"),
json!(9_223_372_036_854_775_808_u64)
);
}
#[test]
fn the_credentials_path_variable_is_not_read_as_a_setting_called_secrets_file() {
let host = Host::new();
host.write("elsewhere.env", "LINEAR_API_KEY=x\n");
let loaded = config::load(
&host.root.path().join("project"),
&Environment::from_pairs([(
SECRETS_FILE_VARIABLE,
host.root
.path()
.join("elsewhere.env")
.to_string_lossy()
.to_string(),
)]),
&Layer::default(),
)
.expect("the override is a path, not a setting");
assert!(
!loaded
.effective
.settings
.iter()
.any(|setting| setting.key.to_string() == "secrets_file")
);
assert_eq!(loaded.secrets.report().variables.len(), 1);
}
#[test]
fn the_resolver_hands_back_the_exported_value_when_the_process_environment_defines_one() {
let host = Host::new();
let resolved = secrets(
&host,
"LINEAR_API_KEY=from-the-file\n",
&[("LINEAR_API_KEY", "from-the-shell")],
)
.expect("the file parses");
let answered = resolved.get("LINEAR_API_KEY").expect("a value");
assert_eq!(
secrecy::ExposeSecret::expose_secret(&answered),
"from-the-shell"
);
}
#[test]
fn the_effective_configuration_says_which_layer_answers_for_each_credential() {
let host = Host::new();
host.write(
&format!("home/{SECRETS_RELATIVE_PATH}"),
"LINEAR_API_KEY=from-the-file\nGH_PROJECTS_TOKEN=also-from-the-file\n",
);
let loaded = config::load(
&host.root.path().join("project"),
&Environment::from_pairs([
(
"XDG_CONFIG_HOME".to_owned(),
host.root.path().join("home").to_string_lossy().to_string(),
),
("LINEAR_API_KEY".to_owned(), "from-the-shell".to_owned()),
]),
&Layer::default(),
)
.expect("the stack loads");
let rendered = loaded.effective.render_text();
assert!(
rendered.contains("LINEAR_API_KEY resolved from the environment"),
"{rendered}"
);
assert!(
rendered.contains("GH_PROJECTS_TOKEN resolved from the secrets file"),
"{rendered}"
);
assert!(!rendered.contains("from-the-file"), "{rendered}");
assert!(!rendered.contains("from-the-shell"), "{rendered}");
}
#[test]
fn a_source_name_that_breaks_the_pattern_names_the_key_it_was_written_at() {
let error = Config::from_document(json!({"sources": {"Work_1": {"plugin": "in-memory"}}}))
.expect_err("an unusable source name");
assert_eq!(error.key(), Some("sources.Work_1"));
assert!(
error.to_string().contains("^[a-z0-9][a-z0-9-]*$"),
"{error}"
);
}
#[test]
fn a_default_source_naming_nothing_configured_lists_the_ones_that_are() {
let error = Config::from_document(json!({
"default_sources": ["nope"],
"sources": {"work": {"plugin": "in-memory"}, "notes": {"plugin": "in-memory"}},
}))
.expect_err("an unconfigured source");
assert!(error.to_string().contains("notes, work"), "{error}");
}
#[test]
fn default_sources_written_as_nothing_is_the_same_as_omitting_it() {
let config = Config::from_document(json!({"default_sources": Value::Null}))
.expect("nothing is not a bad value");
assert_eq!(config.default_sources(), None);
}
#[test]
fn a_document_that_is_not_an_object_at_all_is_refused_at_its_root() {
let error = Config::from_document(json!(7)).expect_err("a number is not a configuration");
assert_eq!(error.key(), Some("the document's root"));
}
#[test]
fn a_field_a_plugin_declares_a_schema_for_is_refused_by_the_field_that_is_wrong() {
let error = source_document("in-memory", json!({"capabilities": {"projekts": "native"}}))
.expect_err("a mistyped field inside a declared block");
assert_eq!(
error.key(),
Some("sources.work.config.capabilities.projekts"),
"the unexpected field is lifted out of the object the validator blamed"
);
assert!(error.to_string().contains("in-memory"), "{error}");
}
#[test]
fn a_declared_field_given_the_wrong_kind_of_value_is_refused_at_that_field() {
let error = source_document("in-memory", json!({"tasks": "one"}))
.expect_err("a list field given a string");
assert_eq!(error.key(), Some("sources.work.config.tasks"));
}
#[test]
fn a_resolved_source_debug_prints_its_name_and_kind_and_none_of_its_work() {
let config = one_source(
"in-memory",
json!({"tasks": [{"id": "T-1", "title": "a private title",
"status": {"category": "todo", "name": "Todo"}, "labels": []}]}),
);
let resolved = resolve(&config, &no_secrets()).expect("the source builds");
let rendered = format!("{:?}", resolved[0]);
assert!(rendered.contains("work"), "{rendered}");
assert!(rendered.contains("in-memory"), "{rendered}");
assert!(
!rendered.contains("a private title"),
"a source's Debug is not a rendering of the work it holds: {rendered}"
);
}