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; pub 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 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 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 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, }
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 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 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 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 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 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!()
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
359fn 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}