rafia_stpa/stpa/
mod.rs

1// *****************************************************************************
2// Copyright (c) 2025 Codethink
3//
4// This program and the accompanying materials are made available under the
5// terms of the Eclipse Public License 2.0 which is available at
6// http://www.eclipse.org/legal/epl-2.0.
7//
8// This Source Code may also be made available under the following Secondary
9// Licenses when the conditions for such availability set forth in the Eclipse
10// Public License, v. 2.0 are satisfied: GNU General Public License, version 2
11// with the GNU Classpath Exception which is
12// available at https://www.gnu.org/software/classpath/license.html.
13//
14// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
15// *****************************************************************************
16use std::{
17    ffi::OsStr,
18    fs::{self, File},
19    io::Write,
20    path::{Path, PathBuf},
21    sync::Arc,
22};
23
24#[cfg(feature = "pyo3")]
25use pyo3::{exceptions::PyRuntimeError, PyErr};
26use serde::{Deserialize, Serialize};
27use strum::EnumIter;
28use thiserror::Error;
29
30pub mod ca_analysis;
31pub mod constraints;
32pub mod control_loop_sequences;
33pub mod control_loops;
34pub mod elements;
35pub mod hazards;
36pub mod interactions;
37pub mod losses;
38pub mod scenarios;
39pub mod uca_contexts;
40pub mod ucas;
41
42use ca_analysis::CaAnalyses;
43use constraints::Constraints;
44use control_loop_sequences::ControlLoopSequences;
45use control_loops::ControlLoops;
46use elements::Elements;
47use hazards::Hazards;
48use interactions::Interactions;
49use losses::Losses;
50use scenarios::Scenarios;
51use uca_contexts::UcaContexts;
52use ucas::Ucas;
53
54use crate::define_default_link_type_impls;
55
56#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
57pub struct CausalScenarioLink(Arc<str>);
58
59#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
60pub struct ControlLoopLink(Arc<str>);
61
62#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
63pub struct ControlLoopSequenceLink(Arc<str>);
64
65#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
66pub struct InteractionLink(Arc<str>);
67
68#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
69pub struct ElementLink(Arc<str>);
70
71#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
72pub struct HazardLink(Arc<str>);
73
74#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
75pub struct ConstraintLink(Arc<str>);
76
77#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
78pub struct UcaContextLink(Arc<str>);
79
80define_default_link_type_impls!(CausalScenarioLink);
81define_default_link_type_impls!(ControlLoopLink);
82define_default_link_type_impls!(ControlLoopSequenceLink);
83define_default_link_type_impls!(InteractionLink);
84define_default_link_type_impls!(ElementLink);
85define_default_link_type_impls!(HazardLink);
86define_default_link_type_impls!(ConstraintLink);
87define_default_link_type_impls!(UcaContextLink);
88
89#[derive(Error, Debug)]
90pub enum LoadError {
91    #[error("Could not read from file")]
92    Io(#[from] std::io::Error),
93    #[error("Could not parse YAML")]
94    Yaml(#[from] serde_yaml::Error),
95    #[error("Could read from file with that format")]
96    NotSupportedFormat,
97    #[error("Could not read from CSV")]
98    Csv(#[from] csv::Error),
99}
100
101#[cfg(feature = "pyo3")]
102impl From<LoadError> for PyErr {
103    fn from(value: LoadError) -> Self {
104        PyRuntimeError::new_err(value.to_string())
105    }
106}
107
108#[derive(Debug, Serialize, Deserialize, PartialEq, EnumIter, Clone, Copy)]
109pub enum UcaType {
110    NotProvided,
111    Provided,
112    MagnitudeLess,
113    MagnitudeMore,
114    DurationShort,
115    DurationLong,
116    TimingEarly,
117    TimingLate,
118    SequenceOrder,
119    Missing,
120}
121
122impl From<&str> for UcaType {
123    fn from(raw: &str) -> Self {
124        match raw.trim() {
125            "NP" => UcaType::NotProvided,
126            "PR" => UcaType::Provided,
127            "ML" => UcaType::MagnitudeLess,
128            "MM" => UcaType::MagnitudeMore,
129            "DS" => UcaType::DurationShort,
130            "DL" => UcaType::DurationLong,
131            "TE" => UcaType::TimingEarly,
132            "TL" => UcaType::TimingLate,
133            "SO" => UcaType::SequenceOrder,
134            _ => UcaType::Missing,
135        }
136    }
137}
138
139#[derive(Debug, Serialize, Deserialize, Clone)]
140pub struct StpaProject {
141    pub root: Option<PathBuf>,
142    pub project_file: Option<PathBuf>,
143    pub losses: Option<PathBuf>,
144    pub hazards: Option<PathBuf>,
145    pub elements: Option<PathBuf>,
146    pub interactions: Option<PathBuf>,
147    pub constraints: Option<PathBuf>,
148    #[serde(rename = "constraints-folder")]
149    pub constraints_folder: Option<PathBuf>,
150    pub ca_analysis: Option<PathBuf>,
151    pub uca_contexts: Option<PathBuf>,
152    pub uca: Option<PathBuf>,
153    pub control_loops: Option<PathBuf>,
154    pub cl_sequences: Option<PathBuf>,
155    pub scenarios: Option<PathBuf>,
156    pub auto: Option<Vec<PathBuf>>,
157    diagram: Option<PathBuf>,
158}
159
160impl StpaProject {
161    pub fn from_yaml(text: &str) -> Result<StpaProject, LoadError> {
162        serde_yaml::from_str::<StpaProject>(text).map_err(LoadError::Yaml)
163    }
164    pub fn to_yaml(&self) -> Result<String, LoadError> {
165        serde_yaml::to_string(&self).map_err(LoadError::Yaml)
166    }
167    pub fn from_file(filename: &Path) -> Result<StpaProject, LoadError> {
168        let mut new = match filename.extension().and_then(OsStr::to_str) {
169            Some("yml") | Some("yaml") => {
170                let text = fs::read_to_string(filename)?;
171                Self::from_yaml(&text)
172            }
173            _ => Err(LoadError::NotSupportedFormat),
174        }?;
175        if new.root.is_none() {
176            if let Some(new_root) = filename.parent() {
177                new.root = Some(new_root.to_owned());
178            }
179        }
180        new.project_file = Some(filename.to_owned());
181        Ok(new)
182    }
183}
184
185#[derive(Debug, Default, Clone, PartialEq)]
186pub struct StpaData {
187    pub losses: Losses,
188    pub elements: Elements,
189    pub interactions: Interactions,
190    pub hazards: Hazards,
191    pub constraints: Constraints,
192    pub ca_analyses: CaAnalyses,
193    pub uca_contexts: UcaContexts,
194    pub ucas: Ucas,
195    pub control_loops: ControlLoops,
196    pub control_loop_sequences: ControlLoopSequences,
197    pub scenarios: Scenarios,
198    pub diagram: Option<url::Url>,
199}
200
201#[derive(Error, Debug)]
202pub enum ProjectError {
203    #[error("Could not parse path")]
204    Io(#[from] std::io::Error),
205    #[error("Could not load losses")]
206    Losses(LoadError, PathBuf),
207    #[error("Could not load hazards")]
208    Hazards(LoadError, PathBuf),
209    #[error("Could not load interactions")]
210    Interactions(LoadError, PathBuf),
211    #[error("Could not load elements")]
212    Elements(LoadError, PathBuf),
213    #[error("Could not load constraints")]
214    Constraints(LoadError, PathBuf),
215    #[error("Could not load CA analysis")]
216    CaAnalyses(LoadError, PathBuf),
217    #[error("Could not load UCAs")]
218    Ucas(LoadError, PathBuf),
219    #[error("Could not load UCA contexts")]
220    UcaContexts(LoadError, PathBuf),
221    #[error("Could not load control loops")]
222    ControlLoops(LoadError, PathBuf),
223    #[error("Could not load control loop sequences")]
224    ControlLoopSequences(LoadError, PathBuf),
225    #[error("Could not load scenarios")]
226    Scenarios(LoadError, PathBuf),
227}
228
229#[cfg(feature = "pyo3")]
230impl From<ProjectError> for PyErr {
231    fn from(value: ProjectError) -> Self {
232        PyRuntimeError::new_err(value.to_string())
233    }
234}
235
236const CYCLE_DETECTED_MSG: &str = "Detected a reference cycle, not expanding further.";
237
238impl StpaData {
239    pub fn save_project(&self, root: &Path) {
240        let mut f = File::create(root.join("stpa-losses.yml")).expect("Unable to create file");
241        f.write_all(self.losses.to_yaml().unwrap().as_bytes())
242            .expect("Unable to write data");
243        let mut f = File::create(root.join("stpa-elements.yml")).expect("Unable to create file");
244        f.write_all(self.elements.to_yaml().unwrap().as_bytes())
245            .expect("Unable to write data");
246        let mut f =
247            File::create(root.join("stpa-interactions.yml")).expect("Unable to create file");
248        f.write_all(self.interactions.to_yaml().unwrap().as_bytes())
249            .expect("Unable to write data");
250        let mut f = File::create(root.join("stpa-hazards.yml")).expect("Unable to create file");
251        f.write_all(self.hazards.to_yaml().unwrap().as_bytes())
252            .expect("Unable to write data");
253        let mut f = File::create(root.join("stpa-constraints.yml")).expect("Unable to create file");
254        f.write_all(self.constraints.to_yaml().unwrap().as_bytes())
255            .expect("Unable to write data");
256        let mut f = File::create(root.join("stpa-ca_analyses.yml")).expect("Unable to create file");
257        f.write_all(self.ca_analyses.to_yaml().unwrap().as_bytes())
258            .expect("Unable to write data");
259        let mut f =
260            File::create(root.join("stpa-uca_contexts.yml")).expect("Unable to create file");
261        f.write_all(self.uca_contexts.to_yaml().unwrap().as_bytes())
262            .expect("Unable to write data");
263        let mut f = File::create(root.join("stpa-ucas.yml")).expect("Unable to create file");
264        f.write_all(self.ucas.to_yaml().unwrap().as_bytes())
265            .expect("Unable to write data");
266        let mut f =
267            File::create(root.join("stpa-control_loops.yml")).expect("Unable to create file");
268        f.write_all(self.control_loops.to_yaml().unwrap().as_bytes())
269            .expect("Unable to write data");
270        let mut f = File::create(root.join("stpa-control_loop_sequences.yml"))
271            .expect("Unable to create file");
272        f.write_all(self.control_loop_sequences.to_yaml().unwrap().as_bytes())
273            .expect("Unable to write data");
274        let mut f = File::create(root.join("stpa-scenarios.yml")).expect("Unable to create file");
275        f.write_all(self.scenarios.to_yaml().unwrap().as_bytes())
276            .expect("Unable to write data");
277    }
278
279    pub fn describe(&self, element_id: &str) -> Result<serde_json::Value, String> {
280        // use a stack of currently expanding items to detect cycles and handle them nicely
281        let mut stack = Vec::new();
282        let desc = self.describe_inner(element_id, &mut stack)?;
283        debug_assert!(stack.is_empty());
284        Ok(desc)
285    }
286
287    fn describe_inner(
288        &self,
289        element_id: &str,
290        currently_expanding: &mut Vec<String>,
291    ) -> Result<serde_json::Value, String> {
292        if let Some(e) = self.losses.find(element_id) {
293            return Ok(e.describe());
294        }
295        if let Some(e) = self.elements.find(element_id) {
296            return Ok(e.describe());
297        }
298        if let Some(e) = self.interactions.find(element_id) {
299            return e.describe(self, currently_expanding);
300        }
301        if let Some(e) = self.hazards.find(element_id) {
302            return e.describe(self, currently_expanding);
303        }
304        if let Some(e) = self.constraints.find(element_id) {
305            return e.describe(self, currently_expanding);
306        }
307        if let Some(e) = self.ca_analyses.find(element_id) {
308            return e.describe(self, currently_expanding);
309        }
310        if let Some(e) = self.uca_contexts.find(element_id) {
311            return Ok(e.describe());
312        }
313        if let Some(e) = self.ucas.find(element_id) {
314            return e.describe(self, currently_expanding);
315        }
316        if let Some(e) = self.control_loops.find(element_id) {
317            return e.describe(self, currently_expanding);
318        }
319        if let Some(e) = self.control_loop_sequences.find(element_id) {
320            return e.describe(self, currently_expanding);
321        }
322        let e = self
323            .scenarios
324            .find(element_id)
325            .ok_or_else(|| format!("Failed to find any item matching {element_id}"))?;
326        e.describe(self, currently_expanding)
327    }
328}
329
330impl TryFrom<StpaProject> for StpaData {
331    type Error = ProjectError;
332
333    fn try_from(project: StpaProject) -> Result<Self, Self::Error> {
334        let mut stpa_data = StpaData::default();
335
336        if let Some(mut losses) = project.losses {
337            if let Some(root) = &project.root {
338                losses = root.join(losses);
339            }
340            let losses = losses.canonicalize().unwrap_or(losses);
341            stpa_data.losses =
342                Losses::from_file(&losses).map_err(|err| ProjectError::Losses(err, losses))?;
343        }
344        if let Some(mut elements) = project.elements {
345            if let Some(root) = &project.root {
346                elements = root.join(elements);
347            }
348            let elements = elements.canonicalize().unwrap_or(elements);
349            stpa_data.elements = Elements::from_file(&elements)
350                .map_err(|err| ProjectError::Elements(err, elements))?;
351        };
352        if let Some(mut interactions) = project.interactions {
353            if let Some(root) = &project.root {
354                interactions = root.join(interactions);
355            }
356            interactions = interactions.canonicalize().unwrap_or(interactions);
357            stpa_data.interactions = Interactions::from_file(&interactions)
358                .map_err(|err| ProjectError::Interactions(err, interactions))?;
359        };
360        if let Some(mut hazards) = project.hazards {
361            if let Some(root) = &project.root {
362                hazards = root.join(hazards);
363            }
364            let hazards = hazards.canonicalize().unwrap_or(hazards);
365            stpa_data.hazards =
366                Hazards::from_file(&hazards).map_err(|err| ProjectError::Hazards(err, hazards))?;
367        };
368        if let Some(mut constraints) = project.constraints {
369            if let Some(root) = &project.root {
370                constraints = root.join(constraints);
371            }
372            let constraints = constraints.canonicalize().unwrap_or(constraints);
373            stpa_data.constraints = Constraints::from_file(&constraints)
374                .map_err(|err| ProjectError::Constraints(err, constraints))?;
375        };
376        if let Some(mut ca_analyses) = project.ca_analysis {
377            if let Some(root) = &project.root {
378                ca_analyses = root.join(ca_analyses);
379            }
380            let ca_analyses = ca_analyses.canonicalize().unwrap_or(ca_analyses);
381            stpa_data.ca_analyses = CaAnalyses::from_file(&ca_analyses)
382                .map_err(|err| ProjectError::CaAnalyses(err, ca_analyses))?;
383        };
384        if let Some(mut uca_contexts) = project.uca_contexts {
385            if let Some(root) = &project.root {
386                uca_contexts = root.join(uca_contexts);
387            }
388            let uca_contexts = uca_contexts.canonicalize().unwrap_or(uca_contexts);
389            stpa_data.uca_contexts = UcaContexts::from_file(&uca_contexts)
390                .map_err(|err| ProjectError::UcaContexts(err, uca_contexts))?;
391        };
392        if let Some(mut ucas) = project.uca {
393            if let Some(root) = &project.root {
394                ucas = root.join(ucas);
395            }
396            let ucas = ucas.canonicalize().unwrap_or(ucas);
397            stpa_data.ucas = Ucas::from_file(&ucas).map_err(|err| ProjectError::Ucas(err, ucas))?;
398        };
399        if let Some(mut control_loops) = project.control_loops {
400            if let Some(root) = &project.root {
401                control_loops = root.join(control_loops);
402            }
403            let control_loops = control_loops.canonicalize().unwrap_or(control_loops);
404            stpa_data.control_loops = ControlLoops::from_file(&control_loops)
405                .map_err(|err| ProjectError::ControlLoops(err, control_loops))?;
406        };
407        if let Some(mut control_loop_sequences) = project.cl_sequences {
408            if let Some(root) = &project.root {
409                control_loop_sequences = root.join(control_loop_sequences);
410            }
411            control_loop_sequences = control_loop_sequences
412                .canonicalize()
413                .unwrap_or(control_loop_sequences);
414            stpa_data.control_loop_sequences =
415                ControlLoopSequences::from_file(&control_loop_sequences).map_err(|err| {
416                    ProjectError::ControlLoopSequences(err, control_loop_sequences)
417                })?;
418        };
419        if let Some(mut scenarios) = project.scenarios {
420            if let Some(root) = &project.root {
421                scenarios = root.join(scenarios);
422            }
423            let scenarios = scenarios.canonicalize().unwrap_or(scenarios);
424            stpa_data.scenarios = Scenarios::from_file(&scenarios)
425                .map_err(|err| ProjectError::Scenarios(err, scenarios))?;
426        };
427        if let Some(mut diagram) = project.diagram {
428            if let Some(root) = &project.root {
429                diagram = root.join(diagram);
430            }
431            stpa_data.diagram = Some(
432                url::Url::from_file_path(diagram.canonicalize()?)
433                    .expect("This should never fail with a canonicalized path"),
434            );
435        };
436        Ok(stpa_data)
437    }
438}