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 crate::version::VirtuosoVersion;
9use std::cell::Cell;
10use std::collections::HashMap;
11use std::io::{Read, Write};
12use std::net::TcpStream;
13use std::time::Instant;
14
15const STX: u8 = 0x02;
16const NAK: u8 = 0x15;
17const MAX_RESPONSE_SIZE: usize = 100 * 1024 * 1024; // 100MB
18
19pub struct VirtuosoClient {
20    host: String,
21    port: u16,
22    timeout: u64,
23    tunnel: Option<SSHClient>,
24    #[allow(dead_code)]
25    pub layout: LayoutOps,
26    pub maestro: MaestroOps,
27    pub schematic: SchematicOps,
28    pub window: WindowOps,
29    cached_version: Cell<Option<VirtuosoVersion>>,
30}
31
32impl VirtuosoClient {
33    pub fn new(host: &str, port: u16, timeout: u64) -> Self {
34        Self {
35            host: host.into(),
36            port,
37            timeout,
38            tunnel: None,
39            layout: LayoutOps::new(),
40            maestro: MaestroOps,
41            schematic: SchematicOps::new(),
42            window: WindowOps,
43            cached_version: Cell::new(None),
44        }
45    }
46
47    pub fn from_env() -> Result<Self> {
48        let cfg = crate::config::Config::from_env()?;
49
50        let tunnel = if cfg.is_remote() {
51            let state = crate::models::TunnelState::load().ok().flatten();
52            if let Some(ref s) = state {
53                if is_port_open(s.port) {
54                    tracing::info!("reusing existing tunnel on port {}", s.port);
55                    let client = SSHClient::from_env(cfg.keep_remote_files)?;
56                    Some(client)
57                } else {
58                    None
59                }
60            } else {
61                None
62            }
63        } else {
64            None
65        };
66
67        // Session-aware port resolution:
68        // 1. --session / VB_SESSION → load port from session file
69        // 2. No session specified → auto-select if exactly one session exists
70        // 3. Fallback to VB_PORT / config.port for backward compat
71        let port = if let Some(base_port) = tunnel.as_ref().and_then(|t| t.saved_port()) {
72            base_port
73        } else if let Ok(session_id) = std::env::var("VB_SESSION") {
74            // VB_SESSION may be a Maestro session name (e.g. "fnxSession8") rather than
75            // a bridge session ID — Maestro sessions don't have session files.
76            // Fall back to VB_PORT in that case.
77            match crate::models::SessionInfo::load(&session_id) {
78                Ok(s) => {
79                    tracing::info!("connecting to session '{}' on port {}", s.id, s.port);
80                    s.port
81                }
82                Err(_) => {
83                    tracing::debug!(
84                        "session '{}' not a bridge session (no file), using VB_PORT",
85                        session_id
86                    );
87                    cfg.port
88                }
89            }
90        } else {
91            // No session specified — try auto-discovery
92            match crate::models::SessionInfo::list() {
93                Ok(sessions) if sessions.len() == 1 => {
94                    let s = &sessions[0];
95                    tracing::info!("auto-selected session '{}' on port {}", s.id, s.port);
96                    s.port
97                }
98                Ok(sessions) if sessions.len() > 1 => {
99                    let ids: Vec<&str> = sessions.iter().map(|s| s.id.as_str()).collect();
100                    return Err(crate::error::VirtuosoError::Config(format!(
101                        "multiple Virtuoso sessions active: {}. Use --session <id> to select one.",
102                        ids.join(", ")
103                    )));
104                }
105                _ => cfg.port, // 0 sessions or list failed → use VB_PORT
106            }
107        };
108
109        Ok(Self {
110            host: "127.0.0.1".into(),
111            port,
112            timeout: cfg.timeout,
113            tunnel,
114            layout: LayoutOps::new(),
115            maestro: MaestroOps,
116            schematic: SchematicOps::new(),
117            window: WindowOps,
118            cached_version: Cell::new(None),
119        })
120    }
121
122    pub fn execute_skill(&self, skill_code: &str, timeout: Option<u64>) -> Result<VirtuosoResult> {
123        // Guard: block SKILL expressions that can hang the daemon
124        if let Some(warning) = check_blocking_skill(skill_code) {
125            return Err(VirtuosoError::Execution(warning));
126        }
127
128        let timeout = timeout.unwrap_or(self.timeout);
129        let start = Instant::now();
130
131        let addr: std::net::SocketAddr = format!("{}:{}", self.host, self.port)
132            .parse()
133            .map_err(|e| VirtuosoError::Connection(format!("invalid address: {e}")))?;
134        let req = serde_json::json!({"skill": skill_code, "timeout": timeout});
135        let req_bytes = serde_json::to_string(&req).map_err(VirtuosoError::Json)?;
136
137        // Drain loop: a new session may find stale "sync_N" responses queued in the
138        // daemon from a previous client. Detect and transparently discard up to 10.
139        for _ in 0..10u8 {
140            let mut stream =
141                TcpStream::connect_timeout(&addr, std::time::Duration::from_secs(timeout))
142                    .map_err(|e| VirtuosoError::Connection(e.to_string()))?;
143            stream
144                .set_read_timeout(Some(std::time::Duration::from_secs(timeout)))
145                .ok();
146            stream
147                .write_all(req_bytes.as_bytes())
148                .map_err(|e| VirtuosoError::Connection(e.to_string()))?;
149            stream
150                .shutdown(std::net::Shutdown::Write)
151                .map_err(|e| VirtuosoError::Connection(e.to_string()))?;
152
153            let mut data = Vec::new();
154            let mut buf = [0u8; 65536];
155            loop {
156                match stream.read(&mut buf) {
157                    Ok(0) => break,
158                    Ok(n) => {
159                        if data.len() + n > MAX_RESPONSE_SIZE {
160                            return Err(VirtuosoError::Execution(format!(
161                                "response exceeds {}MB limit",
162                                MAX_RESPONSE_SIZE / 1024 / 1024
163                            )));
164                        }
165                        data.extend_from_slice(&buf[..n]);
166                    }
167                    Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
168                        return Err(VirtuosoError::Timeout(timeout));
169                    }
170                    Err(e) => return Err(VirtuosoError::Connection(e.to_string())),
171                }
172            }
173
174            if data.is_empty() {
175                return Err(VirtuosoError::Execution(
176                    "empty response from daemon".into(),
177                ));
178            }
179
180            let status_byte = data[0];
181            let payload = String::from_utf8_lossy(&data[1..]).into_owned();
182
183            // Stale sync_N: queued response from a previous session's command.
184            // Discard and retry with the same command on a fresh connection.
185            if status_byte == STX && is_stale_sync(&payload) {
186                continue;
187            }
188
189            let elapsed = start.elapsed().as_secs_f64();
190            let mut result = VirtuosoResult {
191                status: ExecutionStatus::Success,
192                output: String::new(),
193                errors: Vec::new(),
194                warnings: Vec::new(),
195                execution_time: Some(elapsed),
196                metadata: Default::default(),
197            };
198
199            // STX = transport success; NAK = transport error (includes daemon timeout).
200            // The daemon sends NAK+"TimeoutError" (no RS) on deadline — no need to
201            // text-match under STX. Doing so would reject any SKILL function that
202            // legitimately returns the string "TimeoutError".
203            if status_byte == STX {
204                result.output = payload;
205            } else if status_byte == NAK {
206                result.status = ExecutionStatus::Error;
207                result.errors.push(payload);
208            } else {
209                result.output = String::from_utf8_lossy(&data).into_owned();
210                result.warnings.push("non-standard response marker".into());
211            }
212
213            let truncated = if skill_code.len() > 200 {
214                format!("{}...", &skill_code[..200])
215            } else {
216                skill_code.to_string()
217            };
218            crate::command_log::log_command("SKILL", &truncated, Some(start.elapsed().as_millis()));
219
220            return Ok(result);
221        }
222
223        Err(VirtuosoError::Execution(
224            "bridge queue misaligned: 10 consecutive sync_N responses drained".into(),
225        ))
226    }
227
228    /// Batch-fetch object slots from a SKILL list expression in a single RTT.
229    ///
230    /// `list_expr` evaluates to a SKILL list of objects; `fields` names the `~>slot`
231    /// accessors to extract from each object. Returns one `HashMap` per object.
232    ///
233    /// Nil-valued slots are returned as empty strings. Example:
234    /// ```rust,ignore
235    /// client.execute_skill_fetch("maeGetSessions()", &["name", "status"])
236    /// // → [{"name": "fnxSession0", "status": "idle"}, ...]
237    /// ```
238    #[allow(dead_code)]
239    pub fn execute_skill_fetch(
240        &self,
241        list_expr: &str,
242        fields: &[&str],
243    ) -> Result<Vec<HashMap<String, String>>> {
244        if fields.is_empty() {
245            return Ok(Vec::new());
246        }
247        let skill = build_fetch_skill(list_expr, fields);
248        let r = self.execute_skill(&skill, None)?;
249        if !r.ok() {
250            return Err(VirtuosoError::Execution(format!(
251                "execute_skill_fetch failed: {}",
252                r.errors.first().cloned().unwrap_or_default()
253            )));
254        }
255        let sexp = crate::client::skill_sexp::parse_sexp(&r.output)?;
256        match sexp {
257            crate::client::skill_sexp::SexpVal::Nil => Ok(Vec::new()),
258            crate::client::skill_sexp::SexpVal::List(items) => Ok(items
259                .iter()
260                .filter_map(|item| {
261                    let vals = crate::client::skill_sexp::sexp_to_str_list(item)?;
262                    if vals.len() != fields.len() {
263                        return None;
264                    }
265                    Some(
266                        fields
267                            .iter()
268                            .zip(vals.iter())
269                            .map(|(k, v)| (k.to_string(), v.clone().unwrap_or_default()))
270                            .collect(),
271                    )
272                })
273                .collect()),
274            _ => Err(VirtuosoError::Execution(
275                "execute_skill_fetch: expected list from SKILL".into(),
276            )),
277        }
278    }
279
280    pub fn test_connection(&self, timeout: Option<u64>) -> Result<bool> {
281        let result = self.execute_skill("1+1", timeout)?;
282        Ok(result.output.trim() == "2")
283    }
284
285    pub fn open_cell_view(
286        &self,
287        lib: &str,
288        cell: &str,
289        view: &str,
290        mode: &str,
291    ) -> Result<VirtuosoResult> {
292        let lib = escape_skill_string(lib);
293        let cell = escape_skill_string(cell);
294        let view = escape_skill_string(view);
295        let mode = escape_skill_string(mode);
296        let skill = format!(
297            r#"geOpenCellView(?libName "{lib}" ?cellName "{cell}" ?viewName "{view}" ?mode "{mode}")"#
298        );
299        self.execute_skill(&skill, None)
300    }
301
302    pub fn save_current_cellview(&self) -> Result<VirtuosoResult> {
303        self.execute_skill("geSaveEdit()", None)
304    }
305
306    pub fn close_current_cellview(&self) -> Result<VirtuosoResult> {
307        self.execute_skill("geCloseEdit()", None)
308    }
309
310    pub fn get_current_design(&self) -> Result<(String, String, String)> {
311        let result = self.execute_skill(
312            r#"let((cv) cv = geGetEditCellView() list(cv~>libName cv~>cellName cv~>viewName))"#,
313            None,
314        )?;
315        use crate::client::skill_sexp::{parse_sexp, SexpVal};
316        let extract = |v: &SexpVal| {
317            v.as_str()
318                .map(str::to_owned)
319                .ok_or_else(|| VirtuosoError::Execution("unexpected token in cellview list".into()))
320        };
321        match parse_sexp(result.output.trim())? {
322            SexpVal::List(items) if items.len() >= 3 => Ok((
323                extract(&items[0])?,
324                extract(&items[1])?,
325                extract(&items[2])?,
326            )),
327            _ => Err(VirtuosoError::Execution(
328                "failed to get current design".into(),
329            )),
330        }
331    }
332
333    pub fn load_il(&self, local_path: &str) -> Result<VirtuosoResult> {
334        let filename = std::path::Path::new(local_path)
335            .file_name()
336            .ok_or_else(|| VirtuosoError::Config(format!("invalid path: {local_path}")))?
337            .to_string_lossy();
338        let remote_path = format!("/tmp/virtuoso_bridge/{filename}");
339
340        self.upload_file(local_path, &remote_path)?;
341
342        let remote_path_escaped = escape_skill_string(&remote_path);
343        let skill = format!(r#"(load "{remote_path_escaped}")"#);
344        self.execute_skill(&skill, None)
345    }
346
347    pub fn upload_file(&self, local: &str, remote: &str) -> Result<()> {
348        if let Some(ref tunnel) = self.tunnel {
349            tunnel.upload_file(local, remote)
350        } else {
351            std::fs::copy(local, remote)
352                .map(|_| ())
353                .map_err(VirtuosoError::Io)
354        }
355    }
356
357    #[allow(dead_code)]
358    pub fn download_file(&self, remote: &str, local: &str) -> Result<()> {
359        if let Some(ref tunnel) = self.tunnel {
360            tunnel.download_file(remote, local)
361        } else {
362            std::fs::copy(remote, local)
363                .map(|_| ())
364                .map_err(VirtuosoError::Io)
365        }
366    }
367
368    pub fn execute_operations(&self, commands: &[String]) -> Result<VirtuosoResult> {
369        if commands.is_empty() {
370            return Ok(VirtuosoResult::success(""));
371        }
372        let body = commands.join("\n");
373        let skill = format!("progn(\n{body}\n)");
374        self.execute_skill(&skill, None)
375    }
376
377    #[allow(dead_code)]
378    pub fn ciw_print(&self, message: &str) -> Result<VirtuosoResult> {
379        let skill = format!(
380            r#"printf("[virtuoso-cli] {}\n")"#,
381            escape_skill_string(message)
382        );
383        self.execute_skill(&skill, None)
384    }
385
386    #[allow(dead_code)]
387    pub fn run_shell_command(&self, cmd: &str) -> Result<VirtuosoResult> {
388        let cmd = escape_skill_string(cmd);
389        let skill = format!(r#"(csh "{cmd}")"#);
390        self.execute_skill(&skill, None)
391    }
392
393    #[allow(dead_code)]
394    pub fn tunnel(&self) -> Option<&SSHClient> {
395        self.tunnel.as_ref()
396    }
397
398    /// Detect and cache the Virtuoso IC version.
399    /// First call queries the daemon; subsequent calls return the cached result.
400    pub fn version(&self) -> Result<VirtuosoVersion> {
401        if let Some(v) = self.cached_version.get() {
402            return Ok(v);
403        }
404        let v = crate::version::detect_version(self)?;
405        self.cached_version.set(Some(v));
406        Ok(v)
407    }
408}
409
410fn is_port_open(port: u16) -> bool {
411    TcpStream::connect(format!("127.0.0.1:{port}")).is_ok()
412}
413
414fn check_blocking_skill(code: &str) -> Option<String> {
415    if code.contains("system(") || code.contains("sh(") {
416        let lower = code.to_lowercase();
417        if lower.contains("find /") || lower.contains("find \"/") {
418            return Some(
419                "Blocked: system()/sh() with recursive 'find /' can hang the SKILL daemon. \
420                 Use a specific directory instead (e.g., find /home/...)."
421                    .into(),
422            );
423        }
424    }
425    None
426}
427
428/// Returns true for stale `"sync_N"` responses queued from a previous session.
429fn is_stale_sync(payload: &str) -> bool {
430    let p = payload.trim().trim_matches('"');
431    p.starts_with("sync_") && p[5..].parse::<u32>().is_ok()
432}
433
434pub fn escape_skill_string(s: &str) -> String {
435    s.replace('\\', "\\\\")
436        .replace('"', "\\\"")
437        .replace('\n', "\\n")
438}
439
440/// Build a SKILL expression that fetches `~>slot` fields from each object in
441/// `list_expr` and returns a native SKILL list-of-lists in a single RTT.
442///
443/// Generated form (for fields ["name", "value"]):
444/// ```text
445/// mapcar(lambda((o) list(o~>name o~>value)) list_expr)
446/// ```
447///
448/// SKILL output: `(("fnxSession0" "idle") ("fnxSession1" nil) ...)`
449/// Parsed by `execute_skill_fetch` using `skill_sexp::parse_sexp`.
450/// This approach avoids the sprintf-JSON hack that silently corrupts field
451/// values containing `"` or `\n`.
452#[allow(dead_code)]
453fn build_fetch_skill(list_expr: &str, fields: &[&str]) -> String {
454    let field_exprs: Vec<String> = fields.iter().map(|f| format!("o~>{f}")).collect();
455    let fields_str = field_exprs.join(" ");
456    format!("mapcar(lambda((o) list({fields_str})) {list_expr})")
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    #[test]
464    fn fetch_skill_single_field() {
465        let s = build_fetch_skill("maeGetSessions()", &["name"]);
466        assert_eq!(s, "mapcar(lambda((o) list(o~>name)) maeGetSessions())");
467    }
468
469    #[test]
470    fn fetch_skill_multiple_fields() {
471        let s = build_fetch_skill("myList()", &["name", "value"]);
472        assert_eq!(s, "mapcar(lambda((o) list(o~>name o~>value)) myList())");
473    }
474
475    #[test]
476    fn fetch_skill_three_fields() {
477        let s = build_fetch_skill("getSessions()", &["id", "port", "status"]);
478        assert!(s.contains("o~>id"), "{s}");
479        assert!(s.contains("o~>port"), "{s}");
480        assert!(s.contains("o~>status"), "{s}");
481        assert!(s.starts_with("mapcar(lambda((o) list("), "{s}");
482    }
483}