Skip to main content

lean_ctx/core/decisions/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Decision layer (D1): versioned project decisions in `.leanctx/decisions/*.toml`.
3//!
4//! A decision record names what was decided, by whom, where it applies (repos,
5//! path globs, symbols) and, optionally, machine-checkable `[[check]]`s. This
6//! module loads and validates the records (strictly: an unknown key, a bad
7//! glob or a bad regex is an error naming the file), answers scope queries and
8//! renders the deterministic context suffix for reads (#498: no timestamps,
9//! no counters, stable header, sorted by id). [`check`] evaluates the checks
10//! against a change; [`crate::core::pre_edit`] runs them before an edit.
11//!
12//! LeanCTX only detects, reports and hints. Refusing an edit is opt-in
13//! (`[decisions] enforce = "refuse"`); enforcing a land belongs to Weave.
14
15pub mod check;
16mod parse;
17#[cfg(test)]
18mod tests;
19
20use std::collections::HashMap;
21use std::path::{Path, PathBuf};
22use std::sync::{Arc, Mutex};
23
24pub use check::{OtherFiles, Violation, evaluate_change, evaluate_change_with};
25pub use parse::{id_from_slug, parse_decision, scaffold};
26
27/// Directory, relative to the project root, that holds one record per file.
28pub const DECISIONS_DIR: &str = ".leanctx/decisions";
29/// Header of the read/compose suffix. Stable: prompt caches key on it.
30pub const SUFFIX_HEADER: &str = "--- Decisions in scope ---";
31/// Records beyond this many are named by count only in a suffix.
32const MAX_SUFFIX_DECISIONS: usize = 8;
33/// A suffix line longer than this is cut (on a char boundary).
34const MAX_SUFFIX_LINE_CHARS: usize = 200;
35/// Files beyond this many are reported as an error, not parsed.
36const MAX_DECISION_FILES: usize = 512;
37/// A record larger than this is an error (records are short by design).
38const MAX_DECISION_BYTES: u64 = 64 * 1024;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
41pub enum Status {
42    Active,
43    Superseded,
44    Revoked,
45}
46
47impl Status {
48    pub fn as_str(self) -> &'static str {
49        match self {
50            Self::Active => "active",
51            Self::Superseded => "superseded",
52            Self::Revoked => "revoked",
53        }
54    }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum CheckKind {
59    ForbidPath,
60    ForbidRegex,
61    RequireRegex,
62}
63
64impl CheckKind {
65    pub fn as_str(self) -> &'static str {
66        match self {
67            Self::ForbidPath => "forbid_path",
68            Self::ForbidRegex => "forbid_regex",
69            Self::RequireRegex => "require_regex",
70        }
71    }
72}
73
74/// A validated `[[check]]`. `paths` empty means "the decision's scope".
75#[derive(Debug, Clone)]
76pub struct Check {
77    pub kind: CheckKind,
78    pub paths: Vec<String>,
79    globs: Vec<glob::Pattern>,
80    pub pattern: Option<String>,
81    regex: Option<regex::Regex>,
82    pub message: Option<String>,
83}
84
85/// A validated decision record.
86#[derive(Debug, Clone)]
87pub struct Decision {
88    pub id: String,
89    pub title: String,
90    pub statement: String,
91    pub rationale: String,
92    pub status: Status,
93    pub supersedes: Vec<String>,
94    pub decided_by: String,
95    pub decided_at: String,
96    pub source_kind: Option<String>,
97    pub source_ref: Option<String>,
98    pub repos: Vec<String>,
99    pub paths: Vec<String>,
100    path_globs: Vec<glob::Pattern>,
101    pub symbols: Vec<String>,
102    pub checks: Vec<Check>,
103    /// Record file, relative to the project root (forward slashes).
104    pub file: String,
105}
106
107/// Every record of one project, sorted by id, plus the files that failed.
108#[derive(Debug, Clone, Default)]
109pub struct DecisionSet {
110    pub decisions: Vec<Decision>,
111    /// `<file>: <reason>`, sorted. A failing file contributes no decision.
112    pub errors: Vec<String>,
113    /// Names this checkout answers to for `scope.repos` (lower case).
114    repo_names: Vec<String>,
115    /// Working tree the records were loaded from (`require_regex` scans it).
116    root: Option<PathBuf>,
117}
118
119fn match_options() -> glob::MatchOptions {
120    glob::MatchOptions {
121        case_sensitive: true,
122        require_literal_separator: true,
123        require_literal_leading_dot: false,
124    }
125}
126
127impl Check {
128    /// Whether this check looks at `rel_path` (`paths`, else the decision's).
129    pub fn covers(&self, decision: &Decision, rel_path: &str) -> bool {
130        if self.globs.is_empty() {
131            decision.covers_path(rel_path)
132        } else {
133            let opts = match_options();
134            self.globs.iter().any(|g| g.matches_with(rel_path, opts))
135        }
136    }
137
138    pub fn regex(&self) -> Option<&regex::Regex> {
139        self.regex.as_ref()
140    }
141}
142
143impl Decision {
144    /// Repo-wide: no path and no symbol scope.
145    pub fn is_repo_wide(&self) -> bool {
146        self.paths.is_empty() && self.symbols.is_empty()
147    }
148
149    /// Whether `rel_path` (root-relative, forward slashes) is in path scope.
150    /// A symbol-only decision covers no path; a repo-wide one covers all.
151    pub fn covers_path(&self, rel_path: &str) -> bool {
152        if self.is_repo_wide() {
153            return true;
154        }
155        let opts = match_options();
156        self.path_globs
157            .iter()
158            .any(|g| g.matches_with(rel_path, opts))
159    }
160
161    /// Whether `symbol` (`name` or `path::name`) is in symbol scope.
162    pub fn covers_symbol(&self, symbol: &str) -> bool {
163        self.symbols.iter().any(|s| symbol_matches(s, symbol))
164    }
165
166    /// The first non-empty line of the statement, cut to a suffix line.
167    pub fn one_line(&self) -> String {
168        let line = self
169            .statement
170            .lines()
171            .map(str::trim)
172            .find(|l| !l.is_empty())
173            .unwrap_or_default();
174        truncate_chars(line, MAX_SUFFIX_LINE_CHARS)
175    }
176}
177
178/// `a` and `b` name the same symbol: equal, or equal last `::` segment when
179/// at least one side is a bare name.
180fn symbol_matches(a: &str, b: &str) -> bool {
181    let (a, b) = (a.trim(), b.trim());
182    if a.is_empty() || b.is_empty() {
183        return false;
184    }
185    if a == b {
186        return true;
187    }
188    let last = |s: &str| s.rsplit("::").next().unwrap_or(s).to_string();
189    (!a.contains("::") || !b.contains("::")) && last(a) == last(b)
190}
191
192fn truncate_chars(s: &str, max: usize) -> String {
193    if s.chars().count() <= max {
194        return s.to_string();
195    }
196    let cut: String = s.chars().take(max.saturating_sub(1)).collect();
197    format!("{cut}…")
198}
199
200impl DecisionSet {
201    /// Decisions in force: status `active` and not superseded by another
202    /// active decision.
203    pub fn active(&self) -> impl Iterator<Item = &Decision> {
204        let superseded: Vec<&str> = self
205            .decisions
206            .iter()
207            .filter(|d| d.status == Status::Active)
208            .flat_map(|d| d.supersedes.iter().map(String::as_str))
209            .collect();
210        self.decisions
211            .iter()
212            .filter(move |d| d.status == Status::Active && !superseded.contains(&d.id.as_str()))
213    }
214
215    /// The effective status of `d` (an active record named in another active
216    /// record's `supersedes` reads as superseded).
217    pub fn effective_status(&self, d: &Decision) -> Status {
218        if d.status == Status::Active && self.active().all(|a| a.id != d.id) {
219            Status::Superseded
220        } else {
221            d.status
222        }
223    }
224
225    pub fn get(&self, id: &str) -> Option<&Decision> {
226        self.decisions.iter().find(|d| d.id == id)
227    }
228
229    /// Whether `d.scope.repos` admits this checkout.
230    pub fn repo_matches(&self, d: &Decision) -> bool {
231        d.repos.is_empty()
232            || d.repos
233                .iter()
234                .any(|r| self.repo_names.iter().any(|n| n == &r.to_lowercase()))
235    }
236
237    /// Active decisions whose scope covers `rel_path`, sorted by id.
238    pub fn for_path(&self, rel_path: &str) -> Vec<&Decision> {
239        self.active()
240            .filter(|d| self.repo_matches(d) && d.covers_path(rel_path))
241            .collect()
242    }
243
244    /// Active decisions whose symbol scope names `symbol`, sorted by id.
245    pub fn for_symbol(&self, symbol: &str) -> Vec<&Decision> {
246        self.active()
247            .filter(|d| self.repo_matches(d) && d.covers_symbol(symbol))
248            .collect()
249    }
250
251    pub fn is_empty(&self) -> bool {
252        self.decisions.is_empty() && self.errors.is_empty()
253    }
254}
255
256// ── Loading ─────────────────────────────────────────────────────────────────
257
258type Signature = Vec<(String, u64, u128)>;
259
260static CACHE: Mutex<Option<HashMap<PathBuf, (Signature, Arc<DecisionSet>)>>> = Mutex::new(None);
261
262fn record_files(dir: &Path) -> Vec<PathBuf> {
263    let Ok(entries) = std::fs::read_dir(dir) else {
264        return Vec::new();
265    };
266    let mut files: Vec<PathBuf> = entries
267        .filter_map(Result::ok)
268        .map(|e| e.path())
269        .filter(|p| p.extension().is_some_and(|e| e == "toml") && p.is_file())
270        .collect();
271    files.sort();
272    files
273}
274
275fn signature(files: &[PathBuf]) -> Signature {
276    files
277        .iter()
278        .map(|p| {
279            let meta = std::fs::metadata(p).ok();
280            let len = meta.as_ref().map_or(0, std::fs::Metadata::len);
281            let mtime = meta
282                .and_then(|m| m.modified().ok())
283                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
284                .map_or(0, |d| d.as_nanos());
285            (p.to_string_lossy().into_owned(), len, mtime)
286        })
287        .collect()
288}
289
290/// Load (cached by file list, sizes and mtimes) the records under `root`.
291/// A project without `.leanctx/decisions` yields an empty set.
292pub fn load(root: &Path) -> Arc<DecisionSet> {
293    let dir = root.join(DECISIONS_DIR);
294    let files = record_files(&dir);
295    if files.is_empty() {
296        return Arc::new(DecisionSet::default());
297    }
298    let sig = signature(&files);
299    {
300        let guard = CACHE
301            .lock()
302            .unwrap_or_else(std::sync::PoisonError::into_inner);
303        if let Some((cached_sig, set)) = guard.as_ref().and_then(|c| c.get(root))
304            && *cached_sig == sig
305        {
306            return Arc::clone(set);
307        }
308    }
309    let set = Arc::new(load_uncached(root, &files));
310    let mut guard = CACHE
311        .lock()
312        .unwrap_or_else(std::sync::PoisonError::into_inner);
313    guard
314        .get_or_insert_with(HashMap::new)
315        .insert(root.to_path_buf(), (sig, Arc::clone(&set)));
316    set
317}
318
319fn rel_display(root: &Path, file: &Path) -> String {
320    file.strip_prefix(root)
321        .unwrap_or(file)
322        .to_string_lossy()
323        .replace('\\', "/")
324}
325
326fn load_uncached(root: &Path, files: &[PathBuf]) -> DecisionSet {
327    let records: Vec<(String, Result<String, String>)> = files
328        .iter()
329        .take(MAX_DECISION_FILES + 1)
330        .map(|file| {
331            let rel = rel_display(root, file);
332            let text = match std::fs::metadata(file) {
333                Ok(m) if m.len() > MAX_DECISION_BYTES => Err(format!(
334                    "{} bytes, more than the limit of {MAX_DECISION_BYTES}",
335                    m.len()
336                )),
337                _ => std::fs::read_to_string(file).map_err(|e| format!("unreadable: {e}")),
338            };
339            (rel, text)
340        })
341        .collect();
342    let mut set = from_records(root, records);
343    set.root = Some(root.to_path_buf());
344    set
345}
346
347/// A set from record texts (`(root-relative file, text or read error)`),
348/// e.g. read from a base commit. `repo_root` answers `scope.repos`. The set
349/// knows no working tree, so `require_regex` needs an explicit
350/// [`check::OtherFiles`] ([`evaluate_change_with`]).
351pub fn from_records(
352    repo_root: &Path,
353    records: Vec<(String, Result<String, String>)>,
354) -> DecisionSet {
355    let mut set = DecisionSet {
356        repo_names: repo_names(repo_root),
357        ..DecisionSet::default()
358    };
359    if records.len() > MAX_DECISION_FILES {
360        set.errors.push(format!(
361            "{DECISIONS_DIR}: more than the limit of {MAX_DECISION_FILES} record files; none loaded"
362        ));
363        return set;
364    }
365    for (rel, text) in records {
366        let stem = Path::new(&rel)
367            .file_stem()
368            .map(|s| s.to_string_lossy().into_owned())
369            .unwrap_or_default();
370        let text = match text {
371            Ok(t) if t.len() as u64 > MAX_DECISION_BYTES => {
372                set.errors.push(format!(
373                    "{rel}: {} bytes, more than the limit of {MAX_DECISION_BYTES}",
374                    t.len()
375                ));
376                continue;
377            }
378            Ok(t) => t,
379            Err(e) => {
380                set.errors.push(format!("{rel}: {e}"));
381                continue;
382            }
383        };
384        match parse_decision(&text, &stem, &rel) {
385            Ok(d) if set.decisions.iter().any(|o| o.id == d.id) => {
386                set.errors.push(format!("{rel}: duplicate id `{}`", d.id));
387            }
388            Ok(d) => set.decisions.push(d),
389            Err(e) => set.errors.push(format!("{rel}: {e}")),
390        }
391    }
392    set.decisions.sort_by(|a, b| a.id.cmp(&b.id));
393    set.errors.sort();
394    set
395}
396
397/// Names this checkout answers to: the root directory name and the `origin`
398/// remote's repository name (read from git config; no subprocess).
399fn repo_names(root: &Path) -> Vec<String> {
400    let mut names = Vec::new();
401    if let Some(name) = root.file_name() {
402        names.push(name.to_string_lossy().to_lowercase());
403    }
404    if let Some(url) = origin_url(root) {
405        let base = url
406            .trim_end_matches('/')
407            .rsplit(['/', ':'])
408            .next()
409            .unwrap_or_default()
410            .trim_end_matches(".git")
411            .to_lowercase();
412        if !base.is_empty() && !names.contains(&base) {
413            names.push(base);
414        }
415    }
416    names
417}
418
419fn git_common_dir(root: &Path) -> Option<PathBuf> {
420    let dot_git = root.join(".git");
421    if dot_git.is_dir() {
422        return Some(dot_git);
423    }
424    let text = std::fs::read_to_string(&dot_git).ok()?;
425    let gitdir = PathBuf::from(text.trim().strip_prefix("gitdir:")?.trim());
426    let gitdir = if gitdir.is_absolute() {
427        gitdir
428    } else {
429        root.join(gitdir)
430    };
431    match std::fs::read_to_string(gitdir.join("commondir")) {
432        Ok(common) => {
433            let common = PathBuf::from(common.trim());
434            Some(if common.is_absolute() {
435                common
436            } else {
437                gitdir.join(common)
438            })
439        }
440        Err(_) => Some(gitdir),
441    }
442}
443
444fn origin_url(root: &Path) -> Option<String> {
445    let config = std::fs::read_to_string(git_common_dir(root)?.join("config")).ok()?;
446    let mut in_origin = false;
447    for line in config.lines() {
448        let line = line.trim();
449        if line.starts_with('[') {
450            in_origin = line == "[remote \"origin\"]";
451        } else if in_origin
452            && let Some((key, value)) = line.split_once('=')
453            && key.trim() == "url"
454        {
455            return Some(value.trim().to_string());
456        }
457    }
458    None
459}
460
461/// The nearest ancestor of `path` (itself included when a directory) that
462/// holds a `.leanctx/decisions` directory, stopping at the first git root.
463pub fn find_root(path: &Path) -> Option<PathBuf> {
464    let start = if path.is_dir() { path } else { path.parent()? };
465    for dir in start.ancestors() {
466        if dir.join(DECISIONS_DIR).is_dir() {
467            return Some(dir.to_path_buf());
468        }
469        if dir.join(".git").exists() {
470            return None;
471        }
472    }
473    None
474}
475
476/// `path` relative to `root` with forward slashes; `None` outside `root`.
477pub fn relative_path(root: &Path, path: &Path) -> Option<String> {
478    let abs = if path.is_absolute() {
479        path.to_path_buf()
480    } else {
481        root.join(path)
482    };
483    let rel: PathBuf = if let Ok(rel) = abs.strip_prefix(root) {
484        rel.to_path_buf()
485    } else {
486        // Tolerate a symlinked root (e.g. /tmp vs /private/tmp on macOS).
487        // A file that does not exist yet canonicalizes through its parent.
488        let canon_root = root.canonicalize().ok()?;
489        let canon = abs.canonicalize().ok().or_else(|| {
490            let parent = abs.parent()?.canonicalize().ok()?;
491            Some(parent.join(abs.file_name()?))
492        })?;
493        canon.strip_prefix(&canon_root).ok()?.to_path_buf()
494    };
495    let s = rel.to_string_lossy().replace('\\', "/");
496    (!s.is_empty()).then_some(s)
497}
498
499// ── Rendering ───────────────────────────────────────────────────────────────
500
501/// One line per decision: `id: title — statement`.
502pub fn render_lines(decisions: &[&Decision]) -> String {
503    let mut out = String::new();
504    for d in decisions.iter().take(MAX_SUFFIX_DECISIONS) {
505        let line = format!("{}: {} — {}", d.id, d.title.trim(), d.one_line());
506        out.push_str(&truncate_chars(&line, MAX_SUFFIX_LINE_CHARS * 2));
507        out.push('\n');
508    }
509    if decisions.len() > MAX_SUFFIX_DECISIONS {
510        out.push_str(&format!(
511            "(+{} more: lean-ctx decisions list)\n",
512            decisions.len() - MAX_SUFFIX_DECISIONS
513        ));
514    }
515    out
516}
517
518/// Deterministic suffix for a read of `rel_paths` (deduplicated, sorted):
519/// empty when no active decision covers any of them.
520pub fn suffix_for_paths(set: &DecisionSet, rel_paths: &[&str]) -> String {
521    let mut hits: Vec<&Decision> = Vec::new();
522    for p in rel_paths {
523        for d in set.for_path(p) {
524            if !hits.iter().any(|h| h.id == d.id) {
525                hits.push(d);
526            }
527        }
528    }
529    if hits.is_empty() {
530        return String::new();
531    }
532    hits.sort_by(|a, b| a.id.cmp(&b.id));
533    format!("\n\n{SUFFIX_HEADER}\n{}", render_lines(&hits))
534        .trim_end()
535        .to_string()
536}
537
538/// The suffix for a read of `file` (absolute or root-relative). Empty when the
539/// file is outside every project with decisions.
540pub fn suffix_for_file(file: &Path) -> String {
541    let Some(root) = find_root(file) else {
542        return String::new();
543    };
544    let Some(rel) = relative_path(&root, file) else {
545        return String::new();
546    };
547    suffix_for_paths(&load(&root), &[rel.as_str()])
548}
549
550/// The read suffix for a tool reply (`ctx_read`, `ctx_compose`): empty while
551/// a gateway policy is active, since decision records are repository text
552/// that the policy did not admit as a source of this reply.
553pub fn suffix_for_read(file: &str) -> String {
554    if crate::core::policy::runtime::is_active() {
555        return String::new();
556    }
557    suffix_for_file(Path::new(file))
558}
559
560/// The suffix for paths a compose answer named, relative to `root`.
561pub fn suffix_for_root_paths(root: &Path, rel_paths: &[&str]) -> String {
562    if !root.join(DECISIONS_DIR).is_dir() || rel_paths.is_empty() {
563        return String::new();
564    }
565    suffix_for_paths(&load(root), rel_paths)
566}
567
568/// Most distinct files a compose answer contributes to its suffix.
569const MAX_COMPOSE_PATHS: usize = 64;
570
571/// The suffix for a `ctx_compose` answer: decisions covering the files it
572/// names (`path:line …` and `File: path` lines that exist under `root`).
573pub fn suffix_for_compose(root: &Path, text: &str) -> String {
574    if crate::core::policy::runtime::is_active() || !root.join(DECISIONS_DIR).is_dir() {
575        return String::new();
576    }
577    let mut paths: Vec<&str> = Vec::new();
578    for line in text.lines() {
579        let line = line.trim_start();
580        let candidate = match line.strip_prefix("File: ") {
581            Some(rest) => rest.split([' ', '(']).next().unwrap_or_default(),
582            None => match line.split_once(':') {
583                Some((p, rest)) if rest.starts_with(|c: char| c.is_ascii_digit()) => p,
584                _ => continue,
585            },
586        };
587        if candidate.is_empty()
588            || candidate.contains(char::is_whitespace)
589            || paths.contains(&candidate)
590            || !root.join(candidate).is_file()
591        {
592            continue;
593        }
594        paths.push(candidate);
595        if paths.len() >= MAX_COMPOSE_PATHS {
596            break;
597        }
598    }
599    suffix_for_root_paths(root, &paths)
600}
601
602/// Full, multi-line description of one decision (CLI `show`, `ctx_knowledge`).
603pub fn describe(set: &DecisionSet, d: &Decision) -> String {
604    let mut out = format!(
605        "{} [{}] {}\n  statement: {}\n",
606        d.id,
607        set.effective_status(d).as_str(),
608        d.title.trim(),
609        d.statement.trim()
610    );
611    if !d.rationale.trim().is_empty() {
612        out.push_str(&format!("  rationale: {}\n", d.rationale.trim()));
613    }
614    out.push_str(&format!(
615        "  decided: {} by {}\n",
616        d.decided_at, d.decided_by
617    ));
618    if let (Some(kind), Some(r)) = (&d.source_kind, &d.source_ref) {
619        out.push_str(&format!("  source: {kind} {r}\n"));
620    }
621    if !d.supersedes.is_empty() {
622        out.push_str(&format!("  supersedes: {}\n", d.supersedes.join(", ")));
623    }
624    let scope = |v: &[String]| {
625        if v.is_empty() {
626            "*".to_string()
627        } else {
628            v.join(", ")
629        }
630    };
631    out.push_str(&format!(
632        "  scope: repos={} paths={} symbols={}\n",
633        scope(&d.repos),
634        scope(&d.paths),
635        if d.symbols.is_empty() {
636            "-".to_string()
637        } else {
638            d.symbols.join(", ")
639        }
640    ));
641    for (i, c) in d.checks.iter().enumerate() {
642        out.push_str(&format!(
643            "  check[{i}]: {} paths={}{}\n",
644            c.kind.as_str(),
645            scope(&c.paths),
646            c.pattern
647                .as_deref()
648                .map(|p| format!(" pattern={p}"))
649                .unwrap_or_default()
650        ));
651    }
652    out.push_str(&format!("  file: {}\n", d.file));
653    out
654}
655
656/// The decision query of `ctx_knowledge action=decisions` and `lean-ctx
657/// decisions list`: by `path` (decisions whose scope covers it), else by
658/// `symbol`, else all records; invalid records are listed after them.
659pub fn query_text(project_root: &Path, path: Option<&str>, symbol: Option<&str>) -> String {
660    let root = find_root(project_root).unwrap_or_else(|| project_root.to_path_buf());
661    let set = load(&root);
662    if set.is_empty() {
663        return format!(
664            "No decisions in this project ({DECISIONS_DIR}/*.toml). Create one: lean-ctx decisions new <slug>"
665        );
666    }
667    let (label, hits): (String, Vec<&Decision>) = match (path, symbol) {
668        (Some(p), _) => {
669            let rel = relative_path(&root, Path::new(p))
670                .unwrap_or_else(|| p.trim_start_matches("./").to_string());
671            let hits = set.for_path(&rel);
672            (format!("in scope of {rel}"), hits)
673        }
674        (None, Some(s)) => (format!("naming symbol {s}"), set.for_symbol(s)),
675        (None, None) => ("all".to_string(), set.decisions.iter().collect()),
676    };
677    let mut out = format!("Decisions {label}: {}\n", hits.len());
678    for d in hits {
679        out.push_str(&describe(&set, d));
680    }
681    if !set.errors.is_empty() {
682        out.push_str("Invalid records (not applied):\n");
683        for e in &set.errors {
684            out.push_str(&format!("  {e}\n"));
685        }
686    }
687    out.trim_end().to_string()
688}