1use crate::error::{Result, VirtuosoError};
2use dotenvy::dotenv;
3use std::env;
4use std::path::PathBuf;
5
6#[derive(Debug, Clone)]
7pub struct Config {
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 timeout: u64,
17 pub keep_remote_files: bool,
18 pub spectre_cmd: String,
19 pub spectre_args: Vec<String>,
20}
21
22impl Config {
23 fn env_with_profile(key: &str, profile: Option<&str>) -> Option<String> {
25 if let Some(p) = profile {
26 if let Ok(v) = env::var(format!("{key}_{p}")) {
27 if !v.is_empty() {
28 return Some(v);
29 }
30 }
31 }
32 env::var(key).ok().filter(|s| !s.is_empty())
33 }
34
35 pub fn from_env() -> Result<Self> {
36 let profile = env::var("VB_PROFILE").ok();
37 Self::from_env_with_profile(profile.as_deref())
38 }
39
40 pub fn from_env_with_profile(profile: Option<&str>) -> Result<Self> {
41 if let Err(e) = dotenv() {
42 if !e.not_found() {
43 tracing::warn!("failed to load .env: {e}");
44 }
45 }
46
47 let remote_host = Self::env_with_profile("VB_REMOTE_HOST", profile);
48
49 let port: u16 = Self::env_with_profile("VB_PORT", profile)
50 .and_then(|v| v.parse().ok())
51 .unwrap_or_else(Self::default_port);
52
53 if port == 0 {
54 return Err(VirtuosoError::Config(
55 "VB_PORT must be between 1 and 65535".into(),
56 ));
57 }
58
59 Ok(Self {
60 profile: profile.map(|s| s.to_string()),
61 remote_host,
62 remote_user: Self::env_with_profile("VB_REMOTE_USER", profile),
63 port,
64 jump_host: Self::env_with_profile("VB_JUMP_HOST", profile),
65 jump_user: Self::env_with_profile("VB_JUMP_USER", profile),
66 ssh_port: Self::env_with_profile("VB_SSH_PORT", profile).and_then(|v| v.parse().ok()),
67 ssh_key: Self::env_with_profile("VB_SSH_KEY", profile),
68 timeout: Self::env_with_profile("VB_TIMEOUT", profile)
69 .and_then(|v| v.parse().ok())
70 .unwrap_or(30),
71 keep_remote_files: Self::env_with_profile("VB_KEEP_REMOTE_FILES", profile)
72 .map(|v| v == "1" || v.to_lowercase() == "true")
73 .unwrap_or(false),
74 spectre_cmd: Self::env_with_profile("VB_SPECTRE_CMD", profile)
75 .unwrap_or_else(|| "spectre".into()),
76 spectre_args: Self::env_with_profile("VB_SPECTRE_ARGS", profile)
77 .map(|v| shlex::split(&v).unwrap_or_default())
78 .unwrap_or_default(),
79 })
80 }
81
82 fn default_port() -> u16 {
85 let user = env::var("USER")
86 .or_else(|_| env::var("USERNAME"))
87 .unwrap_or_default();
88 let hash: u16 = user.bytes().map(|b| b as u16).sum::<u16>() % 500;
89 65000 + hash
90 }
91
92 pub fn is_remote(&self) -> bool {
93 self.remote_host.is_some()
94 }
95
96 pub fn ssh_target(&self) -> String {
97 let host = self.remote_host.as_deref().unwrap_or("");
98 match &self.remote_user {
99 Some(user) => format!("{user}@{host}"),
100 None => host.to_string(),
101 }
102 }
103
104 pub fn ssh_jump(&self) -> Option<String> {
105 match (&self.jump_host, &self.jump_user) {
106 (Some(host), Some(user)) => Some(format!("{user}@{host}")),
107 (Some(host), None) => Some(host.clone()),
108 _ => None,
109 }
110 }
111}
112
113pub fn find_project_root() -> Option<PathBuf> {
114 let mut current = std::env::current_dir().ok()?;
115 loop {
116 if current.join(".env").exists() {
117 return Some(current);
118 }
119 if current.join("pyproject.toml").exists() {
120 let content = std::fs::read_to_string(current.join("pyproject.toml")).ok()?;
121 if content.contains("virtuoso-bridge") || content.contains("virtuoso-cli") {
122 return Some(current);
123 }
124 }
125 if !current.pop() {
126 break;
127 }
128 }
129 None
130}