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; pub 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 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 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 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, }
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 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 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 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 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 #[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 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
428fn 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#[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}