Skip to main content

virtuoso_cli/
config.rs

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