Skip to main content

virtuoso_cli/commands/
sim.rs

1use crate::client::bridge::{escape_skill_string, VirtuosoClient};
2use crate::error::{Result, VirtuosoError};
3use crate::ocean;
4use crate::ocean::corner::CornerConfig;
5use crate::spectre::jobs::Job;
6use crate::spectre::runner::SpectreSimulator;
7use serde_json::{json, Value};
8use std::collections::HashMap;
9
10pub fn setup(lib: &str, cell: &str, view: &str, simulator: &str) -> Result<Value> {
11    let client = VirtuosoClient::from_env()?;
12    let skill = ocean::setup_skill(lib, cell, view, simulator);
13    let result = client.execute_skill(&skill, None)?;
14
15    if !result.ok() {
16        return Err(VirtuosoError::Execution(result.errors.join("; ")));
17    }
18
19    Ok(json!({
20        "status": "success",
21        "simulator": simulator,
22        "design": { "lib": lib, "cell": cell, "view": view },
23        "results_dir": result.output.trim().trim_matches('"'),
24    }))
25}
26
27pub fn run(analysis: &str, params: &HashMap<String, String>, timeout: u64) -> Result<Value> {
28    let client = VirtuosoClient::from_env()?;
29
30    // Check if resultsDir is set — do NOT override if it is, as changing
31    // resultsDir while an ADE session is active causes run() to silently
32    // return nil (ADE binds the session to a specific results path).
33    let rdir = client.execute_skill("resultsDir()", None)?;
34    let rdir_val = rdir.output.trim().trim_matches('"');
35    if rdir_val == "nil" || rdir_val.is_empty() {
36        return Err(VirtuosoError::Execution(
37            "resultsDir is not set. Run `virtuoso sim setup` first, or open \
38             ADE L for your testbench and run at least one simulation to \
39             establish the session path."
40                .into(),
41        ));
42    }
43
44    // Send analysis setup
45    let analysis_skill = ocean::analysis_skill_simple(analysis, params);
46    let analysis_result = client.execute_skill(&analysis_skill, None)?;
47    if !analysis_result.ok() {
48        return Err(VirtuosoError::Execution(analysis_result.errors.join("; ")));
49    }
50
51    // Send save
52    let _ = client.execute_skill("save('all)", None);
53
54    // Execute run
55    let result = client.execute_skill("run()", Some(timeout))?;
56    if !result.ok() {
57        return Err(VirtuosoError::Execution(result.errors.join("; ")));
58    }
59
60    // Get actual results dir
61    let rdir = client.execute_skill("resultsDir()", None)?;
62    let results_dir = rdir.output.trim().trim_matches('"').to_string();
63
64    // Validate: run() returning nil usually means simulation didn't execute
65    let run_output = result.output.trim().trim_matches('"');
66    if run_output == "nil" {
67        let check =
68            client.execute_skill(&format!(r#"isFile("{results_dir}/psf/spectre.out")"#), None)?;
69        let has_spectre_out = check.output.trim().trim_matches('"');
70        if has_spectre_out == "nil" || has_spectre_out == "0" {
71            return Err(VirtuosoError::Execution(
72                "Simulation failed: run() returned nil and no spectre.out found. \
73                 The netlist may be missing or stale — regenerate via ADE \
74                 (Simulation → Netlist and Run) or `virtuoso sim netlist`."
75                    .into(),
76            ));
77        }
78    }
79
80    Ok(json!({
81        "status": "success",
82        "analysis": analysis,
83        "params": params,
84        "results_dir": results_dir,
85        "execution_time": result.execution_time,
86    }))
87}
88
89/// Reject SKILL expressions that could cause side effects outside of waveform access.
90/// `measure` is intended for read-only PSF queries; block known destructive/execution APIs.
91fn validate_measure_expr(expr: &str) -> Result<()> {
92    // Case-insensitive prefix patterns that indicate non-measurement operations
93    let blocked: &[&str] = &[
94        "system(",
95        "sh(",
96        "ipcbeginprocess(",
97        "ipcwriteprocess(",
98        "ipckillprocess(",
99        "deletefile(",
100        "deletedir(",
101        "copyfile(",
102        "movefile(",
103        "writefile(",
104        "createdir(",
105        "load(",
106        "evalstring(",
107        "hiloaddmenu(",
108    ];
109    let lower = expr.to_lowercase();
110    for pat in blocked {
111        if lower.contains(pat) {
112            return Err(VirtuosoError::Execution(format!(
113                "measure expression contains blocked function '{pat}': \
114                 only waveform access functions are allowed"
115            )));
116        }
117    }
118    Ok(())
119}
120
121pub fn measure(analysis: &str, exprs: &[String]) -> Result<Value> {
122    for expr in exprs {
123        validate_measure_expr(expr)?;
124    }
125
126    let client = VirtuosoClient::from_env()?;
127
128    // Open results from resultsDir PSF and select result type
129    let rdir = client.execute_skill("resultsDir()", None)?;
130    let rdir_val = rdir.output.trim().trim_matches('"');
131    if rdir_val != "nil" && !rdir_val.is_empty() {
132        let open_skill = format!("openResults(\"{rdir_val}/psf\")");
133        let _ = client.execute_skill(&open_skill, None);
134    }
135    let select_skill = format!("selectResult('{analysis})");
136    let _ = client.execute_skill(&select_skill, None);
137
138    // Execute each measure expression individually for reliability
139    let mut measures = Vec::new();
140    for expr in exprs {
141        let result = client.execute_skill(expr, None)?;
142        let value = if result.ok() {
143            result.output.trim().trim_matches('"').to_string()
144        } else {
145            format!("ERROR: {}", result.errors.join("; "))
146        };
147        measures.push(json!({
148            "expr": expr,
149            "value": value,
150        }));
151    }
152
153    // Detect all-nil results and provide diagnostics
154    let all_nil = !measures.is_empty()
155        && measures.iter().all(|m| {
156            m.get("value")
157                .and_then(|v| v.as_str())
158                .map(|s| s == "nil")
159                .unwrap_or(false)
160        });
161
162    let mut warnings: Vec<String> = Vec::new();
163    if all_nil {
164        let rdir_for_check = rdir_val.to_string();
165        let spectre_exists = client
166            .execute_skill(
167                &format!(r#"isFile("{rdir_for_check}/psf/spectre.out")"#),
168                None,
169            )
170            .map(|r| {
171                let v = r.output.trim().trim_matches('"');
172                v != "nil" && v != "0"
173            })
174            .unwrap_or(false);
175
176        if !spectre_exists {
177            warnings.push(
178                "All measurements returned nil. No spectre.out found — simulation \
179                 may not have run. Check netlist with `virtuoso sim netlist`."
180                    .into(),
181            );
182        } else {
183            warnings.push(
184                "All measurements returned nil. Spectre ran but produced no matching \
185                 data — verify signal names match your schematic and that the correct \
186                 analysis type is selected."
187                    .into(),
188            );
189        }
190    }
191
192    Ok(json!({
193        "status": "success",
194        "measures": measures,
195        "warnings": warnings,
196    }))
197}
198
199pub fn sweep(
200    var: &str,
201    from: f64,
202    to: f64,
203    step: f64,
204    analysis: &str,
205    measure_exprs: &[String],
206    timeout: u64,
207) -> Result<Value> {
208    let client = VirtuosoClient::from_env()?;
209
210    // Generate value list
211    let mut values = Vec::new();
212    let mut v = from;
213    while v <= to + step * 0.01 {
214        values.push(v);
215        v += step;
216    }
217
218    let skill = ocean::sweep_skill(var, &values, analysis, measure_exprs);
219    let result = client.execute_skill(&skill, Some(timeout))?;
220
221    if !result.ok() {
222        return Err(VirtuosoError::Execution(result.errors.join("; ")));
223    }
224
225    let parsed = ocean::parse_skill_list(result.output.trim());
226
227    let mut headers = vec![var.to_string()];
228    headers.extend(measure_exprs.iter().cloned());
229
230    let rows: Vec<Value> = parsed
231        .iter()
232        .map(|row| {
233            let mut obj = serde_json::Map::new();
234            for (i, h) in headers.iter().enumerate() {
235                if let Some(val) = row.get(i) {
236                    obj.insert(h.clone(), json!(val));
237                }
238            }
239            Value::Object(obj)
240        })
241        .collect();
242
243    Ok(json!({
244        "status": "success",
245        "variable": var,
246        "points": values.len(),
247        "headers": headers,
248        "data": rows,
249        "execution_time": result.execution_time,
250    }))
251}
252
253pub fn corner(file: &str, timeout: u64) -> Result<Value> {
254    let content = std::fs::read_to_string(file)
255        .map_err(|e| VirtuosoError::NotFound(format!("corner config not found: {file}: {e}")))?;
256
257    let config: CornerConfig = serde_json::from_str(&content)
258        .map_err(|e| VirtuosoError::Config(format!("invalid corner config: {e}")))?;
259
260    let client = VirtuosoClient::from_env()?;
261    let skill = ocean::corner_skill(&config);
262    let result = client.execute_skill(&skill, Some(timeout))?;
263
264    if !result.ok() {
265        return Err(VirtuosoError::Execution(result.errors.join("; ")));
266    }
267
268    let parsed = ocean::parse_skill_list(result.output.trim());
269
270    let mut headers = vec!["corner".to_string(), "temp".to_string()];
271    headers.extend(config.measures.iter().map(|m| m.name.clone()));
272
273    let rows: Vec<Value> = parsed
274        .iter()
275        .map(|row| {
276            let mut obj = serde_json::Map::new();
277            for (i, h) in headers.iter().enumerate() {
278                if let Some(val) = row.get(i) {
279                    obj.insert(h.clone(), json!(val));
280                }
281            }
282            Value::Object(obj)
283        })
284        .collect();
285
286    Ok(json!({
287        "status": "success",
288        "corners": config.corners.len(),
289        "measures": config.measures.len(),
290        "headers": headers,
291        "data": rows,
292        "execution_time": result.execution_time,
293    }))
294}
295
296pub fn results() -> Result<Value> {
297    let client = VirtuosoClient::from_env()?;
298    let result = client.execute_skill("resultsDir()", None)?;
299
300    if !result.ok() {
301        return Err(VirtuosoError::Execution(result.errors.join("; ")));
302    }
303
304    let dir = result.output.trim().trim_matches('"').to_string();
305
306    // Query available result types
307    let types_result = client.execute_skill(
308        &format!(r#"let((dir files) dir="{dir}" when(isDir(dir) files=getDirFiles(dir)) files)"#),
309        None,
310    )?;
311
312    Ok(json!({
313        "status": "success",
314        "results_dir": dir,
315        "contents": types_result.output.trim(),
316    }))
317}
318
319/// Run createNetlist, auto-recovering from OSSHNL-109 ("modified since last extraction").
320///
321/// When a schematic is edited via SKILL (e.g. `dbSave`) without going through
322/// Check & Save, Cadence marks its extraction timestamp as stale and
323/// `createNetlist` returns nil.  We detect this by retrying after
324/// `schCheck(cv)` + `dbSave(cv)`.
325fn create_netlist_inner(
326    client: &VirtuosoClient,
327    lib: &str,
328    cell: &str,
329    view: &str,
330    recreate: bool,
331) -> Result<String> {
332    let cmd = if recreate {
333        "createNetlist(?recreateAll t ?display nil)"
334    } else {
335        "createNetlist(?display nil)"
336    };
337
338    // First attempt
339    let nr = client.execute_skill(cmd, Some(60))?;
340    let nr_out = nr.output.trim().trim_matches('"').to_string();
341    if nr.skill_ok() {
342        return Ok(nr_out);
343    }
344
345    // Auto-fix OSSHNL-109: run schCheck + dbSave to refresh extraction timestamp.
346    // Try to open the cv in write mode; fall back to the already-open write-mode cv
347    // (dbOpenCellViewByType returns nil if the cv is already held in "a" mode by Ocean).
348    let lib_e = escape_skill_string(lib);
349    let cell_e = escape_skill_string(cell);
350    let view_e = escape_skill_string(view);
351    let fix = format!(
352        r#"let((cv chk) cv=dbOpenCellViewByType("{lib_e}" "{cell_e}" "{view_e}") unless(cv cv=car(setof(ocv dbGetOpenCellViews() and(ocv~>libName=="{lib_e}" ocv~>cellName=="{cell_e}" ocv~>viewName=="{view_e}" ocv~>mode=="a")))) if(cv progn(chk=schCheck(cv) when(car(chk)==0 dbSave(cv)) list(car(chk))) list(-1)))"#
353    );
354    let fix_r = client.execute_skill(&fix, None)?;
355
356    // schCheck returns (errorCount warningCount); we wrapped it in list() → "(N)"
357    let raw = fix_r.output.trim().trim_start_matches('(').trim_end_matches(')');
358    let err_count: i64 =
359        raw.split_whitespace().next().and_then(|s| s.parse().ok()).unwrap_or(-1);
360
361    if err_count != 0 {
362        return Err(VirtuosoError::Execution(format!(
363            "createNetlist failed; schematic has {err_count} check error(s) (OSSHNL-109). \
364             Fix schematic connectivity errors before netlisting."
365        )));
366    }
367
368    // Retry after Check and Save
369    let retry = client.execute_skill(cmd, Some(60))?;
370    let retry_out = retry.output.trim().trim_matches('"').to_string();
371    if !retry.skill_ok() {
372        let errs = if retry.errors.is_empty() { "none".into() } else { retry.errors.join("; ") };
373        return Err(VirtuosoError::Execution(format!(
374            "createNetlist returned nil after Check and Save. Errors: {errs}. \
375             Ensure the schematic is saved and PDK models are loaded."
376        )));
377    }
378    Ok(retry_out)
379}
380
381pub fn netlist(lib: &str, cell: &str, view: &str, recreate: bool) -> Result<Value> {
382    let client = VirtuosoClient::from_env()?;
383
384    // Step 1: Establish Ocean session (simulator + design) so createNetlist has
385    // a target even on a cold start without a prior ADE session.
386    // setup_skill ends with resultsDir() — may return "nil" if not yet bound;
387    // that's acceptable here since createNetlist returns the path directly.
388    let setup = ocean::setup_skill(lib, cell, view, "spectre");
389    let sr = client.execute_skill(&setup, None)?;
390    if !sr.ok() {
391        return Err(VirtuosoError::Execution(format!(
392            "sim setup failed before netlisting: {}",
393            sr.errors.join("; ")
394        )));
395    }
396
397    // Step 2: createNetlist — auto-recovers from OSSHNL-109 via schCheck+dbSave.
398    let nr_out = create_netlist_inner(&client, lib, cell, view, recreate)?;
399
400    // Step 3: Resolve the actual netlist path.
401    // createNetlist returns either:
402    //   (a) the full path to input.scs  — use directly
403    //   (b) the resultsDir path         — append /netlist/input.scs
404    //   (c) "t"                         — reuse resultsDir from setup (sr.output)
405    let candidate = if nr_out.ends_with(".scs") {
406        nr_out.clone()
407    } else if nr_out != "t" && !nr_out.is_empty() {
408        format!("{nr_out}/netlist/input.scs")
409    } else {
410        // createNetlist returned "t"; reuse the resultsDir captured during setup,
411        // falling back to an extra SKILL call only if setup returned nil.
412        let rdir_val = {
413            let from_setup = sr.output.trim().trim_matches('"');
414            if from_setup != "nil" && !from_setup.is_empty() {
415                from_setup.to_string()
416            } else {
417                let rdir = client.execute_skill("resultsDir()", None)?;
418                rdir.output.trim().trim_matches('"').to_string()
419            }
420        };
421        if rdir_val == "nil" || rdir_val.is_empty() {
422            return Err(VirtuosoError::Execution(
423                "createNetlist returned 't' but resultsDir() is nil. \
424                 Run `vcli sim setup` first or open ADE L for this cell."
425                    .into(),
426            ));
427        }
428        format!("{rdir_val}/netlist/input.scs")
429    };
430
431    // Step 4: Verify the file actually exists on disk.
432    let check = client.execute_skill(&format!(r#"isFile("{candidate}")"#), None)?;
433    let v = check.output.trim().trim_matches('"');
434    let file_exists = v != "nil" && v != "0";
435
436    if !file_exists {
437        return Err(VirtuosoError::Execution(format!(
438            "createNetlist ran but file not found at '{candidate}'. \
439             createNetlist output was: '{nr_out}'. \
440             Check resultsDir() and ensure write permissions."
441        )));
442    }
443
444    Ok(json!({
445        "status": "success",
446        "netlist_path": candidate,
447    }))
448}
449
450// ── Async job commands ──────────────────────────────────────────────
451
452pub fn run_async(netlist_path: &str) -> Result<Value> {
453    let content = std::fs::read_to_string(netlist_path)
454        .map_err(|e| VirtuosoError::Config(format!("Cannot read netlist '{netlist_path}': {e}")))?;
455    let sim = SpectreSimulator::from_env()?;
456    let job = sim.run_async(&content)?;
457    Ok(json!({
458        "status": "launched",
459        "job_id": job.id,
460        "pid": job.pid,
461        "netlist": netlist_path,
462    }))
463}
464
465#[cfg(test)]
466mod tests {
467    use super::validate_measure_expr;
468
469    #[test]
470    fn safe_waveform_exprs_are_allowed() {
471        for expr in &[
472            "VT(\"vout\" \"VGS\")",
473            "bandwidth(getData(\"vout\") 3)",
474            "value(getData(\"vout\") 1e-9)",
475            "getData(\"/vout\")",
476            "ymax(getData(\"id\"))",
477            "delay(getData(\"vout\") 0.5)",
478        ] {
479            assert!(
480                validate_measure_expr(expr).is_ok(),
481                "should be allowed: {expr}"
482            );
483        }
484    }
485
486    #[test]
487    fn dangerous_exprs_are_blocked() {
488        let cases = [
489            ("system(\"id\")", "system("),
490            ("sh(\"ls\")", "sh("),
491            ("ipcBeginProcess(\"cmd\")", "ipcbeginprocess("),
492            ("deleteFile(\"/etc/hosts\")", "deletefile("),
493            ("load(\"/tmp/evil.il\")", "load("),
494            ("evalstring(\"getData(1)\")", "evalstring("),
495            // case-insensitive
496            ("SYSTEM(\"id\")", "system("),
497            ("DeleteFile(\"/tmp/x\")", "deletefile("),
498        ];
499        for (expr, pat) in &cases {
500            let err = validate_measure_expr(expr).unwrap_err();
501            assert!(
502                err.to_string().contains(pat),
503                "error should mention '{pat}': {err}"
504            );
505        }
506    }
507}
508
509pub fn job_status(id: &str) -> Result<Value> {
510    let mut job = Job::load(id)?;
511    job.refresh()?;
512    serde_json::to_value(&job).map_err(|e| VirtuosoError::Execution(e.to_string()))
513}
514
515pub fn job_list() -> Result<Value> {
516    let mut jobs = Job::list_all()?;
517    for job in &mut jobs {
518        let _ = job.refresh();
519    }
520    let jobs_value = serde_json::to_value(&jobs)
521        .map_err(|e| VirtuosoError::Execution(format!("Failed to serialize jobs: {e}")))?;
522    Ok(json!({
523        "count": jobs.len(),
524        "jobs": jobs_value,
525    }))
526}
527
528pub fn job_cancel(id: &str) -> Result<Value> {
529    let mut job = Job::load(id)?;
530    job.cancel()?;
531    Ok(json!({
532        "status": "cancelled",
533        "job_id": id,
534    }))
535}