Skip to main content

starweaver_cli/
config.rs

1//! CLI configuration resolution.
2
3use std::{
4    collections::{BTreeMap, BTreeSet},
5    env, fs,
6    path::{Path, PathBuf},
7};
8
9use serde::{Deserialize, Serialize};
10use starweaver_model::MaxTokensParameter;
11use toml::Value;
12
13use crate::{
14    CliError, CliResult,
15    args::{Cli, CliCommand, ConfigCommand, HitlPolicy, OutputMode, SetupCommand, TuiRenderMode},
16    environment::{
17        EnvironmentAttachmentAccessMode, LOCAL_ENVIRONMENT_ATTACHMENT_ID,
18        is_valid_environment_attachment_id,
19    },
20    error::io_error,
21    oauth::CODEX_BASE_URL,
22    slash_commands::{SlashCommandDefinition, normalize_command_name, valid_command_name},
23};
24
25mod env_overrides;
26mod metadata;
27mod state;
28mod templates;
29mod values;
30
31use env_overrides::{apply_cli_overrides, apply_env, parse_hitl_policy, parse_output_mode};
32pub use metadata::{mcp_servers, tool_need_approval};
33use metadata::{
34    merge_json_value, read_mcp_config, read_tools_config, resolve_mcp_mode,
35    resolve_user_input_timeout_seconds,
36};
37pub use state::{
38    clear_current_session, ensure_config_dirs, read_current_session,
39    read_last_retention_maintenance, remove_project_state, remove_project_state_if_current_session,
40    write_current_session, write_last_retention_maintenance,
41};
42use templates::default_config_template;
43pub use templates::{
44    DEFAULT_GLOBAL_GITIGNORE_TEMPLATE, DEFAULT_MCP_TEMPLATE, DEFAULT_PROJECT_GITIGNORE_TEMPLATE,
45    DEFAULT_TOOLS_TEMPLATE, init_config_file, write_default_subagent_presets,
46};
47pub use values::{ConfigScope, get_config_value, set_config_value};
48
49/// Model-facing MCP tool exposure mode.
50#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
51#[serde(rename_all = "snake_case")]
52pub enum McpExposureMode {
53    /// Expose every configured server's declared tools with a server namespace.
54    #[default]
55    Direct,
56    /// Expose the fixed `mcp_search_tool` and `mcp_call_tool` proxy surface.
57    Proxy,
58}
59
60/// Resolved CLI configuration.
61#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
62pub struct CliConfig {
63    /// Global config root.
64    pub global_dir: PathBuf,
65    /// Project config root.
66    pub project_dir: PathBuf,
67    /// TUI local state root.
68    pub tui_state_dir: PathBuf,
69    /// `SQLite` database path.
70    pub database_path: PathBuf,
71    /// Local file store path.
72    pub file_store_path: PathBuf,
73    /// Default profile.
74    pub default_profile: String,
75    /// Skill directory search paths.
76    pub skill_dirs: Vec<PathBuf>,
77    /// Subagent directory search paths.
78    pub subagent_dirs: Vec<PathBuf>,
79    /// Disabled subagent names from layered subagent config.
80    pub disabled_subagents: Vec<String>,
81    /// Workspace root for environment providers.
82    pub workspace_root: PathBuf,
83    /// Environment provider kind.
84    pub environment_provider: String,
85    /// Filesystem policy mode.
86    pub files_policy: String,
87    /// Whether shell execution is enabled for environment tools.
88    pub shell_enabled: bool,
89    /// Shell command review configuration.
90    pub shell_review: CliShellReviewConfig,
91    /// Default output mode.
92    pub default_output: OutputMode,
93    /// Default headless human-in-the-loop policy.
94    pub default_hitl: HitlPolicy,
95    /// Default maximum runtime goal retry iterations.
96    pub max_goal_iterations: usize,
97    /// Default TUI transcript rendering mode.
98    pub tui_render_mode: TuiRenderMode,
99    /// Update channel metadata.
100    pub update_channel: String,
101    /// OAuth token refresh supervisor configuration.
102    pub oauth_refresh: OAuthRefreshConfig,
103    /// Default model from `[general] model` fields.
104    pub default_model: Option<CliModelProfile>,
105    /// Named model profiles from `[model_profiles.*]` fields.
106    pub model_profiles: BTreeMap<String, CliModelProfile>,
107    /// Named envd profiles from `[envd_profiles.*]` fields.
108    pub envd_profiles: BTreeMap<String, CliEnvdProfile>,
109    /// Environment variables loaded from config `[env]` sections.
110    pub env_vars: BTreeMap<String, String>,
111    /// Provider API configuration.
112    pub providers: ProviderConfigs,
113    /// Tool config metadata loaded from tools.toml.
114    pub tools_config: serde_json::Value,
115    /// MCP config metadata loaded from mcp.json.
116    pub mcp_config: serde_json::Value,
117    /// Unmapped config metadata preserved for configuration audits.
118    pub unmapped_metadata: serde_json::Value,
119    /// Custom slash commands loaded from `[commands.*]` config sections.
120    pub slash_commands: BTreeMap<String, SlashCommandDefinition>,
121    /// Automatic retention maintenance after a run.
122    pub auto_trim: bool,
123    /// Recent runs to keep for the current session during automatic trim.
124    pub current_session_keep_recent_runs: usize,
125    /// Recent runs to keep per session during all-session retention maintenance.
126    pub all_sessions_keep_recent_runs: usize,
127    /// Age horizon for all-session retention maintenance.
128    pub all_sessions_keep_days: u64,
129    /// Minimum interval between all-session retention maintenance runs.
130    pub all_sessions_interval_hours: u64,
131}
132
133impl CliConfig {
134    /// Return the model-facing MCP tool exposure mode.
135    #[must_use]
136    pub fn mcp_mode(&self) -> McpExposureMode {
137        resolve_mcp_mode(&self.tools_config).unwrap_or_default()
138    }
139
140    /// Return the positive structured-question timeout in seconds.
141    #[must_use]
142    pub fn user_input_timeout_seconds(&self) -> u64 {
143        resolve_user_input_timeout_seconds(&self.tools_config).unwrap_or(120)
144    }
145
146    /// Return the opt-in current-active-desktop Computer Use configuration.
147    #[must_use]
148    pub fn computer_use(&self) -> CliComputerUseConfig {
149        self.unmapped_metadata
150            .get("computer_use")
151            .cloned()
152            .and_then(|value| serde_json::from_value(value).ok())
153            .unwrap_or_default()
154    }
155
156    pub(crate) fn set_computer_use(&mut self, computer_use: &CliComputerUseConfig) {
157        merge_json_value(
158            &mut self.unmapped_metadata,
159            serde_json::json!({"computer_use": computer_use}),
160        );
161    }
162
163    /// Reads a process environment variable with `[env]` config as a fallback.
164    #[must_use]
165    pub fn env_value(&self, name: &str) -> Option<String> {
166        let name = name.trim();
167        if name.is_empty() {
168            return None;
169        }
170        env::var(name)
171            .ok()
172            .filter(|value| !value.trim().is_empty())
173            .or_else(|| {
174                self.env_vars
175                    .get(name)
176                    .cloned()
177                    .filter(|value| !value.trim().is_empty())
178            })
179    }
180
181    /// Returns whether a process or configured environment value is present.
182    #[must_use]
183    pub fn env_value_present(&self, name: Option<&str>) -> bool {
184        name.and_then(|name| self.env_value(name)).is_some()
185    }
186}
187
188/// Config resolver.
189#[allow(clippy::struct_field_names)]
190#[derive(Clone, Debug)]
191pub struct ConfigResolver {
192    global_dir: Option<PathBuf>,
193    project_dir: Option<PathBuf>,
194    shared_agents_dir: Option<PathBuf>,
195    current_dir: Option<PathBuf>,
196}
197
198#[derive(Clone, Debug, Default, Deserialize)]
199struct FileConfig {
200    general: Option<GeneralConfig>,
201    storage: Option<StorageConfig>,
202    environment: Option<EnvironmentConfig>,
203    computer_use: Option<FileComputerUseConfig>,
204    security: Option<FileSecurityConfig>,
205    update: Option<UpdateConfig>,
206    providers: Option<FileProviderConfigs>,
207    oauth_refresh: Option<FileOAuthRefreshConfig>,
208    model_profiles: Option<BTreeMap<String, FileModelProfile>>,
209    envd_profiles: Option<BTreeMap<String, FileEnvdProfile>>,
210    env: Option<BTreeMap<String, String>>,
211    skills: Option<SkillsConfig>,
212    subagents: Option<SubagentsConfig>,
213    commands: Option<BTreeMap<String, FileCommandDefinition>>,
214    tui: Option<TuiConfig>,
215    trim: Option<TrimConfig>,
216}
217
218#[derive(Clone, Debug, Default, Deserialize)]
219struct TuiConfig {
220    render_mode: Option<TuiRenderMode>,
221}
222
223#[derive(Clone, Debug, Default, Deserialize)]
224struct GeneralConfig {
225    default_profile: Option<String>,
226    default_output: Option<OutputMode>,
227    default_hitl: Option<HitlPolicy>,
228    max_goal_iterations: Option<usize>,
229    model: Option<String>,
230    model_settings: Option<String>,
231    model_cfg: Option<String>,
232    max_requests: Option<u64>,
233}
234
235#[derive(Clone, Debug, Default, Deserialize)]
236struct FileOAuthRefreshConfig {
237    enabled: Option<bool>,
238    interval_seconds: Option<u64>,
239    failure_retry_seconds: Option<u64>,
240    refresh_on_startup: Option<bool>,
241}
242
243/// OAuth token refresh supervisor configuration.
244#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
245pub struct OAuthRefreshConfig {
246    /// Whether OAuth refresh supervisor should be started for OAuth-backed models.
247    pub enabled: bool,
248    /// Successful-refresh interval in seconds.
249    pub interval_seconds: u64,
250    /// Retry interval in seconds after the last refresh attempt failed.
251    pub failure_retry_seconds: u64,
252    /// Refresh immediately when the supervisor starts.
253    pub refresh_on_startup: bool,
254}
255
256impl Default for OAuthRefreshConfig {
257    fn default() -> Self {
258        Self {
259            enabled: true,
260            interval_seconds: 30 * 60,
261            failure_retry_seconds: 60,
262            refresh_on_startup: true,
263        }
264    }
265}
266
267#[derive(Clone, Debug, Default, Deserialize)]
268struct FileModelProfile {
269    label: Option<String>,
270    model: Option<String>,
271    model_settings: Option<String>,
272    model_cfg: Option<String>,
273}
274
275#[derive(Clone, Debug, Default, Deserialize)]
276struct FileEnvdProfile {
277    label: Option<String>,
278    enabled: Option<bool>,
279    #[serde(alias = "endpoint_ref", alias = "endpointRef")]
280    endpoint: Option<String>,
281    auth_token: Option<String>,
282    auth_token_env: Option<String>,
283    environment_id: Option<String>,
284    mount_id: Option<String>,
285    mode: Option<String>,
286    #[serde(rename = "default")]
287    is_default: Option<bool>,
288}
289
290#[derive(Clone, Debug, Default, Deserialize)]
291struct FileCommandDefinition {
292    prompt: Option<String>,
293    description: Option<String>,
294    aliases: Option<Vec<String>>,
295}
296
297#[derive(Clone, Debug, Default, Deserialize)]
298struct SkillsConfig {
299    dirs: Option<Vec<String>>,
300    additional_dirs: Option<Vec<String>>,
301}
302
303#[derive(Clone, Debug, Default, Deserialize)]
304struct SubagentsConfig {
305    dirs: Option<Vec<String>>,
306    additional_dirs: Option<Vec<String>>,
307    disabled: Option<Vec<String>>,
308    disabled_builtins: Option<Vec<String>>,
309}
310
311#[derive(Clone, Debug, Default, Deserialize)]
312struct StorageConfig {
313    database_path: Option<String>,
314    file_store_path: Option<String>,
315}
316
317/// CLI-owned opt-in Computer Use configuration.
318#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
319pub struct CliComputerUseConfig {
320    /// Install the current-desktop observe, pointer, and keyboard toolset.
321    #[serde(default)]
322    pub enabled: bool,
323    /// Capture either the primary active display or complete visible desktop.
324    #[serde(default = "default_computer_use_desktop_scope")]
325    pub desktop_scope: String,
326}
327
328impl Default for CliComputerUseConfig {
329    fn default() -> Self {
330        Self {
331            enabled: false,
332            desktop_scope: default_computer_use_desktop_scope(),
333        }
334    }
335}
336
337fn default_computer_use_desktop_scope() -> String {
338    "primary_display".to_owned()
339}
340
341#[derive(Clone, Debug, Default, Deserialize)]
342struct FileComputerUseConfig {
343    enabled: Option<bool>,
344    desktop_scope: Option<String>,
345}
346
347impl FileComputerUseConfig {
348    fn merge_into(self, config: &mut CliComputerUseConfig) {
349        if let Some(enabled) = self.enabled {
350            config.enabled = enabled;
351        }
352        if let Some(desktop_scope) = self.desktop_scope {
353            config.desktop_scope = desktop_scope;
354        }
355    }
356}
357
358#[derive(Clone, Debug, Default, Deserialize)]
359struct EnvironmentConfig {
360    workspace_root: Option<String>,
361    provider: Option<String>,
362    files_policy: Option<String>,
363    shell_enabled: Option<bool>,
364}
365
366#[derive(Clone, Debug, Default, Deserialize)]
367struct FileSecurityConfig {
368    shell_review: Option<FileShellReviewConfig>,
369}
370
371#[derive(Clone, Debug, Default, Deserialize)]
372struct FileShellReviewConfig {
373    enabled: Option<bool>,
374    model: Option<String>,
375    model_settings: Option<String>,
376    on_needs_approval: Option<String>,
377    risk_threshold: Option<String>,
378    system_prompt: Option<String>,
379}
380
381/// CLI shell command review configuration.
382#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
383pub struct CliShellReviewConfig {
384    /// Whether shell review is enabled.
385    pub enabled: bool,
386    /// Review model id.
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub model: Option<String>,
389    /// Review model settings preset.
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub model_settings: Option<String>,
392    /// Action when review reaches threshold: defer or deny.
393    pub on_needs_approval: String,
394    /// Risk threshold: low, medium, high, or `extra_high`.
395    pub risk_threshold: String,
396    /// Optional prompt override.
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    pub system_prompt: Option<String>,
399}
400
401impl Default for CliShellReviewConfig {
402    fn default() -> Self {
403        Self {
404            enabled: false,
405            model: None,
406            model_settings: None,
407            on_needs_approval: "defer".to_string(),
408            risk_threshold: "high".to_string(),
409            system_prompt: None,
410        }
411    }
412}
413
414impl CliShellReviewConfig {
415    fn validate(&self) -> CliResult<()> {
416        if self.enabled
417            && self
418                .model
419                .as_deref()
420                .is_none_or(|model| model.trim().is_empty())
421        {
422            return Err(CliError::Config(
423                "security.shell_review.model is required when shell review is enabled".to_string(),
424            ));
425        }
426        validate_shell_review_action(&self.on_needs_approval)?;
427        validate_shell_review_risk(&self.risk_threshold)?;
428        Ok(())
429    }
430}
431
432#[derive(Clone, Debug, Default, Deserialize)]
433struct UpdateConfig {
434    channel: Option<String>,
435}
436
437/// CLI model profile resolved from config.
438#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
439pub struct CliModelProfile {
440    /// Human label for display.
441    #[serde(default, skip_serializing_if = "Option::is_none")]
442    pub label: Option<String>,
443    /// Provider model id, such as `openai-responses:gpt-5`, `openai-responses-ws:gpt-5`, or `homelab@openai-responses:gpt-5`.
444    pub model_id: String,
445    /// Model config preset name from `model_cfg`.
446    #[serde(default, skip_serializing_if = "Option::is_none")]
447    pub model_cfg: Option<String>,
448    /// Model settings preset name.
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub model_settings: Option<String>,
451}
452
453/// Envd profile resolved from config.
454#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
455pub struct CliEnvdProfile {
456    /// Human label for display.
457    #[serde(default, skip_serializing_if = "Option::is_none")]
458    pub label: Option<String>,
459    /// Whether this profile is active for TUI runs.
460    pub enabled: bool,
461    /// HTTP endpoint for the envd JSON-RPC transport.
462    pub endpoint: String,
463    /// Bearer token stored directly in config. This is intentionally
464    /// request-only and must not be returned by config display surfaces.
465    #[serde(default, skip_serializing)]
466    pub auth_token: Option<String>,
467    /// Environment variable that contains the bearer token.
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub auth_token_env: Option<String>,
470    /// Concrete envd environment id.
471    #[serde(default, skip_serializing_if = "Option::is_none")]
472    pub environment_id: Option<String>,
473    /// Agent-facing mount id.
474    pub mount_id: String,
475    /// Access mode for the mount.
476    pub mode: EnvironmentAttachmentAccessMode,
477    /// Whether this profile should be the default TUI environment.
478    #[serde(rename = "default")]
479    pub is_default: bool,
480}
481
482/// Provider API configuration.
483#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
484pub struct ProviderConfigs {
485    /// `OpenAI` provider config.
486    pub openai: ProviderConfig,
487    /// Anthropic provider config.
488    pub anthropic: ProviderConfig,
489    /// Gemini provider config.
490    pub gemini: ProviderConfig,
491    /// Google Cloud Gemini provider config.
492    pub google_cloud: ProviderConfig,
493    /// Codex OAuth provider config.
494    pub codex: ProviderConfig,
495    /// Named gateway provider configs.
496    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
497    pub gateways: BTreeMap<String, ProviderConfig>,
498}
499
500#[derive(Clone, Debug, Default, Deserialize)]
501struct FileProviderConfigs {
502    openai: Option<FileProviderConfig>,
503    anthropic: Option<FileProviderConfig>,
504    gemini: Option<FileProviderConfig>,
505    google: Option<FileProviderConfig>,
506    #[serde(rename = "google-gla")]
507    google_gla: Option<FileProviderConfig>,
508    #[serde(rename = "google-cloud", alias = "google_cloud")]
509    google_cloud: Option<FileProviderConfig>,
510    #[serde(rename = "google-vertex")]
511    google_vertex: Option<FileProviderConfig>,
512    codex: Option<FileProviderConfig>,
513    #[serde(flatten)]
514    gateways: BTreeMap<String, FileProviderConfig>,
515}
516
517#[derive(Clone, Debug, Default, Deserialize)]
518struct FileProviderConfig {
519    enabled: Option<bool>,
520    api_key_env: Option<String>,
521    auth_token_env: Option<String>,
522    project: Option<String>,
523    location: Option<String>,
524    base_url: Option<String>,
525    endpoint_path: Option<String>,
526    max_tokens_parameter: Option<MaxTokensParameter>,
527}
528
529/// Single provider API configuration.
530#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
531pub struct ProviderConfig {
532    /// Enable provider-backed profile resolution for this provider.
533    pub enabled: bool,
534    /// Environment variable containing the provider API key.
535    pub api_key_env: Option<String>,
536    /// Environment variable containing a bearer access token.
537    pub auth_token_env: Option<String>,
538    /// Provider project identifier when required by the backend.
539    pub project: Option<String>,
540    /// Provider location or region when required by the backend.
541    pub location: Option<String>,
542    /// Provider or gateway base URL.
543    pub base_url: Option<String>,
544    /// Override endpoint path.
545    pub endpoint_path: Option<String>,
546    /// Provider or gateway max-token parameter mapping.
547    pub max_tokens_parameter: MaxTokensParameter,
548}
549
550impl Default for ProviderConfig {
551    fn default() -> Self {
552        Self {
553            enabled: true,
554            api_key_env: None,
555            auth_token_env: None,
556            project: None,
557            location: None,
558            base_url: None,
559            endpoint_path: None,
560            max_tokens_parameter: MaxTokensParameter::Default,
561        }
562    }
563}
564
565#[derive(Clone, Debug, Default, Deserialize)]
566struct TrimConfig {
567    auto_after_run: Option<bool>,
568    current_session_keep_recent_runs: Option<usize>,
569    all_sessions_keep_recent_runs: Option<usize>,
570    all_sessions_keep_days: Option<u64>,
571    all_sessions_interval_hours: Option<u64>,
572}
573
574impl Default for ConfigResolver {
575    fn default() -> Self {
576        Self {
577            global_dir: env::var_os("STARWEAVER_CONFIG_DIR").map(PathBuf::from),
578            project_dir: env::var_os("STARWEAVER_PROJECT_DIR").map(PathBuf::from),
579            shared_agents_dir: None,
580            current_dir: None,
581        }
582    }
583}
584
585impl ConfigResolver {
586    /// Build a resolver pinned to a root for deterministic tests.
587    #[must_use]
588    pub fn for_tests(root: &std::path::Path) -> Self {
589        Self {
590            global_dir: Some(root.join("global")),
591            project_dir: Some(root.join("project/.starweaver")),
592            shared_agents_dir: Some(root.join("shared-agents")),
593            current_dir: Some(root.join("project")),
594        }
595    }
596
597    /// Resolve final config.
598    pub fn resolve(&self, cli: &Cli) -> CliResult<CliConfig> {
599        let current_dir = self.current_dir.clone().unwrap_or_else(default_current_dir);
600        let global_dir = match self.global_dir.clone() {
601            Some(global_dir) => global_dir,
602            None => default_global_dir()?,
603        };
604        let project_dir = self
605            .project_dir
606            .clone()
607            .unwrap_or_else(|| default_project_dir(cli, &global_dir, &current_dir));
608        let shared_agents_dir = self
609            .shared_agents_dir
610            .clone()
611            .unwrap_or_else(default_shared_agents_dir);
612        let mut config = CliConfig {
613            global_dir: global_dir.clone(),
614            project_dir: project_dir.clone(),
615            tui_state_dir: global_dir.join("tui"),
616            database_path: starweaver_storage::canonical_session_database_path(&global_dir),
617            file_store_path: project_dir.join("store"),
618            default_profile: "general".to_string(),
619            skill_dirs: default_skill_dirs(&global_dir, &shared_agents_dir, &project_dir),
620            subagent_dirs: vec![global_dir.join("subagents"), project_dir.join("subagents")],
621            disabled_subagents: Vec::new(),
622            workspace_root: default_workspace_root(&project_dir, &global_dir, &current_dir),
623            environment_provider: "local".to_string(),
624            files_policy: "read_write".to_string(),
625            shell_enabled: true,
626            shell_review: CliShellReviewConfig::default(),
627            default_output: OutputMode::AguiJsonl,
628            default_hitl: HitlPolicy::Defer,
629            max_goal_iterations: 10,
630            tui_render_mode: TuiRenderMode::Concise,
631            update_channel: "stable".to_string(),
632            oauth_refresh: OAuthRefreshConfig::default(),
633            default_model: None,
634            model_profiles: BTreeMap::new(),
635            envd_profiles: BTreeMap::new(),
636            env_vars: BTreeMap::new(),
637            providers: default_provider_configs(),
638            tools_config: serde_json::Value::Null,
639            mcp_config: serde_json::Value::Null,
640            unmapped_metadata: serde_json::json!({}),
641            slash_commands: BTreeMap::new(),
642            auto_trim: true,
643            current_session_keep_recent_runs: 20,
644            all_sessions_keep_recent_runs: 20,
645            all_sessions_keep_days: 60,
646            all_sessions_interval_hours: 24,
647        };
648        bootstrap_global_config_dir(&global_dir)?;
649        apply_file_config(&mut config, &global_dir.join("config.toml"))?;
650        apply_file_config(&mut config, &project_dir.join("config.toml"))?;
651        config.tools_config = read_tools_config(&global_dir, &project_dir)?;
652        resolve_mcp_mode(&config.tools_config)?;
653        resolve_user_input_timeout_seconds(&config.tools_config)?;
654        config.mcp_config = read_mcp_config(&global_dir, &project_dir)?;
655        apply_env(&mut config);
656        apply_cli_overrides(&mut config, cli, &project_dir);
657        config.shell_review.validate()?;
658        Ok(config)
659    }
660}
661
662fn default_global_dir() -> CliResult<PathBuf> {
663    starweaver_storage::default_starweaver_config_dir()
664        .map_err(|error| CliError::Config(error.to_string()))
665}
666
667fn default_shared_agents_dir() -> PathBuf {
668    env::var_os("HOME")
669        .map_or_else(|| PathBuf::from("."), PathBuf::from)
670        .join(".agents")
671}
672
673fn default_current_dir() -> PathBuf {
674    env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
675}
676
677fn default_skill_dirs(
678    global_dir: &std::path::Path,
679    shared_agents_dir: &std::path::Path,
680    project_dir: &std::path::Path,
681) -> Vec<PathBuf> {
682    vec![
683        global_dir.join("skills"),
684        shared_agents_dir.join("skills"),
685        project_dir.join("skills"),
686    ]
687}
688
689fn default_project_dir(cli: &Cli, global_dir: &Path, current_dir: &Path) -> PathBuf {
690    if wants_project_config(cli) {
691        return current_dir.join(".starweaver");
692    }
693    find_project_dir(current_dir, global_dir).unwrap_or_else(|| global_dir.to_path_buf())
694}
695
696fn default_workspace_root(project_dir: &Path, global_dir: &Path, current_dir: &Path) -> PathBuf {
697    if paths_equivalent(project_dir, global_dir) {
698        return current_dir.to_path_buf();
699    }
700    project_dir
701        .parent()
702        .filter(|parent| !parent.as_os_str().is_empty())
703        .map_or_else(|| current_dir.to_path_buf(), std::path::Path::to_path_buf)
704}
705
706const fn wants_project_config(cli: &Cli) -> bool {
707    matches!(
708        &cli.command,
709        Some(
710            CliCommand::Setup(SetupCommand {
711                global: false,
712                project: true,
713                ..
714            }) | CliCommand::Config {
715                command: ConfigCommand::Init {
716                    global: false,
717                    project: true,
718                    ..
719                } | ConfigCommand::Set {
720                    global: false,
721                    project: true,
722                    ..
723                }
724            }
725        )
726    )
727}
728
729fn find_project_dir(start: &Path, global_dir: &Path) -> Option<PathBuf> {
730    let home_project_dir = starweaver_storage::default_starweaver_config_dir().ok();
731    let mut current = start.to_path_buf();
732    loop {
733        let candidate = current.join(".starweaver");
734        let is_global_dir = paths_equivalent(&candidate, global_dir);
735        let is_home_global_dir = home_project_dir
736            .as_ref()
737            .is_some_and(|home| paths_equivalent(&candidate, home));
738        if candidate.join("config.toml").exists() && !is_global_dir && !is_home_global_dir {
739            return Some(candidate);
740        }
741        if !current.pop() {
742            return None;
743        }
744    }
745}
746
747fn paths_equivalent(left: &Path, right: &Path) -> bool {
748    if left == right {
749        return true;
750    }
751    let normalized_left = left.canonicalize().unwrap_or_else(|_| left.to_path_buf());
752    let normalized_right = right.canonicalize().unwrap_or_else(|_| right.to_path_buf());
753    normalized_left == normalized_right
754}
755
756fn bootstrap_global_config_dir(global_dir: &Path) -> CliResult<()> {
757    fs::create_dir_all(global_dir).map_err(|error| io_error(global_dir, error))?;
758    let config_path = global_dir.join("config.toml");
759    if !config_path.exists() {
760        fs::write(&config_path, default_config_template(ConfigScope::Global))
761            .map_err(|error| io_error(&config_path, error))?;
762    }
763    for (path, content) in [
764        (global_dir.join("tools.toml"), DEFAULT_TOOLS_TEMPLATE),
765        (global_dir.join("mcp.json"), DEFAULT_MCP_TEMPLATE),
766        (
767            global_dir.join(".gitignore"),
768            DEFAULT_GLOBAL_GITIGNORE_TEMPLATE,
769        ),
770    ] {
771        if !path.exists() {
772            fs::write(&path, content).map_err(|error| io_error(&path, error))?;
773        }
774    }
775    for name in ["skills", "subagents", "tui"] {
776        let path = global_dir.join(name);
777        fs::create_dir_all(&path).map_err(|error| io_error(path, error))?;
778    }
779    write_default_subagent_presets(global_dir, false)?;
780    Ok(())
781}
782
783fn default_provider_configs() -> ProviderConfigs {
784    ProviderConfigs {
785        openai: ProviderConfig {
786            api_key_env: Some("OPENAI_API_KEY".to_string()),
787            base_url: Some("https://api.openai.com/v1".to_string()),
788            ..ProviderConfig::default()
789        },
790        anthropic: ProviderConfig {
791            api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
792            base_url: Some("https://api.anthropic.com/v1".to_string()),
793            ..ProviderConfig::default()
794        },
795        gemini: ProviderConfig {
796            api_key_env: Some("GEMINI_API_KEY".to_string()),
797            base_url: Some("https://generativelanguage.googleapis.com/v1beta".to_string()),
798            ..ProviderConfig::default()
799        },
800        google_cloud: ProviderConfig {
801            api_key_env: Some("GOOGLE_API_KEY".to_string()),
802            auth_token_env: Some("GOOGLE_CLOUD_ACCESS_TOKEN".to_string()),
803            base_url: Some("https://aiplatform.googleapis.com".to_string()),
804            ..ProviderConfig::default()
805        },
806        codex: ProviderConfig {
807            base_url: Some(CODEX_BASE_URL.to_string()),
808            max_tokens_parameter: MaxTokensParameter::Omit,
809            ..ProviderConfig::default()
810        },
811        gateways: BTreeMap::new(),
812    }
813}
814
815#[allow(clippy::too_many_lines)]
816fn apply_file_config(config: &mut CliConfig, path: &PathBuf) -> CliResult<()> {
817    if !path.exists() {
818        return Ok(());
819    }
820    let content = fs::read_to_string(path).map_err(|error| io_error(path, error))?;
821    let raw = content
822        .parse::<Value>()
823        .map_err(|error| CliError::Config(error.to_string()))?;
824    merge_unmapped_metadata(config, &raw);
825    let parsed = toml::from_str::<FileConfig>(&content)?;
826    let base = path.parent().unwrap_or_else(|| std::path::Path::new("."));
827    if let Some(general) = parsed.general {
828        let has_default_profile = general.default_profile.is_some();
829        let model = general.model.clone();
830        let model_settings = general.model_settings.clone();
831        let model_cfg = general.model_cfg.clone();
832        let max_requests = general.max_requests;
833        if let Some(max_requests) = max_requests {
834            merge_json_value(
835                &mut config.unmapped_metadata,
836                serde_json::json!({"general": {"max_requests": max_requests}}),
837            );
838        }
839        if let Some(profile) = general.default_profile {
840            config.default_profile = profile;
841        }
842        if let Some(output) = general.default_output {
843            config.default_output = output;
844        }
845        if let Some(hitl) = general.default_hitl {
846            config.default_hitl = hitl;
847        }
848        if let Some(max_goal_iterations) = general.max_goal_iterations {
849            config.max_goal_iterations = max_goal_iterations.max(1);
850        }
851        if let Some(model_id) = model {
852            config.default_model = Some(CliModelProfile {
853                label: Some("Default".to_string()),
854                model_id,
855                model_cfg,
856                model_settings,
857            });
858            if !has_default_profile {
859                config.default_profile = "default_model".to_string();
860            }
861        }
862    }
863    if let Some(storage) = parsed.storage {
864        if let Some(database_path) = storage.database_path {
865            config.database_path = expand_path(&database_path, base);
866        }
867        if let Some(file_store_path) = storage.file_store_path {
868            config.file_store_path = expand_path(&file_store_path, base);
869        }
870    }
871    if let Some(environment) = parsed.environment {
872        if let Some(workspace_root) = environment.workspace_root {
873            config.workspace_root = expand_path(&workspace_root, base);
874        }
875        if let Some(provider) = environment.provider {
876            config.environment_provider = provider;
877        }
878        if let Some(files_policy) = environment.files_policy {
879            config.files_policy = files_policy;
880        }
881        if let Some(shell_enabled) = environment.shell_enabled {
882            config.shell_enabled = shell_enabled;
883        }
884    }
885    if let Some(computer_use) = parsed.computer_use {
886        let mut effective = config.computer_use();
887        computer_use.merge_into(&mut effective);
888        config.set_computer_use(&effective);
889    }
890    if let Some(security) = parsed.security
891        && let Some(shell_review) = security.shell_review
892    {
893        merge_shell_review_config(&mut config.shell_review, shell_review)?;
894    }
895    if let Some(update) = parsed.update
896        && let Some(channel) = update.channel
897    {
898        config.update_channel = channel;
899    }
900    if let Some(providers) = parsed.providers {
901        merge_provider_configs(&mut config.providers, providers);
902    }
903    if let Some(oauth_refresh) = parsed.oauth_refresh {
904        merge_oauth_refresh_config(&mut config.oauth_refresh, &oauth_refresh)?;
905    }
906    if let Some(env_vars) = parsed.env {
907        config.env_vars.extend(env_vars);
908    }
909    if let Some(model_profiles) = parsed.model_profiles {
910        for (name, profile) in model_profiles {
911            if let Some(model_id) = profile.model {
912                config.model_profiles.insert(
913                    name,
914                    CliModelProfile {
915                        label: profile.label,
916                        model_id,
917                        model_cfg: profile.model_cfg,
918                        model_settings: profile.model_settings,
919                    },
920                );
921            }
922        }
923    }
924    if let Some(envd_profiles) = parsed.envd_profiles {
925        for (name, profile) in envd_profiles {
926            let profile = resolve_envd_profile(&name, profile)?;
927            config.envd_profiles.insert(name, profile);
928        }
929    }
930    if let Some(skills) = parsed.skills {
931        merge_skill_dirs(config, skills, base);
932    }
933    if let Some(subagents) = parsed.subagents {
934        merge_subagent_config(config, subagents, base);
935    }
936    if let Some(commands) = parsed.commands {
937        merge_slash_commands(config, commands);
938    }
939    if let Some(tui) = parsed.tui
940        && let Some(render_mode) = tui.render_mode
941    {
942        config.tui_render_mode = render_mode;
943    }
944    if let Some(trim) = parsed.trim {
945        if let Some(auto_after_run) = trim.auto_after_run {
946            config.auto_trim = auto_after_run;
947        }
948        if let Some(keep) = trim.current_session_keep_recent_runs {
949            config.current_session_keep_recent_runs = keep;
950        }
951        if let Some(keep) = trim.all_sessions_keep_recent_runs {
952            config.all_sessions_keep_recent_runs = keep;
953        }
954        if let Some(days) = trim.all_sessions_keep_days {
955            config.all_sessions_keep_days = days;
956        }
957        if let Some(hours) = trim.all_sessions_interval_hours {
958            config.all_sessions_interval_hours = hours;
959        }
960    }
961    Ok(())
962}
963
964fn merge_skill_dirs(config: &mut CliConfig, skills: SkillsConfig, base: &std::path::Path) {
965    if let Some(dirs) = skills.dirs {
966        config.skill_dirs = dirs.iter().map(|path| expand_path(path, base)).collect();
967    }
968    if let Some(additional_dirs) = skills.additional_dirs {
969        config
970            .skill_dirs
971            .extend(additional_dirs.iter().map(|path| expand_path(path, base)));
972    }
973}
974
975fn merge_subagent_config(
976    config: &mut CliConfig,
977    subagents: SubagentsConfig,
978    base: &std::path::Path,
979) {
980    if let Some(dirs) = subagents.dirs {
981        config.subagent_dirs = dirs.iter().map(|path| expand_path(path, base)).collect();
982    }
983    if let Some(additional_dirs) = subagents.additional_dirs {
984        config
985            .subagent_dirs
986            .extend(additional_dirs.iter().map(|path| expand_path(path, base)));
987    }
988    if let Some(disabled) = subagents.disabled {
989        config.disabled_subagents.extend(disabled);
990    }
991    if let Some(disabled_builtins) = subagents.disabled_builtins {
992        config.disabled_subagents.extend(disabled_builtins);
993    }
994    config.disabled_subagents.sort();
995    config.disabled_subagents.dedup();
996}
997
998fn merge_slash_commands(config: &mut CliConfig, commands: BTreeMap<String, FileCommandDefinition>) {
999    for (name, command) in commands {
1000        let normalized = normalize_command_name(&name);
1001        if !valid_command_name(&normalized) || reserved_slash_command(&normalized) {
1002            continue;
1003        }
1004        let Some(prompt) = command.prompt.filter(|prompt| !prompt.trim().is_empty()) else {
1005            continue;
1006        };
1007        let aliases = command
1008            .aliases
1009            .unwrap_or_default()
1010            .into_iter()
1011            .map(|alias| normalize_command_name(&alias))
1012            .filter(|alias| valid_command_name(alias) && !reserved_slash_command(alias))
1013            .collect::<BTreeSet<_>>()
1014            .into_iter()
1015            .filter(|alias| alias != &normalized)
1016            .collect::<Vec<_>>();
1017        let definition = SlashCommandDefinition {
1018            name: normalized,
1019            prompt,
1020            description: command.description,
1021            aliases,
1022        };
1023        upsert_slash_command(config, definition);
1024    }
1025}
1026
1027fn resolve_envd_profile(name: &str, profile: FileEnvdProfile) -> CliResult<CliEnvdProfile> {
1028    let endpoint = required_trimmed_envd_field(name, "endpoint", profile.endpoint)?;
1029    let mount_id = profile.mount_id.unwrap_or_else(|| name.to_string());
1030    let mount_id = mount_id.trim().to_string();
1031    if !is_valid_environment_attachment_id(&mount_id) {
1032        return Err(CliError::Config(format!(
1033            "envd profile {name} has invalid mount_id: {mount_id}; expected an ASCII slug"
1034        )));
1035    }
1036    if mount_id == LOCAL_ENVIRONMENT_ATTACHMENT_ID {
1037        return Err(CliError::Config(format!(
1038            "envd profile {name} cannot use reserved mount_id: {mount_id}"
1039        )));
1040    }
1041    let auth_token = profile
1042        .auth_token
1043        .as_deref()
1044        .map(|token| validate_envd_token_field(name, "auth_token", token))
1045        .transpose()?;
1046    let auth_token_env = profile
1047        .auth_token_env
1048        .as_deref()
1049        .map(|env| validate_envd_token_env_field(name, env))
1050        .transpose()?;
1051    if auth_token.is_none() && auth_token_env.is_none() {
1052        return Err(CliError::Config(format!(
1053            "envd profile {name} requires auth_token or auth_token_env"
1054        )));
1055    }
1056    let mode = match profile.mode.as_deref().unwrap_or("read_write").trim() {
1057        "read_only" | "read-only" => EnvironmentAttachmentAccessMode::ReadOnly,
1058        "read_write" | "read-write" => EnvironmentAttachmentAccessMode::ReadWrite,
1059        other => {
1060            return Err(CliError::Config(format!(
1061                "envd profile {name} has invalid mode: {other}; expected read_only or read_write"
1062            )));
1063        }
1064    };
1065    Ok(CliEnvdProfile {
1066        label: profile.label,
1067        enabled: profile.enabled.unwrap_or(true),
1068        endpoint,
1069        auth_token,
1070        auth_token_env,
1071        environment_id: profile
1072            .environment_id
1073            .map(|value| value.trim().to_string())
1074            .filter(|value| !value.is_empty()),
1075        mount_id,
1076        mode,
1077        is_default: profile.is_default.unwrap_or(false),
1078    })
1079}
1080
1081fn required_trimmed_envd_field(
1082    profile_name: &str,
1083    field: &str,
1084    value: Option<String>,
1085) -> CliResult<String> {
1086    let Some(value) = value else {
1087        return Err(CliError::Config(format!(
1088            "envd profile {profile_name} requires {field}"
1089        )));
1090    };
1091    let value = value.trim().to_string();
1092    if value.is_empty() {
1093        return Err(CliError::Config(format!(
1094            "envd profile {profile_name} {field} cannot be empty"
1095        )));
1096    }
1097    Ok(value)
1098}
1099
1100fn validate_envd_token_field(profile_name: &str, field: &str, token: &str) -> CliResult<String> {
1101    let token = token.trim().to_string();
1102    if token.is_empty() {
1103        return Err(CliError::Config(format!(
1104            "envd profile {profile_name} {field} cannot be empty"
1105        )));
1106    }
1107    if token.bytes().any(|byte| matches!(byte, b'\r' | b'\n')) {
1108        return Err(CliError::Config(format!(
1109            "envd profile {profile_name} {field} cannot contain newlines"
1110        )));
1111    }
1112    Ok(token)
1113}
1114
1115fn validate_envd_token_env_field(profile_name: &str, env: &str) -> CliResult<String> {
1116    let env = env.trim().to_string();
1117    if env.is_empty() {
1118        return Err(CliError::Config(format!(
1119            "envd profile {profile_name} auth_token_env cannot be empty"
1120        )));
1121    }
1122    Ok(env)
1123}
1124
1125fn upsert_slash_command(config: &mut CliConfig, mut definition: SlashCommandDefinition) {
1126    let canonical = definition.name.clone();
1127    let stale_aliases = config
1128        .slash_commands
1129        .iter()
1130        .filter(|(lookup, existing)| existing.name == canonical && *lookup != &canonical)
1131        .map(|(lookup, _)| lookup.clone())
1132        .collect::<Vec<_>>();
1133    for alias in stale_aliases {
1134        config.slash_commands.remove(&alias);
1135    }
1136    for existing in config.slash_commands.values_mut() {
1137        if existing.name != canonical {
1138            existing.aliases.retain(|alias| alias != &canonical);
1139        }
1140    }
1141
1142    let requested_aliases = std::mem::take(&mut definition.aliases);
1143    let active_aliases = requested_aliases
1144        .into_iter()
1145        .filter(|alias| {
1146            config
1147                .slash_commands
1148                .get(alias)
1149                .is_none_or(|existing| existing.name == canonical)
1150        })
1151        .collect::<Vec<_>>();
1152    definition.aliases.clone_from(&active_aliases);
1153    config.slash_commands.insert(canonical, definition.clone());
1154    for alias in active_aliases {
1155        config.slash_commands.insert(alias, definition.clone());
1156    }
1157}
1158
1159fn reserved_slash_command(name: &str) -> bool {
1160    crate::command_catalog::builtin_command_names().contains(name)
1161        || matches!(name, "config" | "loop" | "dump" | "load" | "exit")
1162}
1163
1164fn merge_unmapped_metadata(config: &mut CliConfig, raw: &Value) {
1165    let Some(root) = raw.as_table() else {
1166        return;
1167    };
1168    let mut metadata = serde_json::Map::new();
1169    for key in ["display", "subagents", "security"] {
1170        if let Some(value) = root.get(key).cloned()
1171            && let Ok(json) = serde_json::to_value(value)
1172        {
1173            metadata.insert(key.to_string(), json);
1174        }
1175    }
1176    if !metadata.is_empty() {
1177        merge_json_value(
1178            &mut config.unmapped_metadata,
1179            serde_json::Value::Object(metadata),
1180        );
1181    }
1182}
1183
1184fn merge_provider_configs(target: &mut ProviderConfigs, overlay: FileProviderConfigs) {
1185    if let Some(openai) = overlay.openai {
1186        merge_provider_config(&mut target.openai, openai);
1187    }
1188    if let Some(anthropic) = overlay.anthropic {
1189        merge_provider_config(&mut target.anthropic, anthropic);
1190    }
1191    if let Some(gemini) = overlay.gemini {
1192        merge_provider_config(&mut target.gemini, gemini);
1193    }
1194    if let Some(google) = overlay.google {
1195        merge_provider_config(&mut target.gemini, google);
1196    }
1197    if let Some(google_gla) = overlay.google_gla {
1198        merge_provider_config(&mut target.gemini, google_gla);
1199    }
1200    if let Some(google_cloud) = overlay.google_cloud {
1201        merge_provider_config(&mut target.google_cloud, google_cloud);
1202    }
1203    if let Some(google_vertex) = overlay.google_vertex {
1204        merge_provider_config(&mut target.google_cloud, google_vertex);
1205    }
1206    if let Some(codex) = overlay.codex {
1207        merge_provider_config(&mut target.codex, codex);
1208    }
1209    for (name, gateway) in overlay.gateways {
1210        merge_provider_config(target.gateways.entry(name).or_default(), gateway);
1211    }
1212}
1213
1214fn merge_provider_config(target: &mut ProviderConfig, overlay: FileProviderConfig) {
1215    if let Some(enabled) = overlay.enabled {
1216        target.enabled = enabled;
1217    }
1218    if overlay.api_key_env.is_some() {
1219        target.api_key_env = overlay.api_key_env;
1220    }
1221    if overlay.auth_token_env.is_some() {
1222        target.auth_token_env = overlay.auth_token_env;
1223    }
1224    if overlay.project.is_some() {
1225        target.project = overlay.project;
1226    }
1227    if overlay.location.is_some() {
1228        target.location = overlay.location;
1229    }
1230    if overlay.base_url.is_some() {
1231        target.base_url = overlay.base_url;
1232    }
1233    if overlay.endpoint_path.is_some() {
1234        target.endpoint_path = overlay.endpoint_path;
1235    }
1236    if let Some(max_tokens_parameter) = overlay.max_tokens_parameter {
1237        target.max_tokens_parameter = max_tokens_parameter;
1238    }
1239}
1240
1241fn merge_shell_review_config(
1242    target: &mut CliShellReviewConfig,
1243    overlay: FileShellReviewConfig,
1244) -> CliResult<()> {
1245    if let Some(enabled) = overlay.enabled {
1246        target.enabled = enabled;
1247    }
1248    if overlay.model.is_some() {
1249        target.model = overlay.model;
1250    }
1251    if overlay.model_settings.is_some() {
1252        target.model_settings = overlay.model_settings;
1253    }
1254    if let Some(action) = overlay.on_needs_approval {
1255        target.on_needs_approval = validate_shell_review_action(&action)?.to_string();
1256    }
1257    if let Some(threshold) = overlay.risk_threshold {
1258        target.risk_threshold = validate_shell_review_risk(&threshold)?.to_string();
1259    }
1260    if overlay.system_prompt.is_some() {
1261        target.system_prompt = overlay.system_prompt;
1262    }
1263    Ok(())
1264}
1265
1266fn validate_shell_review_action(value: &str) -> CliResult<&'static str> {
1267    match value.trim() {
1268        "defer" => Ok("defer"),
1269        "deny" => Ok("deny"),
1270        other => Err(CliError::Usage(format!(
1271            "invalid security.shell_review.on_needs_approval: {other}; expected defer or deny"
1272        ))),
1273    }
1274}
1275
1276fn validate_shell_review_risk(value: &str) -> CliResult<&'static str> {
1277    match value.trim() {
1278        "low" => Ok("low"),
1279        "medium" => Ok("medium"),
1280        "high" => Ok("high"),
1281        "extra_high" | "extra-high" => Ok("extra_high"),
1282        other => Err(CliError::Usage(format!(
1283            "invalid security.shell_review.risk_threshold: {other}; expected low, medium, high, or extra_high"
1284        ))),
1285    }
1286}
1287
1288fn merge_oauth_refresh_config(
1289    target: &mut OAuthRefreshConfig,
1290    overlay: &FileOAuthRefreshConfig,
1291) -> CliResult<()> {
1292    if let Some(enabled) = overlay.enabled {
1293        target.enabled = enabled;
1294    }
1295    if let Some(interval_seconds) = overlay.interval_seconds {
1296        if interval_seconds == 0 {
1297            return Err(CliError::Usage(
1298                "invalid oauth_refresh.interval_seconds: value must be positive".to_string(),
1299            ));
1300        }
1301        target.interval_seconds = interval_seconds;
1302    }
1303    if let Some(failure_retry_seconds) = overlay.failure_retry_seconds {
1304        if failure_retry_seconds == 0 {
1305            return Err(CliError::Usage(
1306                "invalid oauth_refresh.failure_retry_seconds: value must be positive".to_string(),
1307            ));
1308        }
1309        target.failure_retry_seconds = failure_retry_seconds;
1310    }
1311    if let Some(refresh_on_startup) = overlay.refresh_on_startup {
1312        target.refresh_on_startup = refresh_on_startup;
1313    }
1314    Ok(())
1315}
1316
1317fn expand_path(value: &str, base: &std::path::Path) -> PathBuf {
1318    if let Some(rest) = value.strip_prefix("~/") {
1319        return env::var_os("HOME")
1320            .map_or_else(|| PathBuf::from("."), PathBuf::from)
1321            .join(rest);
1322    }
1323    let path = PathBuf::from(value);
1324    if path.is_absolute() {
1325        path
1326    } else {
1327        base.join(path)
1328    }
1329}
1330
1331#[cfg(test)]
1332mod tests;