Skip to main content

virtuoso_cli/client/
maestro_ops.rs

1use crate::client::bridge::escape_skill_string;
2use crate::version::VirtuosoVersion;
3
4pub struct MaestroOps;
5
6impl MaestroOps {
7    /// Returns session handle like `"fnxSession4"`.
8    pub fn open_session(&self, lib: &str, cell: &str, view: &str) -> String {
9        let lib = escape_skill_string(lib);
10        let cell = escape_skill_string(cell);
11        let view = escape_skill_string(view);
12        format!(r#"maeOpenSetup("{lib}" "{cell}" "{view}")"#)
13    }
14
15    /// Force-closes the session, cancels any in-flight simulation.
16    pub fn close_session(&self, session: &str) -> String {
17        let session = escape_skill_string(session);
18        format!(r#"maeCloseSession("{session}" ?forceClose t)"#)
19    }
20
21    pub fn list_sessions(&self) -> String {
22        skill_strings_to_json("maeGetSessions()")
23    }
24
25    pub fn set_var(&self, name: &str, value: &str) -> String {
26        let name = escape_skill_string(name);
27        let value = escape_skill_string(value);
28        format!(r#"maeSetVar("{name}" "{value}")"#)
29    }
30
31    pub fn get_var(&self, name: &str) -> String {
32        let name = escape_skill_string(name);
33        format!(r#"maeGetVar("{name}")"#)
34    }
35
36    pub fn list_vars(&self) -> String {
37        r#"let((vars out sep) vars = asiGetDesignVarList(asiGetCurrentSession()) out = "[" sep = "" foreach(v vars out = strcat(out sep sprintf(nil "{\"name\":\"%s\",\"value\":\"%s\"}" car(v) cadr(v))) sep = ",") strcat(out "]"))"#.into()
38    }
39
40    /// Get enabled analyses. Always returns a JSON string array; empty → `[]` (not an error).
41    pub fn get_analyses(&self, session: &str) -> String {
42        let session = escape_skill_string(session);
43        skill_strings_to_json(&format!(
44            r#"let((setup) setup = car(maeGetSetup(?session "{session}")) maeGetEnabledAnalysis(setup))"#
45        ))
46    }
47
48    /// Enable an analysis type — version-aware.
49    ///
50    /// IC23: `maeSetAnalysis(setupName analysisType)`.
51    /// IC25: `maeSetAnalysis(analysisType ?session s ?enable t ?options \`(...))`.
52    ///
53    /// `options_skill_alist` is validated and converted at the command layer before this is called.
54    pub fn set_analysis(
55        &self,
56        session: &str,
57        analysis_type: &str,
58        options_skill_alist: Option<&str>,
59        version: VirtuosoVersion,
60    ) -> String {
61        let session = escape_skill_string(session);
62        let analysis_type = escape_skill_string(analysis_type);
63        if version.is_ic25() {
64            let options_part = match options_skill_alist {
65                Some(alist) => format!(" ?options `{alist}"),
66                None => String::new(),
67            };
68            format!(
69                r#"maeSetAnalysis("{analysis_type}" ?session "{session}" ?enable t{options_part})"#
70            )
71        } else {
72            // IC23: positional — setup name first; options not supported in this path
73            format!(
74                r#"let((setup) setup = car(maeGetSetup(?session "{session}")) maeSetAnalysis(setup "{analysis_type}"))"#
75            )
76        }
77    }
78
79    /// Run simulation asynchronously. Returns immediately.
80    pub fn run_simulation(&self, session: &str) -> String {
81        let session = escape_skill_string(session);
82        format!(r#"maeRunSimulation(?session "{session}")"#)
83    }
84
85    #[allow(dead_code)]
86    /// IC23.1: `maeGetTestOutputs` returns list-of-lists; elements accessed via car/cadr/caddr.
87    pub fn get_outputs(&self, test_name: &str) -> String {
88        let test_name = escape_skill_string(test_name);
89        format!(
90            r#"let((outs out sep) outs = maeGetTestOutputs("{test_name}") out = "[" sep = "" foreach(o outs out = strcat(out sep sprintf(nil "{{\"name\":\"%s\",\"test\":\"%s\",\"expr\":\"%s\"}}" car(o) cadr(o) if(caddr(o) then caddr(o) else ""))) sep = ",") strcat(out "]"))"#
91        )
92    }
93
94    pub fn add_output(&self, output_name: &str, test_name: &str, expr: &str) -> String {
95        let output_name = escape_skill_string(output_name);
96        let test_name = escape_skill_string(test_name);
97        let expr = escape_skill_string(expr);
98        format!(r#"maeAddOutput("{output_name}" "{test_name}" ?expr "{expr}")"#)
99    }
100
101    #[allow(dead_code)]
102    pub fn set_design(&self, session: &str, lib: &str, cell: &str, view: &str) -> String {
103        let session = escape_skill_string(session);
104        let lib = escape_skill_string(lib);
105        let cell = escape_skill_string(cell);
106        let view = escape_skill_string(view);
107        format!(
108            r#"maeSetDesign(?session "{session}" ?libName "{lib}" ?cellName "{cell}" ?viewName "{view}")"#
109        )
110    }
111
112    pub fn save_setup(&self, session: &str) -> String {
113        let session = escape_skill_string(session);
114        format!(r#"maeSaveSetup(?session "{session}")"#)
115    }
116
117    pub fn get_sim_messages(&self, session: &str) -> String {
118        let session = escape_skill_string(session);
119        format!(r#"maeGetSimulationMessages(?session "{session}")"#)
120    }
121
122    /// Get focused ADE window name, davSession, all window names, sessions, and run_dir in one RTT.
123    ///
124    /// Returns a 5-element SKILL list:
125    ///   (title davSession (all_titles...) (sessions...) run_dir_or_nil)
126    ///
127    /// `davSession` is `cw->davSession` — the Maestro session name bound to the ADE window.
128    /// `run_dir_or_nil` is bundled so callers need only 1 RTT when the focused window has a session.
129    pub fn focused_window_skill(&self) -> String {
130        r#"let((cw sess) cw=hiGetCurrentWindow() sess=if(cw cw->davSession nil) list(if(cw hiGetWindowName(cw) nil) sess mapcar(lambda((w) hiGetWindowName(w)) hiGetWindowList()) maeGetSessions() if(sess let((s) s=asiGetSession(sess) if(s asiGetAnalogRunDir(s) nil)) nil)))"#.into()
131    }
132
133    /// Get simulation run directory for a maestro session via asiGetAnalogRunDir.
134    /// Used when the caller provides a different session than the focused window's davSession.
135    pub fn run_dir_skill(&self, session: &str) -> String {
136        let session = escape_skill_string(session);
137        format!(
138            r#"let((sess) sess=asiGetSession("{session}") if(sess asiGetAnalogRunDir(sess) nil))"#
139        )
140    }
141
142    /// Export results to CSV via maeExportOutputView.
143    pub fn export_results(
144        &self,
145        session: &str,
146        file_path: &str,
147        test_name: Option<&str>,
148        history: Option<&str>,
149    ) -> String {
150        let session = escape_skill_string(session);
151        let file_path = escape_skill_string(file_path);
152        let test_name_part = match test_name {
153            Some(t) => format!(r#" ?testName "{}""#, escape_skill_string(t)),
154            None => String::new(),
155        };
156        let history_part = match history {
157            Some(h) => format!(r#" ?historyName "{}""#, escape_skill_string(h)),
158            None => String::new(),
159        };
160        format!(
161            r#"maeExportOutputView(?session "{session}"{test_name_part}{history_part} ?view "Detail" ?fileName "{file_path}")"#
162        )
163    }
164
165    // =========================================================================
166    // Result Reading Functions (IC23/IC25 compatible)
167    // =========================================================================
168
169    pub fn open_results(&self, history: &str) -> String {
170        let history = escape_skill_string(history);
171        format!(r#"maeOpenResults(?history "{history}")"#)
172    }
173
174    pub fn close_results(&self) -> String {
175        r#"maeCloseResults()"#.into()
176    }
177
178    /// List all test names that have results in the current history.
179    pub fn get_result_tests(&self) -> String {
180        skill_strings_to_json("maeGetResultTests()")
181    }
182
183    pub fn get_result_outputs(&self, test_name: &str) -> String {
184        let test_name = escape_skill_string(test_name);
185        skill_strings_to_json(&format!(r#"maeGetResultOutputs(?testName "{test_name}")"#))
186    }
187
188    pub fn get_output_value(&self, name: &str, test_name: &str, corner: Option<&str>) -> String {
189        let name = escape_skill_string(name);
190        let test_name = escape_skill_string(test_name);
191        match corner {
192            Some(c) => {
193                let c = escape_skill_string(c);
194                format!(r#"maeGetOutputValue("{name}" "{test_name}" ?cornerName "{c}")"#)
195            }
196            None => format!(r#"maeGetOutputValue("{name}" "{test_name}")"#),
197        }
198    }
199
200    pub fn get_spec_status(&self, name: &str, test_name: &str) -> String {
201        let name = escape_skill_string(name);
202        let test_name = escape_skill_string(test_name);
203        format!(r#"maeGetSpecStatus("{name}" "{test_name}")"#)
204    }
205
206    /// List available history runs for a Maestro session.
207    /// Uses maeGetAllExplorerHistoryNames(sessionName) — IC23.1 documented API.
208    /// Pass the Maestro session name from maeGetSessions(), not the Ocean session.
209    pub fn get_history_list(&self, session: &str) -> String {
210        let session = escape_skill_string(session);
211        skill_strings_to_json(&format!(r#"maeGetAllExplorerHistoryNames("{session}")"#))
212    }
213
214    #[allow(dead_code)]
215    pub fn get_current_session(&self) -> String {
216        r#"let((sess) sess = asiGetCurrentSession() if(sess then sess~>name else nil))"#.into()
217    }
218}
219
220/// Wrap a SKILL expression that returns a list-of-strings into a JSON array string.
221///
222/// If `list_expr` returns nil (empty), the output is `"[]"`.
223/// This ensures list-returning ops never produce SKILL nil — callers use r.ok() not r.skill_ok().
224fn skill_strings_to_json(list_expr: &str) -> String {
225    format!(
226        r#"let((xs out sep) xs = {list_expr} out = "[" sep = "" foreach(x xs out = strcat(out sep sprintf(nil "\"%s\"" x)) sep = ",") strcat(out "]"))"#
227    )
228}
229
230/// Convert a JSON object string to a SKILL association list.
231///
232/// Input:  `{"start":"1","stop":"10G","dec":"20"}`
233/// Output: `(("start" "1") ("stop" "10G") ("dec" "20"))`
234///
235/// Returns `Err` if the input is not valid JSON or not a JSON object.
236pub(crate) fn json_to_skill_alist(json_str: &str) -> Result<String, String> {
237    let parsed: serde_json::Value =
238        serde_json::from_str(json_str).map_err(|e| format!("invalid JSON: {e}"))?;
239    let obj = parsed
240        .as_object()
241        .ok_or_else(|| "expected a JSON object".to_string())?;
242    let pairs: Vec<String> = obj
243        .iter()
244        .map(|(k, v)| {
245            let binding = v.to_string();
246            let val = v.as_str().unwrap_or(&binding);
247            format!("(\"{k}\" \"{val}\")")
248        })
249        .collect();
250    Ok(format!("({})", pairs.join(" ")))
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    fn ops() -> MaestroOps {
258        MaestroOps
259    }
260
261    #[test]
262    fn open_session_quoting() {
263        let s = ops().open_session("myLib", "myCell", "adexl");
264        assert_eq!(s, r#"maeOpenSetup("myLib" "myCell" "adexl")"#);
265    }
266
267    #[test]
268    fn open_session_escapes_quotes() {
269        let s = ops().open_session(r#"lib"x"#, "cell", "adexl");
270        assert!(s.contains(r#"lib\"x"#), "{s}");
271    }
272
273    #[test]
274    fn set_var_format() {
275        let s = ops().set_var("Vdd", "1.8");
276        assert_eq!(s, r#"maeSetVar("Vdd" "1.8")"#);
277    }
278
279    #[test]
280    fn run_simulation_includes_session() {
281        let s = ops().run_simulation("sess1");
282        assert!(s.contains("maeRunSimulation"), "{s}");
283        assert!(s.contains("\"sess1\""), "{s}");
284    }
285
286    #[test]
287    fn list_sessions_uses_helper() {
288        let s = ops().list_sessions();
289        assert!(s.contains("maeGetSessions()"), "{s}");
290        assert!(s.contains("foreach"), "{s}");
291        assert!(s.contains(r#"strcat(out "]")"#), "{s}");
292    }
293
294    #[test]
295    fn get_analyses_resolves_setup() {
296        let s = ops().get_analyses("sess1");
297        assert!(s.contains("maeGetSetup"), "must resolve setup: {s}");
298        assert!(s.contains("maeGetEnabledAnalysis"), "{s}");
299        assert!(s.contains("foreach"), "must produce JSON array: {s}");
300    }
301
302    #[test]
303    fn get_result_tests_uses_helper() {
304        let s = ops().get_result_tests();
305        assert!(s.contains("maeGetResultTests()"), "{s}");
306        assert!(s.contains("foreach"), "{s}");
307    }
308
309    #[test]
310    fn get_history_list_uses_helper() {
311        let s = ops().get_history_list("fnxSession0");
312        assert!(s.contains("maeGetAllExplorerHistoryNames"), "{s}");
313        assert!(s.contains("fnxSession0"), "{s}");
314        assert!(s.contains("foreach"), "{s}");
315    }
316
317    #[test]
318    fn export_results_minimal() {
319        let s = ops().export_results("sess1", "/tmp/out.csv", None, None);
320        assert!(s.contains("maeExportOutputView"), "{s}");
321        assert!(s.contains(r#"?session "sess1""#), "{s}");
322        assert!(s.contains(r#"?fileName "/tmp/out.csv""#), "{s}");
323        assert!(s.contains(r#"?view "Detail""#), "{s}");
324        assert!(!s.contains("?testName"), "should be absent when None: {s}");
325        assert!(
326            !s.contains("?historyName"),
327            "should be absent when None: {s}"
328        );
329    }
330
331    #[test]
332    fn export_results_with_all_params() {
333        let s = ops().export_results("sess1", "/tmp/out.csv", Some("AC"), Some("ExplorerRun.0"));
334        assert!(s.contains(r#"?testName "AC""#), "{s}");
335        assert!(s.contains(r#"?historyName "ExplorerRun.0""#), "{s}");
336    }
337
338    #[test]
339    fn set_analysis_ic23_positional() {
340        let s = ops().set_analysis("sess1", "ac", None, VirtuosoVersion::IC23);
341        assert!(s.contains("maeGetSetup"), "IC23 must resolve setup: {s}");
342        assert!(s.contains("maeSetAnalysis"), "{s}");
343        assert!(s.contains("\"ac\""), "{s}");
344    }
345
346    #[test]
347    fn set_analysis_ic23_no_options() {
348        let s = ops().set_analysis("sess1", "ac", None, VirtuosoVersion::IC23);
349        assert!(
350            !s.contains("?options"),
351            "IC23 path must not inject options: {s}"
352        );
353    }
354
355    #[test]
356    fn add_output_includes_expr() {
357        let s = ops().add_output("gain", "AC", "getData(\"vout\")");
358        assert!(s.contains("maeAddOutput"), "{s}");
359        assert!(s.contains("\"gain\""), "{s}");
360        assert!(s.contains("\"AC\""), "{s}");
361    }
362
363    #[test]
364    fn json_to_skill_alist_valid_input() {
365        let input = r#"{"start":"1","stop":"10G"}"#;
366        let out = json_to_skill_alist(input).unwrap();
367        assert!(out.contains("(\"start\" \"1\")"), "{out}");
368        assert!(out.contains("(\"stop\" \"10G\")"), "{out}");
369    }
370
371    #[test]
372    fn json_to_skill_alist_invalid_json_returns_err() {
373        assert!(json_to_skill_alist("not json").is_err());
374    }
375
376    #[test]
377    fn json_to_skill_alist_non_object_returns_err() {
378        assert!(json_to_skill_alist("[1,2,3]").is_err());
379    }
380}