dsh-ops 0.0.0-stage → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +202 -0
- package/LICENSE +30 -0
- package/NOTICE +106 -0
- package/PROVENANCE.md +435 -0
- package/README.en.md +126 -0
- package/README.md +115 -2
- package/README.zh.md +116 -0
- package/bin/dsh-ops.mjs +1216 -0
- package/cordis.patch.yml +160 -0
- package/docs/manual-validation.md +53 -0
- package/docs/release-0.2.1.md +72 -0
- package/docs/schema-baseline.json +64 -0
- package/docs/schema-current.json +84 -0
- package/docs/schema-measurement.md +17 -0
- package/dsh-plugin.json +88 -0
- package/icon.svg +12 -0
- package/lib/binary.js +409 -0
- package/lib/config.js +198 -0
- package/lib/handshake.js +252 -0
- package/lib/index.js +108 -0
- package/lib/jobs.js +42 -0
- package/lib/policy.js +64 -0
- package/lib/presentation.js +63 -0
- package/lib/profile-install.js +61 -0
- package/lib/rust.js +194 -0
- package/lib/session-shells.js +78 -0
- package/lib/shells.js +998 -0
- package/lib/tools.js +657 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +114 -4
- package/vendor/fastctx/Cargo.lock +3210 -0
- package/vendor/fastctx/Cargo.toml +94 -0
- package/vendor/fastctx/FORK.md +119 -0
- package/vendor/fastctx/LICENSE-APACHE +201 -0
- package/vendor/fastctx/NOTICE +40 -0
- package/vendor/fastctx/README.md +439 -0
- package/vendor/fastctx/THIRD_PARTY_LICENSES.md +17 -0
- package/vendor/fastctx/THIRD_PARTY_LICENSES_RUST.md +7914 -0
- package/vendor/fastctx/UPSTREAM.md +49 -0
- package/vendor/fastctx/build.rs +413 -0
- package/vendor/fastctx/src/background_status.rs +403 -0
- package/vendor/fastctx/src/binary.rs +75 -0
- package/vendor/fastctx/src/bounded_sort.rs +500 -0
- package/vendor/fastctx/src/budget.rs +781 -0
- package/vendor/fastctx/src/cli/mod.rs +110 -0
- package/vendor/fastctx/src/context_guard.rs +289 -0
- package/vendor/fastctx/src/control/mod.rs +6 -0
- package/vendor/fastctx/src/control/paths.rs +49 -0
- package/vendor/fastctx/src/control/settings.rs +753 -0
- package/vendor/fastctx/src/control/transaction.rs +531 -0
- package/vendor/fastctx/src/edit/document.rs +535 -0
- package/vendor/fastctx/src/edit/locks.rs +371 -0
- package/vendor/fastctx/src/edit/mod.rs +213 -0
- package/vendor/fastctx/src/edit/private_storage/unix.rs +315 -0
- package/vendor/fastctx/src/edit/private_storage/windows.rs +793 -0
- package/vendor/fastctx/src/edit/private_storage.rs +234 -0
- package/vendor/fastctx/src/edit/replace.rs +1030 -0
- package/vendor/fastctx/src/edit_server.rs +53 -0
- package/vendor/fastctx/src/encoding/reference_v011.rs +587 -0
- package/vendor/fastctx/src/encoding/snapshot_pipeline.rs +1678 -0
- package/vendor/fastctx/src/encoding.rs +1118 -0
- package/vendor/fastctx/src/file_executor.rs +1151 -0
- package/vendor/fastctx/src/file_snapshot.rs +1491 -0
- package/vendor/fastctx/src/glob_filter.rs +98 -0
- package/vendor/fastctx/src/glob_tool.rs +653 -0
- package/vendor/fastctx/src/grep_sink.rs +1162 -0
- package/vendor/fastctx/src/grep_tool.rs +2449 -0
- package/vendor/fastctx/src/lib.rs +45 -0
- package/vendor/fastctx/src/main.rs +15 -0
- package/vendor/fastctx/src/model.rs +51 -0
- package/vendor/fastctx/src/model_guidance.rs +62 -0
- package/vendor/fastctx/src/operation.rs +356 -0
- package/vendor/fastctx/src/ordered_window.rs +1235 -0
- package/vendor/fastctx/src/os_environment.rs +414 -0
- package/vendor/fastctx/src/path_codec.rs +850 -0
- package/vendor/fastctx/src/paths.rs +244 -0
- package/vendor/fastctx/src/process_identity.rs +763 -0
- package/vendor/fastctx/src/process_policy.rs +74 -0
- package/vendor/fastctx/src/read_tool/batch.rs +496 -0
- package/vendor/fastctx/src/read_tool/hex_file.rs +141 -0
- package/vendor/fastctx/src/read_tool/image_file.rs +88 -0
- package/vendor/fastctx/src/read_tool/mod.rs +245 -0
- package/vendor/fastctx/src/read_tool/pdf.rs +470 -0
- package/vendor/fastctx/src/read_tool/pdf_disabled.rs +47 -0
- package/vendor/fastctx/src/read_tool/pdf_engine.rs +664 -0
- package/vendor/fastctx/src/read_tool/text_file.rs +351 -0
- package/vendor/fastctx/src/render_plan.rs +468 -0
- package/vendor/fastctx/src/runtime/activity.rs +159 -0
- package/vendor/fastctx/src/runtime/hosts.rs +99 -0
- package/vendor/fastctx/src/runtime/journal.rs +556 -0
- package/vendor/fastctx/src/runtime/local_ipc.rs +186 -0
- package/vendor/fastctx/src/runtime/mod.rs +746 -0
- package/vendor/fastctx/src/runtime/protocol.rs +296 -0
- package/vendor/fastctx/src/runtime/session.rs +536 -0
- package/vendor/fastctx/src/runtime/windows_process.rs +66 -0
- package/vendor/fastctx/src/search_parallelism.rs +106 -0
- package/vendor/fastctx/src/search_text.rs +227 -0
- package/vendor/fastctx/src/server.rs +359 -0
- package/vendor/fastctx/src/server_manifest.rs +468 -0
- package/vendor/fastctx/src/server_support.rs +826 -0
- package/vendor/fastctx/src/session.rs +629 -0
- package/vendor/fastctx/src/shell/apply_patch_hint.rs +41 -0
- package/vendor/fastctx/src/shell/bash.rs +263 -0
- package/vendor/fastctx/src/shell/buffer.rs +108 -0
- package/vendor/fastctx/src/shell/encoding.rs +403 -0
- package/vendor/fastctx/src/shell/foreground.rs +115 -0
- package/vendor/fastctx/src/shell/jobs/admission.rs +91 -0
- package/vendor/fastctx/src/shell/jobs/background.rs +146 -0
- package/vendor/fastctx/src/shell/jobs/host.rs +830 -0
- package/vendor/fastctx/src/shell/jobs/identity.rs +29 -0
- package/vendor/fastctx/src/shell/jobs/mod.rs +1513 -0
- package/vendor/fastctx/src/shell/jobs/model.rs +244 -0
- package/vendor/fastctx/src/shell/jobs/output_log.rs +1148 -0
- package/vendor/fastctx/src/shell/jobs/store.rs +1300 -0
- package/vendor/fastctx/src/shell/mod.rs +345 -0
- package/vendor/fastctx/src/shell/normalize.rs +389 -0
- package/vendor/fastctx/src/shell/output.rs +406 -0
- package/vendor/fastctx/src/shell/process.rs +493 -0
- package/vendor/fastctx/src/shell_server.rs +156 -0
- package/vendor/fastctx/src/skip_report.rs +83 -0
- package/vendor/fastctx/src/stdio_transport.rs +177 -0
- package/vendor/fastctx/src/tool_schema.rs +204 -0
- package/vendor/fastctx/src/traversal.rs +846 -0
- package/vendor/fastctx/third-party/pdfium-7763/LICENSE +9 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/abseil.txt +202 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/agg23.txt +14 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/fast_float.txt +27 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/freetype.txt +169 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/icu.txt +542 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/lcms.txt +27 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.ijg +260 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.md +135 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libopenjpeg.txt +32 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libpng.txt +134 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libtiff.txt +21 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/llvm-libc.txt +278 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/pdfium.txt +230 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/simdutf.txt +18 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/zlib.txt +29 -0
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
// Modified by dsh-ops (https://github.com/T-Auto/dsh-ops): dropped the update/language/applied-record/TUI-only settings and the startup-normalization writer, keeping the job, replace, search, and tool-budget settings the MCP server loads.
|
|
2
|
+
//! Stable format and atomic I/O for `~/.fastctx/config.toml`.
|
|
3
|
+
|
|
4
|
+
use crate::control::paths::ControlPaths;
|
|
5
|
+
use crate::control::transaction;
|
|
6
|
+
use crate::search_parallelism::{self, SearchParallelism};
|
|
7
|
+
use serde::{Deserialize, Serialize};
|
|
8
|
+
use std::fmt;
|
|
9
|
+
use std::fs;
|
|
10
|
+
use std::path::Path;
|
|
11
|
+
|
|
12
|
+
const CURRENT_SCHEMA_VERSION: u32 = 1;
|
|
13
|
+
const LEGACY_SCHEMA_VERSION: u32 = 0;
|
|
14
|
+
/// Default current-user disk allowance for retained background-job records.
|
|
15
|
+
pub const DEFAULT_JOB_STORAGE_LIMIT_MIB: u64 = 1_024;
|
|
16
|
+
/// Default current-user number of simultaneously running background jobs.
|
|
17
|
+
pub const DEFAULT_MAX_RUNNING_JOBS: u64 = 128;
|
|
18
|
+
/// Default number of background-job records returned by one `job_list` call.
|
|
19
|
+
pub const DEFAULT_JOB_LIST_LIMIT: u64 = 20;
|
|
20
|
+
/// Largest configurable page size accepted by `job_list`.
|
|
21
|
+
pub const MAX_JOB_LIST_LIMIT: u64 = 100;
|
|
22
|
+
/// Default replace input and output safety limit in MiB.
|
|
23
|
+
pub const DEFAULT_REPLACE_FILE_LIMIT_MIB: i64 = 256;
|
|
24
|
+
/// Smallest replace safety limit accepted by the control plane.
|
|
25
|
+
pub const MIN_REPLACE_FILE_LIMIT_MIB: i64 = 64;
|
|
26
|
+
/// Largest replace safety limit offered by the control plane.
|
|
27
|
+
pub const MAX_REPLACE_FILE_LIMIT_MIB: i64 = 4_096;
|
|
28
|
+
|
|
29
|
+
/// Current-user grep/glob CPU settings, read when the shared control center starts.
|
|
30
|
+
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
|
31
|
+
#[serde(default)]
|
|
32
|
+
pub struct SearchSettings {
|
|
33
|
+
/// Maximum CPU lanes including the request-local base lane; omission keeps automatic mode.
|
|
34
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
35
|
+
pub max_cpu_cores: Option<i64>,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
impl SearchSettings {
|
|
39
|
+
fn is_default(&self) -> bool {
|
|
40
|
+
self.max_cpu_cores.is_none()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// Current-user replace memory-safety settings, reloaded for every request.
|
|
45
|
+
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
|
46
|
+
#[serde(default)]
|
|
47
|
+
pub struct ReplaceSettings {
|
|
48
|
+
/// Maximum size of both an input file and its replacement result, in MiB.
|
|
49
|
+
pub max_file_size_mib: i64,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
impl ReplaceSettings {
|
|
53
|
+
fn is_default(&self) -> bool {
|
|
54
|
+
*self == Self::default()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// Resolves a persisted limit without silently clamping an invalid user choice.
|
|
58
|
+
pub(crate) fn resolved_file_limit_mib(self) -> Result<u64, String> {
|
|
59
|
+
if !(MIN_REPLACE_FILE_LIMIT_MIB..=MAX_REPLACE_FILE_LIMIT_MIB)
|
|
60
|
+
.contains(&self.max_file_size_mib)
|
|
61
|
+
{
|
|
62
|
+
return Err(format!(
|
|
63
|
+
"replace.max_file_size_mib must be a whole number from {MIN_REPLACE_FILE_LIMIT_MIB}..={MAX_REPLACE_FILE_LIMIT_MIB} MiB"
|
|
64
|
+
));
|
|
65
|
+
}
|
|
66
|
+
Ok(self.max_file_size_mib as u64)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
impl Default for ReplaceSettings {
|
|
71
|
+
fn default() -> Self {
|
|
72
|
+
Self {
|
|
73
|
+
max_file_size_mib: DEFAULT_REPLACE_FILE_LIMIT_MIB,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// Codex host output tier.
|
|
79
|
+
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
|
80
|
+
#[serde(rename_all = "kebab-case")]
|
|
81
|
+
pub enum Tier {
|
|
82
|
+
/// Conservative 20k host limit with an 18k FastCtx budget.
|
|
83
|
+
Compact,
|
|
84
|
+
/// Recommended 60k host limit with a 54k FastCtx budget.
|
|
85
|
+
#[default]
|
|
86
|
+
Standard,
|
|
87
|
+
/// Widest 100k host limit with a 90k FastCtx budget.
|
|
88
|
+
#[serde(alias = "extra-high")]
|
|
89
|
+
High,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
impl Tier {
|
|
93
|
+
/// Host token limit written to Codex.
|
|
94
|
+
pub const fn host_limit(self) -> i64 {
|
|
95
|
+
match self {
|
|
96
|
+
Self::Compact => 20_000,
|
|
97
|
+
Self::Standard => 60_000,
|
|
98
|
+
Self::High => 100_000,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/// Global token budget written to the FastCtx environment, ten percent below the host limit.
|
|
103
|
+
pub const fn fastctx_budget(self) -> usize {
|
|
104
|
+
match self {
|
|
105
|
+
Self::Compact => 18_000,
|
|
106
|
+
Self::Standard => 54_000,
|
|
107
|
+
Self::High => 90_000,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// Recommended per-tool shares for this tier, used wherever the user set no explicit share.
|
|
112
|
+
///
|
|
113
|
+
/// Only `read` scales with the tier: it is the one tool asked to deliver a whole file at once,
|
|
114
|
+
/// so a wider tier exists precisely to widen it. The other four deliver excerpts, listings,
|
|
115
|
+
/// summaries, or output that also survives on disk, and their useful size barely moves between
|
|
116
|
+
/// tiers, so their shares shrink as the global budget grows. Each tool's resolved absolute
|
|
117
|
+
/// budget still rises monotonically from Compact to High; `tier_defaults_grow_with_the_tier`
|
|
118
|
+
/// fails if a future edit breaks that.
|
|
119
|
+
pub const fn default_budgets(self) -> ToolBudgets {
|
|
120
|
+
match self {
|
|
121
|
+
Self::Compact => ToolBudgets {
|
|
122
|
+
read: ToolBudgetLevel::Inherit,
|
|
123
|
+
grep: ToolBudgetLevel::Percent(50),
|
|
124
|
+
glob: ToolBudgetLevel::Percent(25),
|
|
125
|
+
run: ToolBudgetLevel::Percent(50),
|
|
126
|
+
job_output: ToolBudgetLevel::Percent(25),
|
|
127
|
+
},
|
|
128
|
+
Self::Standard => ToolBudgets {
|
|
129
|
+
read: ToolBudgetLevel::Inherit,
|
|
130
|
+
grep: ToolBudgetLevel::Percent(20),
|
|
131
|
+
glob: ToolBudgetLevel::Percent(10),
|
|
132
|
+
run: ToolBudgetLevel::Percent(20),
|
|
133
|
+
job_output: ToolBudgetLevel::Percent(10),
|
|
134
|
+
},
|
|
135
|
+
Self::High => ToolBudgets {
|
|
136
|
+
read: ToolBudgetLevel::Inherit,
|
|
137
|
+
grep: ToolBudgetLevel::Percent(12),
|
|
138
|
+
glob: ToolBudgetLevel::Percent(6),
|
|
139
|
+
run: ToolBudgetLevel::Percent(12),
|
|
140
|
+
job_output: ToolBudgetLevel::Percent(6),
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/// One tool's share of the global budget, as a whole percent.
|
|
147
|
+
///
|
|
148
|
+
/// A full share is `Inherit` rather than `Percent(100)`: it omits the per-tool environment
|
|
149
|
+
/// variable entirely so the server falls back to the global budget, which also keeps budget
|
|
150
|
+
/// errors pointing at the global variable instead of an equal per-tool one.
|
|
151
|
+
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
152
|
+
pub enum ToolBudgetLevel {
|
|
153
|
+
/// Omit the per-tool environment variable so the server inherits the global value.
|
|
154
|
+
#[default]
|
|
155
|
+
Inherit,
|
|
156
|
+
/// Explicit share between 1 and 99 percent of the global budget.
|
|
157
|
+
Percent(u8),
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
impl ToolBudgetLevel {
|
|
161
|
+
/// Builds a share from a whole percent, normalizing a full share to inheritance.
|
|
162
|
+
///
|
|
163
|
+
/// Rejects anything outside `1..=100`; a zero share resolves to a budget the server refuses.
|
|
164
|
+
pub const fn from_percent(percent: u8) -> Option<Self> {
|
|
165
|
+
if percent == 0 || percent > 100 {
|
|
166
|
+
None
|
|
167
|
+
} else if percent == 100 {
|
|
168
|
+
Some(Self::Inherit)
|
|
169
|
+
} else {
|
|
170
|
+
Some(Self::Percent(percent))
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/// Returns the concrete budget to write, or `None` for inheritance.
|
|
175
|
+
pub const fn resolve(self, global: usize) -> Option<usize> {
|
|
176
|
+
let percent = match self {
|
|
177
|
+
Self::Inherit => return None,
|
|
178
|
+
Self::Percent(percent) => percent as usize,
|
|
179
|
+
};
|
|
180
|
+
let raw = (global * percent + 50) / 100;
|
|
181
|
+
let rounded = ((raw + 50) / 100) * 100;
|
|
182
|
+
// Rounding to hundreds reaches zero for a small share of a small budget, and the server
|
|
183
|
+
// rejects a zero budget outright, so keep the smallest representable step instead.
|
|
184
|
+
Some(if rounded == 0 { 100 } else { rounded })
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/// Parses a configuration-file spelling.
|
|
188
|
+
///
|
|
189
|
+
/// The four fixed names are what releases before arbitrary percentages wrote, so they stay
|
|
190
|
+
/// readable forever; `"17%"` is accepted too because that is how the UI shows a share and
|
|
191
|
+
/// therefore how someone hand-editing the file will most likely spell it.
|
|
192
|
+
fn from_config_str(value: &str) -> Option<Self> {
|
|
193
|
+
match value.trim() {
|
|
194
|
+
"inherit" => Some(Self::Inherit),
|
|
195
|
+
"percent75" => Some(Self::Percent(75)),
|
|
196
|
+
"percent50" => Some(Self::Percent(50)),
|
|
197
|
+
"percent25" => Some(Self::Percent(25)),
|
|
198
|
+
other => Self::from_percent(other.strip_suffix('%')?.trim().parse().ok()?),
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
impl Serialize for ToolBudgetLevel {
|
|
204
|
+
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
205
|
+
where
|
|
206
|
+
S: serde::Serializer,
|
|
207
|
+
{
|
|
208
|
+
// Shares an older release can still parse keep their legacy spelling so a downgrade reads
|
|
209
|
+
// the file back instead of failing on an unrecognized shape. Only a share that older
|
|
210
|
+
// releases could never express falls through to the numeric form. (2026-07-25)
|
|
211
|
+
match self {
|
|
212
|
+
Self::Inherit => serializer.serialize_str("inherit"),
|
|
213
|
+
Self::Percent(75) => serializer.serialize_str("percent75"),
|
|
214
|
+
Self::Percent(50) => serializer.serialize_str("percent50"),
|
|
215
|
+
Self::Percent(25) => serializer.serialize_str("percent25"),
|
|
216
|
+
Self::Percent(percent) => serializer.serialize_u8(*percent),
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
impl<'de> Deserialize<'de> for ToolBudgetLevel {
|
|
222
|
+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
223
|
+
where
|
|
224
|
+
D: serde::Deserializer<'de>,
|
|
225
|
+
{
|
|
226
|
+
deserializer.deserialize_any(ToolBudgetLevelVisitor)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
struct ToolBudgetLevelVisitor;
|
|
231
|
+
|
|
232
|
+
impl serde::de::Visitor<'_> for ToolBudgetLevelVisitor {
|
|
233
|
+
type Value = ToolBudgetLevel;
|
|
234
|
+
|
|
235
|
+
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
236
|
+
formatter.write_str("\"inherit\" or a whole percent between 1 and 100")
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
|
|
240
|
+
where
|
|
241
|
+
E: serde::de::Error,
|
|
242
|
+
{
|
|
243
|
+
ToolBudgetLevel::from_config_str(value)
|
|
244
|
+
.ok_or_else(|| E::invalid_value(serde::de::Unexpected::Str(value), &self))
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
|
|
248
|
+
where
|
|
249
|
+
E: serde::de::Error,
|
|
250
|
+
{
|
|
251
|
+
u8::try_from(value)
|
|
252
|
+
.ok()
|
|
253
|
+
.and_then(ToolBudgetLevel::from_percent)
|
|
254
|
+
.ok_or_else(|| E::invalid_value(serde::de::Unexpected::Unsigned(value), &self))
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
|
|
258
|
+
where
|
|
259
|
+
E: serde::de::Error,
|
|
260
|
+
{
|
|
261
|
+
let unsigned = u64::try_from(value)
|
|
262
|
+
.map_err(|_| E::invalid_value(serde::de::Unexpected::Signed(value), &self))?;
|
|
263
|
+
self.visit_u64(unsigned)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/// The five long-output tools' shares in effect for one Apply.
|
|
268
|
+
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
|
269
|
+
#[serde(default)]
|
|
270
|
+
pub struct ToolBudgets {
|
|
271
|
+
/// Share for read.
|
|
272
|
+
pub read: ToolBudgetLevel,
|
|
273
|
+
/// Share for grep.
|
|
274
|
+
pub grep: ToolBudgetLevel,
|
|
275
|
+
/// Share for glob.
|
|
276
|
+
pub glob: ToolBudgetLevel,
|
|
277
|
+
/// Share for run; effective only when the shell group is enabled.
|
|
278
|
+
pub run: ToolBudgetLevel,
|
|
279
|
+
/// Share for job_output; effective only when the shell group is enabled.
|
|
280
|
+
pub job_output: ToolBudgetLevel,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/// Per-tool shares the user set explicitly; every unset entry follows the selected tier.
|
|
284
|
+
///
|
|
285
|
+
/// Keeping "unset" distinct from an equal explicit share is what lets a tier change re-target the
|
|
286
|
+
/// budgets nobody touched while preserving the ones somebody did. Collapsing the two would leave
|
|
287
|
+
/// only bad options: either a tier change silently discards explicit shares, or one edit freezes
|
|
288
|
+
/// that tool at a share chosen for a different global budget.
|
|
289
|
+
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
|
290
|
+
#[serde(default)]
|
|
291
|
+
pub struct ToolBudgetPreferences {
|
|
292
|
+
/// Explicit share for read, or `None` to follow the tier.
|
|
293
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
294
|
+
pub read: Option<ToolBudgetLevel>,
|
|
295
|
+
/// Explicit share for grep, or `None` to follow the tier.
|
|
296
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
297
|
+
pub grep: Option<ToolBudgetLevel>,
|
|
298
|
+
/// Explicit share for glob, or `None` to follow the tier.
|
|
299
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
300
|
+
pub glob: Option<ToolBudgetLevel>,
|
|
301
|
+
/// Explicit share for run, or `None` to follow the tier.
|
|
302
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
303
|
+
pub run: Option<ToolBudgetLevel>,
|
|
304
|
+
/// Explicit share for job_output, or `None` to follow the tier.
|
|
305
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
306
|
+
pub job_output: Option<ToolBudgetLevel>,
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
impl ToolBudgetPreferences {
|
|
310
|
+
/// Fills every unset entry from the tier's recommended shares.
|
|
311
|
+
pub fn resolve(self, tier: Tier) -> ToolBudgets {
|
|
312
|
+
let defaults = tier.default_budgets();
|
|
313
|
+
ToolBudgets {
|
|
314
|
+
read: self.read.unwrap_or(defaults.read),
|
|
315
|
+
grep: self.grep.unwrap_or(defaults.grep),
|
|
316
|
+
glob: self.glob.unwrap_or(defaults.glob),
|
|
317
|
+
run: self.run.unwrap_or(defaults.run),
|
|
318
|
+
job_output: self.job_output.unwrap_or(defaults.job_output),
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
fn is_default(&self) -> bool {
|
|
323
|
+
*self == Self::default()
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/// Current-user background-job limits.
|
|
328
|
+
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
|
329
|
+
#[serde(default)]
|
|
330
|
+
pub struct FastShellSettings {
|
|
331
|
+
/// Whether the next Apply should publish the shell tools.
|
|
332
|
+
pub enabled: bool,
|
|
333
|
+
/// Maximum total size of the persistent job registry before terminal records are reaped.
|
|
334
|
+
#[serde(deserialize_with = "deserialize_job_storage_limit")]
|
|
335
|
+
pub job_storage_limit_mib: u64,
|
|
336
|
+
/// Maximum number of background jobs running across all FastCtx sessions.
|
|
337
|
+
#[serde(deserialize_with = "deserialize_max_running_jobs")]
|
|
338
|
+
pub max_running_jobs: u64,
|
|
339
|
+
/// Default maximum records returned by `job_list`; explicit tool arguments override it once.
|
|
340
|
+
#[serde(deserialize_with = "deserialize_job_list_limit")]
|
|
341
|
+
pub job_list_limit: u64,
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
impl Default for FastShellSettings {
|
|
345
|
+
fn default() -> Self {
|
|
346
|
+
Self {
|
|
347
|
+
enabled: false,
|
|
348
|
+
job_storage_limit_mib: DEFAULT_JOB_STORAGE_LIMIT_MIB,
|
|
349
|
+
max_running_jobs: DEFAULT_MAX_RUNNING_JOBS,
|
|
350
|
+
job_list_limit: DEFAULT_JOB_LIST_LIMIT,
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
fn deserialize_job_storage_limit<'de, D>(deserializer: D) -> Result<u64, D::Error>
|
|
356
|
+
where
|
|
357
|
+
D: serde::Deserializer<'de>,
|
|
358
|
+
{
|
|
359
|
+
deserialize_positive_or_default(deserializer, DEFAULT_JOB_STORAGE_LIMIT_MIB)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
fn deserialize_max_running_jobs<'de, D>(deserializer: D) -> Result<u64, D::Error>
|
|
363
|
+
where
|
|
364
|
+
D: serde::Deserializer<'de>,
|
|
365
|
+
{
|
|
366
|
+
deserialize_positive_or_default(deserializer, DEFAULT_MAX_RUNNING_JOBS)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
fn deserialize_job_list_limit<'de, D>(deserializer: D) -> Result<u64, D::Error>
|
|
370
|
+
where
|
|
371
|
+
D: serde::Deserializer<'de>,
|
|
372
|
+
{
|
|
373
|
+
let value = serde_json::Value::deserialize(deserializer)?;
|
|
374
|
+
Ok(value
|
|
375
|
+
.as_u64()
|
|
376
|
+
.filter(|value| (1..=MAX_JOB_LIST_LIMIT).contains(value))
|
|
377
|
+
.unwrap_or(DEFAULT_JOB_LIST_LIMIT))
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
fn deserialize_positive_or_default<'de, D>(deserializer: D, fallback: u64) -> Result<u64, D::Error>
|
|
381
|
+
where
|
|
382
|
+
D: serde::Deserializer<'de>,
|
|
383
|
+
{
|
|
384
|
+
let value = serde_json::Value::deserialize(deserializer)?;
|
|
385
|
+
Ok(value
|
|
386
|
+
.as_u64()
|
|
387
|
+
.filter(|value| *value > 0)
|
|
388
|
+
.unwrap_or(fallback))
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
impl Default for ToolBudgets {
|
|
392
|
+
fn default() -> Self {
|
|
393
|
+
Tier::default().default_budgets()
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/// FastCtx's own configuration.
|
|
398
|
+
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
|
399
|
+
#[serde(default)]
|
|
400
|
+
pub struct FastCtxSettings {
|
|
401
|
+
/// Configuration format version.
|
|
402
|
+
pub schema_version: u32,
|
|
403
|
+
/// Software-version watermark retained from releases that normalized it at startup.
|
|
404
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
405
|
+
pub last_seen_version: Option<String>,
|
|
406
|
+
/// Per-tool budget-defaults generation retained from releases that normalized it at startup.
|
|
407
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
408
|
+
pub tool_budget_epoch: Option<u32>,
|
|
409
|
+
/// Host tier used to resolve the per-tool shares.
|
|
410
|
+
pub tier: Tier,
|
|
411
|
+
/// Advanced per-tool overrides; unset entries follow the tier.
|
|
412
|
+
#[serde(skip_serializing_if = "ToolBudgetPreferences::is_default")]
|
|
413
|
+
pub tool_budgets: ToolBudgetPreferences,
|
|
414
|
+
/// Optional fastshell server, disabled by default.
|
|
415
|
+
pub fastshell: FastShellSettings,
|
|
416
|
+
/// Current-user grep/glob CPU limit, effective after the shared control center restarts.
|
|
417
|
+
#[serde(skip_serializing_if = "SearchSettings::is_default")]
|
|
418
|
+
pub search: SearchSettings,
|
|
419
|
+
/// Current-user replace input and result limit, effective on the next replace request.
|
|
420
|
+
#[serde(skip_serializing_if = "ReplaceSettings::is_default")]
|
|
421
|
+
pub replace: ReplaceSettings,
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
impl Default for FastCtxSettings {
|
|
425
|
+
fn default() -> Self {
|
|
426
|
+
Self {
|
|
427
|
+
schema_version: CURRENT_SCHEMA_VERSION,
|
|
428
|
+
last_seen_version: None,
|
|
429
|
+
tool_budget_epoch: None,
|
|
430
|
+
tier: Tier::Standard,
|
|
431
|
+
tool_budgets: ToolBudgetPreferences::default(),
|
|
432
|
+
fastshell: FastShellSettings::default(),
|
|
433
|
+
search: SearchSettings::default(),
|
|
434
|
+
replace: ReplaceSettings::default(),
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/// Loads FastCtx configuration, returning defaults when the file does not exist.
|
|
440
|
+
pub fn load(paths: &ControlPaths) -> Result<FastCtxSettings, String> {
|
|
441
|
+
load_from(&paths.fastctx_config)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
impl FastCtxSettings {
|
|
445
|
+
/// Resolves the effective search parallelism or rejects an invalid explicit limit.
|
|
446
|
+
pub(crate) fn search_parallelism(&self) -> Result<SearchParallelism, String> {
|
|
447
|
+
search_parallelism::resolve(self.search.max_cpu_cores)
|
|
448
|
+
.map_err(|error| format!("search.max_cpu_cores {error}"))
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/// Resolves the replace limit or reports the exact persisted key that needs repair.
|
|
452
|
+
pub(crate) fn replace_file_limit_mib(&self) -> Result<u64, String> {
|
|
453
|
+
self.replace.resolved_file_limit_mib()
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
fn search_parallelism_repair_hint() -> String {
|
|
458
|
+
format!(
|
|
459
|
+
"For search.max_cpu_cores, use a whole number from 1..={} or remove the key for automatic mode. ",
|
|
460
|
+
search_parallelism::detected_available()
|
|
461
|
+
)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
fn replace_limit_repair_hint() -> String {
|
|
465
|
+
format!(
|
|
466
|
+
"For replace.max_file_size_mib, use a whole number from {MIN_REPLACE_FILE_LIMIT_MIB}..={MAX_REPLACE_FILE_LIMIT_MIB} MiB. "
|
|
467
|
+
)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
fn source_mentions_search_parallelism(source: &str) -> bool {
|
|
471
|
+
let mut in_search_table = false;
|
|
472
|
+
for line in source.lines() {
|
|
473
|
+
let line = line.split('#').next().unwrap_or_default().trim();
|
|
474
|
+
if line.starts_with('[') {
|
|
475
|
+
in_search_table = line == "[search]";
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
let Some((key, _value)) = line.split_once('=') else {
|
|
479
|
+
continue;
|
|
480
|
+
};
|
|
481
|
+
let key = key.trim();
|
|
482
|
+
if key == "search.max_cpu_cores" || (in_search_table && key == "max_cpu_cores") {
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
false
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
fn source_mentions_replace_limit(source: &str) -> bool {
|
|
490
|
+
let mut in_replace_table = false;
|
|
491
|
+
for line in source.lines() {
|
|
492
|
+
let line = line.split('#').next().unwrap_or_default().trim();
|
|
493
|
+
if line.starts_with('[') {
|
|
494
|
+
in_replace_table = line == "[replace]";
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
let Some((key, _value)) = line.split_once('=') else {
|
|
498
|
+
continue;
|
|
499
|
+
};
|
|
500
|
+
let key = key.trim();
|
|
501
|
+
if key == "replace.max_file_size_mib" || (in_replace_table && key == "max_file_size_mib") {
|
|
502
|
+
return true;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
false
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
fn validate_search_parallelism_type(
|
|
509
|
+
document: &toml_edit::DocumentMut,
|
|
510
|
+
path: &Path,
|
|
511
|
+
) -> Result<(), String> {
|
|
512
|
+
let Some(search) = document.get("search") else {
|
|
513
|
+
return Ok(());
|
|
514
|
+
};
|
|
515
|
+
let Some(table) = search.as_table_like() else {
|
|
516
|
+
return Err(format!(
|
|
517
|
+
"Cannot parse fastctx settings {}: search must be a table. {}Repair the file and retry.",
|
|
518
|
+
crate::paths::display_path(path),
|
|
519
|
+
search_parallelism_repair_hint()
|
|
520
|
+
));
|
|
521
|
+
};
|
|
522
|
+
if table
|
|
523
|
+
.get("max_cpu_cores")
|
|
524
|
+
.is_some_and(|value| value.as_integer().is_none())
|
|
525
|
+
{
|
|
526
|
+
return Err(format!(
|
|
527
|
+
"Cannot parse fastctx settings {}: search.max_cpu_cores must be an integer. {}Repair the file and retry.",
|
|
528
|
+
crate::paths::display_path(path),
|
|
529
|
+
search_parallelism_repair_hint()
|
|
530
|
+
));
|
|
531
|
+
}
|
|
532
|
+
Ok(())
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
fn validate_replace_limit_type(
|
|
536
|
+
document: &toml_edit::DocumentMut,
|
|
537
|
+
path: &Path,
|
|
538
|
+
) -> Result<(), String> {
|
|
539
|
+
let Some(replace) = document.get("replace") else {
|
|
540
|
+
return Ok(());
|
|
541
|
+
};
|
|
542
|
+
let Some(table) = replace.as_table_like() else {
|
|
543
|
+
return Err(format!(
|
|
544
|
+
"Cannot parse fastctx settings {}: replace must be a table. {}Repair the file and retry.",
|
|
545
|
+
crate::paths::display_path(path),
|
|
546
|
+
replace_limit_repair_hint()
|
|
547
|
+
));
|
|
548
|
+
};
|
|
549
|
+
if table
|
|
550
|
+
.get("max_file_size_mib")
|
|
551
|
+
.is_some_and(|value| value.as_integer().is_none())
|
|
552
|
+
{
|
|
553
|
+
return Err(format!(
|
|
554
|
+
"Cannot parse fastctx settings {}: replace.max_file_size_mib must be an integer. {}Repair the file and retry.",
|
|
555
|
+
crate::paths::display_path(path),
|
|
556
|
+
replace_limit_repair_hint()
|
|
557
|
+
));
|
|
558
|
+
}
|
|
559
|
+
Ok(())
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/// Loads configuration from a supplied path for tests and migrations.
|
|
563
|
+
pub fn load_from(path: &Path) -> Result<FastCtxSettings, String> {
|
|
564
|
+
let source = match fs::read_to_string(path) {
|
|
565
|
+
Ok(source) => source,
|
|
566
|
+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
567
|
+
return Ok(FastCtxSettings::default());
|
|
568
|
+
}
|
|
569
|
+
Err(error) => {
|
|
570
|
+
return Err(format!(
|
|
571
|
+
"Cannot read fastctx settings {}: {error}",
|
|
572
|
+
crate::paths::display_path(path)
|
|
573
|
+
));
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
decode_source(path, &source)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
fn decode_source(path: &Path, source: &str) -> Result<FastCtxSettings, String> {
|
|
580
|
+
let document = source.parse::<toml_edit::DocumentMut>().map_err(|error| {
|
|
581
|
+
let mut hint = String::new();
|
|
582
|
+
if source_mentions_search_parallelism(source) {
|
|
583
|
+
hint.push_str(&search_parallelism_repair_hint());
|
|
584
|
+
}
|
|
585
|
+
if source_mentions_replace_limit(source) {
|
|
586
|
+
hint.push_str(&replace_limit_repair_hint());
|
|
587
|
+
}
|
|
588
|
+
format!(
|
|
589
|
+
"Cannot parse fastctx settings {}: {error}. {hint}Repair or remove the file and retry.",
|
|
590
|
+
crate::paths::display_path(path)
|
|
591
|
+
)
|
|
592
|
+
})?;
|
|
593
|
+
let schema_version = document
|
|
594
|
+
.get("schema_version")
|
|
595
|
+
.ok_or_else(|| {
|
|
596
|
+
format!(
|
|
597
|
+
"Cannot parse fastctx settings {}: schema_version is missing. Repair or remove the file and retry.",
|
|
598
|
+
crate::paths::display_path(path)
|
|
599
|
+
)
|
|
600
|
+
})?
|
|
601
|
+
.as_integer()
|
|
602
|
+
.and_then(|value| u32::try_from(value).ok())
|
|
603
|
+
.ok_or_else(|| {
|
|
604
|
+
format!(
|
|
605
|
+
"Cannot parse fastctx settings {}: schema_version must be a non-negative integer. Repair the file and retry.",
|
|
606
|
+
crate::paths::display_path(path)
|
|
607
|
+
)
|
|
608
|
+
})?;
|
|
609
|
+
if schema_version > CURRENT_SCHEMA_VERSION {
|
|
610
|
+
return Err(format!(
|
|
611
|
+
"Cannot write fastctx settings {}: schema_version {} was written by a newer fastctx. Upgrade fastctx and retry.",
|
|
612
|
+
crate::paths::display_path(path),
|
|
613
|
+
schema_version
|
|
614
|
+
));
|
|
615
|
+
}
|
|
616
|
+
if !matches!(
|
|
617
|
+
schema_version,
|
|
618
|
+
LEGACY_SCHEMA_VERSION | CURRENT_SCHEMA_VERSION
|
|
619
|
+
) {
|
|
620
|
+
return Err(format!(
|
|
621
|
+
"Unsupported fastctx settings schema_version {schema_version} in {}. Upgrade fastctx or repair the file.",
|
|
622
|
+
crate::paths::display_path(path)
|
|
623
|
+
));
|
|
624
|
+
}
|
|
625
|
+
validate_search_parallelism_type(&document, path)?;
|
|
626
|
+
validate_replace_limit_type(&document, path)?;
|
|
627
|
+
let mut settings: FastCtxSettings = toml_edit::de::from_str(source).map_err(|error| {
|
|
628
|
+
format!(
|
|
629
|
+
"Cannot parse fastctx settings {}: {error}. Repair or remove the file and retry.",
|
|
630
|
+
crate::paths::display_path(path)
|
|
631
|
+
)
|
|
632
|
+
})?;
|
|
633
|
+
if schema_version == LEGACY_SCHEMA_VERSION {
|
|
634
|
+
settings.schema_version = CURRENT_SCHEMA_VERSION;
|
|
635
|
+
}
|
|
636
|
+
Ok(settings)
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/// Encodes configuration as stable UTF-8 TOML.
|
|
640
|
+
pub fn encode(settings: &FastCtxSettings) -> Result<Vec<u8>, String> {
|
|
641
|
+
if settings.schema_version != CURRENT_SCHEMA_VERSION {
|
|
642
|
+
return Err(format!(
|
|
643
|
+
"Refusing to write fastctx settings schema_version {}; this fastctx only writes schema_version {CURRENT_SCHEMA_VERSION}.",
|
|
644
|
+
settings.schema_version
|
|
645
|
+
));
|
|
646
|
+
}
|
|
647
|
+
settings
|
|
648
|
+
.search_parallelism()
|
|
649
|
+
.map_err(|error| format!("Cannot encode fastctx settings: {error}."))?;
|
|
650
|
+
settings
|
|
651
|
+
.replace_file_limit_mib()
|
|
652
|
+
.map_err(|error| format!("Cannot encode fastctx settings: {error}."))?;
|
|
653
|
+
let mut source = toml_edit::ser::to_string_pretty(settings)
|
|
654
|
+
.map_err(|error| format!("Cannot encode fastctx settings: {error}"))?;
|
|
655
|
+
if !source.ends_with('\n') {
|
|
656
|
+
source.push('\n');
|
|
657
|
+
}
|
|
658
|
+
Ok(source.into_bytes())
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/// Atomically saves FastCtx configuration.
|
|
662
|
+
pub fn save(paths: &ControlPaths, settings: &FastCtxSettings) -> Result<bool, String> {
|
|
663
|
+
let bytes = encode(settings)?;
|
|
664
|
+
let original = transaction::read_snapshot(&paths.fastctx_config)?;
|
|
665
|
+
if original.as_deref() == Some(bytes.as_slice()) {
|
|
666
|
+
crate::shell::jobs::reap(paths).map_err(|error| {
|
|
667
|
+
format!(
|
|
668
|
+
"Settings were unchanged, but finished job records could not be reaped: {error}"
|
|
669
|
+
)
|
|
670
|
+
})?;
|
|
671
|
+
return Ok(false);
|
|
672
|
+
}
|
|
673
|
+
fs::create_dir_all(&paths.fastctx_dir).map_err(|error| {
|
|
674
|
+
format!(
|
|
675
|
+
"Cannot create fastctx settings directory {}: {error}",
|
|
676
|
+
crate::paths::display_path(&paths.fastctx_dir)
|
|
677
|
+
)
|
|
678
|
+
})?;
|
|
679
|
+
transaction::atomic_replace(&paths.fastctx_config, &bytes, None)?;
|
|
680
|
+
crate::shell::jobs::reap(paths).map_err(|error| {
|
|
681
|
+
format!("Settings were saved, but finished job records could not be reaped: {error}")
|
|
682
|
+
})?;
|
|
683
|
+
Ok(true)
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
#[cfg(test)]
|
|
687
|
+
mod tests {
|
|
688
|
+
use super::{Tier, load_from};
|
|
689
|
+
use crate::control::paths::ControlPaths;
|
|
690
|
+
|
|
691
|
+
#[test]
|
|
692
|
+
fn future_or_missing_schema_versions_are_read_only_failures() {
|
|
693
|
+
let temp = tempfile::tempdir().unwrap();
|
|
694
|
+
let future = temp.path().join("future.toml");
|
|
695
|
+
std::fs::write(&future, b"schema_version = 999\n").unwrap();
|
|
696
|
+
let error = load_from(&future).unwrap_err();
|
|
697
|
+
assert!(error.contains("written by a newer fastctx"), "{error}");
|
|
698
|
+
assert_eq!(std::fs::read(&future).unwrap(), b"schema_version = 999\n");
|
|
699
|
+
|
|
700
|
+
let missing = temp.path().join("missing-version.toml");
|
|
701
|
+
std::fs::write(&missing, b"tier = \"high\"\n").unwrap();
|
|
702
|
+
let error = load_from(&missing).unwrap_err();
|
|
703
|
+
assert!(error.contains("schema_version is missing"), "{error}");
|
|
704
|
+
assert_eq!(std::fs::read(&missing).unwrap(), b"tier = \"high\"\n");
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
#[test]
|
|
708
|
+
fn stored_tool_budgets_are_loaded_and_resolved_against_the_tier() {
|
|
709
|
+
let temp = tempfile::tempdir().unwrap();
|
|
710
|
+
let config = temp.path().join("config.toml");
|
|
711
|
+
std::fs::write(
|
|
712
|
+
&config,
|
|
713
|
+
concat!(
|
|
714
|
+
"schema_version = 1\n",
|
|
715
|
+
"tier = \"high\"\n",
|
|
716
|
+
"\n[tool_budgets]\n",
|
|
717
|
+
"read = \"percent25\"\n",
|
|
718
|
+
"grep = \"percent75\"\n",
|
|
719
|
+
"glob = \"inherit\"\n",
|
|
720
|
+
"run = \"percent75\"\n",
|
|
721
|
+
"job_output = \"inherit\"\n",
|
|
722
|
+
),
|
|
723
|
+
)
|
|
724
|
+
.unwrap();
|
|
725
|
+
|
|
726
|
+
let settings = load_from(&config).unwrap();
|
|
727
|
+
assert_eq!(settings.tier, Tier::High);
|
|
728
|
+
let resolved = settings.tool_budgets.resolve(settings.tier);
|
|
729
|
+
assert_eq!(
|
|
730
|
+
resolved.read,
|
|
731
|
+
super::ToolBudgetLevel::Percent(25),
|
|
732
|
+
"an explicit share must survive the tier"
|
|
733
|
+
);
|
|
734
|
+
assert_eq!(
|
|
735
|
+
resolved.glob,
|
|
736
|
+
super::ToolBudgetLevel::Inherit,
|
|
737
|
+
"an inherit entry stays inherited"
|
|
738
|
+
);
|
|
739
|
+
assert_eq!(
|
|
740
|
+
resolved.run,
|
|
741
|
+
super::ToolBudgetLevel::Percent(75),
|
|
742
|
+
"an explicit share must survive the tier"
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
#[test]
|
|
747
|
+
fn a_missing_config_file_loads_the_defaults() {
|
|
748
|
+
let temp = tempfile::tempdir().unwrap();
|
|
749
|
+
let paths = ControlPaths::for_home(temp.path());
|
|
750
|
+
let settings = super::load(&paths).unwrap();
|
|
751
|
+
assert_eq!(settings, super::FastCtxSettings::default());
|
|
752
|
+
}
|
|
753
|
+
}
|