Skip to main content

lds_core/
config.rs

1//! First-class configuration for lds.
2//!
3//! Reads and writes `~/.config/lds/config.toml` (or an explicit path).
4//! The primary design constraints are:
5//!
6//! 1. **patch-safe write** — `Config::save` uses `toml_edit` to update only
7//!    the `recipes.dirs` array while preserving comments and unrelated sections.
8//! 2. **tilde expansion** — any path stored on disk must be an absolute path;
9//!    tilde literals are never written to `config.toml`.
10//! 3. **shared file, decoupled schemas** — the same `config.toml` (both the
11//!    user-global file and a session's project-local override) also carries
12//!    `[[route]]` / `[[export]]` array-of-tables consumed by the `lds-router`
13//!    crate (see `lds_router::RouteConfig` / `lds_router::ExportConfig`).
14//!    `Config` has no `route` or `export` field and does not depend on
15//!    `lds-router` — serde's default "ignore unrecognized keys" behavior
16//!    (no `#[serde(deny_unknown_fields)]` here or on `lds_router`'s
17//!    deserialization target) means each side parses the same file and
18//!    silently skips the sections it does not own. This keeps the two crates
19//!    decoupled while letting one physical file hold both.
20
21use std::io;
22use std::path::{Path, PathBuf};
23
24use serde::{Deserialize, Serialize};
25use thiserror::Error;
26use toml_edit::{Array, DocumentMut, Item, Value};
27
28// ---------------------------------------------------------------------------
29// Error type
30// ---------------------------------------------------------------------------
31
32/// Errors that can occur during config load or save operations.
33#[derive(Debug, Error)]
34pub enum ConfigError {
35    /// An I/O error (e.g. permission denied, parent directory not found).
36    #[error("config I/O error: {0}")]
37    Io(#[from] io::Error),
38
39    /// TOML deserialization error (returned by `Config::load`).
40    #[error("config parse error: {0}")]
41    Parse(#[from] toml::de::Error),
42
43    /// `toml_edit` document-level error (returned by `Config::save`).
44    #[error("config edit error: {0}")]
45    Edit(#[from] toml_edit::TomlError),
46
47    /// TOML serialization error.
48    #[error("config serialize error: {0}")]
49    Serialize(#[from] toml::ser::Error),
50}
51
52// ---------------------------------------------------------------------------
53// Config structs
54// ---------------------------------------------------------------------------
55
56/// Top-level configuration for lds.
57///
58/// Deserializes from `~/.config/lds/config.toml`.  Missing sections fall back
59/// to `Default` via `#[serde(default)]`.
60#[derive(Debug, Clone, Default, Deserialize, Serialize)]
61#[serde(default)]
62pub struct Config {
63    /// Recipe directory settings.
64    pub recipes: Recipes,
65    /// Path overrides.
66    pub paths: Paths,
67    /// `lds pack` classification overrides.
68    pub pack: Pack,
69    /// Remote data-plane endpoints (`[remote.outline]` / `[remote.journal]`).
70    pub remote: Remote,
71}
72
73/// Remote data-plane endpoints.
74///
75/// When an endpoint is declared, the corresponding lds module forwards to a
76/// central `--mcp-http` daemon (the SSOT host) instead of embedding the
77/// upstream server in-process. Absent sections mean "embed locally"
78/// (backward-compatible default).
79///
80/// Declared in the same `config.toml` as `[recipes]` / `[pack]`:
81/// user-global `~/.config/lds/config.toml`, overridden field-wise by the
82/// project-local `<session_root>/config.toml`, overridden by env
83/// (`LDS_OUTLINE_REMOTE_URL` etc.) for one-off switches.
84#[derive(Debug, Clone, Default, Deserialize, Serialize)]
85#[serde(default)]
86pub struct Remote {
87    /// Outline books daemon (`outline-mcp --mcp-http`).
88    pub outline: Option<RemoteEndpoint>,
89    /// Journal EventLog daemon (`journal-mcp --mcp-http`, future).
90    pub journal: Option<RemoteEndpoint>,
91}
92
93/// One remote MCP endpoint declaration.
94///
95/// The token itself is never written here — `token_env` names the
96/// environment variable that carries it, because `config.toml` travels
97/// through pack / backup paths where a literal credential must not.
98#[derive(Debug, Clone, Default, Deserialize, Serialize)]
99#[serde(default)]
100pub struct RemoteEndpoint {
101    /// Endpoint URL, e.g. `http://ssot-host:8486/mcp`.
102    pub url: String,
103    /// Env var name holding the bearer token. `None` falls back to the
104    /// daemon's conventional name (e.g. `OUTLINE_MCP_HTTP_TOKEN`); an unset
105    /// or empty variable means "no token" (loopback daemons allow that).
106    pub token_env: Option<String>,
107    /// Project key sent instead of a local path (journal only, future).
108    pub project_key: Option<String>,
109}
110
111/// Recipe-related configuration.
112#[derive(Debug, Clone, Default, Deserialize, Serialize)]
113#[serde(default)]
114pub struct Recipes {
115    /// Additional global recipe directories (highest priority source).
116    ///
117    /// Entries are absolute paths.  Tilde is expanded on load and must be
118    /// absent from `config.toml` on disk.
119    pub dirs: Vec<PathBuf>,
120}
121
122/// Classification overrides for `lds pack`.
123///
124/// Every list here is **added to** the built-in defaults rather than replacing
125/// them, so a project that declares one project-specific secret name does not
126/// silently lose the protection of the built-in list. `keep` is the one escape
127/// hatch that subtracts: it names files the built-ins would classify as secret
128/// or cache but that this operator wants carried anyway.
129///
130/// Every list here scopes its globs the way `.gitignore` does: one with no `/`
131/// (`*.vault`, `my-app-keys.json`) matches the **file name** at any depth, one
132/// with a `/` (`docs/samples/*.pem`, `frontend/dist`) is anchored to that
133/// **path relative to the project root**.
134///
135/// Reaching the whole tree is the right default for naming a kind of file, and
136/// the hazard when naming one particular file: `keep = ["*.pem"]` written to
137/// carry one sample key carries every private key in the project. Anchor such a
138/// rule to a path and it stays where it was meant to apply.
139#[derive(Debug, Clone, Default, Deserialize, Serialize)]
140#[serde(default)]
141pub struct Pack {
142    /// Extra globs to treat as secrets (never packed, only reported).
143    pub secret_globs: Vec<String>,
144    /// Extra directories to treat as regenerable caches (never packed).
145    pub cache_dirs: Vec<String>,
146    /// Globs that must be packed even if a built-in rule excludes them.
147    ///
148    /// The only subtractive list, and so the only way a file the secret rules
149    /// named ends up in the archive. Anything it rescues is recorded in the
150    /// manifest's `kept_over_secret`.
151    pub keep: Vec<String>,
152    /// Path globs whose symlinks are packed but left out of the link report.
153    ///
154    /// A symlink is a problem by default: it breaks when the project is carried
155    /// somewhere else, so every one is reported for the operator to deal with.
156    /// The exception is a directory that is *meant* to be links — a shared
157    /// dotfile tree such as `.zsh/`, deployed the same way on every machine the
158    /// operator uses. Those are already known, so reporting them is noise that
159    /// hides the links that do need attention.
160    ///
161    /// Scoped like the lists above, and in practice always with a `/` — what
162    /// makes links expected is where they sit.
163    ///
164    /// No built-in default: only the operator knows which of their directories
165    /// are link-by-design. Left unset, every symlink is reported.
166    ///
167    /// Suppression affects the report alone — the links are packed either way,
168    /// and every rule that suppressed something is named in the manifest, so a
169    /// silent report can always be told apart from an empty one.
170    pub no_link_report: Vec<String>,
171}
172
173/// Path overrides for well-known lds locations.
174#[derive(Debug, Clone, Default, Deserialize, Serialize)]
175#[serde(default)]
176pub struct Paths {
177    /// Override for the global justfile path (default: `~/.config/lds/justfile`).
178    pub global_justfile: Option<PathBuf>,
179}
180
181// ---------------------------------------------------------------------------
182// tilde_expand
183// ---------------------------------------------------------------------------
184
185/// Expand a leading `~/` or lone `~` to the user's home directory.
186///
187/// # Arguments
188///
189/// * `input` — A path string that may start with `~/`.
190///
191/// # Returns
192///
193/// An absolute `PathBuf`.  If `input` does not start with `~/` or `~`, it is
194/// returned as-is wrapped in `PathBuf`.
195///
196/// # Errors
197///
198/// Returns `ConfigError::Io(NotFound)` when the home directory cannot be
199/// determined (e.g. `$HOME` is unset on Unix).
200pub fn tilde_expand(input: &str) -> Result<PathBuf, ConfigError> {
201    if input == "~" {
202        let home = dirs::home_dir().ok_or_else(|| {
203            ConfigError::Io(io::Error::new(io::ErrorKind::NotFound, "HOME not set"))
204        })?;
205        Ok(home)
206    } else if let Some(rest) = input.strip_prefix("~/") {
207        let home = dirs::home_dir().ok_or_else(|| {
208            ConfigError::Io(io::Error::new(io::ErrorKind::NotFound, "HOME not set"))
209        })?;
210        Ok(home.join(rest))
211    } else {
212        Ok(PathBuf::from(input))
213    }
214}
215
216// ---------------------------------------------------------------------------
217// Config impl
218// ---------------------------------------------------------------------------
219
220/// Resolve the well-known path to the user-global config file
221/// (`~/.config/lds/config.toml`).
222///
223/// Returns `None` if the home directory cannot be determined (e.g. `$HOME`
224/// is unset). Shared by [`Config::load_or_default`] and by the `lds` binary
225/// crate, which also points `lds_router::RouteConfig::load_all` at this same
226/// path so `[[route]]` / `[[export]]` declarations live in the one file.
227pub fn user_config_path() -> Option<PathBuf> {
228    dirs::home_dir().map(|home| home.join(".config/lds/config.toml"))
229}
230
231impl Config {
232    /// Load configuration from an explicit file path.
233    ///
234    /// # Arguments
235    ///
236    /// * `path` — Path to a TOML configuration file.
237    ///
238    /// # Returns
239    ///
240    /// A fully populated `Config`.  Missing optional sections are filled with
241    /// `Default`.
242    ///
243    /// # Errors
244    ///
245    /// - `ConfigError::Io` if the file cannot be read.
246    /// - `ConfigError::Parse` if the TOML is malformed.
247    pub fn load(path: &Path) -> Result<Self, ConfigError> {
248        let content = std::fs::read_to_string(path)?;
249        let config: Config = toml::from_str(&content)?;
250        Ok(config)
251    }
252
253    /// Load configuration from the default path (`~/.config/lds/config.toml`).
254    ///
255    /// If the file does not exist this returns `Config::default()` silently.
256    /// Any other I/O error or parse error is also silently swallowed and the
257    /// default is returned — suitable for startup where a missing config is
258    /// expected to be common.
259    ///
260    /// # Returns
261    ///
262    /// A `Config`, falling back to `Default` on any error.
263    pub fn load_or_default() -> Self {
264        let Some(path) = user_config_path() else {
265            return Self::default();
266        };
267        match Self::load(&path) {
268            Ok(cfg) => cfg,
269            Err(ConfigError::Io(e)) if e.kind() == io::ErrorKind::NotFound => Self::default(),
270            Err(e) => {
271                tracing::warn!("failed to load config from {}: {}", path.display(), e);
272                Self::default()
273            }
274        }
275    }
276
277    /// Save the `recipes.dirs` list to `path` using a **patch-safe** write.
278    ///
279    /// The file is parsed by `toml_edit` so that comments and sections not
280    /// managed by this function (e.g. `[paths]`) are preserved verbatim.
281    /// Only the `recipes.dirs` array is replaced.
282    ///
283    /// All paths in `dirs` must already be absolute (tilde-expanded before
284    /// calling this function).  Passing a tilde literal is a logic error and
285    /// will be written literally — callers are responsible for expanding first.
286    ///
287    /// If the parent directory does not exist it is created with
288    /// `fs::create_dir_all`.
289    ///
290    /// # Arguments
291    ///
292    /// * `path` — Destination file (typically `~/.config/lds/config.toml`).
293    /// * `dirs` — Absolute paths to persist in `recipes.dirs`.
294    ///
295    /// # Errors
296    ///
297    /// - `ConfigError::Io` for I/O failures (create dir, read, write).
298    /// - `ConfigError::Edit` if the existing file is not valid TOML.
299    pub fn save(path: &Path, dirs: &[PathBuf]) -> Result<(), ConfigError> {
300        // Ensure parent directory exists.
301        if let Some(parent) = path.parent() {
302            std::fs::create_dir_all(parent)?;
303        }
304
305        // Read existing content (empty string when file is absent).
306        let existing = match std::fs::read_to_string(path) {
307            Ok(s) => s,
308            Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(),
309            Err(e) => return Err(ConfigError::Io(e)),
310        };
311
312        // Parse with toml_edit to preserve comments and unrelated sections.
313        let mut doc: DocumentMut = existing.parse::<DocumentMut>()?;
314
315        // Build a fresh TOML array from `dirs`.
316        let mut arr = Array::new();
317        for dir in dirs {
318            // Safety: PathBuf::to_string_lossy is infallible (may be lossy on
319            // non-UTF-8 systems, but that is acceptable given TOML's UTF-8 requirement).
320            arr.push(dir.to_string_lossy().as_ref());
321        }
322
323        // Write `recipes.dirs` — create intermediate tables as needed.
324        if !doc.contains_table("recipes") {
325            doc["recipes"] = toml_edit::table();
326        }
327        doc["recipes"]["dirs"] = Item::Value(Value::Array(arr));
328
329        std::fs::write(path, doc.to_string())?;
330        Ok(())
331    }
332}
333
334// ---------------------------------------------------------------------------
335// Tests
336// ---------------------------------------------------------------------------
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use std::fs;
342    use tempfile::TempDir;
343
344    // ------------------------------------------------------------------
345    // T1: happy-path / property tests
346    // ------------------------------------------------------------------
347
348    /// T1-a: round-trip — serialize a Config and read it back identically.
349    #[test]
350    fn test_round_trip_load_save() {
351        let dir = TempDir::new().unwrap(); // justification: TempDir::new is infallible in practice; any failure surfaces as a test setup panic which is acceptable in test code
352        let path = dir.path().join("config.toml");
353
354        let other_root = TempDir::new().unwrap();
355        let dirs_in = vec![
356            dir.path().join("shared-recipes"),
357            other_root.path().join("team-recipes"),
358        ];
359
360        Config::save(&path, &dirs_in).expect("save should succeed");
361        let cfg = Config::load(&path).expect("load should succeed");
362
363        assert_eq!(cfg.recipes.dirs, dirs_in);
364    }
365
366    /// T1-b: load_or_default returns Default when no file exists.
367    #[test]
368    fn test_load_or_default_missing_file() {
369        // Temporarily override HOME to a directory with no config.toml.
370        let dir = TempDir::new().unwrap(); // justification: same as above
371        // We cannot easily unset HOME in a portable way, so we test Config::load
372        // directly with a non-existent path to exercise the NotFound branch.
373        let path = dir.path().join("nonexistent/config.toml");
374        match Config::load(&path) {
375            Err(ConfigError::Io(e)) => {
376                assert_eq!(e.kind(), io::ErrorKind::NotFound);
377            }
378            other => panic!("expected Io(NotFound), got {:?}", other),
379        }
380    }
381
382    /// T1-c0: `user_config_path` resolves to `<home>/.config/lds/config.toml`
383    /// when the home directory is available.
384    #[test]
385    fn test_user_config_path_under_home() {
386        let Some(home) = dirs::home_dir() else {
387            return;
388        };
389        let path = user_config_path().expect("home dir is available in this test branch");
390        assert_eq!(path, home.join(".config/lds/config.toml"));
391    }
392
393    /// T1-c: tilde_expand returns an absolute path for a ~/... input.
394    #[test]
395    fn test_tilde_expand_tilde_slash() {
396        // Only run when HOME is available.
397        if dirs::home_dir().is_none() {
398            return;
399        }
400        let result = tilde_expand("~/foo/bar").expect("tilde_expand should succeed");
401        let home = dirs::home_dir().unwrap(); // justification: we just checked it is Some above
402        assert_eq!(result, home.join("foo/bar"));
403    }
404
405    /// T1-d: tilde_expand with bare `~`.
406    #[test]
407    fn test_tilde_expand_bare_tilde() {
408        if dirs::home_dir().is_none() {
409            return;
410        }
411        let result = tilde_expand("~").expect("bare tilde should expand");
412        let home = dirs::home_dir().unwrap(); // justification: checked is Some above
413        assert_eq!(result, home);
414    }
415
416    // ------------------------------------------------------------------
417    // T2: boundary / edge-case tests
418    // ------------------------------------------------------------------
419
420    /// T2-a: empty dirs list produces empty `recipes.dirs` array.
421    #[test]
422    fn test_save_empty_dirs() {
423        let dir = TempDir::new().unwrap(); // justification: test setup
424        let path = dir.path().join("config.toml");
425
426        Config::save(&path, &[]).expect("save should succeed");
427        let cfg = Config::load(&path).expect("load should succeed");
428        assert!(cfg.recipes.dirs.is_empty());
429    }
430
431    /// T2-b: load_or_default on truly missing file via `Config::load` NotFound.
432    #[test]
433    fn test_load_or_default_does_not_panic_on_missing() {
434        // Exercise the public load_or_default by calling it; if HOME is not
435        // set or the file is absent it returns Default without panic.
436        let _cfg = Config::load_or_default();
437        // No assertion needed — absence of panic is the contract.
438    }
439
440    /// T2-c: tilde_expand with no tilde passes through unchanged.
441    #[test]
442    fn test_tilde_expand_no_tilde() {
443        let result = tilde_expand("/absolute/path").expect("should succeed");
444        assert_eq!(result, PathBuf::from("/absolute/path"));
445    }
446
447    /// T2-d: tilde_expand with a relative path (no tilde) passes through.
448    #[test]
449    fn test_tilde_expand_relative() {
450        let result = tilde_expand("relative/path").expect("should succeed");
451        assert_eq!(result, PathBuf::from("relative/path"));
452    }
453
454    /// T2-e: Config::load on an empty file returns all-default values.
455    #[test]
456    fn test_load_empty_file() {
457        let dir = TempDir::new().unwrap(); // justification: test setup
458        let path = dir.path().join("config.toml");
459        fs::write(&path, "").unwrap(); // justification: writing empty file in test, infallible on tempdir
460
461        let cfg = Config::load(&path).expect("empty file should parse as default");
462        assert!(cfg.recipes.dirs.is_empty());
463        assert!(cfg.paths.global_justfile.is_none());
464    }
465
466    /// T2-f: Config::load on a file with only [paths] section (no [recipes]).
467    #[test]
468    fn test_load_partial_file_no_recipes() {
469        let dir = TempDir::new().unwrap(); // justification: test setup
470        let path = dir.path().join("config.toml");
471        fs::write(&path, "[paths]\nglobal_justfile = \"/etc/lds/justfile\"\n").unwrap(); // justification: writing known-good TOML in test
472
473        let cfg = Config::load(&path).expect("partial file should parse");
474        assert!(
475            cfg.recipes.dirs.is_empty(),
476            "missing [recipes] should default to empty"
477        );
478        assert_eq!(
479            cfg.paths.global_justfile,
480            Some(PathBuf::from("/etc/lds/justfile"))
481        );
482    }
483
484    /// T2-g: `Config::load` ignores `[[route]]` / `[[export]]` sections.
485    ///
486    /// `lds-router` parses these same array-of-tables out of the same
487    /// physical `config.toml` (see the module doc comment's "shared file,
488    /// decoupled schemas" note); `Config` has no `route`/`export` field, so
489    /// this exercises serde's "unrecognized top-level keys are ignored"
490    /// default behavior rather than a hard failure — this is the sole
491    /// mechanism that lets the two crates share one file without either
492    /// depending on the other's types.
493    #[test]
494    fn test_load_ignores_route_and_export_sections() {
495        let dir = TempDir::new().unwrap(); // justification: test setup
496        let path = dir.path().join("config.toml");
497        fs::write(
498            &path,
499            r#"
500[recipes]
501dirs = ["/opt/shared-recipes"]
502
503[[route]]
504name = "outline"
505command = "outline-mcp"
506
507[[export]]
508route = "outline"
509tools = ["snapshot_create"]
510"#,
511        )
512        .unwrap(); // justification: writing known-good TOML in test
513
514        let cfg = Config::load(&path).expect("route/export sections must not fail Config parsing");
515        assert_eq!(cfg.recipes.dirs, vec![PathBuf::from("/opt/shared-recipes")]);
516    }
517
518    // ------------------------------------------------------------------
519    // T3: error-path tests
520    // ------------------------------------------------------------------
521
522    /// T3-a: Config::load on a non-existent path returns ConfigError::Io(NotFound).
523    #[test]
524    fn test_load_nonexistent_returns_io_not_found() {
525        let result = Config::load(Path::new("/nonexistent/path/config.toml"));
526        match result {
527            Err(ConfigError::Io(e)) => {
528                assert_eq!(e.kind(), io::ErrorKind::NotFound);
529            }
530            other => panic!("expected Io(NotFound), got {:?}", other),
531        }
532    }
533
534    /// T3-b: Config::load on malformed TOML returns ConfigError::Parse.
535    #[test]
536    fn test_load_malformed_toml_returns_parse_error() {
537        let dir = TempDir::new().unwrap(); // justification: test setup
538        let path = dir.path().join("config.toml");
539        fs::write(&path, "this is not = valid toml [\n").unwrap(); // justification: intentional bad TOML for error path test
540
541        let result = Config::load(&path);
542        assert!(
543            matches!(result, Err(ConfigError::Parse(_))),
544            "malformed TOML should yield Parse error, got {:?}",
545            result
546        );
547    }
548
549    // ------------------------------------------------------------------
550    // Crux 2 preservation test: patch-safe write
551    // ------------------------------------------------------------------
552
553    /// Crux 2: `Config::save` must preserve comments and unrelated sections.
554    ///
555    /// This test writes a config.toml with a comment and `[paths]` section,
556    /// then calls `Config::save` to update `recipes.dirs`, and asserts that
557    /// the comment and `[paths]` section survive unmodified.
558    #[test]
559    fn test_save_preserves_comments_and_other_sections() {
560        let dir = TempDir::new().unwrap(); // justification: test setup
561        let path = dir.path().join("config.toml");
562
563        // Seed file with a comment and [paths] section.
564        let initial = r#"# This is a user comment that must survive.
565[recipes]
566dirs = []
567
568[paths]
569global_justfile = "/etc/lds/justfile"
570"#;
571        fs::write(&path, initial).unwrap(); // justification: seeding known-good TOML in test
572
573        let new_dirs = vec![PathBuf::from("/opt/recipes")];
574        Config::save(&path, &new_dirs).expect("save should succeed");
575
576        let saved = fs::read_to_string(&path).unwrap(); // justification: reading back tempfile in test
577
578        // Comment must be preserved.
579        assert!(
580            saved.contains("# This is a user comment that must survive."),
581            "comment was not preserved:\n{}",
582            saved
583        );
584
585        // [paths] section must be preserved.
586        assert!(
587            saved.contains("[paths]"),
588            "[paths] section was not preserved:\n{}",
589            saved
590        );
591        assert!(
592            saved.contains("global_justfile"),
593            "global_justfile key was not preserved:\n{}",
594            saved
595        );
596
597        // recipes.dirs must be updated.
598        let cfg = Config::load(&path).expect("load after save should succeed");
599        assert_eq!(cfg.recipes.dirs, new_dirs);
600
601        // Crux 2: tilde literal must not appear on disk.
602        assert!(
603            !saved.contains('~'),
604            "tilde literal found on disk — crux 2 violation:\n{}",
605            saved
606        );
607    }
608
609    /// Crux 2 (tilde): paths saved to disk must be absolute (no tilde literal).
610    #[test]
611    fn test_save_does_not_write_tilde_literal() {
612        if dirs::home_dir().is_none() {
613            return;
614        }
615        let dir = TempDir::new().unwrap(); // justification: test setup
616        let path = dir.path().join("config.toml");
617
618        // Expand tilde before saving — as callers are required to do.
619        let raw = "~/my-recipes";
620        let expanded = tilde_expand(raw).expect("tilde_expand should succeed");
621        assert!(
622            !expanded.to_string_lossy().contains('~'),
623            "expanded path must not contain tilde"
624        );
625
626        Config::save(&path, &[expanded]).expect("save should succeed");
627
628        let saved = fs::read_to_string(&path).unwrap(); // justification: reading back tempfile in test
629        assert!(
630            !saved.contains('~'),
631            "tilde literal found on disk after save — crux 2 violation:\n{}",
632            saved
633        );
634    }
635}