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 pub ssh_config: Option<String>,
18 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 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 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
126fn 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}