trusty-common 0.52.0

Shared utilities and provider-agnostic streaming chat (ChatProvider, OllamaProvider, OpenRouter, tool-use) for trusty-* projects
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
//! The `~/.trusty-tools/<crate>/config.yaml` cross-crate config convention (#1220).
//!
//! Why: every trusty-* crate had been inventing its own config location and
//! format (`~/.trusty-mpm/config.toml`, trusty-memory's bespoke
//! `.trusty-tools/trusty-memory.yaml` pin file, env-var-only settings, …). #1220
//! standardises ONE convention so an operator always knows where a crate's
//! configuration lives: `~/.trusty-tools/<crate>/config.yaml`. Centralising the
//! path resolution and the typed YAML load/save here means each crate adopts the
//! convention by calling two functions rather than re-deriving the path and the
//! graceful-fallback semantics every time.
//!
//! What: [`crate_config_dir`] / [`crate_config_path`] resolve the canonical
//! directory and file for a crate; [`load`] / [`load_or_default`] deserialise the
//! YAML into a typed value (absent file → `None`/defaults; malformed → typed
//! error / logged-defaults); [`save`] serialises a typed value back atomically.
//! All path-taking variants (`*_at`) take an explicit base so they are hermetically
//! testable against a temp dir; the home-resolving wrappers delegate to them.
//!
//! Format note: YAML is the #1220-mandated format (RFC #1225 Q4). It is parsed by
//! `serde_yaml` 0.9 (pure-Rust `libyaml-safer`), pinned in the workspace manifest;
//! the migration to a maintained successor is tracked in #1250 (out of #1220 scope).
//!
//! Test: `crate_config_path_layout`, `load_absent_is_none`, `save_then_load_round_trips`,
//! `load_or_default_on_missing`, `load_malformed_is_err` in the `tests` module.
//!
//! [`crate_config_dir`]: crate::crate_config::crate_config_dir
//! [`crate_config_path`]: crate::crate_config::crate_config_path
//! [`load`]: crate::crate_config::load
//! [`load_or_default`]: crate::crate_config::load_or_default
//! [`save`]: crate::crate_config::save

use std::path::{Path, PathBuf};

use serde::Serialize;
use serde::de::DeserializeOwned;

/// Top-level directory (under `$HOME`) holding every crate's config tree.
///
/// Why: a single named constant keeps every crate agreeing on the root so the
/// convention cannot drift to `.trusty_tools` / `.trustytools` variants.
/// What: `".trusty-tools"`.
/// Test: `crate_config_path_layout`.
pub const TRUSTY_TOOLS_DIR: &str = ".trusty-tools";

/// Canonical config filename within a crate's directory.
///
/// Why: #1220 fixes the file as `config.yaml` (not `config.yml` / `settings.yaml`)
/// so tooling and docs can reference one exact path.
/// What: `"config.yaml"`.
/// Test: `crate_config_path_layout`.
pub const CONFIG_FILE: &str = "config.yaml";

/// Errors raised while loading or saving a crate config.
///
/// Why: callers need to distinguish "the file is missing" (expected on a fresh
/// install — handled by `Option`/defaults, never an error) from a genuine I/O or
/// parse failure they must surface. A typed enum lets binaries map each to the
/// right log level or exit behaviour.
/// What: an I/O variant (carrying the offending path) and a parse/serialise
/// variant (carrying the path + the `serde_yaml` message).
/// Test: `load_malformed_is_err` exercises the `Yaml` variant.
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
    /// Reading or writing the config file failed (other than a benign not-found).
    #[error("config I/O error at {path}: {source}")]
    Io {
        /// The path the failed operation targeted.
        path: PathBuf,
        /// The underlying I/O error.
        source: std::io::Error,
    },

    /// The YAML could not be parsed (load) or produced (save).
    #[error("config YAML error at {path}: {message}")]
    Yaml {
        /// The path being parsed/serialised when the error occurred.
        path: PathBuf,
        /// The `serde_yaml` error message.
        message: String,
    },
}

/// Resolve the config directory for `crate_name` under an explicit base.
///
/// Why: the hermetic core for [`crate_config_dir`]; tests point `base` at a temp
/// dir so they never touch the real `~/.trusty-tools`.
/// What: `<base>/.trusty-tools/<crate_name>`.
/// Test: `crate_config_path_layout`.
pub fn crate_config_dir_at(base: &Path, crate_name: &str) -> PathBuf {
    base.join(TRUSTY_TOOLS_DIR).join(crate_name)
}

/// Resolve the config file for `crate_name` under an explicit base.
///
/// Why: the hermetic core for [`crate_config_path`].
/// What: `<base>/.trusty-tools/<crate_name>/config.yaml`.
/// Test: `crate_config_path_layout`.
pub fn crate_config_path_at(base: &Path, crate_name: &str) -> PathBuf {
    crate_config_dir_at(base, crate_name).join(CONFIG_FILE)
}

/// Resolve the canonical config directory for `crate_name`.
///
/// Why: production callers want `~/.trusty-tools/<crate>` without resolving the
/// home directory themselves.
/// What: `~/.trusty-tools/<crate_name>`. Returns `None` only when the home
/// directory cannot be determined (a stripped CI environment).
/// Test: covered indirectly via `crate_config_dir_at`.
pub fn crate_config_dir(crate_name: &str) -> Option<PathBuf> {
    dirs::home_dir().map(|home| crate_config_dir_at(&home, crate_name))
}

/// Resolve the canonical config file for `crate_name`.
///
/// Why: the single path every crate reads/writes; surfacing it lets binaries log
/// "edit this file" hints.
/// What: `~/.trusty-tools/<crate_name>/config.yaml`. `None` when home is unknown.
/// Test: covered indirectly via `crate_config_path_at`.
pub fn crate_config_path(crate_name: &str) -> Option<PathBuf> {
    dirs::home_dir().map(|home| crate_config_path_at(&home, crate_name))
}

/// Load and deserialise the config at an explicit path.
///
/// Why: the hermetic core for [`load`]; keeps the absent-vs-malformed distinction
/// in one tested place.
/// What: returns `Ok(None)` when the file does not exist (the expected fresh
/// state), `Ok(Some(value))` on a successful parse, `Err(ConfigError::Io)` on a
/// non-not-found I/O error, and `Err(ConfigError::Yaml)` on a parse failure.
/// Test: `load_absent_is_none`, `save_then_load_round_trips`, `load_malformed_is_err`.
pub fn load_at<T: DeserializeOwned>(path: &Path) -> Result<Option<T>, ConfigError> {
    let raw = match std::fs::read_to_string(path) {
        Ok(raw) => raw,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(e) => {
            return Err(ConfigError::Io {
                path: path.to_path_buf(),
                source: e,
            });
        }
    };
    let value = serde_yaml::from_str::<T>(&raw).map_err(|e| ConfigError::Yaml {
        path: path.to_path_buf(),
        message: e.to_string(),
    })?;
    Ok(Some(value))
}

/// Load and deserialise the canonical config for `crate_name`.
///
/// Why: the convention's primary read entry point — a crate calls this once at
/// startup to pick up `~/.trusty-tools/<crate>/config.yaml`.
/// What: resolves the path via [`crate_config_path`] then delegates to
/// [`load_at`]. Returns `Ok(None)` when home is unknown OR the file is absent
/// (both mean "no config", which the caller treats as defaults).
/// Test: covered via `load_at` tests + `save_then_load_round_trips`.
pub fn load<T: DeserializeOwned>(crate_name: &str) -> Result<Option<T>, ConfigError> {
    match crate_config_path(crate_name) {
        Some(path) => load_at(path.as_path()),
        None => Ok(None),
    }
}

/// Load the canonical config for `crate_name`, falling back to `Default`.
///
/// Why: most binaries want a value they can use unconditionally and should never
/// abort startup over a bad config. This collapses absent/home-unknown to the
/// type's `Default` and logs (at `warn`) a malformed file rather than erroring.
/// What: returns the parsed value when present and valid; otherwise
/// `T::default()`. A malformed file is logged to stderr (never stdout — MCP
/// framing) and downgraded to defaults so the process still starts.
/// Test: `load_or_default_on_missing` (absent → default).
pub fn load_or_default<T: DeserializeOwned + Default>(crate_name: &str) -> T {
    match load::<T>(crate_name) {
        Ok(Some(value)) => value,
        Ok(None) => T::default(),
        Err(e) => {
            tracing::warn!("{e}; falling back to default {crate_name} config");
            T::default()
        }
    }
}

/// Serialise and write `value` to an explicit config path (atomic).
///
/// Why: the hermetic core for [`save`]; the console's config-write path and any
/// CLI `config set` command both need the same atomic, parent-creating write so a
/// concurrent reader never observes a torn file.
/// What: creates the parent directory, serialises `value` to YAML (prefixed with a
/// provenance header comment), writes a sibling `config.yaml.tmp`, then renames it
/// over the target. Returns the path written.
/// Test: `save_then_load_round_trips`.
pub fn save_at<T: Serialize>(path: &Path, value: &T) -> Result<PathBuf, ConfigError> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).map_err(|e| ConfigError::Io {
            path: parent.to_path_buf(),
            source: e,
        })?;
    }
    let yaml = serde_yaml::to_string(value).map_err(|e| ConfigError::Yaml {
        path: path.to_path_buf(),
        message: e.to_string(),
    })?;
    let header = "# .trusty-tools/<crate>/config.yaml\n\
                  # Managed by the trusty-tools config convention (#1220).\n\
                  # Edit by hand or via the trusty-console Config tab.\n\n";
    let content = format!("{header}{yaml}");
    save_raw_at(path, &content)
}

/// Write `contents` verbatim to a config path, atomically.
///
/// Why: the atomic half of [`save_at`], reachable by a caller that already holds
/// the exact bytes to land — `tm issue seed-config` appends a template textually
/// so it can preserve an operator's comments, which a serialise-and-write cannot
/// (#7067). Without this seam that caller would reimplement the temp-and-rename
/// dance, giving the workspace a second atomic-config-write implementation.
/// What: creates the parent directory, writes a sibling `<stem>.yaml.tmp`, then
/// renames it over the target. A reader never observes a torn file, and a failed
/// write leaves the target byte-identical because the target is never opened for
/// writing. Returns the path written.
/// Test: `save_then_load_round_trips`, `save_raw_at_leaves_no_tmp_sibling`,
/// `a_failed_raw_write_leaves_the_target_byte_identical`.
pub fn save_raw_at(path: &Path, contents: &str) -> Result<PathBuf, ConfigError> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).map_err(|e| ConfigError::Io {
            path: parent.to_path_buf(),
            source: e,
        })?;
    }
    let tmp = path.with_extension("yaml.tmp");
    std::fs::write(&tmp, contents).map_err(|e| ConfigError::Io {
        path: tmp.clone(),
        source: e,
    })?;
    std::fs::rename(&tmp, path).map_err(|e| ConfigError::Io {
        path: path.to_path_buf(),
        source: e,
    })?;
    Ok(path.to_path_buf())
}

/// Serialise and write `value` to the canonical config for `crate_name`.
///
/// Why: the convention's primary write entry point (console toggle, `config set`).
/// What: resolves the path via [`crate_config_path`] then delegates to
/// [`save_at`]. Errors with [`ConfigError::Io`] (synthesising a path-less error)
/// when the home directory cannot be resolved.
/// Test: covered via `save_at` + `save_then_load_round_trips`.
pub fn save<T: Serialize>(crate_name: &str, value: &T) -> Result<PathBuf, ConfigError> {
    match crate_config_path(crate_name) {
        Some(path) => save_at(path.as_path(), value),
        None => Err(ConfigError::Io {
            path: PathBuf::from(format!("~/{TRUSTY_TOOLS_DIR}/{crate_name}/{CONFIG_FILE}")),
            source: std::io::Error::new(std::io::ErrorKind::NotFound, "home directory unavailable"),
        }),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde::Deserialize;

    #[derive(Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
    struct Sample {
        #[serde(default)]
        name: String,
        #[serde(default)]
        count: u32,
    }

    /// Why: the convention fixes the on-disk layout; this pins
    /// `<base>/.trusty-tools/<crate>/config.yaml` exactly.
    /// Test: itself.
    #[test]
    fn crate_config_path_layout() {
        let p = crate_config_path_at(Path::new("/home/bob"), "trusty-mpm");
        assert_eq!(
            p,
            PathBuf::from("/home/bob/.trusty-tools/trusty-mpm/config.yaml")
        );
        let d = crate_config_dir_at(Path::new("/home/bob"), "trusty-mpm");
        assert_eq!(d, PathBuf::from("/home/bob/.trusty-tools/trusty-mpm"));
    }

    /// Why: an absent file is the expected fresh-install state and must be
    /// `Ok(None)`, never an error.
    /// Test: itself.
    #[test]
    fn load_absent_is_none() {
        let tmp = tempfile::TempDir::new().unwrap();
        let path = crate_config_path_at(tmp.path(), "trusty-mpm");
        let got: Option<Sample> = load_at(&path).unwrap();
        assert_eq!(got, None);
    }

    /// Why: the read/write round-trip is the core of the convention; a value
    /// written via `save_at` must load back identically.
    /// Test: itself.
    #[test]
    fn save_then_load_round_trips() {
        let tmp = tempfile::TempDir::new().unwrap();
        let path = crate_config_path_at(tmp.path(), "trusty-mpm");
        let value = Sample {
            name: "demo".into(),
            count: 7,
        };
        let written = save_at(&path, &value).unwrap();
        assert_eq!(written, path);
        let got: Sample = load_at(&path).unwrap().expect("present");
        assert_eq!(got, value);
        // The provenance header must be present so a human editing the file sees
        // where it came from.
        let raw = std::fs::read_to_string(&path).unwrap();
        assert!(raw.contains("trusty-tools config convention"));
    }

    /// Why: the temp-and-rename write must leave nothing behind — a surviving
    /// `config.yaml.tmp` would be a half-written config sitting next to the real
    /// one, and the next reader that globs the directory would find two.
    /// Test: itself.
    #[test]
    fn save_raw_at_leaves_no_tmp_sibling() {
        let tmp = tempfile::TempDir::new().unwrap();
        let path = crate_config_path_at(tmp.path(), "trusty-mpm");

        let written = save_raw_at(&path, "name: demo\n").unwrap();

        assert_eq!(written, path);
        assert_eq!(std::fs::read_to_string(&path).unwrap(), "name: demo\n");
        let siblings: Vec<PathBuf> = std::fs::read_dir(path.parent().unwrap())
            .unwrap()
            .map(|e| e.unwrap().path())
            .collect();
        assert_eq!(siblings, vec![path], "a .tmp sibling survived the write");
    }

    /// Why: the reason [`save_raw_at`] exists. A plain `std::fs::write` truncates
    /// the target and then writes into it, so a write that cannot complete costs
    /// the operator their config. Writing a sibling first means the target is
    /// never opened for writing at all, so a failure leaves it byte-identical.
    /// What: a read-only parent directory blocks creating the `.tmp` sibling
    /// while leaving the existing file readable and (to `write(2)`) writable —
    /// exactly the case that distinguishes the two writes. Skipped when the
    /// process can create files in a read-only directory anyway (running as
    /// root), since the failure being asserted cannot be provoked there.
    /// Test: itself.
    #[cfg(unix)]
    #[test]
    fn a_failed_raw_write_leaves_the_target_byte_identical() {
        use std::os::unix::fs::PermissionsExt;

        let tmp = tempfile::TempDir::new().unwrap();
        let path = crate_config_path_at(tmp.path(), "trusty-mpm");
        let dir = path.parent().unwrap().to_path_buf();
        std::fs::create_dir_all(&dir).unwrap();
        let original = "# hand-written\nname: operator\n";
        std::fs::write(&path, original).unwrap();

        let restore = std::fs::metadata(&dir).unwrap().permissions();
        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).unwrap();
        let root_can_still_write = std::fs::write(dir.join("probe"), b"x").is_ok();

        let result = if root_can_still_write {
            std::fs::remove_file(dir.join("probe")).ok();
            None
        } else {
            Some(save_raw_at(&path, "name: clobbered\n"))
        };

        std::fs::set_permissions(&dir, restore).unwrap();
        let Some(result) = result else {
            return; // running as root: the write cannot be made to fail here.
        };
        assert!(result.is_err(), "the write was expected to fail");
        assert_eq!(
            std::fs::read_to_string(&path).unwrap(),
            original,
            "a failed write modified the target"
        );
    }

    /// Why: `load_or_default` must collapse an absent file to `T::default()` so
    /// callers can use it unconditionally.
    /// Test: itself (drives `load_or_default` via an explicit-path round-trip is
    /// not possible — it resolves home — so we assert the absent-path core here).
    #[test]
    fn load_or_default_on_missing() {
        let tmp = tempfile::TempDir::new().unwrap();
        let path = crate_config_path_at(tmp.path(), "absent-crate");
        // The path-taking core returns None for an absent file…
        assert_eq!(load_at::<Sample>(&path).unwrap(), None);
        // …which `load_or_default` would turn into the type default.
        assert_eq!(
            Sample::default(),
            Sample {
                name: String::new(),
                count: 0
            }
        );
    }

    /// Why: a malformed YAML file must surface as a typed `Yaml` error from the
    /// strict loader (binaries decide whether to downgrade to defaults).
    /// Test: itself.
    #[test]
    fn load_malformed_is_err() {
        let tmp = tempfile::TempDir::new().unwrap();
        let path = crate_config_path_at(tmp.path(), "trusty-mpm");
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        // `count` expects a u32; a string makes deserialisation fail.
        std::fs::write(&path, "name: demo\ncount: not-a-number\n").unwrap();
        let err = load_at::<Sample>(&path).unwrap_err();
        assert!(matches!(err, ConfigError::Yaml { .. }), "got {err:?}");
    }
}