feat(sidecar): make the sidecar installable — CLI, --print-config, anchored data paths
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 7m52s
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 7m52s
Phase 0.2 of the installer plan (docs/installer/PLAN.md §5). The installer has to drive this binary non-interactively, and today it cannot: the auth token is only readable by scraping the startup log, the config path can only be named through an environment variable, and a relative db path follows the process working directory — which a service manager, not the operator, chooses. - Add a four-flag CLI (cli.rs): --print-config, --config <PATH>, --version, --help. Hand-rolled; an argument-parsing dependency would be larger than the code it replaced. An unrecognized flag exits 2 rather than starting a sidecar that is not the one that was asked for. - --print-config resolves the configuration exactly as a normal start does — including writing a missing config file and generating a blank auth token — and prints it as JSON on stdout: versions, protocol, both bind addresses, ws path, resolved db path, and the token. config_created / token_generated let a re-run tell "read an existing install" from "provisioned a new one". The log subscriber is deliberately not started in this mode, so the document is the whole output. - Anchor a relative [store].path to the config file's directory instead of the CWD, and report resolved absolute paths. A unit pinning UOLINK_CONFIG now keeps its database beside its config rather than in %SystemRoot%\System32 or a VirtualStore redirect. Development is unaffected: under cargo run the two directories are the same. :memory: and file: URIs are left alone. - Hand the db path to sqlx as a filesystem path instead of formatting it into a sqlite:// URL, which percent-decodes it and splits it on '?'. An installed path containing %20 previously opened a different file; verified it now does not. - Create the config's and the database's parent directories when missing, so a service can name /var/lib/runicgateway on a host where nothing made it yet. - 22 unit tests covering argument parsing, path anchoring, token persistence, the generated config template, and the --print-config document. No protocol change: PROTOCOL_VERSION stays 3. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -4,15 +4,26 @@
|
||||
//! first run, if the file is absent, a default one is written with a freshly generated auth token,
|
||||
//! so the sidecar is secured out of the box and the operator just copies the token to the website.
|
||||
//!
|
||||
//! File path: `$UOLINK_CONFIG`, else `sidecar.toml` in the working directory.
|
||||
//! File path: `--config <PATH>`, else `$UOLINK_CONFIG`, else `sidecar.toml` in the working
|
||||
//! directory.
|
||||
//!
|
||||
//! **Paths are anchored to the config file, not the working directory.** A relative
|
||||
//! `[store].path` resolves against the directory holding `sidecar.toml`. A service started with
|
||||
//! `UOLINK_CONFIG=/etc/runicgateway/sidecar.toml` therefore keeps its database beside its config
|
||||
//! instead of wherever the service manager happened to set the working directory — which on
|
||||
//! Windows can be `%SystemRoot%\System32` or, under `C:\Program Files\`, a silently redirected
|
||||
//! VirtualStore copy. The values reported by `--print-config` are the resolved absolute ones.
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tracing::info;
|
||||
|
||||
use crate::PROTOCOL_VERSION;
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
@@ -33,8 +44,8 @@ pub struct ShardCfg {
|
||||
pub struct WebCfg {
|
||||
#[serde(default = "default_web_bind")]
|
||||
pub bind: String,
|
||||
/// Shared secret the website must present. Empty means the web surface is unauthenticated —
|
||||
/// only acceptable when `bind` is loopback; refused otherwise (see `Config::validate`).
|
||||
/// Shared secret the website must present. Never empty in practice — `Config::load` generates
|
||||
/// and persists one when it finds none, so the web surface is authenticated from first boot.
|
||||
#[serde(default)]
|
||||
pub auth_token: String,
|
||||
}
|
||||
@@ -45,6 +56,20 @@ pub struct StoreCfg {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// A loaded configuration plus what loading it *did* — an installer re-running the binary needs to
|
||||
/// distinguish "read an existing install" from "provisioned a new one", and it cannot tell from the
|
||||
/// values alone.
|
||||
#[derive(Debug)]
|
||||
pub struct Loaded {
|
||||
pub cfg: Config,
|
||||
/// Absolute path of the config file that was read or written.
|
||||
pub path: PathBuf,
|
||||
/// The config file did not exist and was created by this run.
|
||||
pub config_created: bool,
|
||||
/// No usable token was configured, so one was generated and saved.
|
||||
pub token_generated: bool,
|
||||
}
|
||||
|
||||
fn default_shard_bind() -> String {
|
||||
"127.0.0.1:7788".into()
|
||||
}
|
||||
@@ -79,9 +104,20 @@ impl Default for StoreCfg {
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> anyhow::Result<Self> {
|
||||
let path = env::var("UOLINK_CONFIG").unwrap_or_else(|_| "sidecar.toml".into());
|
||||
let existed = Path::new(&path).exists();
|
||||
/// Which config file this invocation will use: `--config`, else `$UOLINK_CONFIG`, else
|
||||
/// `sidecar.toml` beside the working directory. Always returned absolute, so every later
|
||||
/// message names a path the operator can act on.
|
||||
pub fn resolve_path(cli_override: Option<&str>) -> PathBuf {
|
||||
let raw = cli_override
|
||||
.map(str::to_string)
|
||||
.or_else(|| env::var("UOLINK_CONFIG").ok())
|
||||
.unwrap_or_else(|| "sidecar.toml".into());
|
||||
absolutize(PathBuf::from(raw))
|
||||
}
|
||||
|
||||
pub fn load(cli_override: Option<&str>) -> anyhow::Result<Loaded> {
|
||||
let path = Self::resolve_path(cli_override);
|
||||
let existed = path.exists();
|
||||
|
||||
let mut cfg: Config = if existed {
|
||||
let text = fs::read_to_string(&path)?;
|
||||
@@ -95,12 +131,16 @@ impl Config {
|
||||
// Authentication is always on. A blank token is never allowed — if none is set (fresh
|
||||
// install, or someone cleared it), generate one, save it, and continue. This keeps setup
|
||||
// effortless while making it impossible to accidentally run with auth off.
|
||||
if cfg.web.auth_token.trim().is_empty() {
|
||||
let token_generated = cfg.web.auth_token.trim().is_empty();
|
||||
if token_generated {
|
||||
let token = generate_token();
|
||||
|
||||
if existed {
|
||||
persist_token(&path, &token)?;
|
||||
} else {
|
||||
// The parent may not exist yet when an installer points at a fresh
|
||||
// /etc/runicgateway; failing here would mean "run me again after mkdir".
|
||||
create_parent_dir(&path)?;
|
||||
fs::write(&path, default_file(&token))?;
|
||||
}
|
||||
|
||||
@@ -108,10 +148,17 @@ impl Config {
|
||||
|
||||
info!("No auth token configured.");
|
||||
info!("Generated new token: {}", token);
|
||||
info!("Saved to {}. Authentication is on.", path);
|
||||
info!("Saved to {}. Authentication is on.", path.display());
|
||||
}
|
||||
|
||||
Ok(cfg)
|
||||
cfg.anchor_store_path(&path);
|
||||
|
||||
Ok(Loaded {
|
||||
cfg,
|
||||
path,
|
||||
config_created: !existed,
|
||||
token_generated,
|
||||
})
|
||||
}
|
||||
|
||||
/// Environment overrides, so a deployment can set secrets without editing the file.
|
||||
@@ -130,15 +177,102 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves `[store].path` against the config file's directory (see the module docs). Absolute
|
||||
/// paths and SQLite's non-filesystem spellings are left exactly as written.
|
||||
fn anchor_store_path(&mut self, config_path: &Path) {
|
||||
if is_sqlite_special(&self.store.path) {
|
||||
return;
|
||||
}
|
||||
let raw = PathBuf::from(&self.store.path);
|
||||
let anchored = if raw.is_absolute() {
|
||||
raw
|
||||
} else {
|
||||
config_dir(config_path).join(raw)
|
||||
};
|
||||
self.store.path = absolutize(anchored).to_string_lossy().into_owned();
|
||||
}
|
||||
|
||||
pub fn auth_required(&self) -> bool {
|
||||
// Always true now — load() guarantees a non-empty token.
|
||||
!self.web.auth_token.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// The `--print-config` document: everything an installer needs to register this sidecar with a
|
||||
/// website, in one non-interactive read.
|
||||
///
|
||||
/// **This includes the auth token in clear text**, which is the point — §2.4 of the installer plan
|
||||
/// calls the manual token hunt the largest "I installed it and nothing happened" failure mode. The
|
||||
/// caller prints it to stdout and starts no log subscriber, so the document is the whole output.
|
||||
pub fn describe(loaded: &Loaded) -> serde_json::Value {
|
||||
json!({
|
||||
"component": "uo-link-sidecar",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"protocol": PROTOCOL_VERSION,
|
||||
"config_path": loaded.path.to_string_lossy(),
|
||||
"config_created": loaded.config_created,
|
||||
"token_generated": loaded.token_generated,
|
||||
"shard": { "bind": loaded.cfg.shard.bind },
|
||||
"web": {
|
||||
"bind": loaded.cfg.web.bind,
|
||||
"ws_path": crate::web::WS_PATH,
|
||||
"auth_required": loaded.cfg.auth_required(),
|
||||
"auth_token": loaded.cfg.web.auth_token,
|
||||
},
|
||||
"store": { "path": loaded.cfg.store.path },
|
||||
})
|
||||
}
|
||||
|
||||
/// Directory holding the config file. A bare `sidecar.toml` has no parent component, which would
|
||||
/// join into an empty base — treat it as the current directory.
|
||||
fn config_dir(config_path: &Path) -> PathBuf {
|
||||
match config_path.parent() {
|
||||
Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
|
||||
_ => PathBuf::from("."),
|
||||
}
|
||||
}
|
||||
|
||||
/// Prefixes the working directory onto a relative path, then drops the `.` components that
|
||||
/// joining leaves behind — cosmetic, but these paths are printed and pasted into service units.
|
||||
fn absolutize(p: PathBuf) -> PathBuf {
|
||||
let joined = if p.is_absolute() {
|
||||
p
|
||||
} else {
|
||||
match env::current_dir() {
|
||||
Ok(cwd) => cwd.join(p),
|
||||
Err(_) => p,
|
||||
}
|
||||
};
|
||||
let cleaned: PathBuf = joined
|
||||
.components()
|
||||
.filter(|c| !matches!(c, Component::CurDir))
|
||||
.collect();
|
||||
if cleaned.as_os_str().is_empty() {
|
||||
joined
|
||||
} else {
|
||||
cleaned
|
||||
}
|
||||
}
|
||||
|
||||
/// `:memory:` and `file:` URIs are instructions to SQLite, not paths on disk. Anchoring them to a
|
||||
/// directory would turn a working in-memory store into an attempt to create a file called
|
||||
/// `:memory:` — which Windows cannot even name.
|
||||
fn is_sqlite_special(path: &str) -> bool {
|
||||
path == ":memory:" || path.starts_with("file:")
|
||||
}
|
||||
|
||||
fn create_parent_dir(path: &Path) -> anyhow::Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
if !dir.as_os_str().is_empty() && !dir.exists() {
|
||||
fs::create_dir_all(dir)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rewrites the `auth_token` line in an existing config file, preserving everything else. Falls
|
||||
/// back to inserting it under `[web]`, or appending a `[web]` section, if the key is absent.
|
||||
fn persist_token(path: &str, token: &str) -> anyhow::Result<()> {
|
||||
fn persist_token(path: &Path, token: &str) -> anyhow::Result<()> {
|
||||
let text = fs::read_to_string(path)?;
|
||||
let line = format!("auth_token = \"{token}\"");
|
||||
|
||||
@@ -213,10 +347,269 @@ bind = "127.0.0.1:8080"
|
||||
# WebSocket: add ?token=<token> to the connect URL
|
||||
# Authentication is always on: if this is left blank, the sidecar generates a new
|
||||
# token here on startup. Rotate by changing this value and restarting.
|
||||
# Read it back without starting the sidecar: uo-link-sidecar --print-config
|
||||
auth_token = "{token}"
|
||||
|
||||
[store]
|
||||
# Relative paths resolve against the directory holding THIS FILE, not the working
|
||||
# directory of the process.
|
||||
path = "uo-link.db"
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A unique scratch directory. `std::env::temp_dir()` plus the test name keeps the cases
|
||||
/// independent under the default parallel test runner.
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = env::temp_dir().join(format!("uo-link-cfg-test-{name}"));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).expect("create scratch dir");
|
||||
dir
|
||||
}
|
||||
|
||||
/// `Config::load` consults the process environment, and a developer may have `UOLINK_*` set
|
||||
/// for a local shard. Mutating shared env state from a test thread is worse than skipping, so
|
||||
/// the two cases that exercise the full load path bail out instead of failing spuriously.
|
||||
fn env_overrides_present() -> bool {
|
||||
[
|
||||
"UOLINK_SHARD_BIND",
|
||||
"UOLINK_WEB_BIND",
|
||||
"UOLINK_WEB_TOKEN",
|
||||
"UOLINK_DB_PATH",
|
||||
]
|
||||
.iter()
|
||||
.any(|k| env::var_os(k).is_some())
|
||||
}
|
||||
|
||||
fn cfg_with_store(path: &str) -> Config {
|
||||
Config {
|
||||
store: StoreCfg { path: path.into() },
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_store_path_anchors_to_the_config_directory() {
|
||||
// The working-directory trap: the service pins UOLINK_CONFIG but the service manager
|
||||
// decides the CWD, so a relative db path must not follow the CWD.
|
||||
let mut cfg = cfg_with_store("uo-link.db");
|
||||
let config_path = if cfg!(windows) {
|
||||
PathBuf::from(r"C:\ProgramData\RunicGateway\sidecar.toml")
|
||||
} else {
|
||||
PathBuf::from("/etc/runicgateway/sidecar.toml")
|
||||
};
|
||||
cfg.anchor_store_path(&config_path);
|
||||
|
||||
let expected = config_path.parent().unwrap().join("uo-link.db");
|
||||
assert_eq!(Path::new(&cfg.store.path), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_store_path_is_left_alone() {
|
||||
let absolute = if cfg!(windows) {
|
||||
r"C:\ProgramData\RunicGateway\uo-link.db"
|
||||
} else {
|
||||
"/var/lib/runicgateway/uo-link.db"
|
||||
};
|
||||
let mut cfg = cfg_with_store(absolute);
|
||||
cfg.anchor_store_path(Path::new("/etc/runicgateway/sidecar.toml"));
|
||||
assert_eq!(cfg.store.path, absolute);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_config_filename_anchors_to_the_working_directory() {
|
||||
// `cargo run` in the crate root: config dir and CWD are the same, so the historical
|
||||
// behavior (db beside the binary's CWD) is preserved exactly.
|
||||
let mut cfg = cfg_with_store("uo-link.db");
|
||||
cfg.anchor_store_path(Path::new("sidecar.toml"));
|
||||
assert_eq!(
|
||||
Path::new(&cfg.store.path),
|
||||
env::current_dir().unwrap().join("uo-link.db")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_special_paths_are_not_anchored() {
|
||||
for special in [":memory:", "file:cache?mode=memory"] {
|
||||
let mut cfg = cfg_with_store(special);
|
||||
cfg.anchor_store_path(Path::new("/etc/runicgateway/sidecar.toml"));
|
||||
assert_eq!(cfg.store.path, special);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_path_prefers_the_cli_override() {
|
||||
// Absolute in, absolute out — and unchanged, so the operator sees the path they passed.
|
||||
let explicit = if cfg!(windows) {
|
||||
r"C:\tmp\custom.toml"
|
||||
} else {
|
||||
"/tmp/custom.toml"
|
||||
};
|
||||
assert_eq!(
|
||||
Config::resolve_path(Some(explicit)),
|
||||
PathBuf::from(explicit)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_path_makes_a_relative_override_absolute() {
|
||||
let resolved = Config::resolve_path(Some("./conf/sidecar.toml"));
|
||||
assert!(resolved.is_absolute(), "{}", resolved.display());
|
||||
assert_eq!(
|
||||
resolved,
|
||||
env::current_dir()
|
||||
.unwrap()
|
||||
.join("conf")
|
||||
.join("sidecar.toml")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_token_replaces_an_existing_key() {
|
||||
let dir = scratch("replace");
|
||||
let path = dir.join("sidecar.toml");
|
||||
fs::write(
|
||||
&path,
|
||||
"[web]\nbind = \"127.0.0.1:8080\"\nauth_token = \"\"\n\n[store]\npath = \"x.db\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
persist_token(&path, "deadbeef").unwrap();
|
||||
|
||||
let out = fs::read_to_string(&path).unwrap();
|
||||
assert!(out.contains("auth_token = \"deadbeef\""), "{out}");
|
||||
assert_eq!(out.matches("auth_token").count(), 1, "{out}");
|
||||
// Everything else survives — the file is the operator's, not ours to rewrite.
|
||||
assert!(out.contains("bind = \"127.0.0.1:8080\""), "{out}");
|
||||
assert!(out.contains("path = \"x.db\""), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_token_inserts_under_an_existing_web_section() {
|
||||
let dir = scratch("insert");
|
||||
let path = dir.join("sidecar.toml");
|
||||
fs::write(&path, "[web]\nbind = \"127.0.0.1:8080\"\n").unwrap();
|
||||
|
||||
persist_token(&path, "deadbeef").unwrap();
|
||||
|
||||
let out = fs::read_to_string(&path).unwrap();
|
||||
let web = out.find("[web]").unwrap();
|
||||
let token = out.find("auth_token").unwrap();
|
||||
assert!(token > web, "token must land inside [web]: {out}");
|
||||
assert!(out.contains("auth_token = \"deadbeef\""), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_token_appends_a_web_section_when_there_is_none() {
|
||||
let dir = scratch("append");
|
||||
let path = dir.join("sidecar.toml");
|
||||
fs::write(&path, "[shard]\nbind = \"127.0.0.1:7788\"\n").unwrap();
|
||||
|
||||
persist_token(&path, "deadbeef").unwrap();
|
||||
|
||||
let out = fs::read_to_string(&path).unwrap();
|
||||
assert!(out.contains("[shard]"), "{out}");
|
||||
assert!(out.contains("[web]\nauth_token = \"deadbeef\""), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_generated_config_round_trips_through_the_parser() {
|
||||
// The template is a format! string, so a stray brace or a bad key would only ever surface
|
||||
// on someone's first run.
|
||||
let cfg: Config = toml::from_str(&default_file("deadbeef")).expect("template parses");
|
||||
assert_eq!(cfg.web.auth_token, "deadbeef");
|
||||
assert_eq!(cfg.shard.bind, "127.0.0.1:7788");
|
||||
assert_eq!(cfg.web.bind, "127.0.0.1:8080");
|
||||
assert_eq!(cfg.store.path, "uo-link.db");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_tokens_are_random_and_hex() {
|
||||
let (a, b) = (generate_token(), generate_token());
|
||||
assert_ne!(a, b);
|
||||
assert_eq!(a.len(), 48);
|
||||
assert!(a.chars().all(|c| c.is_ascii_hexdigit()), "{a}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_reports_the_resolved_configuration() {
|
||||
let loaded = Loaded {
|
||||
cfg: Config {
|
||||
shard: ShardCfg {
|
||||
bind: "127.0.0.1:7788".into(),
|
||||
},
|
||||
web: WebCfg {
|
||||
bind: "0.0.0.0:8080".into(),
|
||||
auth_token: "deadbeef".into(),
|
||||
},
|
||||
store: StoreCfg {
|
||||
path: "/var/lib/runicgateway/uo-link.db".into(),
|
||||
},
|
||||
},
|
||||
path: PathBuf::from("/etc/runicgateway/sidecar.toml"),
|
||||
config_created: true,
|
||||
token_generated: true,
|
||||
};
|
||||
|
||||
let doc = describe(&loaded);
|
||||
|
||||
assert_eq!(doc["component"], "uo-link-sidecar");
|
||||
assert_eq!(doc["version"], env!("CARGO_PKG_VERSION"));
|
||||
assert_eq!(doc["protocol"], PROTOCOL_VERSION);
|
||||
assert_eq!(doc["config_path"], "/etc/runicgateway/sidecar.toml");
|
||||
assert_eq!(doc["config_created"], true);
|
||||
assert_eq!(doc["token_generated"], true);
|
||||
assert_eq!(doc["shard"]["bind"], "127.0.0.1:7788");
|
||||
assert_eq!(doc["web"]["bind"], "0.0.0.0:8080");
|
||||
assert_eq!(doc["web"]["ws_path"], "/ws");
|
||||
assert_eq!(doc["web"]["auth_required"], true);
|
||||
assert_eq!(doc["web"]["auth_token"], "deadbeef");
|
||||
assert_eq!(doc["store"]["path"], "/var/lib/runicgateway/uo-link.db");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_provisions_a_missing_config_and_reports_it() {
|
||||
if env_overrides_present() {
|
||||
return;
|
||||
}
|
||||
let dir = scratch("provision");
|
||||
let path = dir.join("sidecar.toml");
|
||||
|
||||
let loaded = Config::load(Some(path.to_str().unwrap())).unwrap();
|
||||
|
||||
assert!(loaded.config_created);
|
||||
assert!(loaded.token_generated);
|
||||
assert!(
|
||||
path.exists(),
|
||||
"the config file must be written, not just held in memory"
|
||||
);
|
||||
assert!(!loaded.cfg.web.auth_token.is_empty());
|
||||
// The db lands beside the config, whatever the working directory is.
|
||||
assert_eq!(Path::new(&loaded.cfg.store.path), dir.join("uo-link.db"));
|
||||
|
||||
// Second run: same token, and nothing reported as new.
|
||||
let again = Config::load(Some(path.to_str().unwrap())).unwrap();
|
||||
assert!(!again.config_created);
|
||||
assert!(!again.token_generated);
|
||||
assert_eq!(again.cfg.web.auth_token, loaded.cfg.web.auth_token);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_creates_the_config_directory() {
|
||||
if env_overrides_present() {
|
||||
return;
|
||||
}
|
||||
// An installer pointing at a fresh /etc/runicgateway should not have to mkdir first.
|
||||
let dir = scratch("mkdir").join("nested").join("deeper");
|
||||
let path = dir.join("sidecar.toml");
|
||||
|
||||
let loaded = Config::load(Some(path.to_str().unwrap())).unwrap();
|
||||
|
||||
assert!(path.exists(), "{}", path.display());
|
||||
assert!(loaded.config_created);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user