fabro/lib/apps/fabro-cli/src/user_config.rs
2026-09-02 13:44:33 -04:00

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")
);
}
}