Skip to main content

foundry_local_sdk/
configuration.rs

1use std::collections::HashMap;
2use std::fmt;
3use std::sync::Arc;
4
5use crate::detail::api::{to_cstring, Api, Kvps};
6use crate::detail::ffi::*;
7use crate::error::{FoundryLocalError, Result};
8
9/// Log level for the Foundry Local service.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum LogLevel {
12    Trace,
13    Debug,
14    Info,
15    Warn,
16    Error,
17    Fatal,
18}
19
20impl LogLevel {
21    /// Map to the native `flLogLevel` value.
22    fn as_native(&self) -> flLogLevel {
23        match self {
24            Self::Trace => FOUNDRY_LOCAL_LOG_VERBOSE,
25            Self::Debug => FOUNDRY_LOCAL_LOG_DEBUG,
26            Self::Info => FOUNDRY_LOCAL_LOG_INFO,
27            Self::Warn => FOUNDRY_LOCAL_LOG_WARNING,
28            Self::Error => FOUNDRY_LOCAL_LOG_ERROR,
29            Self::Fatal => FOUNDRY_LOCAL_LOG_FATAL,
30        }
31    }
32}
33
34/// Application-level logger that the SDK can use to emit diagnostic messages.
35///
36/// This is a stub — the logger is stored in the configuration and passed
37/// through to the manager, but it is not wired into the native core yet.
38pub trait Logger: Send + Sync {
39    /// Log a message at the given severity level.
40    fn log(&self, level: LogLevel, message: &str);
41}
42
43/// User-facing configuration for initializing the Foundry Local SDK.
44///
45/// Construct with [`FoundryLocalConfig::new`] and customise via the builder
46/// methods:
47///
48/// ```ignore
49/// let config = FoundryLocalConfig::new("my_app")
50///     .log_level(LogLevel::Debug)
51///     .model_cache_dir("/path/to/cache");
52/// ```
53#[derive(Default)]
54pub struct FoundryLocalConfig {
55    app_name: String,
56    app_data_dir: Option<String>,
57    model_cache_dir: Option<String>,
58    logs_dir: Option<String>,
59    log_level: Option<LogLevel>,
60    catalog_urls: Vec<(String, Option<String>)>,
61    catalog_region: Option<String>,
62    web_service_urls: Option<String>,
63    service_endpoint: Option<String>,
64    library_path: Option<String>,
65    additional_settings: Option<HashMap<String, String>>,
66    logger: Option<Box<dyn Logger>>,
67}
68
69impl fmt::Debug for FoundryLocalConfig {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        f.debug_struct("FoundryLocalConfig")
72            .field("app_name", &self.app_name)
73            .field("app_data_dir", &self.app_data_dir)
74            .field("model_cache_dir", &self.model_cache_dir)
75            .field("logs_dir", &self.logs_dir)
76            .field("log_level", &self.log_level)
77            .field("catalog_urls", &self.catalog_urls)
78            .field("catalog_region", &self.catalog_region)
79            .field("web_service_urls", &self.web_service_urls)
80            .field("service_endpoint", &self.service_endpoint)
81            .field("library_path", &self.library_path)
82            .field("additional_settings", &self.additional_settings)
83            .field("logger", &self.logger.as_ref().map(|_| ".."))
84            .finish()
85    }
86}
87
88impl FoundryLocalConfig {
89    /// Create a new configuration with the given application name.
90    ///
91    /// All other fields default to `None`. Use the builder methods to
92    /// customise:
93    ///
94    /// ```ignore
95    /// let config = FoundryLocalConfig::new("my_app")
96    ///     .log_level(LogLevel::Debug)
97    ///     .model_cache_dir("/path/to/cache");
98    /// ```
99    pub fn new(app_name: impl Into<String>) -> Self {
100        Self {
101            app_name: app_name.into(),
102            ..Self::default()
103        }
104    }
105
106    /// Override the application-data directory.
107    pub fn app_data_dir(mut self, dir: impl Into<String>) -> Self {
108        self.app_data_dir = Some(dir.into());
109        self
110    }
111
112    /// Override the model-cache directory.
113    pub fn model_cache_dir(mut self, dir: impl Into<String>) -> Self {
114        self.model_cache_dir = Some(dir.into());
115        self
116    }
117
118    /// Override the logs directory.
119    pub fn logs_dir(mut self, dir: impl Into<String>) -> Self {
120        self.logs_dir = Some(dir.into());
121        self
122    }
123
124    /// Set the log level.
125    pub fn log_level(mut self, level: LogLevel) -> Self {
126        self.log_level = Some(level);
127        self
128    }
129
130    /// Add a catalog URL.
131    ///
132    /// Call this method multiple times to configure multiple catalogs. Catalogs
133    /// retain insertion order, which determines their priority. Use
134    /// [`catalog_url_with_filter`](Self::catalog_url_with_filter) to attach a
135    /// per-catalog filter override.
136    pub fn catalog_url(mut self, url: impl Into<String>) -> Self {
137        self.catalog_urls.push((url.into(), None));
138        self
139    }
140
141    /// Add a catalog URL with a per-catalog filter override.
142    ///
143    /// Behaves like [`catalog_url`](Self::catalog_url) but also records a filter
144    /// override that is applied to this catalog only.
145    pub fn catalog_url_with_filter(
146        mut self,
147        url: impl Into<String>,
148        filter_override: impl Into<String>,
149    ) -> Self {
150        self.catalog_urls
151            .push((url.into(), Some(filter_override.into())));
152        self
153    }
154
155    /// Set the Azure region used by the catalog service.
156    pub fn catalog_region(mut self, region: impl Into<String>) -> Self {
157        self.catalog_region = Some(region.into());
158        self
159    }
160
161    /// Set the web-service listen URLs (e.g. `"http://localhost:5273"`).
162    pub fn web_service_urls(mut self, urls: impl Into<String>) -> Self {
163        self.web_service_urls = Some(urls.into());
164        self
165    }
166
167    /// Set an external service endpoint URL.
168    pub fn service_endpoint(mut self, endpoint: impl Into<String>) -> Self {
169        self.service_endpoint = Some(endpoint.into());
170        self
171    }
172
173    /// Override the path to the native Foundry Local Core library.
174    pub fn library_path(mut self, path: impl Into<String>) -> Self {
175        self.library_path = Some(path.into());
176        self
177    }
178
179    /// Add a single key-value pair to the additional settings map.
180    pub fn additional_setting(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
181        self.additional_settings
182            .get_or_insert_with(HashMap::new)
183            .insert(key.into(), value.into());
184        self
185    }
186
187    /// Provide an application logger.
188    ///
189    /// **Not wired to native logging.** The native core's configuration ABI
190    /// exposes only a log level and a logs directory (see [`log_level`] and
191    /// [`logs_dir`]) — there is no logger-callback hook — so a custom [`Logger`]
192    /// cannot currently receive the core's log records. The logger is stored for
193    /// forward compatibility and any SDK-side use, but setting it has no effect
194    /// on native logging today; use [`log_level`] / [`logs_dir`] to control core
195    /// logging.
196    ///
197    /// [`log_level`]: Self::log_level
198    /// [`logs_dir`]: Self::logs_dir
199    pub fn logger(mut self, logger: impl Logger + 'static) -> Self {
200        self.logger = Some(Box::new(logger));
201        self
202    }
203
204    // ── Crate-internal helpers ───────────────────────────────────────────────
205
206    /// The configured native library path override, if any.
207    pub(crate) fn library_path_ref(&self) -> Option<&str> {
208        self.library_path.as_deref()
209    }
210
211    /// Take ownership of the configured logger (consumed once by the manager).
212    pub(crate) fn take_logger(&mut self) -> Option<Box<dyn Logger>> {
213        self.logger.take()
214    }
215
216    /// Build a native `flConfiguration` from this configuration.
217    ///
218    /// Returns [`FoundryLocalError::InvalidConfiguration`] when `app_name` is
219    /// empty or blank.
220    pub(crate) fn build_native(&self, api: &Arc<Api>) -> Result<NativeConfig> {
221        let app_name = self.app_name.trim();
222        if app_name.is_empty() {
223            return Err(FoundryLocalError::InvalidConfiguration {
224                reason: "app_name must be set and non-empty".into(),
225            });
226        }
227
228        let cfg = NativeConfig::create(Arc::clone(api), app_name)?;
229        let c = api.config_api();
230
231        if let Some(dir) = &self.app_data_dir {
232            let s = to_cstring(dir)?;
233            // SAFETY: ptr is valid; the native call copies the string.
234            api.check(unsafe { (c.SetAppDataDir)(cfg.ptr, s.as_ptr()) })?;
235        }
236        if let Some(dir) = &self.model_cache_dir {
237            let s = to_cstring(dir)?;
238            api.check(unsafe { (c.SetModelCacheDir)(cfg.ptr, s.as_ptr()) })?;
239        }
240        if let Some(dir) = &self.logs_dir {
241            let s = to_cstring(dir)?;
242            api.check(unsafe { (c.SetLogsDir)(cfg.ptr, s.as_ptr()) })?;
243        }
244        if let Some(level) = self.log_level {
245            api.check(unsafe { (c.SetDefaultLogLevel)(cfg.ptr, level.as_native()) })?;
246        }
247        for (url, filter_override) in &self.catalog_urls {
248            let url = to_cstring(url)?;
249            let filter_override = filter_override.as_deref().map(to_cstring).transpose()?;
250            let filter_override_ptr = filter_override
251                .as_ref()
252                .map_or(std::ptr::null(), |filter| filter.as_ptr());
253            api.check(unsafe { (c.AddCatalogUrl)(cfg.ptr, url.as_ptr(), filter_override_ptr) })?;
254        }
255        if let Some(region) = &self.catalog_region {
256            let region = to_cstring(region)?;
257            api.check(unsafe { (c.SetCatalogRegion)(cfg.ptr, region.as_ptr()) })?;
258        }
259        if let Some(urls) = &self.web_service_urls {
260            for url in urls.split(',').map(str::trim).filter(|u| !u.is_empty()) {
261                let s = to_cstring(url)?;
262                api.check(unsafe { (c.AddWebServiceEndpoint)(cfg.ptr, s.as_ptr()) })?;
263            }
264        }
265        if let Some(endpoint) = &self.service_endpoint {
266            let s = to_cstring(endpoint)?;
267            api.check(unsafe { (c.SetExternalServiceUrl)(cfg.ptr, s.as_ptr()) })?;
268        }
269        if let Some(extra) = &self.additional_settings {
270            if !extra.is_empty() {
271                let kvps = Kvps::from_pairs(Arc::clone(api), extra.iter())?;
272                api.check(unsafe { (c.SetAdditionalOptions)(cfg.ptr, kvps.as_ptr()) })?;
273            }
274        }
275
276        Ok(cfg)
277    }
278}
279
280/// Owning wrapper around a native `flConfiguration`, released on drop.
281pub(crate) struct NativeConfig {
282    api: Arc<Api>,
283    ptr: *mut flConfiguration,
284}
285
286impl NativeConfig {
287    fn create(api: Arc<Api>, app_name: &str) -> Result<Self> {
288        let name = to_cstring(app_name)?;
289        let mut ptr: *mut flConfiguration = std::ptr::null_mut();
290        // SAFETY: `Create` writes a valid handle into `ptr` on success.
291        let status = unsafe { (api.config_api().Create)(name.as_ptr(), &mut ptr) };
292        api.check(status)?;
293        Ok(Self { api, ptr })
294    }
295
296    pub(crate) fn as_ptr(&self) -> *const flConfiguration {
297        self.ptr
298    }
299}
300
301impl Drop for NativeConfig {
302    fn drop(&mut self) {
303        if !self.ptr.is_null() {
304            // SAFETY: `ptr` was created by `Create` and not yet released.
305            unsafe { (self.api.config_api().Configuration_Release)(self.ptr) };
306            self.ptr = std::ptr::null_mut();
307        }
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn catalog_builder_preserves_urls_filters_and_priority() {
317        let config = FoundryLocalConfig::new("test")
318            .catalog_url("https://first.example/catalog")
319            .catalog_url_with_filter("https://second.example/catalog", "device=cpu");
320
321        assert_eq!(
322            config.catalog_urls,
323            vec![
324                ("https://first.example/catalog".into(), None),
325                (
326                    "https://second.example/catalog".into(),
327                    Some("device=cpu".into())
328                ),
329            ]
330        );
331    }
332
333    #[test]
334    fn catalog_region_is_optional_and_set_by_builder() {
335        assert_eq!(FoundryLocalConfig::new("test").catalog_region, None);
336        assert_eq!(
337            FoundryLocalConfig::new("test")
338                .catalog_region("australiaeast")
339                .catalog_region,
340            Some("australiaeast".into())
341        );
342    }
343}