mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-21 00:21:27 +00:00
732 lines
24 KiB
Rust
732 lines
24 KiB
Rust
use std::path::{Path, PathBuf};
|
|
use std::str::FromStr;
|
|
|
|
use anyhow::{Context, Result, anyhow};
|
|
pub(crate) use fabro_client::ServerTarget;
|
|
use fabro_config::parse::{SettingsSource, validate_settings_source};
|
|
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, SettingsLayer,
|
|
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 tokio::fs;
|
|
use toml_edit::{DocumentMut, Item, Table, value};
|
|
use tracing::debug;
|
|
|
|
use crate::args::ServerTargetArgs;
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
pub(crate) struct RunSettingsKeyPresence {
|
|
pub(crate) run: bool,
|
|
pub(crate) environments: bool,
|
|
}
|
|
|
|
impl RunSettingsKeyPresence {
|
|
fn from_document(document: &toml::Value) -> Self {
|
|
Self {
|
|
run: document.get("run").is_some(),
|
|
environments: document.get("environments").is_some(),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn key_paths(self) -> Vec<&'static str> {
|
|
[(self.run, "run"), (self.environments, "environments")]
|
|
.into_iter()
|
|
.filter_map(|(present, key)| present.then_some(key))
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
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) run_settings_key_presence: RunSettingsKeyPresence,
|
|
}
|
|
|
|
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 run_settings_key_presence = RunSettingsKeyPresence::from_document(&document);
|
|
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,
|
|
run_settings_key_presence,
|
|
})
|
|
}
|
|
|
|
pub(crate) async fn read_project_run_settings_key_presence(
|
|
path: &Path,
|
|
) -> anyhow::Result<RunSettingsKeyPresence> {
|
|
let parse_error =
|
|
|source| fabro_config::Error::parse_file("Failed to parse settings file", path, source);
|
|
let source = fs::read_to_string(path)
|
|
.await
|
|
.map_err(|source| fabro_config::Error::read_file(path, source))?;
|
|
let document: toml::Value = toml::from_str(&source)
|
|
.map_err(|source| parse_error(ParseError::Toml(source.to_string())))?;
|
|
let layer = source.parse::<SettingsLayer>().map_err(parse_error)?;
|
|
validate_settings_source(&layer, SettingsSource::Project).map_err(parse_error)?;
|
|
Ok(RunSettingsKeyPresence::from_document(&document))
|
|
}
|
|
|
|
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 run_settings_key_presence = RunSettingsKeyPresence::from_document(&document);
|
|
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,
|
|
run_settings_key_presence,
|
|
})
|
|
}
|
|
|
|
#[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 run_settings_key_presence_distinguishes_absent_empty_and_populated_keys() {
|
|
let absent: toml::Value = toml::from_str("_version = 1\n").unwrap();
|
|
let empty_run: toml::Value = toml::from_str("_version = 1\n\n[run]\n").unwrap();
|
|
let empty_environments: toml::Value =
|
|
toml::from_str("_version = 1\n\n[environments]\n").unwrap();
|
|
let populated: toml::Value = toml::from_str(
|
|
"_version = 1\n\n[run.model]\nname = \"gpt-5\"\n\n[environments.cloud]\nprovider = \"docker\"\n",
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
RunSettingsKeyPresence::from_document(&absent),
|
|
RunSettingsKeyPresence::default()
|
|
);
|
|
assert_eq!(
|
|
RunSettingsKeyPresence::from_document(&empty_run),
|
|
RunSettingsKeyPresence {
|
|
run: true,
|
|
environments: false,
|
|
}
|
|
);
|
|
assert_eq!(
|
|
RunSettingsKeyPresence::from_document(&empty_environments),
|
|
RunSettingsKeyPresence {
|
|
run: false,
|
|
environments: true,
|
|
}
|
|
);
|
|
assert_eq!(
|
|
RunSettingsKeyPresence::from_document(&populated),
|
|
RunSettingsKeyPresence {
|
|
run: true,
|
|
environments: true,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn project_run_settings_key_presence_validates_the_same_source() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("project.toml");
|
|
fs::write(&path, "_version = 1\n\n[run]\n\n[environments]\n")
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
read_project_run_settings_key_presence(&path).await.unwrap(),
|
|
RunSettingsKeyPresence {
|
|
run: true,
|
|
environments: true,
|
|
}
|
|
);
|
|
|
|
fs::write(
|
|
&path,
|
|
"_version = 1\n\n[environments.cloud]\ncwd = \"/tmp\"\n",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let error = read_project_run_settings_key_presence(&path)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(error.to_string().contains(&path.display().to_string()));
|
|
assert!(error.source().is_some());
|
|
}
|
|
|
|
#[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")
|
|
);
|
|
}
|
|
}
|