mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Third reducing PR of the interpolation unification (**D11 resolution
(c)**): the control plane never interpolates. `InterpString` is now
strictly the user-facing workflow config language; server identity,
storage, listen, object-store, and GitHub App identifiers are plain
`String`, consumed where needed with no resolution point.
## Demoted to `String` (was `InterpString`)
Both the layer and resolved types:
- `server.listen.unix.path`, `server.api.url`, `server.web.url`
- `server.storage.root`, `server.artifacts.prefix`,
`server.slatedb.prefix`
- object store: `Local.root`, S3 `bucket` / `region` / `endpoint`
(shared by artifacts + slatedb)
- `github.app_id` / `client_id` / `slug`
**Kept `InterpString`:** `slack.default_channel` (run-time consumption —
the one server-defined survivor). `server.listen.tcp.address` stays the
`SocketAddr` `parsed_value` special case.
## Native `FABRO_WEB_URL` read
Deployment-time late binding now goes through a native env read instead
of a `{{ env.* }}` token: `FABRO_WEB_URL` overrides `server.web.url`
(**env override > settings literal > default**), applied in
`canonical_origin` and reused by the JWT issuer, cookie-secure check,
and system-info. `docker/split-web` no longer ferries the value through
a settings token (compose still sets the env var). `canonical_origin`'s
error message now advertises a knob that is actually true for everyone.
## Behavior change (release notes)
- `{{ env.* }}` / `{{ vars.* }}` tokens in the demoted server fields are
now **literal text**, not interpolated. The resolve layer emits
`warn_if_demoted_template` for every demoted field, so operators with
tokens still in server config **fail loud** rather than silently
treating the token as a literal.
- Operators who relied on env-based storage location should use the
existing native `FABRO_STORAGE_DIR` (`--storage-dir`) override.
`FABRO_STORAGE_ROOT` promotion is intentionally deferred (not a proven
need).
## Cleanup
`fabro-server`'s `crate::interp` shrinks to just the process-env lookup
facade; `resolve_interp` / `_path` / `_with` and the
`AppState::resolve_interp` seam are deleted (nothing resolves
server-scope `InterpString` anymore).
## Verification
- `cargo build --workspace` ✅
- `cargo +nightly clippy --workspace --all-targets -- -D warnings` ✅
(incl. the `as_source` gate)
- `cargo +nightly fmt --check --all` ✅
- `cargo nextest run --workspace`: 6305 passed; added two tests covering
the `FABRO_WEB_URL` override precedence (env-wins and settings-literal
fallback).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
620 lines
20 KiB
Rust
620 lines
20 KiB
Rust
use std::path::{Path, PathBuf};
|
|
use std::str::FromStr;
|
|
|
|
use anyhow::{Context, Result, anyhow};
|
|
pub(crate) use fabro_client::ServerTarget;
|
|
pub(crate) use fabro_config::user::{active_settings_path, default_storage_dir};
|
|
use fabro_config::user::{default_settings_path, default_socket_path};
|
|
use fabro_config::{
|
|
CliLayer, LogFilter, ParseError, RunSettingsBuilder, ServerSettingsBuilder, UserSettingsBuilder,
|
|
};
|
|
use fabro_static::EnvVars;
|
|
use fabro_types::settings::RunNamespace;
|
|
use fabro_types::settings::cli::CliTargetSettings;
|
|
use fabro_types::settings::server::LogDestination;
|
|
use fabro_types::{ServerSettings, UserSettings};
|
|
use fabro_util::error::SharedError;
|
|
use fabro_util::version::FABRO_VERSION;
|
|
use toml_edit::{DocumentMut, Item, Table, value};
|
|
use tracing::debug;
|
|
|
|
use crate::args::ServerTargetArgs;
|
|
|
|
pub(crate) struct LoadedSettings {
|
|
pub(crate) storage_dir: PathBuf,
|
|
pub(crate) config_log_level: Option<LogFilter>,
|
|
pub(crate) config_log_destination: Option<LogDestination>,
|
|
pub(crate) run_settings: std::result::Result<RunNamespace, SharedError>,
|
|
pub(crate) server_settings: std::result::Result<ServerSettings, SharedError>,
|
|
pub(crate) user_settings: UserSettings,
|
|
}
|
|
|
|
pub(crate) fn load_resolved_settings(
|
|
config_path: Option<&Path>,
|
|
storage_dir: Option<&Path>,
|
|
cli_layer: Option<&CliLayer>,
|
|
) -> anyhow::Result<LoadedSettings> {
|
|
let document = load_settings_document(config_path)?;
|
|
let storage_override = storage_dir.map(Path::to_path_buf);
|
|
let storage_dir = storage_dir_from_document(&document, storage_dir);
|
|
let pre_tracing_config = pre_tracing_config_from_document(&document)?;
|
|
let run_settings = load_run_settings(config_path).map_err(SharedError::new);
|
|
let server_settings = load_server_settings(config_path)
|
|
.map(|settings| match storage_override.as_deref() {
|
|
Some(dir) => settings.with_storage_override(dir),
|
|
None => settings,
|
|
})
|
|
.map_err(SharedError::new);
|
|
let user_settings = load_user_settings(config_path, cli_layer)?;
|
|
|
|
Ok(LoadedSettings {
|
|
storage_dir,
|
|
config_log_level: pre_tracing_config.log_level,
|
|
config_log_destination: pre_tracing_config.log_destination,
|
|
run_settings,
|
|
server_settings,
|
|
user_settings,
|
|
})
|
|
}
|
|
|
|
fn load_settings_document(config_path: Option<&Path>) -> anyhow::Result<toml::Value> {
|
|
load_settings_document_with_lookup(config_path, process_env_var_os)
|
|
}
|
|
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "sync settings load during CLI startup; not on a Tokio path"
|
|
)]
|
|
fn load_settings_document_with_lookup(
|
|
config_path: Option<&Path>,
|
|
lookup: impl Fn(&str) -> Option<std::ffi::OsString>,
|
|
) -> anyhow::Result<toml::Value> {
|
|
let config_path = config_path
|
|
.map(Path::to_path_buf)
|
|
.or_else(|| lookup(EnvVars::FABRO_CONFIG).map(PathBuf::from));
|
|
|
|
let path = if let Some(path) = config_path {
|
|
path
|
|
} else {
|
|
let default_path = default_settings_path();
|
|
if !default_path.is_file() {
|
|
return Ok(toml::Value::Table(toml::Table::new()));
|
|
}
|
|
default_path
|
|
};
|
|
|
|
let contents = std::fs::read_to_string(&path)
|
|
.map_err(|source| fabro_config::Error::read_file(&path, source))?;
|
|
let table: toml::Table = toml::from_str(&contents).map_err(|source| {
|
|
fabro_config::Error::parse_file(
|
|
"Failed to parse settings file",
|
|
&path,
|
|
ParseError::Toml(source.to_string()),
|
|
)
|
|
})?;
|
|
Ok(toml::Value::Table(table))
|
|
}
|
|
|
|
fn load_run_settings(config_path: Option<&Path>) -> anyhow::Result<RunNamespace> {
|
|
let catalog = fabro_environment::seeded_catalog_layer();
|
|
Ok(match config_path {
|
|
Some(path) => RunSettingsBuilder::load_from_with_catalog(path, catalog)?,
|
|
None => RunSettingsBuilder::load_default_with_catalog(catalog)?,
|
|
})
|
|
}
|
|
|
|
fn load_server_settings(config_path: Option<&Path>) -> anyhow::Result<ServerSettings> {
|
|
Ok(match config_path {
|
|
Some(path) => ServerSettingsBuilder::load_from(path)?,
|
|
None => ServerSettingsBuilder::load_default()?,
|
|
})
|
|
}
|
|
|
|
fn load_user_settings(
|
|
config_path: Option<&Path>,
|
|
cli_layer: Option<&CliLayer>,
|
|
) -> anyhow::Result<UserSettings> {
|
|
Ok(match (config_path, cli_layer) {
|
|
(Some(path), Some(cli_layer)) => {
|
|
UserSettingsBuilder::load_from_with_cli_overrides(path, cli_layer)?
|
|
}
|
|
(Some(path), None) => UserSettingsBuilder::load_from(path)?,
|
|
(None, Some(cli_layer)) => UserSettingsBuilder::load_default_with_cli_overrides(cli_layer)?,
|
|
(None, None) => UserSettingsBuilder::load_default()?,
|
|
})
|
|
}
|
|
|
|
struct PreTracingConfig {
|
|
log_level: Option<LogFilter>,
|
|
log_destination: Option<LogDestination>,
|
|
}
|
|
|
|
fn pre_tracing_config_from_document(document: &toml::Value) -> Result<PreTracingConfig> {
|
|
Ok(PreTracingConfig {
|
|
log_level: log_filter_at_path(document, &["server", "logging", "level"])?,
|
|
log_destination: log_destination_at_path(document, &["server", "logging", "destination"])?,
|
|
})
|
|
}
|
|
|
|
fn log_filter_at_path(document: &toml::Value, path: &[&str]) -> Result<Option<LogFilter>> {
|
|
let Some(value) = value_at_path(document, path) else {
|
|
return Ok(None);
|
|
};
|
|
let raw = value
|
|
.as_str()
|
|
.ok_or_else(|| anyhow!("{} must be a string", path.join(".")))?;
|
|
LogFilter::parse(raw)
|
|
.with_context(|| format!("invalid {} `{raw}`", path.join(".")))
|
|
.map(Some)
|
|
}
|
|
|
|
fn log_destination_at_path(
|
|
document: &toml::Value,
|
|
path: &[&str],
|
|
) -> Result<Option<LogDestination>> {
|
|
let Some(value) = value_at_path(document, path) else {
|
|
return Ok(None);
|
|
};
|
|
let raw = value
|
|
.as_str()
|
|
.ok_or_else(|| anyhow!("{} must be a string", path.join(".")))?;
|
|
raw.parse::<LogDestination>()
|
|
.with_context(|| {
|
|
format!(
|
|
"invalid {} `{raw}`; expected `file` or `stdout`",
|
|
path.join(".")
|
|
)
|
|
})
|
|
.map(Some)
|
|
}
|
|
|
|
pub(crate) fn storage_dir_from_document(
|
|
document: &toml::Value,
|
|
storage_dir: Option<&Path>,
|
|
) -> PathBuf {
|
|
if let Some(dir) = storage_dir {
|
|
return dir.to_path_buf();
|
|
}
|
|
|
|
// `server.storage.root` is plain control-plane config and does not
|
|
// interpolate; the FABRO_STORAGE_DIR-backed `storage_dir` argument above is
|
|
// the deployment-time override.
|
|
let storage_root = string_at_path(document, &["server", "storage", "root"])
|
|
.unwrap_or_else(|| default_storage_dir().to_string_lossy().into_owned());
|
|
PathBuf::from(storage_root)
|
|
}
|
|
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "CLI settings loading owns the process-env facade for config path lookup."
|
|
)]
|
|
fn process_env_var_os(name: &str) -> Option<std::ffi::OsString> {
|
|
std::env::var_os(name)
|
|
}
|
|
|
|
fn string_at_path(document: &toml::Value, path: &[&str]) -> Option<String> {
|
|
value_at_path(document, path).and_then(|value| value.as_str().map(str::to_owned))
|
|
}
|
|
|
|
fn value_at_path<'a>(document: &'a toml::Value, path: &[&str]) -> Option<&'a toml::Value> {
|
|
let mut current = document;
|
|
for segment in path {
|
|
current = current.get(*segment)?;
|
|
}
|
|
Some(current)
|
|
}
|
|
|
|
fn configured_server_target(settings: &UserSettings) -> Result<Option<ServerTarget>> {
|
|
match settings.cli.target.as_ref() {
|
|
Some(CliTargetSettings::Http { url }) => ServerTarget::http_url(url).map(Some),
|
|
Some(CliTargetSettings::Unix { path }) => ServerTarget::unix_socket_path(path).map(Some),
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn default_server_target() -> ServerTarget {
|
|
ServerTarget::unix_socket_path(default_socket_path()).expect("default socket path is absolute")
|
|
}
|
|
|
|
fn parse_server_target(value: &str) -> Result<ServerTarget> {
|
|
ServerTarget::from_str(value)
|
|
}
|
|
|
|
fn explicit_server_target(args: &ServerTargetArgs) -> Result<Option<ServerTarget>> {
|
|
args.as_deref().map(parse_server_target).transpose()
|
|
}
|
|
|
|
pub(crate) fn resolve_nondefault_server_target(
|
|
args: &ServerTargetArgs,
|
|
settings: &UserSettings,
|
|
) -> Result<Option<ServerTarget>> {
|
|
Ok(explicit_server_target(args)?.or(configured_server_target(settings)?))
|
|
}
|
|
|
|
pub(crate) fn resolve_server_target(
|
|
args: &ServerTargetArgs,
|
|
settings: &UserSettings,
|
|
) -> Result<ServerTarget> {
|
|
Ok(resolve_nondefault_server_target(args, settings)?.unwrap_or_else(default_server_target))
|
|
}
|
|
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "CLI auth/login updates the user settings file synchronously after successful login."
|
|
)]
|
|
pub(crate) fn configure_cli_target_if_missing(
|
|
config_path: &Path,
|
|
settings: &UserSettings,
|
|
target: &ServerTarget,
|
|
) -> Result<bool> {
|
|
if settings.cli.target.is_some() {
|
|
return Ok(false);
|
|
}
|
|
|
|
let mut document = read_settings_document_for_write(config_path)?;
|
|
if document
|
|
.get("cli")
|
|
.and_then(Item::as_table)
|
|
.is_some_and(|cli| cli.get("target").is_some_and(|target| !target.is_none()))
|
|
{
|
|
return Ok(false);
|
|
}
|
|
|
|
if document.get("_version").is_none() {
|
|
document["_version"] = value(1);
|
|
}
|
|
|
|
let cli = ensure_document_table(&mut document, "cli")?;
|
|
cli.insert("target", Item::Table(cli_target_table(target)));
|
|
|
|
if let Some(parent) = config_path.parent() {
|
|
std::fs::create_dir_all(parent)
|
|
.with_context(|| format!("failed to create {}", parent.display()))?;
|
|
}
|
|
std::fs::write(config_path, document.to_string())
|
|
.with_context(|| format!("failed to write {}", config_path.display()))?;
|
|
|
|
Ok(true)
|
|
}
|
|
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "CLI auth/login reads the user settings file synchronously before writing it."
|
|
)]
|
|
fn read_settings_document_for_write(config_path: &Path) -> Result<DocumentMut> {
|
|
if !config_path.exists() {
|
|
return Ok(DocumentMut::new());
|
|
}
|
|
|
|
let contents = std::fs::read_to_string(config_path)
|
|
.with_context(|| format!("failed to read {}", config_path.display()))?;
|
|
contents
|
|
.parse::<DocumentMut>()
|
|
.with_context(|| format!("failed to parse {}", config_path.display()))
|
|
}
|
|
|
|
fn ensure_document_table<'a>(document: &'a mut DocumentMut, key: &str) -> Result<&'a mut Table> {
|
|
if document.get(key).is_none() {
|
|
let mut table = Table::new();
|
|
table.set_implicit(true);
|
|
document[key] = Item::Table(table);
|
|
}
|
|
document[key]
|
|
.as_table_mut()
|
|
.ok_or_else(|| anyhow!("settings.toml [{key}] is not a table"))
|
|
}
|
|
|
|
fn cli_target_table(target: &ServerTarget) -> Table {
|
|
let mut table = Table::new();
|
|
if let Some(url) = target.as_http_url() {
|
|
table.insert("type", value("http"));
|
|
table.insert("url", value(url));
|
|
} else if let Some(path) = target.as_unix_socket_path() {
|
|
table.insert("type", value("unix"));
|
|
table.insert("path", value(path.display().to_string()));
|
|
}
|
|
table
|
|
}
|
|
|
|
pub(crate) fn exec_server_target(args: &ServerTargetArgs) -> Result<Option<ServerTarget>> {
|
|
let target = explicit_server_target(args)?;
|
|
debug!(?target, "Resolved exec server target");
|
|
Ok(target)
|
|
}
|
|
|
|
pub(crate) fn cli_http_client_builder() -> fabro_http::HttpClientBuilder {
|
|
fabro_http::HttpClientBuilder::new().user_agent(format!("fabro-cli/{FABRO_VERSION}"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn load_resolved_settings_from_toml(
|
|
source: &str,
|
|
storage_dir: Option<&Path>,
|
|
cli_layer: Option<&CliLayer>,
|
|
) -> anyhow::Result<LoadedSettings> {
|
|
let document: toml::Value = toml::from_str(source).context("failed to parse settings file")?;
|
|
let storage_override = storage_dir.map(Path::to_path_buf);
|
|
let storage_dir = storage_dir_from_document(&document, storage_dir);
|
|
let pre_tracing_config = pre_tracing_config_from_document(&document)?;
|
|
let run_settings = RunSettingsBuilder::from_toml_with_catalog(
|
|
source,
|
|
fabro_environment::seeded_catalog_layer(),
|
|
)
|
|
.map_err(|err| SharedError::new(anyhow::Error::new(err)));
|
|
let server_settings = ServerSettingsBuilder::from_toml(source)
|
|
.map(|settings| match storage_override.as_deref() {
|
|
Some(dir) => settings.with_storage_override(dir),
|
|
None => settings,
|
|
})
|
|
.map_err(|err| SharedError::new(anyhow::Error::new(err)));
|
|
let user_settings = match cli_layer {
|
|
Some(cli_layer) => UserSettingsBuilder::from_toml_with_cli_overrides(source, cli_layer)?,
|
|
None => UserSettingsBuilder::from_toml(source)?,
|
|
};
|
|
|
|
Ok(LoadedSettings {
|
|
storage_dir,
|
|
config_log_level: pre_tracing_config.log_level,
|
|
config_log_destination: pre_tracing_config.log_destination,
|
|
run_settings,
|
|
server_settings,
|
|
user_settings,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::path::PathBuf;
|
|
|
|
use fabro_config::UserSettingsBuilder;
|
|
use fabro_config::user::default_storage_dir;
|
|
use fabro_types::UserSettings;
|
|
|
|
use super::*;
|
|
use crate::args::ServerTargetArgs;
|
|
|
|
fn server_target_args(value: Option<&str>) -> ServerTargetArgs {
|
|
ServerTargetArgs {
|
|
server: value.map(str::to_string),
|
|
}
|
|
}
|
|
|
|
fn parse_user_settings(source: &str) -> UserSettings {
|
|
UserSettingsBuilder::from_toml(source).expect("fixture should resolve")
|
|
}
|
|
|
|
#[test]
|
|
fn exec_has_no_server_target_by_default() {
|
|
assert_eq!(exec_server_target(&server_target_args(None)).unwrap(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn exec_uses_cli_server_target() {
|
|
assert_eq!(
|
|
exec_server_target(&server_target_args(Some("https://cli.example.com"))).unwrap(),
|
|
Some(ServerTarget::http_url("https://cli.example.com").unwrap())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn exec_supports_explicit_unix_socket_target() {
|
|
assert_eq!(
|
|
exec_server_target(&server_target_args(Some("/tmp/fabro.sock"))).unwrap(),
|
|
Some(ServerTarget::unix_socket_path("/tmp/fabro.sock").unwrap())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn exec_ignores_configured_server_target_without_cli_override() {
|
|
assert_eq!(exec_server_target(&server_target_args(None)).unwrap(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_server_target_uses_configured_server_target() {
|
|
let settings = parse_user_settings(
|
|
r#"
|
|
_version = 1
|
|
|
|
[cli.target]
|
|
type = "http"
|
|
url = "https://config.example.com"
|
|
"#,
|
|
);
|
|
assert_eq!(
|
|
resolve_server_target(&server_target_args(None), &settings).unwrap(),
|
|
ServerTarget::http_url("https://config.example.com").unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_server_target_explicit_target_overrides_config_target() {
|
|
let settings = parse_user_settings(
|
|
r#"
|
|
_version = 1
|
|
|
|
[cli.target]
|
|
type = "http"
|
|
url = "https://config.example.com"
|
|
"#,
|
|
);
|
|
assert_eq!(
|
|
resolve_server_target(
|
|
&server_target_args(Some("https://cli.example.com")),
|
|
&settings
|
|
)
|
|
.unwrap(),
|
|
ServerTarget::http_url("https://cli.example.com").unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_server_target_defaults_to_default_unix_socket_target() {
|
|
let settings = UserSettings::default();
|
|
assert_eq!(
|
|
resolve_server_target(&server_target_args(None), &settings).unwrap(),
|
|
ServerTarget::unix_socket_path(dirs::home_dir().unwrap().join(".fabro/fabro.sock"))
|
|
.unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_server_target_overrides_config_target() {
|
|
let settings = parse_user_settings(
|
|
r#"
|
|
_version = 1
|
|
|
|
[cli.target]
|
|
type = "http"
|
|
url = "https://config.example.com"
|
|
"#,
|
|
);
|
|
assert_eq!(
|
|
resolve_server_target(
|
|
&server_target_args(Some("https://cli.example.com")),
|
|
&settings
|
|
)
|
|
.unwrap(),
|
|
ServerTarget::http_url("https://cli.example.com").unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_server_target_is_rejected() {
|
|
let error = exec_server_target(&server_target_args(Some("fabro.internal"))).unwrap_err();
|
|
assert_eq!(
|
|
error.to_string(),
|
|
"server target must be an http(s) URL or absolute Unix socket path"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "unit test writes a temporary settings fixture with sync std::fs"
|
|
)]
|
|
fn configure_cli_target_creates_settings_file_when_missing() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let config_path = dir.path().join(".fabro").join("settings.toml");
|
|
let settings = UserSettings::default();
|
|
let target = ServerTarget::http_url("http://127.0.0.1:32276").unwrap();
|
|
|
|
assert!(configure_cli_target_if_missing(&config_path, &settings, &target).unwrap());
|
|
|
|
let contents = std::fs::read_to_string(&config_path).unwrap();
|
|
insta::assert_snapshot!(contents, @r#"
|
|
_version = 1
|
|
|
|
[cli.target]
|
|
type = "http"
|
|
url = "http://127.0.0.1:32276"
|
|
"#);
|
|
let settings = UserSettingsBuilder::load_from(&config_path).unwrap();
|
|
assert_eq!(
|
|
resolve_server_target(&server_target_args(None), &settings).unwrap(),
|
|
target
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "unit test writes a temporary settings fixture with sync std::fs"
|
|
)]
|
|
fn configure_cli_target_does_not_overwrite_existing_target() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let config_path = dir.path().join("settings.toml");
|
|
let existing = r#"
|
|
_version = 1
|
|
|
|
[cli.target]
|
|
type = "http"
|
|
url = "https://configured.example.com"
|
|
"#;
|
|
std::fs::write(&config_path, existing).unwrap();
|
|
let settings = UserSettingsBuilder::load_from(&config_path).unwrap();
|
|
let target = ServerTarget::http_url("https://new.example.com").unwrap();
|
|
|
|
assert!(!configure_cli_target_if_missing(&config_path, &settings, &target).unwrap());
|
|
|
|
assert_eq!(std::fs::read_to_string(&config_path).unwrap(), existing);
|
|
}
|
|
|
|
#[test]
|
|
fn storage_dir_defaults_without_server_auth_methods() {
|
|
let document = toml::Value::Table(toml::Table::new());
|
|
|
|
assert_eq!(
|
|
storage_dir_from_document(&document, None),
|
|
default_storage_dir()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn storage_dir_uses_explicit_server_storage_root() {
|
|
let document: toml::Value = toml::from_str(
|
|
r#"
|
|
_version = 1
|
|
|
|
[server.storage]
|
|
root = "/srv/fabro"
|
|
"#,
|
|
)
|
|
.expect("fixture should parse");
|
|
|
|
assert_eq!(
|
|
storage_dir_from_document(&document, None),
|
|
PathBuf::from("/srv/fabro")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn storage_dir_keeps_template_token_literal() {
|
|
// server.storage.root is plain control-plane config now: a `{{ env.* }}`
|
|
// token is used verbatim, never resolved. Deployment-time overrides go
|
|
// through the FABRO_STORAGE_DIR-backed `storage_dir` argument instead.
|
|
let document: toml::Value = toml::from_str(
|
|
r#"
|
|
_version = 1
|
|
|
|
[server.storage]
|
|
root = "{{ env.FABRO_STORAGE_ROOT }}"
|
|
"#,
|
|
)
|
|
.expect("fixture should parse");
|
|
|
|
assert_eq!(
|
|
storage_dir_from_document(&document, None),
|
|
PathBuf::from("{{ env.FABRO_STORAGE_ROOT }}")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "unit test writes a temporary settings fixture with sync std::fs::write"
|
|
)]
|
|
fn load_settings_document_uses_fabro_config_env_for_storage_root() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let config_path = dir.path().join("settings.toml");
|
|
std::fs::write(
|
|
&config_path,
|
|
r#"
|
|
_version = 1
|
|
|
|
[server.storage]
|
|
root = "/srv/fabro"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let document = load_settings_document_with_lookup(None, |_| {
|
|
Some(config_path.clone().into_os_string())
|
|
})
|
|
.expect("settings document should load");
|
|
|
|
assert_eq!(
|
|
storage_dir_from_document(&document, None),
|
|
PathBuf::from("/srv/fabro")
|
|
);
|
|
}
|
|
}
|