Skip to main content

virtuoso_cli/
config.rs

1use crate::error::{Result, VirtuosoError};
2use std::env;
3use std::path::PathBuf;
4
5#[derive(Debug, Clone)]
6pub struct Config {
7    #[allow(dead_code)]
8    pub profile: Option<String>,
9    pub remote_host: Option<String>,
10    pub remote_user: Option<String>,
11    pub port: u16,
12    pub jump_host: Option<String>,
13    pub jump_user: Option<String>,
14    pub ssh_port: Option<u16>,
15    pub ssh_key: Option<String>,
16    /// Path to a custom SSH config file (VB_SSH_CONFIG). Passed as `-F` to ssh.
17    pub ssh_config: Option<String>,
18    /// Disable SSH ControlMaster multiplexing (VB_DISABLE_CONTROL_MASTER=1).
19    /// Set this on WSL2/Windows when the CM socket path contains non-ASCII chars.
20    pub disable_control_master: bool,
21    pub timeout: u64,
22    pub keep_remote_files: bool,
23    pub spectre_cmd: String,
24    pub spectre_args: Vec<String>,
25}
26
27impl Config {
28    /// Read a config variable, checking profile-specific first (e.g. VB_REMOTE_HOST_prod).
29    fn env_with_profile(key: &str, profile: Option<&str>) -> Option<String> {
30        if let Some(p) = profile {
31            if let Ok(v) = env::var(format!("{key}_{p}")) {
32                if !v.is_empty() {
33                    return Some(v);
34                }
35            }
36        }
37        env::var(key).ok().filter(|s| !s.is_empty())
38    }
39
40    pub fn from_env() -> Result<Self> {
41        let profile = env::var("VB_PROFILE").ok();
42        Self::from_env_with_profile(profile.as_deref())
43    }
44
45    pub fn from_env_with_profile(profile: Option<&str>) -> Result<Self> {
46        load_dotenv_upward();
47
48
49        let remote_host = Self::env_with_profile("VB_REMOTE_HOST", profile);
50
51        let port: u16 = Self::env_with_profile("VB_PORT", profile)
52            .and_then(|v| v.parse().ok())
53            .unwrap_or_else(Self::default_port);
54
55        if port == 0 {
56            return Err(VirtuosoError::Config(
57                "VB_PORT must be between 1 and 65535".into(),
58            ));
59        }
60
61        let sessions_dir = dirs::cache_dir().map(|d| d.join("virtuoso_bridge").join("sessions"));
62        if let Some(ref d) = sessions_dir {
63            tracing::debug!("session dir: {}", d.display());
64        }
65
66        Ok(Self {
67            profile: profile.map(|s| s.to_string()),
68            remote_host,
69            remote_user: Self::env_with_profile("VB_REMOTE_USER", profile),
70            port,
71            jump_host: Self::env_with_profile("VB_JUMP_HOST", profile),
72            jump_user: Self::env_with_profile("VB_JUMP_USER", profile),
73            ssh_port: Self::env_with_profile("VB_SSH_PORT", profile).and_then(|v| v.parse().ok()),
74            ssh_key: Self::env_with_profile("VB_SSH_KEY", profile),
75            ssh_config: Self::env_with_profile("VB_SSH_CONFIG", profile),
76            disable_control_master: Self::env_with_profile("VB_DISABLE_CONTROL_MASTER", profile)
77                .map(|v| v == "1" || v.to_lowercase() == "true")
78                .unwrap_or(false),
79            timeout: Self::env_with_profile("VB_TIMEOUT", profile)
80                .and_then(|v| v.parse().ok())
81                .unwrap_or(30),
82            keep_remote_files: Self::env_with_profile("VB_KEEP_REMOTE_FILES", profile)
83                .map(|v| v == "1" || v.to_lowercase() == "true")
84                .unwrap_or(false),
85            spectre_cmd: Self::env_with_profile("VB_SPECTRE_CMD", profile)
86                .unwrap_or_else(|| "spectre".into()),
87            spectre_args: Self::env_with_profile("VB_SPECTRE_ARGS", profile)
88                .map(|v| shlex::split(&v).unwrap_or_default())
89                .unwrap_or_default(),
90        })
91    }
92
93    /// Derive a stable default port from the current username.
94    /// Range: 65000-65499, deterministic per user to reduce collisions.
95    fn default_port() -> u16 {
96        let user = env::var("USER")
97            .or_else(|_| env::var("USERNAME"))
98            .unwrap_or_default();
99        let hash: u16 = user.bytes().map(|b| b as u16).sum::<u16>() % 500;
100        65000 + hash
101    }
102
103    pub fn is_remote(&self) -> bool {
104        self.remote_host.is_some()
105    }
106
107    #[allow(dead_code)]
108    pub fn ssh_target(&self) -> String {
109        let host = self.remote_host.as_deref().unwrap_or("");
110        match &self.remote_user {
111            Some(user) => format!("{user}@{host}"),
112            None => host.to_string(),
113        }
114    }
115
116    #[allow(dead_code)]
117    pub fn ssh_jump(&self) -> Option<String> {
118        match (&self.jump_host, &self.jump_user) {
119            (Some(host), Some(user)) => Some(format!("{user}@{host}")),
120            (Some(host), None) => Some(host.clone()),
121            _ => None,
122        }
123    }
124}
125
126/// Walk cwd → parent → … until a `.env` is found, then load it.
127/// Stops at filesystem root if no `.env` exists anywhere.
128fn load_dotenv_upward() {
129    let Ok(start) = std::env::current_dir() else { return };
130    let mut dir = start.as_path();
131    loop {
132        let candidate = dir.join(".env");
133        if candidate.exists() {
134            match dotenvy::from_path(&candidate) {
135                Ok(()) => tracing::debug!("loaded .env from {}", candidate.display()),
136                Err(e) => tracing::warn!("failed to load .env from {}: {e}", candidate.display()),
137            }
138            return;
139        }
140        match dir.parent() {
141            Some(p) => dir = p,
142            None => return,
143        }
144    }
145}
146
147#[allow(dead_code)]
148pub fn find_project_root() -> Option<PathBuf> {
149    let mut current = std::env::current_dir().ok()?;
150    loop {
151        if current.join(".env").exists() {
152            return Some(current);
153        }
154        if current.join("pyproject.toml").exists() {
155            let content = std::fs::read_to_string(current.join("pyproject.toml")).ok()?;
156            if content.contains("virtuoso-bridge") || content.contains("virtuoso-cli") {
157                return Some(current);
158            }
159        }
160        if !current.pop() {
161            break;
162        }
163    }
164    None
165}