1use std::{collections::BTreeMap, io::Write, path::PathBuf};
6
7use anyhow::{Context, Result, bail};
8use scv_client::Layout;
9
10use super::{
11 Config, ConfigOverrides, Deprecated, ProviderConfig, legacy,
12 validate::{validate_project_keys, validate_project_not_weaker},
13};
14
15const MAX_CONFIG_BYTES: u64 = 1024 * 1024;
16
17impl Config {
18 pub fn init_user_config(layout: &Layout) -> Result<PathBuf> {
21 let path = layout.config();
22 if let Some(parent) = path.parent() {
23 std::fs::create_dir_all(parent).context("create config directory")?;
24 ensure_private_dir(parent)?;
25 }
26 let content = "[provider]\nactive = \"openai\"\n\n[providers.openai]\nkind = \"openai-compatible\"\nmodel = \"gpt-4.1-mini\"\nbase_url = \"https://api.openai.com/v1\"\napi_key_env = \"OPENAI_API_KEY\"\n";
27 if !path.exists() {
28 let parent = path
29 .parent()
30 .ok_or_else(|| anyhow::anyhow!("configuration path has no parent"))?;
31 let mut temporary = tempfile::NamedTempFile::new_in(parent)
32 .context("create temporary example configuration")?;
33 #[cfg(unix)]
34 {
35 use std::os::unix::fs::PermissionsExt;
36 temporary
37 .as_file()
38 .set_permissions(std::fs::Permissions::from_mode(0o600))
39 .context("secure temporary configuration")?;
40 }
41 temporary
42 .write_all(content.as_bytes())
43 .context("write example configuration")?;
44 temporary
45 .as_file()
46 .sync_all()
47 .context("sync example configuration")?;
48 match temporary.persist(&path) {
49 Ok(_) => {}
50 Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => {}
51 Err(error) => return Err(error.error).context("install example configuration"),
52 }
53 }
54 Ok(path)
55 }
56 pub(crate) fn active_provider(&self) -> Result<ProviderConfig> {
57 if let Some(name) = self
58 .provider_active
59 .as_deref()
60 .or(self.provider.active.as_deref())
61 {
62 let profile =
63 self.providers.get(name).cloned().ok_or_else(|| {
64 anyhow::anyhow!("active provider profile {name:?} was not found")
65 })?;
66 if self.provider.reasoning_effort.is_some() {
69 bail!(
70 "provider.reasoning_effort has no effect while profile {name:?} is \
71 active; set it in [providers.{name}]"
72 );
73 }
74 return Ok(profile);
75 }
76 Ok(self.provider.clone())
77 }
78}
79
80impl Config {
81 pub fn load(
84 layout: &Layout,
85 workspace: &std::path::Path,
86 overrides: ConfigOverrides,
87 ) -> Result<Self> {
88 Self::load_layers(layout, Some(workspace), overrides)
89 }
90
91 pub fn load_user(layout: &Layout, overrides: ConfigOverrides) -> Result<Self> {
94 Self::load_layers(layout, None, overrides)
95 }
96
97 fn load_layers(
98 layout: &Layout,
99 workspace: Option<&std::path::Path>,
100 overrides: ConfigOverrides,
101 ) -> Result<Self> {
102 let instance_home = layout.home().to_owned();
103 std::fs::create_dir_all(&instance_home).context("create SCV instance home")?;
104 ensure_private_dir(&instance_home)?;
105 let mut value: toml::Value = toml::from_str(
106 &toml::to_string(&Self::default()).context("serialize default configuration")?,
107 )?;
108
109 let mut deprecated = Vec::new();
110 let user_path = layout.config();
111 if user_path.is_file() {
112 #[cfg(unix)]
113 {
114 use std::os::unix::fs::PermissionsExt;
115 if std::fs::metadata(&user_path)?.permissions().mode() & 0o077 != 0 {
116 bail!("user configuration is readable by group or others; run chmod 600");
117 }
118 }
119 merge(
120 &mut value,
121 read_migrated(&user_path, USER_LAYER, &mut deprecated)?,
122 );
123 }
124 let user_baseline: Self = value
125 .clone()
126 .try_into()
127 .context("parse user configuration")?;
128
129 if let Some(workspace) = workspace {
130 let project_path = workspace.join(".scv/config.toml");
131 let user_file = std::fs::canonicalize(layout.config()).ok();
135 if project_path.is_file() {
136 let canonical_project = std::fs::canonicalize(&project_path)
137 .with_context(|| format!("resolve configuration {}", project_path.display()))?;
138 if user_file.as_ref() != Some(&canonical_project) {
139 if !canonical_project.starts_with(workspace) {
140 bail!("project configuration escaped workspace");
141 }
142 let project =
143 read_migrated(&canonical_project, PROJECT_LAYER, &mut deprecated)?;
144 validate_project_keys(&project)?;
145 let mut candidate_value = value.clone();
146 merge(&mut candidate_value, project);
147 let candidate: Self = candidate_value
148 .clone()
149 .try_into()
150 .context("parse project configuration")?;
151 validate_project_not_weaker(&user_baseline, &candidate)?;
152 value = candidate_value;
153 }
154 }
155 }
156
157 if let Some(path) = &overrides.config_file {
158 #[cfg(unix)]
159 {
160 use std::os::unix::fs::PermissionsExt;
161 if std::fs::metadata(path)?.permissions().mode() & 0o077 != 0 {
162 bail!("explicit configuration is readable by group or others; run chmod 600");
163 }
164 }
165 let explicit = read_migrated(path, EXPLICIT_LAYER, &mut deprecated)?;
166 if explicit.get("channels").is_some() {
167 bail!(
168 "{} cannot set [channels]; channel accounts belong in the instance's config.toml",
169 path.display()
170 );
171 }
172 merge(&mut value, explicit);
173 }
174 let mut config: Self = value.try_into().context("parse merged configuration")?;
175 if let Some(name) = overrides.provider.as_deref() {
176 config.provider_active = Some(name.to_owned());
177 }
178 let selected = config.active_provider()?;
179 config.provider = selected;
180 if let Ok(model) = std::env::var("SCV_MODEL") {
181 config.provider.model = model;
182 }
183 if let Ok(base_url) = std::env::var("SCV_BASE_URL") {
184 config.provider.base_url = base_url;
185 }
186 if let Ok(api_key_env) = std::env::var("SCV_API_KEY_ENV") {
187 config.provider.api_key_env = Some(api_key_env);
188 }
189 if let Some(model) = overrides.model {
190 config.provider.model = model;
191 }
192 if let Some(base_url) = overrides.base_url {
193 config.provider.base_url = base_url;
194 }
195 if let Some(mode) = overrides.mode {
196 config.policy.mode = mode;
197 }
198 if config.skills.user_dir == std::path::Path::new("~/.scv/skills") {
199 config.skills.user_dir = layout.skills();
200 }
201 config.skills.user_dir = expand_home(&config.skills.user_dir);
202 if let Some(archive) = &config.history.archive_dir {
203 config.history.archive_dir = Some(expand_home(archive));
204 }
205 config.instance_home = instance_home;
206 config.deprecated = deprecated;
207 config.validate()?;
208 Ok(config)
209 }
210
211 pub fn settings_with_origins(
216 layout: &Layout,
217 workspace: Option<&std::path::Path>,
218 overrides: &ConfigOverrides,
219 ) -> Result<Vec<Setting>> {
220 let mut settings: BTreeMap<String, (toml::Value, String)> = BTreeMap::new();
221 let mut apply = |value: &toml::Value, origin: &str| {
222 flatten(value, String::new(), &mut |key, value| {
223 settings.insert(key, (value.clone(), origin.to_owned()));
224 });
225 };
226 let mut deprecated = Vec::new();
227 let defaults: toml::Value = toml::from_str(
228 &toml::to_string(&Self::default()).context("serialize default configuration")?,
229 )?;
230 apply(&defaults, "default");
231 let mut merged = defaults;
232 let user = Some(layout.config()).filter(|path| path.is_file());
233 if let Some(path) = &user {
234 let layer = read_migrated(path, USER_LAYER, &mut deprecated)?;
235 apply(&layer, USER_LAYER);
236 merge(&mut merged, layer);
237 }
238 if let Some(workspace) = workspace {
239 let project = workspace.join(".scv/config.toml");
240 let user_file = user
241 .as_ref()
242 .and_then(|path| std::fs::canonicalize(path).ok());
243 if project.is_file() && std::fs::canonicalize(&project).ok() != user_file {
244 let layer = read_migrated(&project, PROJECT_LAYER, &mut deprecated)?;
245 apply(&layer, PROJECT_LAYER);
246 merge(&mut merged, layer);
247 }
248 }
249 if let Some(path) = &overrides.config_file {
250 let layer = read_migrated(path, EXPLICIT_LAYER, &mut deprecated)?;
251 apply(&layer, EXPLICIT_LAYER);
252 merge(&mut merged, layer);
253 }
254 let active = overrides.provider.clone().or_else(|| {
257 merged
258 .get("provider")?
259 .get("active")?
260 .as_str()
261 .map(ToOwned::to_owned)
262 });
263 let has_profiles = merged
264 .get("providers")
265 .and_then(toml::Value::as_table)
266 .is_some_and(|profiles| !profiles.is_empty());
267 let prefix = match active {
268 Some(name) if has_profiles => format!("providers.{name}"),
269 _ => "provider".into(),
270 };
271 let mut set = |key: String, value: String, origin: &str| {
272 settings.insert(key, (toml::Value::String(value), origin.to_owned()));
273 };
274 if let Some(name) = &overrides.provider {
275 set("provider.active".into(), name.clone(), "--provider flag");
276 }
277 for (field, variable) in [
278 ("model", "SCV_MODEL"),
279 ("base_url", "SCV_BASE_URL"),
280 ("api_key_env", "SCV_API_KEY_ENV"),
281 ] {
282 if let Ok(value) = std::env::var(variable) {
283 set(
284 format!("{prefix}.{field}"),
285 value,
286 &format!("env {variable}"),
287 );
288 }
289 }
290 for (field, value, flag) in [
291 ("model", &overrides.model, "--model flag"),
292 ("base_url", &overrides.base_url, "--base-url flag"),
293 ] {
294 if let Some(value) = value {
295 set(format!("{prefix}.{field}"), value.clone(), flag);
296 }
297 }
298 if let Some(mode) = overrides.mode {
299 settings.insert(
300 "policy.mode".into(),
301 (mode.as_str().into(), "--policy flag".into()),
302 );
303 }
304 Ok(settings
305 .into_iter()
306 .filter(|(key, _)| !key.starts_with("channels."))
307 .map(|(key, (value, origin))| Setting {
308 value: if is_secret_key(&key) {
309 "<hidden>".into()
310 } else {
311 value.to_string()
312 },
313 note: deprecated
315 .iter()
316 .rev()
317 .find(|old: &&Deprecated| old.replacement == key && old.origin == origin)
318 .map(Deprecated::note),
319 key,
320 origin,
321 })
322 .collect())
323 }
324}
325
326const USER_LAYER: &str = "config.toml";
328const PROJECT_LAYER: &str = "project .scv/config.toml";
329const EXPLICIT_LAYER: &str = "SCV_CONFIG";
330
331fn read_migrated(
334 path: &std::path::Path,
335 origin: &str,
336 deprecated: &mut Vec<Deprecated>,
337) -> Result<toml::Value> {
338 let mut layer = read_layer(path)?;
339 deprecated.extend(legacy::migrate(&mut layer, origin)?);
340 Ok(layer)
341}
342
343pub(super) fn ensure_private_dir(path: &std::path::Path) -> Result<()> {
344 #[cfg(unix)]
345 {
346 use std::os::unix::fs::PermissionsExt;
347 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
348 .with_context(|| format!("secure directory {}", path.display()))?;
349 }
350 Ok(())
351}
352
353pub fn read_layer(path: &std::path::Path) -> Result<toml::Value> {
356 let size = std::fs::metadata(path)
357 .with_context(|| format!("stat configuration {}", path.display()))?
358 .len();
359 if size > MAX_CONFIG_BYTES {
360 bail!("configuration {} exceeds 1 MiB", path.display());
361 }
362 let content = std::fs::read_to_string(path)
363 .with_context(|| format!("read configuration {}", path.display()))?;
364 toml::from_str(&content).map_err(|error: toml::de::Error| {
367 let line = error.span().map_or_else(String::new, |span| {
368 format!(
369 " line {}",
370 content[..span.start.min(content.len())]
371 .matches('\n')
372 .count()
373 + 1
374 )
375 });
376 anyhow::anyhow!(
377 "parse configuration {}{line}: {}",
378 path.display(),
379 error.message()
380 )
381 })
382}
383
384pub(super) fn merge(base: &mut toml::Value, overlay: toml::Value) {
385 match (base, overlay) {
386 (toml::Value::Table(base), toml::Value::Table(overlay)) => {
387 for (key, value) in overlay {
388 match base.get_mut(&key) {
389 Some(existing) => merge(existing, value),
390 None => {
391 base.insert(key, value);
392 }
393 }
394 }
395 }
396 (base, overlay) => *base = overlay,
397 }
398}
399
400#[derive(Debug, Clone, PartialEq, Eq)]
402pub struct Setting {
403 pub key: String,
405 pub value: String,
407 pub origin: String,
410 pub note: Option<String>,
413}
414
415fn flatten(value: &toml::Value, prefix: String, visit: &mut impl FnMut(String, &toml::Value)) {
417 match value {
418 toml::Value::Table(table) => {
419 for (key, value) in table {
420 let key = if prefix.is_empty() {
421 key.clone()
422 } else {
423 format!("{prefix}.{key}")
424 };
425 flatten(value, key, visit);
426 }
427 }
428 leaf => visit(prefix, leaf),
429 }
430}
431
432pub(super) fn is_secret_key(key: &str) -> bool {
435 let last = key.rsplit('.').next().unwrap_or(key);
436 last == "api_key"
437 || last.ends_with("_api_key")
438 || last.contains("secret")
439 || last.contains("password")
440 || key.split('.').any(|segment| segment == "headers")
441}
442
443fn expand_home(path: &std::path::Path) -> PathBuf {
444 let value = path.to_string_lossy();
445 if value == "~" {
446 return dirs::home_dir().unwrap_or_else(|| path.to_path_buf());
447 }
448 if let Some(rest) = value.strip_prefix("~/")
449 && let Some(home) = dirs::home_dir()
450 {
451 return home.join(rest);
452 }
453 path.to_path_buf()
454}