rafia_stpa/stpa/
ca_analysis.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    path::Path,
20};
21
22use csv;
23use serde::{Deserialize, Serialize};
24use serde_yaml;
25use strum::EnumIter;
26
27use crate::stpa::{InteractionLink, LoadError, UcaContextLink, UcaType, CYCLE_DETECTED_MSG};
28
29use super::{HazardLink, StpaData};
30
31#[derive(Debug, Serialize, Deserialize)]
32pub struct CaAnalysisCsv {
33    #[serde(rename = "CA Analysis ID")]
34    pub id: String,
35    #[serde(rename = "CA Id")]
36    pub ca_id: String,
37    #[serde(rename = "UCAType")]
38    pub uca_type: String,
39    #[serde(rename = "UCA Context")]
40    pub uca_context: String,
41    #[serde(rename = "Analysis Result")]
42    pub analysis_result: String,
43    #[serde(rename = "Hazard(s)")]
44    pub link_hazards: String,
45    #[serde(rename = "Justification")]
46    pub justification: String,
47}
48
49#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
50pub struct CaAnalysis {
51    pub id: String,
52    pub ca_id: InteractionLink,
53    pub uca_type: UcaType,
54    pub uca_context: UcaContextLink,
55    pub analysis_result: CaResult,
56    pub link_to_hazards: Vec<HazardLink>,
57    pub justification: String,
58}
59
60impl CaAnalysis {
61    pub fn describe(
62        &self,
63        stpa_data: &StpaData,
64        currently_expanding: &mut Vec<String>,
65    ) -> Result<serde_json::Value, String> {
66        // check we aren't currently expanding this ca_analysis
67        if currently_expanding.contains(&self.id) {
68            return Ok(serde_json::json!({
69                "ca_analysis": {
70                    "id": self.id,
71                    "message": CYCLE_DETECTED_MSG,
72                }
73            }));
74        } else {
75            currently_expanding.push(self.id.clone());
76        }
77
78        let mut obj = serde_json::json!({"ca_analysis": self});
79
80        obj["ca_analysis"]["ca_id"] =
81            stpa_data.describe_inner(self.ca_id.0.as_ref(), currently_expanding)?;
82        obj["ca_analysis"]["uca_context"] =
83            stpa_data.describe_inner(self.uca_context.0.as_ref(), currently_expanding)?;
84
85        let hazards = self
86            .link_to_hazards
87            .iter()
88            .map(|hazard| {
89                stpa_data
90                    .describe_inner(hazard.0.as_ref(), currently_expanding)
91                    .map_err(|e| {
92                        format!(
93                            "Failed while resolving hazard id {hazard} of ca_analysis {}: {e}",
94                            self.id
95                        )
96                    })
97            })
98            .collect::<Result<Vec<_>, _>>()?;
99        obj["ca_analysis"]["link_to_hazards"] = serde_json::json!(hazards);
100
101        let popped = currently_expanding.pop();
102        debug_assert_eq!(popped.as_ref(), Some(&self.id));
103        Ok(obj)
104    }
105}
106
107impl From<CaAnalysisCsv> for CaAnalysis {
108    fn from(csv: CaAnalysisCsv) -> Self {
109        CaAnalysis {
110            id: csv.id,
111            ca_id: InteractionLink::from(csv.ca_id),
112            uca_type: UcaType::from(csv.uca_type.as_str()),
113            uca_context: UcaContextLink::from(csv.uca_context),
114            analysis_result: CaResult::from(csv.analysis_result.as_str()),
115            link_to_hazards: csv
116                .link_hazards
117                .split(",")
118                .filter(|e| !e.is_empty())
119                .map(|s| HazardLink::from(s.trim().to_string()))
120                .collect(),
121            justification: csv.justification,
122        }
123    }
124}
125
126#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Default)]
127pub struct CaAnalyses {
128    pub ca_analyses: Vec<CaAnalysis>,
129}
130
131impl CaAnalyses {
132    pub fn from_yaml(text: &str) -> Result<CaAnalyses, LoadError> {
133        serde_yaml::from_str::<CaAnalyses>(text).map_err(LoadError::Yaml)
134    }
135    pub fn to_yaml(&self) -> Result<String, LoadError> {
136        serde_yaml::to_string(&self).map_err(LoadError::Yaml)
137    }
138    pub fn from_csv(filename: &Path) -> Result<CaAnalyses, LoadError> {
139        let mut ca_analyses = vec![];
140        let file = File::open(filename)?;
141        for ca_analysis in csv::ReaderBuilder::new()
142            .from_reader(file)
143            .deserialize::<CaAnalysisCsv>()
144        {
145            ca_analyses.push(ca_analysis?.into());
146        }
147        Ok(CaAnalyses { ca_analyses })
148    }
149    pub fn from_file(filename: &Path) -> Result<CaAnalyses, LoadError> {
150        match filename.extension().and_then(OsStr::to_str) {
151            Some("csv") => Self::from_csv(filename),
152            Some("yml") | Some("yaml") => {
153                let text = fs::read_to_string(filename)?;
154                Self::from_yaml(&text)
155            }
156            _ => Err(LoadError::NotSupportedFormat),
157        }
158    }
159    pub fn find(&self, id: &str) -> Option<&CaAnalysis> {
160        self.ca_analyses.iter().find(|ca| ca.id == id)
161    }
162}
163
164#[derive(Debug, Serialize, Deserialize, PartialEq, EnumIter, Clone, Copy)]
165pub enum CaResult {
166    UCA,
167    Safe,
168    NA,
169    NotYetAnalysed,
170    Missing,
171}
172
173impl From<&str> for CaResult {
174    fn from(raw: &str) -> Self {
175        match raw.trim() {
176            "UCA" => CaResult::UCA,
177            "Safe" => CaResult::Safe,
178            "N/A" => CaResult::NA,
179            "TBD" | "" => CaResult::NotYetAnalysed,
180            _ => CaResult::Missing,
181        }
182    }
183}