Skip to main content

virtuoso_cli/
models.rs

1use crate::error::{Result, VirtuosoError};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
6pub enum ExecutionStatus {
7    Success,
8    Failure,
9    Partial,
10    Error,
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct VirtuosoResult {
15    pub status: ExecutionStatus,
16    pub output: String,
17    pub errors: Vec<String>,
18    pub warnings: Vec<String>,
19    pub execution_time: Option<f64>,
20    pub metadata: HashMap<String, String>,
21}
22
23impl VirtuosoResult {
24    /// Transport-level success: bridge returned STX (not NAK/timeout).
25    /// Does NOT mean the SKILL call succeeded — SKILL functions return "nil"
26    /// on failure via STX. Use skill_ok() to check SKILL-level success.
27    pub fn ok(&self) -> bool {
28        self.status == ExecutionStatus::Success
29    }
30
31    /// True when the bridge succeeded AND SKILL returned a non-nil value.
32    /// Use this whenever a SKILL function signals failure by returning nil
33    /// (e.g. design(), dbOpenCellViewByType(), getData()).
34    pub fn skill_ok(&self) -> bool {
35        self.status == ExecutionStatus::Success && self.output.trim() != "nil"
36    }
37
38    /// Propagate a SKILL-level failure as `Err(VirtuosoError::Execution)`.
39    /// `context` is the operation name; the error message becomes `"{context} failed: {detail}"`.
40    /// When output is empty (NAK transport error), falls back to the first error in `errors`.
41    pub fn ok_or_exec(self, context: &str) -> Result<Self> {
42        if self.skill_ok() {
43            Ok(self)
44        } else {
45            let detail = if self.output.is_empty() {
46                self.errors.first().cloned().unwrap_or_default()
47            } else {
48                self.output.clone()
49            };
50            Err(VirtuosoError::Execution(format!("{context} failed: {detail}")))
51        }
52    }
53
54    /// Return the output string with surrounding SKILL double-quotes stripped.
55    pub fn output_unquoted(&self) -> &str {
56        self.output.trim_matches('"')
57    }
58
59    pub fn success(output: impl Into<String>) -> Self {
60        Self {
61            status: ExecutionStatus::Success,
62            output: output.into(),
63            errors: Vec::new(),
64            warnings: Vec::new(),
65            execution_time: None,
66            metadata: HashMap::new(),
67        }
68    }
69
70    #[allow(dead_code)]
71    pub fn error(errors: Vec<String>) -> Self {
72        Self {
73            status: ExecutionStatus::Error,
74            output: String::new(),
75            errors,
76            warnings: Vec::new(),
77            execution_time: None,
78            metadata: HashMap::new(),
79        }
80    }
81
82    #[allow(dead_code)]
83    pub fn save_json(&self, path: &std::path::Path) -> std::io::Result<()> {
84        let json =
85            serde_json::to_string_pretty(self).map_err(|e| std::io::Error::other(e.to_string()))?;
86        std::fs::write(path, json)
87    }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct SimulationResult {
92    pub status: ExecutionStatus,
93    pub tool_version: Option<String>,
94    pub data: HashMap<String, Vec<f64>>,
95    pub errors: Vec<String>,
96    pub warnings: Vec<String>,
97    pub metadata: HashMap<String, String>,
98}
99
100#[allow(dead_code)]
101impl SimulationResult {
102    pub fn ok(&self) -> bool {
103        self.status == ExecutionStatus::Success
104    }
105
106    pub fn save_json(&self, path: &std::path::Path) -> std::io::Result<()> {
107        let json =
108            serde_json::to_string_pretty(self).map_err(|e| std::io::Error::other(e.to_string()))?;
109        std::fs::write(path, json)
110    }
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct RemoteTaskResult {
115    pub success: bool,
116    pub returncode: i32,
117    pub stdout: String,
118    pub stderr: String,
119    pub remote_dir: Option<String>,
120    pub error: Option<String>,
121    pub timings: HashMap<String, f64>,
122}
123
124fn default_version() -> u32 {
125    1
126}
127
128/// Runtime metrics written by the daemon to `/tmp/.ramic_stats_{port}` after each request.
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct DaemonStats {
131    pub calls: u64,
132    pub errors: u64,
133    pub uptime_secs: u64,
134}
135
136impl DaemonStats {
137    pub fn path(port: u16) -> String {
138        format!("/tmp/.ramic_stats_{port}")
139    }
140
141    pub fn load(port: u16) -> Option<Self> {
142        let json = std::fs::read_to_string(Self::path(port)).ok()?;
143        serde_json::from_str(&json).ok()
144    }
145}
146
147/// Registration record written by bridge.il when a Virtuoso session starts.
148/// Lives at ~/.cache/virtuoso_bridge/sessions/<id>.json
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct SessionInfo {
151    pub id: String,
152    pub port: u16,
153    pub pid: u32,
154    pub host: String,
155    pub user: String,
156    pub created: String,
157}
158
159impl SessionInfo {
160    pub(crate) fn sessions_dir() -> std::path::PathBuf {
161        dirs::cache_dir()
162            .unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
163            .join("virtuoso_bridge")
164            .join("sessions")
165    }
166
167    pub fn load(id: &str) -> std::io::Result<Self> {
168        let path = Self::sessions_dir().join(format!("{id}.json"));
169        let json = std::fs::read_to_string(&path)
170            .map_err(|e| std::io::Error::new(e.kind(), format!("session '{id}' not found: {e}")))?;
171        serde_json::from_str(&json).map_err(|e| std::io::Error::other(e.to_string()))
172    }
173
174    pub fn list() -> std::io::Result<Vec<Self>> {
175        let dir = Self::sessions_dir();
176        if !dir.exists() {
177            return Ok(Vec::new());
178        }
179        let mut sessions = Vec::new();
180        for entry in std::fs::read_dir(&dir)? {
181            let entry = entry?;
182            let path = entry.path();
183            if path.extension().is_some_and(|e| e == "json") {
184                if let Ok(json) = std::fs::read_to_string(&path) {
185                    if let Ok(s) = serde_json::from_str::<Self>(&json) {
186                        sessions.push(s);
187                    }
188                }
189            }
190        }
191        sessions.sort_by(|a, b| a.id.cmp(&b.id));
192        Ok(sessions)
193    }
194
195    /// List sessions on a remote host via SSH.
196    /// Reads all session JSON files from `~/.cache/virtuoso_bridge/sessions/`.
197    pub fn list_remote(runner: &crate::transport::ssh::SSHRunner) -> std::io::Result<Vec<Self>> {
198        let script = r#"for f in "$HOME"/.cache/virtuoso_bridge/sessions/*.json; do [ -f "$f" ] && echo "---SESSION---" && cat "$f"; done"#;
199        let result = runner
200            .run_command(script, None)
201            .map_err(|e| std::io::Error::other(e.to_string()))?;
202
203        let mut sessions = Vec::new();
204        for chunk in result.stdout.split("---SESSION---") {
205            let chunk = chunk.trim();
206            if chunk.is_empty() {
207                continue;
208            }
209            if let Ok(s) = serde_json::from_str::<Self>(chunk) {
210                sessions.push(s);
211            }
212        }
213        sessions.sort_by(|a, b| a.id.cmp(&b.id));
214        Ok(sessions)
215    }
216
217    /// Fetch remote sessions and sync them to the local sessions directory.
218    /// Returns the number of sessions synced.
219    pub fn sync_from_remote(runner: &crate::transport::ssh::SSHRunner) -> std::io::Result<usize> {
220        let remote = Self::list_remote(runner)?;
221        let dir = Self::sessions_dir();
222        std::fs::create_dir_all(&dir)?;
223        let mut count = 0;
224        for s in &remote {
225            let path = dir.join(format!("{}.json", s.id));
226            let json = serde_json::to_string_pretty(s)
227                .map_err(|e| std::io::Error::other(e.to_string()))?;
228            std::fs::write(path, json)?;
229            count += 1;
230        }
231        Ok(count)
232    }
233
234    /// Check if the daemon is still alive by checking if the port is bound.
235    pub fn is_alive(&self) -> bool {
236        use std::net::TcpStream;
237        use std::time::Duration;
238        TcpStream::connect_timeout(
239            &format!("127.0.0.1:{}", self.port).parse().unwrap(),
240            Duration::from_millis(200),
241        )
242        .is_ok()
243    }
244
245    /// Return only sessions whose daemon is currently alive.
246    pub fn list_alive() -> Vec<Self> {
247        Self::list().unwrap_or_default().into_iter().filter(|s| s.is_alive()).collect()
248    }
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct TunnelState {
253    #[serde(default = "default_version")]
254    pub version: u32,
255    pub port: u16,
256    pub pid: u32,
257    pub remote_host: String,
258    pub setup_path: Option<String>,
259}
260
261impl TunnelState {
262    fn state_path(profile: Option<&str>) -> std::path::PathBuf {
263        let cache_dir = dirs::cache_dir()
264            .unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
265            .join("virtuoso_bridge");
266        let _ = std::fs::create_dir_all(&cache_dir);
267        let filename = match profile {
268            Some(p) if !p.is_empty() => format!("state_{p}.json"),
269            _ => "state.json".into(),
270        };
271        cache_dir.join(filename)
272    }
273
274    pub fn save_with_profile(&self, profile: Option<&str>) -> std::io::Result<()> {
275        let path = Self::state_path(profile);
276        let json =
277            serde_json::to_string_pretty(self).map_err(|e| std::io::Error::other(e.to_string()))?;
278        std::fs::write(path, json)
279    }
280
281    pub fn save(&self) -> std::io::Result<()> {
282        self.save_with_profile(std::env::var("VB_PROFILE").ok().as_deref())
283    }
284
285    pub fn load_with_profile(profile: Option<&str>) -> std::io::Result<Option<Self>> {
286        let path = Self::state_path(profile);
287        if !path.exists() {
288            return Ok(None);
289        }
290        let json = std::fs::read_to_string(path)?;
291        serde_json::from_str(&json)
292            .map(Some)
293            .map_err(|e| std::io::Error::other(e.to_string()))
294    }
295
296    pub fn load() -> std::io::Result<Option<Self>> {
297        Self::load_with_profile(std::env::var("VB_PROFILE").ok().as_deref())
298    }
299
300    pub fn clear_with_profile(profile: Option<&str>) -> std::io::Result<()> {
301        let path = Self::state_path(profile);
302        if path.exists() {
303            std::fs::remove_file(path)?;
304        }
305        Ok(())
306    }
307
308    pub fn clear() -> std::io::Result<()> {
309        Self::clear_with_profile(std::env::var("VB_PROFILE").ok().as_deref())
310    }
311}