Skip to main content

whycodes_config/
load.rs

1//! Load, save, env overlay, and custom-command markdown.
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use crate::types::{
7    CONFIG_SCHEMA_VERSION, CommandConfig, Config, CustomCommandConfig, SystemPromptOverlays,
8    nonempty_opt, overlay_key, parse_notify_on_csv,
9};
10use whycodes_core::network;
11use whycodes_core::types::{AgentInfo, ModelConfig, ProviderConfig};
12use whycodes_core::{Error, Result};
13
14pub(crate) fn toml_err(msg: String) -> Error {
15    Error::Config(msg)
16}
17
18pub(crate) fn ensure_parent_dir(path: &Path) -> Result<()> {
19    if let Some(parent) = path.parent()
20        && !parent.as_os_str().is_empty()
21    {
22        std::fs::create_dir_all(parent)?;
23    }
24    Ok(())
25}
26
27pub(crate) fn map_toml_ser(r: std::result::Result<String, toml::ser::Error>) -> Result<String> {
28    match r {
29        Ok(s) => Ok(s),
30        Err(e) => Err(toml_err(e.to_string())),
31    }
32}
33
34pub(crate) fn encode_toml(cfg: &Config) -> Result<String> {
35    map_toml_ser(toml::to_string_pretty(cfg))
36}
37
38/// Sibling tempfile + rename so a crash never leaves a half-written `config.toml`.
39///
40/// The temp name includes a thread id as well as a process-wide sequence.
41/// Parallel `save()` calls (CLI tests, migrate + explicit save) used to share
42/// `.{name}.tmp-{pid}-{seq}` and then fail with `File exists` when two
43/// `std::fs::write` calls landed on the same path.
44///
45/// `rename` replaces the destination atomically on Unix and on Windows
46/// (`MoveFileExW` + `MOVEFILE_REPLACE_EXISTING`), so there is no remove
47/// first: remove-then-rename left no `config.toml` if the process died in
48/// between, and two parallel saves raced on the remove (`NotFound`). One
49/// retry covers a transient Windows sharing violation.
50pub(crate) fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
51    let parent = path
52        .parent()
53        .filter(|p| !p.as_os_str().is_empty())
54        .unwrap_or_else(|| Path::new("."));
55    static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
56    let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
57    let thread = std::thread::current().id();
58    let tmp = parent.join(format!(
59        ".{}.tmp-{}-{thread:?}-{n}",
60        path.file_name()
61            .and_then(|n| n.to_str())
62            .unwrap_or("config.toml"),
63        std::process::id()
64    ));
65    std::fs::write(&tmp, contents)?;
66    let persist = || std::fs::rename(&tmp, path);
67    let Err(first) = persist() else { return Ok(()) };
68    persist().map_err(|e| fold_tmp_cleanup(e, &first, std::fs::remove_file(&tmp)).into())
69}
70
71/// Fold a failed temp cleanup into the persist error so the caller still sees
72/// one `Err`. Kept out of `write_atomic`: a temp that cannot be removed right
73/// after it was written is not reproducible in tests.
74pub(crate) fn fold_tmp_cleanup(
75    err: std::io::Error,
76    first: &std::io::Error,
77    cleanup: std::io::Result<()>,
78) -> std::io::Error {
79    match cleanup {
80        Ok(()) => err,
81        Err(cleanup) => std::io::Error::other(format!(
82            "{err}; tmp cleanup: {cleanup} (first attempt: {first})"
83        )),
84    }
85}
86
87/// A global `config.toml` text as a [`Config`]. A `[providers.foo]` table
88/// without `name` takes the key as its name.
89pub(crate) fn parse_global_text(content: &str) -> Result<Config> {
90    let mut cfg: Config = toml::from_str(content).map_err(|e| toml_err(e.to_string()))?;
91    for (key, provider) in &mut cfg.providers {
92        if provider.name.is_empty() {
93            provider.name = key.clone();
94        }
95    }
96    Ok(cfg)
97}
98
99/// A project overlay text as the layer `load_layered` merges: schema bumped,
100/// and the keys a project may not set dropped (with a warning).
101pub(crate) fn parse_project_text(
102    content: &str,
103    path: &Path,
104) -> std::result::Result<Config, toml::de::Error> {
105    let mut project = toml::from_str::<Config>(content)?;
106    clear_unset_default_agent(&mut project, content);
107    if project.schema_version < CONFIG_SCHEMA_VERSION {
108        project.schema_version = CONFIG_SCHEMA_VERSION;
109    }
110    let dropped = project.websearch.strip_project_only_keys();
111    if let Some(msg) = project_websearch_warning(path, &dropped) {
112        tracing::warn!("{msg}");
113    }
114    Ok(project)
115}
116
117/// A project file without `default_agent` leaves it empty, so merging keeps
118/// the lower layer's choice instead of the serde default (`build`).
119pub(crate) fn clear_unset_default_agent(project: &mut Config, content: &str) {
120    let table = toml::from_str::<toml::Table>(content).unwrap_or_default();
121    if !table.contains_key("default_agent") {
122        project.default_agent.clear();
123    }
124}
125
126fn warn_project_config(kind: &str, path: &Path, e: impl std::fmt::Display) {
127    let msg = format!("Failed to {kind} project config at {}: {e}", path.display());
128    tracing::warn!("{msg}");
129}
130
131/// Warning for `[websearch]` keys a project config may not set (see
132/// [`crate::WebSearchConfig`]); `None` when nothing was dropped.
133pub(crate) fn project_websearch_warning(path: &Path, dropped: &[&str]) -> Option<String> {
134    if dropped.is_empty() {
135        return None;
136    }
137    Some(format!(
138        "Ignoring {} from project config at {}: the custom search endpoint, its headers and header_env are read only from the global config.",
139        dropped.join(", "),
140        path.display()
141    ))
142}
143
144/// Copy `config.toml` / `auth.json` / `whycodes.db` from a pre-0.7 ProjectDirs
145/// location into `~/.whycodes` when the new root is empty. Best-effort: a
146/// read-only dest or missing source is a no-op. `WHYCODES_HOME` skips this
147/// (tests and isolated instances own their own tree).
148pub(crate) fn migrate_legacy_instance() {
149    if whycodes_core::paths::whycodes_home().is_some() {
150        return;
151    }
152    let dest = whycodes_core::paths::instance_root();
153    let sources = whycodes_core::paths::legacy_instance_roots();
154    migrate_legacy_into(&dest, &sources);
155}
156
157pub(crate) fn migrate_legacy_into(dest: &Path, sources: &[PathBuf]) {
158    if dest_has_instance_files(dest) {
159        return;
160    }
161    let Some(src) = sources.iter().find(|root| dest_has_instance_files(root)) else {
162        return;
163    };
164    if let Err(e) = std::fs::create_dir_all(dest) {
165        tracing::warn!("legacy config migrate: mkdir {}: {e}", dest.display());
166        return;
167    }
168    for name in ["config.toml", "auth.json", "whycodes.db"] {
169        copy_if_missing(&src.join(name), &dest.join(name));
170    }
171}
172
173pub(crate) fn dest_has_instance_files(dest: &Path) -> bool {
174    dest.join("config.toml").is_file()
175        || dest.join("auth.json").is_file()
176        || dest.join("whycodes.db").is_file()
177}
178
179pub(crate) fn copy_if_missing(src: &Path, dest: &Path) {
180    if !src.is_file() || dest.exists() {
181        return;
182    }
183    match std::fs::copy(src, dest) {
184        Ok(_) => tracing::info!("migrated {} → {}", src.display(), dest.display()),
185        Err(e) => tracing::warn!("legacy config migrate: copy {} failed: {e}", dest.display()),
186    }
187}
188
189impl Config {
190    // ── Loading / Saving ────────────────────────────────────────────────
191
192    /// Load config from the default location.
193    ///
194    /// A missing file returns the OpenRouter seed **without** writing. Call
195    /// [`Self::load_or_create`] (or [`Self::load_layered`]) to persist it.
196    /// Tests and TUI dialogs that only peek at disk must not create
197    /// `~/.whycodes/config.toml` as a side effect.
198    pub fn load() -> Result<Self> {
199        migrate_legacy_instance();
200        Self::read_or_seed(&Self::default_path()?)
201    }
202
203    /// Load, and on first run write the OpenRouter seed to `config.toml`.
204    pub fn load_or_create() -> Result<Self> {
205        migrate_legacy_instance();
206        let path = Self::default_path()?;
207        if !path.exists() {
208            let cfg = Self::seeded_openrouter();
209            match cfg.save() {
210                Ok(()) => return Ok(cfg),
211                Err(e) => {
212                    tracing::warn!("first-run config.toml could not be written: {e}");
213                    return Ok(cfg);
214                }
215            }
216        }
217        Self::read_or_seed(&path)
218    }
219
220    fn read_or_seed(path: &Path) -> Result<Self> {
221        let mut cfg = Self::read_raw(path)?;
222        cfg.expand_notify_secrets();
223        Ok(cfg)
224    }
225
226    /// The file as written, schema bumped. `${VAR}` placeholders stay
227    /// unexpanded, so the schema rewrite never writes a secret's value.
228    fn read_raw(path: &Path) -> Result<Self> {
229        if !path.exists() {
230            return Ok(Self::seeded_openrouter());
231        }
232        let content = std::fs::read_to_string(path)?;
233        let mut cfg = parse_global_text(&content)?;
234        cfg.migrate_schema()?;
235        Ok(cfg)
236    }
237
238    /// The global `config.toml` alone, as written: no project layer, no
239    /// environment overlay, no `${VAR}` expansion, nothing from CLI flags.
240    /// A missing file is the first-run seed.
241    pub fn load_global_raw() -> Result<Self> {
242        migrate_legacy_instance();
243        Self::read_raw(&Self::default_path()?)
244    }
245
246    /// Persist one change: apply `edit` to [`Self::load_global_raw`] and
247    /// save that. Callers keep their merged, in-memory config for the run
248    /// and pass the same edit here, so values from the project layer, the
249    /// environment or CLI flags never land in the global file.
250    ///
251    /// Only the keys the edit changed are written: the file keeps its other
252    /// keys, comments and order, and gains no defaults it did not have.
253    pub fn update_global(edit: &mut dyn FnMut(&mut Config)) -> Result<()> {
254        let before = Self::load_global_raw()?;
255        let mut after = before.clone();
256        edit(&mut after);
257        Self::patch_global_file(&before, &after)
258    }
259
260    /// Write the change from `before` to `after` into the global file. No
261    /// file yet (first run): the whole config, as [`Self::save`] does.
262    fn patch_global_file(before: &Config, after: &Config) -> Result<()> {
263        let path = Self::default_path()?;
264        if !path.exists() {
265            return after.save();
266        }
267        let text = std::fs::read_to_string(&path)?;
268        let content =
269            crate::patch::patched_text(&text, &before.to_toml_value()?, &after.to_toml_value()?)?;
270        write_atomic(&path, content.as_bytes())
271    }
272
273    /// Rewrite an older `config.toml` in place so later loads see the current
274    /// schema. Missing `schema_version` is treated as `0`.
275    pub fn migrate_schema(&mut self) -> Result<bool> {
276        if self.schema_version >= CONFIG_SCHEMA_VERSION {
277            return Ok(false);
278        }
279        let before = self.clone();
280        self.schema_version = CONFIG_SCHEMA_VERSION;
281        // Best-effort: a read-only home should still run with the in-memory
282        // defaults for new fields. The next writable save persists the bump.
283        // Only `schema_version` changes in the file.
284        match Self::patch_global_file(&before, self) {
285            Ok(()) => Ok(true),
286            Err(e) => {
287                tracing::warn!("config schema migrate could not rewrite config.toml: {e}");
288                Ok(true)
289            }
290        }
291    }
292
293    /// Save config to the default location
294    pub fn save(&self) -> Result<()> {
295        let path = Self::default_path()?;
296        ensure_parent_dir(&path)?;
297        let content = encode_toml(self)?;
298        write_atomic(&path, content.as_bytes())?;
299        Ok(())
300    }
301
302    /// Get default config path
303    pub fn default_path() -> Result<PathBuf> {
304        Ok(whycodes_core::paths::config_file())
305    }
306
307    /// Get data directory for sessions, caches, etc.
308    pub fn data_dir() -> Result<PathBuf> {
309        Ok(whycodes_core::paths::data_dir())
310    }
311
312    // ── Layered config loading ──────────────────────────────────────────
313
314    /// Load config using priority-based layering:
315    /// 1. Built-in defaults
316    /// 2. Global config (`~/.whycodes/config.toml`, or `$WHYCODES_HOME/config.toml`)
317    /// 3. Project config (`<project>/.whycodes/config.toml`)
318    /// 4. Environment variables (WHYCODES_*)
319    ///
320    /// Returns the merged configuration.
321    pub fn load_layered(project_dir: &Path) -> Result<Self> {
322        let mut config = Self::default();
323
324        // Layer 2: global config (creates the OpenRouter seed on first run)
325        match Self::load_or_create() {
326            Ok(global) => {
327                config = config.merge_with(&global);
328            }
329            Err(e) => {
330                // Don't silently drop a broken config.toml — that falls back to
331                // hard-coded anthropic and looks like "default model ignored".
332                tracing::error!("Failed to load global config: {e}");
333                return Err(e);
334            }
335        }
336
337        // Layer 3: project config
338        if let Some(project) = Self::load_project_layer(project_dir) {
339            config = config.merge_with(&project);
340        }
341        // `.whycodes/rules/*.md` stream rules overlay the config tables.
342        config.load_stream_rule_files(project_dir);
343
344        // Layer 4: environment variables
345        config.apply_env_overrides();
346        config.expand_notify_secrets();
347        config.apply_slop_file(project_dir);
348
349        Ok(config)
350    }
351
352    /// Layer 3 of [`Self::load_layered`] on its own: `<project>/.whycodes/config.toml`
353    /// parsed but not merged. `None` when the file is absent, unreadable or
354    /// malformed (the last two are logged, as the layered load does).
355    pub fn load_project_layer(project_dir: &Path) -> Option<Self> {
356        let project_config_path = Self::project_config_path(project_dir);
357        if !project_config_path.exists() {
358            return None;
359        }
360        match std::fs::read_to_string(&project_config_path) {
361            Ok(content) => match parse_project_text(&content, &project_config_path) {
362                Ok(project) => Some(project),
363                Err(e) => {
364                    warn_project_config("parse", &project_config_path, e);
365                    None
366                }
367            },
368            Err(e) => {
369                warn_project_config("read", &project_config_path, e);
370                None
371            }
372        }
373    }
374
375    /// Set an OpenRouter API key here and in the global `config.toml`.
376    pub fn set_openrouter_api_key(&mut self, key: &str) -> Result<()> {
377        let trimmed = key.trim();
378        if trimmed.is_empty() {
379            return Err(toml_err("OpenRouter API key is empty".into()));
380        }
381        self.put_openrouter_api_key(trimmed);
382        Self::update_global(&mut |global| global.put_openrouter_api_key(trimmed))
383    }
384
385    fn put_openrouter_api_key(&mut self, trimmed: &str) {
386        use crate::types::{DEFAULT_MODEL_ID, DEFAULT_PROVIDER};
387        let entry = self
388            .providers
389            .entry(DEFAULT_PROVIDER.to_string())
390            .or_insert_with(|| ProviderConfig {
391                name: DEFAULT_PROVIDER.to_string(),
392                api_key: None,
393                api_base: None,
394                base_url: None,
395                headers: None,
396                models: Vec::new(),
397                tool_arguments: None,
398                extra: HashMap::new(),
399                credentials: Default::default(),
400            });
401        if entry.name.is_empty() {
402            entry.name = DEFAULT_PROVIDER.to_string();
403        }
404        entry.api_key = Some(trimmed.to_string());
405        if self.default_model.is_none() {
406            self.default_model = Some(ModelConfig {
407                model_id: DEFAULT_MODEL_ID.to_string(),
408                provider_id: DEFAULT_PROVIDER.to_string(),
409                max_tokens: None,
410                context_window: None,
411                temperature: None,
412                top_p: None,
413                thinking: None,
414                supports_tools: None,
415                supports_images: None,
416            });
417        }
418    }
419
420    /// Remember that the first-launch OpenRouter key modal was skipped.
421    pub fn mark_openrouter_key_prompt_skipped(&mut self) -> Result<()> {
422        self.tui.skip_openrouter_key_prompt = true;
423        Self::update_global(&mut |global| global.tui.skip_openrouter_key_prompt = true)
424    }
425
426    /// First-run `config.toml`: OpenRouter provider, no API key yet.
427    pub fn seeded_openrouter() -> Self {
428        use crate::types::{DEFAULT_MODEL_ID, DEFAULT_PROVIDER};
429        let mut cfg = Self::default();
430        cfg.providers.insert(
431            DEFAULT_PROVIDER.to_string(),
432            ProviderConfig {
433                name: DEFAULT_PROVIDER.to_string(),
434                api_key: None,
435                api_base: None,
436                base_url: None,
437                headers: None,
438                models: Vec::new(),
439                tool_arguments: None,
440                extra: HashMap::new(),
441                credentials: Default::default(),
442            },
443        );
444        cfg.default_model = Some(ModelConfig {
445            model_id: DEFAULT_MODEL_ID.to_string(),
446            provider_id: DEFAULT_PROVIDER.to_string(),
447            max_tokens: None,
448            context_window: None,
449            temperature: None,
450            top_p: None,
451            thinking: None,
452            supports_tools: None,
453            supports_images: None,
454        });
455        cfg
456    }
457
458    /// Overlay `.whycodes/slop.toml` when present (project-local tripwires).
459    pub fn apply_slop_file(&mut self, project_dir: &Path) {
460        let path = crate::project_slop_path(project_dir);
461        if !path.exists() {
462            return;
463        }
464        match std::fs::read_to_string(&path) {
465            Ok(text) => match toml::from_str::<crate::types::SlopConfig>(&text) {
466                Ok(overlay) => self.slop = self.slop.merge_with(&overlay),
467                Err(e) => {
468                    tracing::warn!("Failed to parse slop config at {}: {e}", path.display());
469                }
470            },
471            Err(e) => {
472                tracing::warn!("Failed to read slop config at {}: {e}", path.display());
473            }
474        }
475    }
476
477    /// Apply environment variable overrides to this config in-place.
478    pub fn apply_env_overrides(&mut self) {
479        self.apply_provider_model_env();
480        self.apply_session_env();
481        self.apply_security_env();
482        self.apply_feature_env();
483        self.apply_swarm_env();
484        self.apply_notify_env();
485    }
486
487    fn apply_provider_model_env(&mut self) {
488        // WHYCODES_PROVIDER — set as the default provider (first entry) and
489        // also populate a basic ProviderConfig if one doesn't exist.
490        if let Ok(provider_name) = std::env::var("WHYCODES_PROVIDER") {
491            if !self.providers.contains_key(&provider_name) {
492                // Auto-create a basic provider entry
493                let api_key = std::env::var(format!("{}_API_KEY", provider_name.to_uppercase()))
494                    .ok()
495                    .or_else(|| std::env::var("OPENAI_API_KEY").ok())
496                    .or_else(|| std::env::var("ANTHROPIC_API_KEY").ok());
497
498                self.providers.insert(
499                    provider_name.clone(),
500                    ProviderConfig {
501                        name: provider_name.clone(),
502                        api_key,
503                        api_base: None,
504                        base_url: None,
505                        headers: None,
506                        models: Vec::new(),
507                        tool_arguments: None,
508                        extra: HashMap::new(),
509                        credentials: Default::default(),
510                    },
511                );
512            }
513
514            // If no default model is set, try to pick it up from WHYCODES_MODEL
515            if self.default_model.is_none()
516                && let Ok(model_name) = std::env::var("WHYCODES_MODEL")
517            {
518                self.default_model = Some(ModelConfig {
519                    model_id: model_name,
520                    provider_id: provider_name,
521                    max_tokens: None,
522                    context_window: None,
523                    temperature: None,
524                    top_p: None,
525                    thinking: None,
526                    supports_tools: None,
527                    supports_images: None,
528                });
529            }
530        } else if let Ok(model_name) = std::env::var("WHYCODES_MODEL") {
531            // Model set without provider — update default model's model_id if
532            // one exists, otherwise create a minimal entry.
533            if let Some(ref mut default_model) = self.default_model {
534                default_model.model_id = model_name;
535            } else {
536                self.default_model = Some(ModelConfig {
537                    model_id: model_name,
538                    provider_id: String::new(),
539                    max_tokens: None,
540                    context_window: None,
541                    temperature: None,
542                    top_p: None,
543                    thinking: None,
544                    supports_tools: None,
545                    supports_images: None,
546                });
547            }
548        }
549    }
550
551    fn apply_session_env(&mut self) {
552        // WHYCODES_MAX_TURNS
553        if let Ok(val) = std::env::var("WHYCODES_MAX_TURNS")
554            && let Ok(n) = val.parse::<usize>()
555        {
556            self.session.max_context_tokens = n;
557        }
558        if let Ok(val) = std::env::var("WHYCODES_UNDO_DEPTH")
559            && let Ok(n) = val.parse::<usize>()
560        {
561            self.session.undo_depth = n;
562        }
563
564        // WHYCODES_LOG_LEVEL
565        if let Ok(val) = std::env::var("WHYCODES_LOG_LEVEL") {
566            self.general.log_level = Some(val);
567        }
568
569        // WHYCODES_PROJECT_DIR
570        if let Ok(val) = std::env::var("WHYCODES_PROJECT_DIR") {
571            self.general.project_path = Some(PathBuf::from(val));
572        }
573
574        // WHYCODES_COMPACTION_METHOD — `auto` | `local`; anything else is ignored.
575        if let Ok(val) = std::env::var("WHYCODES_COMPACTION_METHOD") {
576            let val = val.trim().to_ascii_lowercase();
577            if matches!(val.as_str(), "auto" | "local") {
578                self.session.compaction_method = val;
579            }
580        }
581    }
582
583    fn apply_security_env(&mut self) {
584        if let Ok(val) = std::env::var("WHYCODES_SANDBOX") {
585            self.security.sandbox = val;
586        }
587        if let Ok(val) = std::env::var("WHYCODES_SANDBOX_NETWORK") {
588            self.security.sandbox_network = matches!(
589                val.to_ascii_lowercase().as_str(),
590                "1" | "true" | "yes" | "on"
591            );
592        }
593        if let Ok(val) = std::env::var("WHYCODES_SANDBOX_FALLBACK") {
594            self.security.sandbox_fallback = val;
595        }
596        if let Ok(val) = std::env::var("WHYCODES_REDACT_SECRETS") {
597            self.security.redact_secrets = matches!(
598                val.to_ascii_lowercase().as_str(),
599                "1" | "true" | "yes" | "on"
600            );
601        }
602        if let Ok(val) = std::env::var("WHYCODES_NETWORK_ALLOWLIST") {
603            self.security.network_allowlist = network::parse_domain_list(&val);
604        }
605        if let Ok(val) = std::env::var("WHYCODES_NETWORK_DENYLIST") {
606            self.security.network_denylist = network::parse_domain_list(&val);
607        }
608        if let Ok(val) = std::env::var("WHYCODES_WEBSEARCH_ENGINE")
609            && !val.trim().is_empty()
610        {
611            self.websearch.engine = Some(val.trim().to_ascii_lowercase());
612        }
613    }
614
615    fn apply_feature_env(&mut self) {
616        // WHYCODES_NO_MEMORY=1 disables cross-session memory inject/write.
617        if let Ok(val) = std::env::var("WHYCODES_NO_MEMORY")
618            && matches!(
619                val.to_ascii_lowercase().as_str(),
620                "1" | "true" | "yes" | "on"
621            )
622        {
623            self.memory.enabled = false;
624        }
625        if let Ok(val) = std::env::var("WHYCODES_NO_AUTO_UPDATE")
626            && matches!(
627                val.to_ascii_lowercase().as_str(),
628                "1" | "true" | "yes" | "on"
629            )
630        {
631            self.general.auto_update = false;
632        }
633        if let Ok(val) = std::env::var("WHYCODES_AUTO_UPDATE") {
634            match val.to_ascii_lowercase().as_str() {
635                "0" | "false" | "no" | "off" => self.general.auto_update = false,
636                "1" | "true" | "yes" | "on" => self.general.auto_update = true,
637                _ => {}
638            }
639        }
640        if let Ok(val) = std::env::var("WHYCODES_APPROVAL_MODE")
641            && let Some(mode) = whycodes_core::types::ApprovalMode::parse(&val)
642        {
643            self.general.approval_mode = Some(mode);
644        }
645        if let Ok(val) = std::env::var("WHYCODES_HEADLESS_ASK")
646            && let Some(mode) = crate::HeadlessAskMode::parse(&val)
647        {
648            self.session.headless_ask = Some(mode);
649        }
650        if let Ok(val) = std::env::var("WHYCODES_PREWALK") {
651            match val.to_ascii_lowercase().as_str() {
652                "0" | "false" | "no" | "off" => self.session.prewalk = Some(false),
653                "1" | "true" | "yes" | "on" => self.session.prewalk = Some(true),
654                _ => {}
655            }
656        }
657        if let Ok(val) = std::env::var("WHYCODES_MEMORY") {
658            match val.to_ascii_lowercase().as_str() {
659                "0" | "false" | "no" | "off" => self.memory.enabled = false,
660                "1" | "true" | "yes" | "on" => self.memory.enabled = true,
661                _ => {}
662            }
663        }
664    }
665
666    fn apply_swarm_env(&mut self) {
667        // WHYCODES_SWARM=0/1 toggles parallel multi-agent.
668        if let Ok(val) = std::env::var("WHYCODES_SWARM") {
669            match val.to_ascii_lowercase().as_str() {
670                "0" | "false" | "no" | "off" => self.swarm.enabled = false,
671                "1" | "true" | "yes" | "on" => self.swarm.enabled = true,
672                _ => {}
673            }
674        }
675        if let Ok(val) = std::env::var("WHYCODES_SWARM_MAX_AGENTS")
676            && let Ok(n) = val.parse::<usize>()
677        {
678            self.swarm.max_agents = n.clamp(1, 8);
679        }
680        if let Ok(val) = std::env::var("WHYCODES_SWARM_WORKTREES") {
681            match val.to_ascii_lowercase().as_str() {
682                "0" | "false" | "no" | "off" => self.swarm.worktrees = false,
683                "1" | "true" | "yes" | "on" => self.swarm.worktrees = true,
684                _ => {}
685            }
686        }
687    }
688
689    fn apply_notify_env(&mut self) {
690        if let Ok(val) = std::env::var("WHYCODES_NOTIFY_ON") {
691            self.notify.on = parse_notify_on_csv(&val);
692        }
693        if let Ok(val) = std::env::var("WHYCODES_NOTIFY_DISCORD_WEBHOOK") {
694            self.notify.discord_webhook = Some(val);
695        }
696        if let Ok(val) = std::env::var("WHYCODES_NOTIFY_TELEGRAM_BOT_TOKEN") {
697            self.notify.telegram_bot_token = Some(val);
698        }
699        if let Ok(val) = std::env::var("WHYCODES_NOTIFY_TELEGRAM_CHAT_ID") {
700            self.notify.telegram_chat_id = Some(val);
701        }
702        if let Ok(val) = std::env::var("WHYCODES_NOTIFY_TIMEOUT_SECS")
703            && let Ok(n) = val.parse::<u64>()
704        {
705            self.notify.timeout_secs = n.clamp(1, 60);
706        }
707        if let Ok(val) = std::env::var("WHYCODES_NOTIFY_DESKTOP") {
708            match val.trim().to_ascii_lowercase().as_str() {
709                "1" | "true" | "yes" | "on" => self.notify.desktop = Some(true),
710                "0" | "false" | "no" | "off" => self.notify.desktop = Some(false),
711                _ => {}
712            }
713        }
714    }
715
716    /// Load custom commands from markdown files:
717    /// - global: `~/.config/.../commands/*.md`
718    /// - project: `<project>/.whycodes/commands/*.md`
719    pub fn load_command_files(&mut self, project_dir: &Path) {
720        if let Ok(global_dir) = Self::default_path()
721            && let Some(parent) = global_dir.parent()
722        {
723            load_commands_from_dir(&mut self.commands, &parent.join("commands"));
724        }
725        load_commands_from_dir(
726            &mut self.commands,
727            &whycodes_core::project_dir(project_dir).join("commands"),
728        );
729        // also accept OpenCode-style .opencode/commands
730        load_commands_from_dir(
731            &mut self.commands,
732            &project_dir.join(".opencode").join("commands"),
733        );
734        // Built-ins last only for missing keys — user/project markdown wins.
735        self.ensure_builtin_prompt_commands();
736        self.load_prompt_files(project_dir);
737    }
738
739    /// Load provider/model system-prompt extras from markdown:
740    /// - global: `~/.config/.../prompts/`
741    /// - project: `<project>/.whycodes/prompts/` (wins on the same key)
742    pub fn load_prompt_files(&mut self, project_dir: &Path) {
743        if let Ok(global_dir) = Self::default_path()
744            && let Some(parent) = global_dir.parent()
745        {
746            load_prompt_overlays(&mut self.system_prompt_overlays, &parent.join("prompts"));
747        }
748        load_prompt_overlays(
749            &mut self.system_prompt_overlays,
750            &whycodes_core::project_dir(project_dir).join("prompts"),
751        );
752    }
753
754    /// PromptCommands: fixed prompts that kick a turn.
755    /// Does not overwrite user-defined `commands.*` or `commands/*.md`.
756    pub fn ensure_builtin_prompt_commands(&mut self) {
757        for (name, cmd) in builtin_prompt_commands() {
758            self.commands.entry(name.to_string()).or_insert(cmd);
759        }
760    }
761}
762
763/// Built-in workflow slash prompts (A1). Keys without leading `/`.
764fn builtin_prompt_commands() -> Vec<(&'static str, CustomCommandConfig)> {
765    vec![
766        (
767            "review",
768            CustomCommandConfig {
769                description: Some("AI code review of git changes".into()),
770                agent: Some("plan".into()),
771                model: None,
772                subtask: None,
773                template: r#"Review the current working tree changes for this project.
774
7751. Run git_status and git_diff (unstaged and staged) to see what changed.
7762. Read only the files that matter for the diff.
7773. Produce a structured review:
778   - Summary (2–4 sentences)
779   - Strengths
780   - Issues (severity: high / medium / low) with file:line when possible
781   - Suggested follow-ups
7824. Do NOT write or edit files unless the user explicitly asked to apply fixes.
7835. Prefer read-only tools (git_*, read, grep, glob, list).
784
785Extra context from the user: $ARGUMENTS"#
786                    .into(),
787            },
788        ),
789        (
790            "security-review",
791            CustomCommandConfig {
792                description: Some("Security-focused review of changes".into()),
793                agent: Some("plan".into()),
794                model: None,
795                subtask: None,
796                template: r#"Perform a security-focused review of the current git changes.
797
7981. Inspect git_status / git_diff and relevant files.
7992. Look for: secrets/credentials, injection (SQL/command/path), authz gaps,
800   unsafe shell, SSRF, insecure deserialization, dependency risks, XSS.
8013. Output:
802   - Executive summary
803   - Findings table: severity | location | issue | remediation
804   - What looks safe
8054. Do not modify files unless asked. Prefer read-only tools.
806
807Focus / notes: $ARGUMENTS"#
808                    .into(),
809            },
810        ),
811        (
812            "commit",
813            CustomCommandConfig {
814                description: Some("Draft a git commit message from the diff".into()),
815                agent: None,
816                model: None,
817                subtask: None,
818                template: r#"Prepare a git commit for the current changes.
819
8201. Run git_status and git_diff (include staged if any).
8212. Draft a concise commit message (subject ≤72 chars; body if needed).
8223. If the user asked to commit (or says "commit now" / "yes"), stage relevant
823   files and use git_commit. Otherwise show the proposed message and ask.
8244. Never force-push, never amend published history, never commit secrets (.env, keys).
825
826User notes: $ARGUMENTS"#
827                    .into(),
828            },
829        ),
830    ]
831}
832
833fn load_prompt_overlays(into: &mut SystemPromptOverlays, dir: &Path) {
834    load_prompt_overlays_at(into, dir, None);
835}
836
837fn load_prompt_overlays_at(into: &mut SystemPromptOverlays, dir: &Path, provider: Option<&str>) {
838    let entries = match std::fs::read_dir(dir) {
839        Ok(e) => e,
840        Err(_missing_dir) => return,
841    };
842    for entry in entries.flatten() {
843        let path = entry.path();
844        if path.is_dir() {
845            if provider.is_some() {
846                continue;
847            }
848            let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
849                continue;
850            };
851            if name.eq_ignore_ascii_case("models") {
852                load_model_overlay_dir(into, &path);
853            } else {
854                load_prompt_overlays_at(into, &path, Some(name));
855            }
856            continue;
857        }
858        if path.extension().and_then(|e| e.to_str()) != Some("md") {
859            continue;
860        }
861        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
862            continue;
863        };
864        let Some(body) = read_overlay_markdown(&path) else {
865            continue;
866        };
867        if let Some(p) = provider {
868            into.models
869                .insert(format!("{}/{}", overlay_key(p), overlay_key(stem)), body);
870        } else {
871            into.providers.insert(overlay_key(stem), body);
872        }
873    }
874}
875
876fn load_model_overlay_dir(into: &mut SystemPromptOverlays, dir: &Path) {
877    let entries = match std::fs::read_dir(dir) {
878        Ok(e) => e,
879        Err(_missing_dir) => return,
880    };
881    for entry in entries.flatten() {
882        let path = entry.path();
883        if path.is_dir() {
884            continue;
885        }
886        if path.extension().and_then(|e| e.to_str()) != Some("md") {
887            continue;
888        }
889        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
890            continue;
891        };
892        let Some(body) = read_overlay_markdown(&path) else {
893            continue;
894        };
895        into.models.insert(overlay_key(stem), body);
896    }
897}
898
899fn read_overlay_markdown(path: &Path) -> Option<String> {
900    let content = std::fs::read_to_string(path).ok()?;
901    let body = strip_overlay_frontmatter(&content);
902    if body.is_empty() { None } else { Some(body) }
903}
904
905fn strip_overlay_frontmatter(content: &str) -> String {
906    let trimmed = content.trim();
907    if let Some(rest) = trimmed.strip_prefix("---")
908        && let Some((_, body)) = rest.split_once("---")
909    {
910        return body.trim().to_string();
911    }
912    trimmed.to_string()
913}
914
915fn load_commands_from_dir(into: &mut HashMap<String, CustomCommandConfig>, dir: &Path) {
916    let entries = match std::fs::read_dir(dir) {
917        Ok(e) => e,
918        Err(_) => return,
919    };
920    for entry in entries.flatten() {
921        let path = entry.path();
922        if path.extension().and_then(|e| e.to_str()) != Some("md") {
923            continue;
924        }
925        let name = path
926            .file_stem()
927            .and_then(|s| s.to_str())
928            .unwrap_or("cmd")
929            .to_string();
930        if let Ok(content) = std::fs::read_to_string(&path)
931            && let Some(cmd) = parse_command_markdown(&content)
932        {
933            into.insert(name, cmd);
934        }
935    }
936}
937
938/// Parse command markdown with YAML frontmatter.
939pub(crate) fn parse_command_markdown(content: &str) -> Option<CustomCommandConfig> {
940    let content = content.trim();
941    if !content.starts_with("---") {
942        return Some(CustomCommandConfig {
943            template: content.to_string(),
944            description: None,
945            agent: None,
946            model: None,
947            subtask: None,
948        });
949    }
950    let (front, body) = content.strip_prefix("---")?.split_once("---")?;
951    let front = front.trim();
952    let body = body.trim().to_string();
953
954    let mut description = None;
955    let mut agent = None;
956    let mut model = None;
957    let mut subtask = None;
958    for line in front.lines() {
959        let line = line.trim();
960        if let Some((k, v)) = line.split_once(':') {
961            let k = k.trim();
962            let v = v.trim().trim_matches('"').trim_matches('\'').to_string();
963            match k {
964                "description" => description = Some(v),
965                "agent" => agent = Some(v),
966                "model" => model = Some(v),
967                "subtask" => {
968                    subtask = Some(matches!(
969                        v.to_ascii_lowercase().as_str(),
970                        "true" | "yes" | "1"
971                    ))
972                }
973                _ => {}
974            }
975        }
976    }
977
978    Some(CustomCommandConfig {
979        template: body,
980        description,
981        agent,
982        model,
983        subtask,
984    })
985}
986
987impl Config {
988    /// Get per-command configuration overrides for the given command name.
989    pub fn get_command_config(&self, command: &str) -> Option<&CommandConfig> {
990        self.command_configs.get(command)
991    }
992
993    /// Expand `${VAR}` / `$VAR` in Discord / Telegram secret fields.
994    ///
995    /// Call after layered load so `config.toml` can store
996    /// `discord_webhook = "${DISCORD_WEBHOOK_URL}"` without committing the URL.
997    pub fn expand_notify_secrets(&mut self) {
998        if let Some(url) = self.notify.discord_webhook.take() {
999            let expanded = Self::substitute_vars(&url);
1000            self.notify.discord_webhook = nonempty_opt(expanded);
1001        }
1002        if let Some(token) = self.notify.telegram_bot_token.take() {
1003            let expanded = Self::substitute_vars(&token);
1004            self.notify.telegram_bot_token = nonempty_opt(expanded);
1005        }
1006        if let Some(chat) = self.notify.telegram_chat_id.take() {
1007            let expanded = Self::substitute_vars(&chat);
1008            self.notify.telegram_chat_id = nonempty_opt(expanded);
1009        }
1010    }
1011
1012    // ── Variable substitution ───────────────────────────────────────────
1013
1014    /// Replace `${VAR_NAME}` and `$VAR_NAME` patterns with environment
1015    /// variable values. Unknown variables are left as-is.
1016    pub fn substitute_vars(value: &str) -> String {
1017        let mut result = String::with_capacity(value.len());
1018        let mut chars = value.chars().peekable();
1019
1020        while let Some(ch) = chars.next() {
1021            if ch == '$' {
1022                match chars.peek() {
1023                    Some('{') => {
1024                        // ${VAR_NAME} form
1025                        chars.next(); // consume '{'
1026                        let mut var_name = String::new();
1027                        while let Some(&c) = chars.peek() {
1028                            if c == '}' {
1029                                chars.next(); // consume '}'
1030                                break;
1031                            }
1032                            var_name.push(c);
1033                            chars.next();
1034                        }
1035                        // Replace if env var exists, otherwise leave literal
1036                        if let Ok(val) = std::env::var(&var_name) {
1037                            result.push_str(&val);
1038                        } else {
1039                            result.push_str(&format!("${{{}}}", var_name));
1040                        }
1041                    }
1042                    Some(c) if c.is_alphanumeric() || *c == '_' => {
1043                        // $VAR_NAME form
1044                        let mut var_name = String::new();
1045                        while let Some(&c) = chars.peek() {
1046                            if c.is_alphanumeric() || c == '_' {
1047                                var_name.push(c);
1048                                chars.next();
1049                            } else {
1050                                break;
1051                            }
1052                        }
1053                        if let Ok(val) = std::env::var(&var_name) {
1054                            result.push_str(&val);
1055                        } else {
1056                            result.push_str(&format!("${}", var_name));
1057                        }
1058                    }
1059                    _ => {
1060                        // lone '$' — pass through
1061                        result.push('$');
1062                    }
1063                }
1064            } else {
1065                result.push(ch);
1066            }
1067        }
1068
1069        result
1070    }
1071
1072    /// Get the configured project path or current directory
1073    pub fn project_path(&self) -> PathBuf {
1074        self.general
1075            .project_path
1076            .clone()
1077            .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
1078    }
1079
1080    /// Get provider by name
1081    pub fn get_provider(&self, name: &str) -> Option<&ProviderConfig> {
1082        self.providers.get(name)
1083    }
1084
1085    /// Get model config
1086    pub fn get_model(&self, provider: &str, model: &str) -> Option<&ModelConfig> {
1087        self.models
1088            .values()
1089            .find(|m| m.provider_id == provider && m.model_id == model)
1090    }
1091
1092    /// Explicit `context_window` from config for this provider/model, if any.
1093    ///
1094    /// Does not consult the built-in catalog — call
1095    /// `whycodes_llm::resolve_context_window` for the full chain.
1096    pub fn configured_context_window(&self, provider: &str, model: &str) -> Option<u32> {
1097        self.get_model(provider, model)
1098            .and_then(|m| m.context_window)
1099            .or_else(|| {
1100                self.default_model.as_ref().and_then(|m| {
1101                    if m.model_id == model
1102                        && (m.provider_id.is_empty() || m.provider_id == provider)
1103                    {
1104                        m.context_window
1105                    } else {
1106                        None
1107                    }
1108                })
1109            })
1110    }
1111
1112    /// Get agent by name
1113    pub fn get_agent(&self, name: &str) -> Option<&AgentInfo> {
1114        self.agents.iter().find(|a| a.name == name)
1115    }
1116
1117    /// Get the default agent
1118    pub fn default_agent(&self) -> Option<&AgentInfo> {
1119        self.get_agent(&self.default_agent)
1120    }
1121}
1122
1123#[cfg(test)]
1124mod tests {
1125    use super::*;
1126    use crate::types::Config;
1127    use std::path::Path;
1128
1129    #[test]
1130    fn helpers_markdown_and_parent_dir() {
1131        assert!(toml_err("x".into()).to_string().contains("x"));
1132        let cfg = Config::default();
1133        let encoded = encode_toml(&cfg).unwrap();
1134        assert!(!encoded.is_empty());
1135        assert!(map_toml_ser(Ok("ok".into())).unwrap() == "ok");
1136
1137        let dir = tempfile::tempdir().unwrap();
1138        let nested = dir.path().join("a").join("b.toml");
1139        ensure_parent_dir(&nested).unwrap();
1140        assert!(nested.parent().unwrap().is_dir());
1141        ensure_parent_dir(Path::new("no-parent")).unwrap();
1142
1143        let plain = parse_command_markdown("echo hi").unwrap();
1144        assert_eq!(plain.template, "echo hi");
1145        assert!(plain.description.is_none());
1146
1147        let with_front = parse_command_markdown(
1148            "---\ndescription: d\nagent: build\nmodel: gpt\nsubtask: true\nunknown: x\n---\nbody",
1149        )
1150        .unwrap();
1151        assert_eq!(with_front.template, "body");
1152        assert_eq!(with_front.description.as_deref(), Some("d"));
1153        assert_eq!(with_front.agent.as_deref(), Some("build"));
1154        assert_eq!(with_front.model.as_deref(), Some("gpt"));
1155        assert_eq!(with_front.subtask, Some(true));
1156
1157        let yes = parse_command_markdown("---\nsubtask: yes\n---\nx").unwrap();
1158        assert_eq!(yes.subtask, Some(true));
1159        let one = parse_command_markdown("---\nsubtask: 1\n---\nx").unwrap();
1160        assert_eq!(one.subtask, Some(true));
1161        let no = parse_command_markdown("---\nsubtask: false\n---\nx").unwrap();
1162        assert_eq!(no.subtask, Some(false));
1163        assert!(parse_command_markdown("---\nno-end").is_none());
1164
1165        assert!(cfg.get_command_config("missing").is_none());
1166    }
1167
1168    #[test]
1169    fn prompt_overlay_loader_covers_missing_and_junk() {
1170        let dir = tempfile::tempdir().unwrap();
1171        let missing = dir.path().join("no-such-prompts");
1172        let mut empty = SystemPromptOverlays::default();
1173        load_prompt_overlays(&mut empty, &missing);
1174        load_model_overlay_dir(&mut empty, &missing);
1175        assert!(empty.is_empty());
1176        assert!(read_overlay_markdown(&missing).is_none());
1177        assert!(read_overlay_markdown(dir.path()).is_none());
1178
1179        let models = dir.path().join("models");
1180        std::fs::create_dir_all(models.join("nested")).unwrap();
1181        std::fs::write(models.join("skip.txt"), "ignored").unwrap();
1182        std::fs::write(models.join("empty.md"), "  \n").unwrap();
1183        std::fs::write(models.join("ok.md"), "bare model extra").unwrap();
1184        let mut loaded = SystemPromptOverlays::default();
1185        load_model_overlay_dir(&mut loaded, &models);
1186        assert_eq!(
1187            loaded.models.get("ok").map(String::as_str),
1188            Some("bare model extra")
1189        );
1190        assert!(!loaded.models.contains_key("skip"));
1191        assert!(!loaded.models.contains_key("empty"));
1192        assert!(!loaded.models.contains_key("nested"));
1193
1194        std::fs::write(dir.path().join("notes.txt"), "not markdown").unwrap();
1195        let mut mixed = SystemPromptOverlays::default();
1196        load_prompt_overlays(&mut mixed, dir.path());
1197        assert_eq!(
1198            mixed.models.get("ok").map(String::as_str),
1199            Some("bare model extra")
1200        );
1201
1202        let provider_dir = dir.path().join("xai");
1203        std::fs::create_dir_all(provider_dir.join("nested")).unwrap();
1204        std::fs::write(provider_dir.join("nested/skip.md"), "nope").unwrap();
1205        std::fs::write(provider_dir.join("notes.txt"), "ignored").unwrap();
1206        let mut under_provider = SystemPromptOverlays::default();
1207        load_prompt_overlays_at(&mut under_provider, &provider_dir, Some("xai"));
1208        assert!(under_provider.models.is_empty());
1209        assert!(under_provider.providers.is_empty());
1210    }
1211
1212    /// `file_name` / `file_stem` `to_str()` is `None` for non-UTF-8 paths (Linux CI).
1213    #[cfg(unix)]
1214    #[test]
1215    fn prompt_overlay_skips_non_utf8_names() {
1216        use std::os::unix::ffi::OsStrExt;
1217        let dir = tempfile::tempdir().unwrap();
1218        let bad_dir = dir.path().join(std::ffi::OsStr::from_bytes(b"bad\xffdir"));
1219        std::fs::create_dir(&bad_dir).unwrap();
1220        std::fs::write(
1221            dir.path().join(std::ffi::OsStr::from_bytes(b"bad\xff.md")),
1222            "x",
1223        )
1224        .unwrap();
1225        let models = dir.path().join("models");
1226        std::fs::create_dir(&models).unwrap();
1227        std::fs::write(models.join(std::ffi::OsStr::from_bytes(b"m\xff.md")), "y").unwrap();
1228
1229        let mut loaded = SystemPromptOverlays::default();
1230        load_prompt_overlays(&mut loaded, dir.path());
1231        assert!(loaded.providers.is_empty());
1232        assert!(loaded.models.is_empty());
1233    }
1234}