Skip to main content

virtuoso_cli/client/
bridge.rs

1use crate::client::layout_ops::LayoutOps;
2use crate::client::maestro_ops::MaestroOps;
3use crate::client::schematic_ops::SchematicOps;
4use crate::client::window_ops::WindowOps;
5use crate::error::{Result, VirtuosoError};
6use crate::models::{ExecutionStatus, VirtuosoResult};
7use crate::transport::tunnel::SSHClient;
8use std::io::{Read, Write};
9use std::net::TcpStream;
10use std::time::Instant;
11
12const STX: u8 = 0x02;
13const NAK: u8 = 0x15;
14const MAX_RESPONSE_SIZE: usize = 100 * 1024 * 1024; // 100MB
15
16pub struct VirtuosoClient {
17    host: String,
18    port: u16,
19    timeout: u64,
20    tunnel: Option<SSHClient>,
21    pub layout: LayoutOps,
22    pub maestro: MaestroOps,
23    pub schematic: SchematicOps,
24    pub window: WindowOps,
25}
26
27impl VirtuosoClient {
28    pub fn new(host: &str, port: u16, timeout: u64) -> Self {
29        Self {
30            host: host.into(),
31            port,
32            timeout,
33            tunnel: None,
34            layout: LayoutOps::new(),
35            maestro: MaestroOps,
36            schematic: SchematicOps::new(),
37            window: WindowOps,
38        }
39    }
40
41    pub fn from_env() -> Result<Self> {
42        let cfg = crate::config::Config::from_env()?;
43
44        let tunnel = if cfg.is_remote() {
45            let state = crate::models::TunnelState::load().ok().flatten();
46            if let Some(ref s) = state {
47                if is_port_open(s.port) {
48                    tracing::info!("reusing existing tunnel on port {}", s.port);
49                    let client = SSHClient::from_env(cfg.keep_remote_files)?;
50                    Some(client)
51                } else {
52                    None
53                }
54            } else {
55                None
56            }
57        } else {
58            None
59        };
60
61        // Session-aware port resolution:
62        // 1. --session / VB_SESSION → load port from session file
63        // 2. No session specified → auto-select if exactly one session exists
64        // 3. Fallback to VB_PORT / config.port for backward compat
65        let port = if let Some(base_port) = tunnel.as_ref().and_then(|t| t.saved_port()) {
66            base_port
67        } else if let Ok(session_id) = std::env::var("VB_SESSION") {
68            // VB_SESSION may be a Maestro session name (e.g. "fnxSession8") rather than
69            // a bridge session ID — Maestro sessions don't have session files.
70            // Fall back to VB_PORT in that case.
71            match crate::models::SessionInfo::load(&session_id) {
72                Ok(s) => {
73                    tracing::info!("connecting to session '{}' on port {}", s.id, s.port);
74                    s.port
75                }
76                Err(_) => {
77                    tracing::debug!(
78                        "session '{}' not a bridge session (no file), using VB_PORT",
79                        session_id
80                    );
81                    cfg.port
82                }
83            }
84        } else {
85            // No session specified — try auto-discovery
86            match crate::models::SessionInfo::list() {
87                Ok(sessions) if sessions.len() == 1 => {
88                    let s = &sessions[0];
89                    tracing::info!("auto-selected session '{}' on port {}", s.id, s.port);
90                    s.port
91                }
92                Ok(sessions) if sessions.len() > 1 => {
93                    let ids: Vec<&str> = sessions.iter().map(|s| s.id.as_str()).collect();
94                    return Err(crate::error::VirtuosoError::Config(format!(
95                        "multiple Virtuoso sessions active: {}. Use --session <id> to select one.",
96                        ids.join(", ")
97                    )));
98                }
99                _ => cfg.port, // 0 sessions or list failed → use VB_PORT
100            }
101        };
102
103        Ok(Self {
104            host: "127.0.0.1".into(),
105            port,
106            timeout: cfg.timeout,
107            tunnel,
108            layout: LayoutOps::new(),
109            maestro: MaestroOps,
110            schematic: SchematicOps::new(),
111            window: WindowOps,
112        })
113    }
114
115    pub fn local(host: &str, port: u16, timeout: u64) -> Self {
116        Self::new(host, port, timeout)
117    }
118
119    pub fn execute_skill(&self, skill_code: &str, timeout: Option<u64>) -> Result<VirtuosoResult> {
120        // Guard: block SKILL expressions that can hang the daemon
121        if let Some(warning) = check_blocking_skill(skill_code) {
122            return Err(VirtuosoError::Execution(warning));
123        }
124
125        let timeout = timeout.unwrap_or(self.timeout);
126        let start = Instant::now();
127
128        let addr: std::net::SocketAddr = format!("{}:{}", self.host, self.port)
129            .parse()
130            .map_err(|e| VirtuosoError::Connection(format!("invalid address: {e}")))?;
131        let req = serde_json::json!({"skill": skill_code, "timeout": timeout});
132        let req_bytes = serde_json::to_string(&req).map_err(VirtuosoError::Json)?;
133
134        // Drain loop: a new session may find stale "sync_N" responses queued in the
135        // daemon from a previous client. Detect and transparently discard up to 10.
136        for drain in 0..=10u8 {
137            let mut stream =
138                TcpStream::connect_timeout(&addr, std::time::Duration::from_secs(timeout))
139                    .map_err(|e| VirtuosoError::Connection(e.to_string()))?;
140            stream
141                .set_read_timeout(Some(std::time::Duration::from_secs(timeout)))
142                .ok();
143            stream
144                .write_all(req_bytes.as_bytes())
145                .map_err(|e| VirtuosoError::Connection(e.to_string()))?;
146            stream
147                .shutdown(std::net::Shutdown::Write)
148                .map_err(|e| VirtuosoError::Connection(e.to_string()))?;
149
150            let mut data = Vec::new();
151            let mut buf = [0u8; 65536];
152            loop {
153                match stream.read(&mut buf) {
154                    Ok(0) => break,
155                    Ok(n) => {
156                        if data.len() + n > MAX_RESPONSE_SIZE {
157                            return Err(VirtuosoError::Execution(format!(
158                                "response exceeds {}MB limit",
159                                MAX_RESPONSE_SIZE / 1024 / 1024
160                            )));
161                        }
162                        data.extend_from_slice(&buf[..n]);
163                    }
164                    Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
165                        return Err(VirtuosoError::Timeout(timeout));
166                    }
167                    Err(e) => return Err(VirtuosoError::Connection(e.to_string())),
168                }
169            }
170
171            if data.is_empty() {
172                return Err(VirtuosoError::Execution(
173                    "empty response from daemon".into(),
174                ));
175            }
176
177            let status_byte = data[0];
178            let payload = String::from_utf8_lossy(&data[1..]).to_string();
179
180            // Stale sync_N: queued response from a previous session's command.
181            // Discard and retry with the same command on a fresh connection.
182            if status_byte == STX && is_stale_sync(&payload) {
183                continue;
184            }
185
186            if drain == 10 {
187                return Err(VirtuosoError::Execution(
188                    "bridge queue misaligned: 10 consecutive sync_N responses drained".into(),
189                ));
190            }
191
192            let elapsed = start.elapsed().as_secs_f64();
193            let mut result = VirtuosoResult {
194                status: ExecutionStatus::Success,
195                output: String::new(),
196                errors: Vec::new(),
197                warnings: Vec::new(),
198                execution_time: Some(elapsed),
199                metadata: Default::default(),
200            };
201
202            // STX = transport success; NAK = transport error (includes daemon timeout).
203            // The daemon sends NAK+"TimeoutError" (no RS) on deadline — no need to
204            // text-match under STX. Doing so would reject any SKILL function that
205            // legitimately returns the string "TimeoutError".
206            if status_byte == STX {
207                result.output = payload;
208            } else if status_byte == NAK {
209                result.status = ExecutionStatus::Error;
210                result.errors.push(payload);
211            } else {
212                result.output = String::from_utf8_lossy(&data).to_string();
213                result.warnings.push("non-standard response marker".into());
214            }
215
216            // Log command execution
217            let truncated = if skill_code.len() > 200 {
218                format!("{}...", &skill_code[..200])
219            } else {
220                skill_code.to_string()
221            };
222            crate::command_log::log_command("SKILL", &truncated, Some(start.elapsed().as_millis()));
223
224            return Ok(result);
225        }
226
227        // Unreachable: the loop always returns or continues; drain == 10 returns Err above.
228        unreachable!()
229    }
230
231    pub fn test_connection(&self, timeout: Option<u64>) -> Result<bool> {
232        let result = self.execute_skill("1+1", timeout)?;
233        Ok(result.output.trim() == "2")
234    }
235
236    pub fn open_cell_view(
237        &self,
238        lib: &str,
239        cell: &str,
240        view: &str,
241        mode: &str,
242    ) -> Result<VirtuosoResult> {
243        let lib = escape_skill_string(lib);
244        let cell = escape_skill_string(cell);
245        let view = escape_skill_string(view);
246        let mode = escape_skill_string(mode);
247        let skill = format!(
248            r#"geOpenCellView(?libName "{lib}" ?cellName "{cell}" ?viewName "{view}" ?mode "{mode}")"#
249        );
250        self.execute_skill(&skill, None)
251    }
252
253    pub fn save_current_cellview(&self) -> Result<VirtuosoResult> {
254        self.execute_skill("geSaveEdit()", None)
255    }
256
257    pub fn close_current_cellview(&self) -> Result<VirtuosoResult> {
258        self.execute_skill("geCloseEdit()", None)
259    }
260
261    pub fn get_current_design(&self) -> Result<(String, String, String)> {
262        let result = self.execute_skill(
263            r#"let((cv) cv = geGetEditCellView() list(cv~>libName cv~>cellName cv~>viewName))"#,
264            None,
265        )?;
266        let cleaned = result.output.trim().trim_matches(|c| c == '(' || c == ')');
267        let parts: Vec<&str> = cleaned.split_whitespace().collect();
268        if parts.len() >= 3 {
269            let strip = |s: &str| s.trim_matches('"').to_string();
270            Ok((strip(parts[0]), strip(parts[1]), strip(parts[2])))
271        } else {
272            Err(VirtuosoError::Execution(
273                "failed to get current design".into(),
274            ))
275        }
276    }
277
278    pub fn load_il(&self, local_path: &str) -> Result<VirtuosoResult> {
279        let remote_path = format!("/tmp/virtuoso_bridge/{}", {
280            std::path::Path::new(local_path)
281                .file_name()
282                .unwrap_or_default()
283                .to_string_lossy()
284        });
285
286        self.upload_file(local_path, &remote_path)?;
287
288        let remote_path_escaped = escape_skill_string(&remote_path);
289        let skill = format!(r#"(load "{remote_path_escaped}")"#);
290        self.execute_skill(&skill, None)
291    }
292
293    pub fn upload_file(&self, local: &str, remote: &str) -> Result<()> {
294        if let Some(ref tunnel) = self.tunnel {
295            tunnel.upload_file(local, remote)
296        } else {
297            std::fs::copy(local, remote)
298                .map(|_| ())
299                .map_err(VirtuosoError::Io)
300        }
301    }
302
303    pub fn download_file(&self, remote: &str, local: &str) -> Result<()> {
304        if let Some(ref tunnel) = self.tunnel {
305            tunnel.download_file(remote, local)
306        } else {
307            std::fs::copy(remote, local)
308                .map(|_| ())
309                .map_err(VirtuosoError::Io)
310        }
311    }
312
313    pub fn execute_operations(&self, commands: &[String]) -> Result<VirtuosoResult> {
314        if commands.is_empty() {
315            return Ok(VirtuosoResult::success(""));
316        }
317        let body = commands.join("\n");
318        let skill = format!("progn(\n{body}\n)");
319        self.execute_skill(&skill, None)
320    }
321
322    pub fn ciw_print(&self, message: &str) -> Result<VirtuosoResult> {
323        let skill = format!(
324            r#"printf("[virtuoso-cli] {}\n")"#,
325            escape_skill_string(message)
326        );
327        self.execute_skill(&skill, None)
328    }
329
330    pub fn run_shell_command(&self, cmd: &str) -> Result<VirtuosoResult> {
331        let cmd = escape_skill_string(cmd);
332        let skill = format!(r#"(csh "{cmd}")"#);
333        self.execute_skill(&skill, None)
334    }
335
336    pub fn tunnel(&self) -> Option<&SSHClient> {
337        self.tunnel.as_ref()
338    }
339}
340
341fn is_port_open(port: u16) -> bool {
342    TcpStream::connect(format!("127.0.0.1:{port}")).is_ok()
343}
344
345fn check_blocking_skill(code: &str) -> Option<String> {
346    if code.contains("system(") || code.contains("sh(") {
347        let lower = code.to_lowercase();
348        if lower.contains("find /") || lower.contains("find \"/") {
349            return Some(
350                "Blocked: system()/sh() with recursive 'find /' can hang the SKILL daemon. \
351                 Use a specific directory instead (e.g., find /home/...)."
352                    .into(),
353            );
354        }
355    }
356    None
357}
358
359/// Returns true for stale `"sync_N"` responses queued from a previous session.
360fn is_stale_sync(payload: &str) -> bool {
361    let p = payload.trim().trim_matches('"');
362    p.starts_with("sync_") && p[5..].parse::<u32>().is_ok()
363}
364
365pub fn escape_skill_string(s: &str) -> String {
366    s.replace('\\', "\\\\")
367        .replace('"', "\\\"")
368        .replace('\n', "\\n")
369}