rafia_stpa/stpa/
interactions.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    fmt,
19    fs::{self, File},
20    io::Write,
21    path::Path,
22};
23
24use csv;
25use serde::{Deserialize, Serialize};
26use serde_yaml;
27use strum::EnumIter;
28
29use crate::stpa::{ElementLink, LoadError, CYCLE_DETECTED_MSG};
30
31use super::StpaData;
32
33#[derive(Debug, Serialize, Deserialize)]
34pub struct InteractionCsv {
35    #[serde(rename = "Interaction Id")]
36    pub id: String,
37    #[serde(rename = "Diagram Label")]
38    pub diagram_label: String,
39    #[serde(rename = "Interaction description")]
40    pub description: String,
41    #[serde(rename = "Type")]
42    pub interaction_type: String,
43    #[serde(rename = "Provider Id")]
44    pub start: String,
45    #[serde(rename = "Receiver Id")]
46    pub end: String,
47    #[serde(rename = "Category")]
48    pub catigory: String,
49    #[serde(rename = "Notes / Questions")]
50    pub notes: String,
51}
52
53#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, EnumIter)]
54pub enum InteractionType {
55    ControlAction,
56    Feedback,
57    Information,
58    ProcessInOut, // Controlled Process input or output
59    OtherInteraction,
60    Missing,
61}
62
63impl fmt::Display for InteractionType {
64    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
65        write!(f, "{self:?}")
66    }
67}
68
69impl<T: AsRef<str>> From<T> for InteractionType {
70    fn from(raw: T) -> Self {
71        match raw.as_ref().trim() {
72            "C" => InteractionType::ControlAction,
73            "F" => InteractionType::Feedback,
74            "I" => InteractionType::Information,
75            "P" => InteractionType::ProcessInOut,
76            "X" => InteractionType::OtherInteraction,
77            _ => InteractionType::Missing,
78        }
79    }
80}
81
82#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Default)]
83pub struct Interactions {
84    pub interactions: Vec<Interaction>,
85}
86
87#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
88pub struct Interaction {
89    pub id: String,
90    pub diagram_label: String,
91    pub description: String,
92    pub interaction_type: InteractionType,
93    pub start: ElementLink,
94    pub end: ElementLink,
95    pub category: InteractionCategory,
96    pub note: String,
97}
98
99impl Interaction {
100    pub fn describe(
101        &self,
102        stpa_data: &StpaData,
103        currently_expanding: &mut Vec<String>,
104    ) -> Result<serde_json::Value, String> {
105        // check we aren't currently expanding this interaction
106        if currently_expanding.contains(&self.id) {
107            return Ok(serde_json::json!({
108                "interaction": {
109                    "id": self.id,
110                    "message": CYCLE_DETECTED_MSG,
111                }
112            }));
113        } else {
114            currently_expanding.push(self.id.clone());
115        }
116
117        // json-ify our object but fill in the ElementLinks with actual Elements
118        let mut obj = serde_json::json!({
119            "interaction": self,
120        });
121
122        let start = stpa_data
123            .describe_inner(self.start.as_ref(), currently_expanding)
124            .map_err(|e| format!("Failed while resolving interaction.start: {e}"))?;
125        let end = stpa_data
126            .describe_inner(self.end.as_ref(), currently_expanding)
127            .map_err(|e| format!("Failed while resolving interaction.end: {e}"))?;
128
129        obj["interaction"]["start"] = start;
130        obj["interaction"]["end"] = end;
131
132        let popped = currently_expanding.pop();
133        debug_assert_eq!(popped.as_ref(), Some(&self.id));
134        Ok(obj)
135    }
136}
137
138impl From<InteractionCsv> for Interaction {
139    fn from(csv: InteractionCsv) -> Self {
140        Interaction {
141            id: csv.id,
142            diagram_label: csv.diagram_label,
143            description: csv.description,
144            interaction_type: InteractionType::from(csv.interaction_type.as_str()),
145            start: ElementLink::from(csv.start),
146            end: ElementLink::from(csv.end),
147            category: InteractionCategory::from(csv.catigory.as_str()),
148            note: csv.notes,
149        }
150    }
151}
152
153impl Interactions {
154    pub fn from_yaml(text: &str) -> Result<Interactions, LoadError> {
155        serde_yaml::from_str::<Interactions>(text).map_err(LoadError::Yaml)
156    }
157    pub fn to_yaml(&self) -> Result<String, LoadError> {
158        serde_yaml::to_string(&self).map_err(LoadError::Yaml)
159    }
160    pub fn from_csv(filename: &Path) -> Result<Interactions, LoadError> {
161        let mut interactions = vec![];
162        let file = File::open(filename)?;
163        for interaction in csv::ReaderBuilder::new()
164            .from_reader(file)
165            .deserialize::<InteractionCsv>()
166        {
167            interactions.push(interaction?.into());
168        }
169        Ok(Interactions { interactions })
170    }
171    pub fn from_file(filename: &Path) -> Result<Interactions, LoadError> {
172        match filename.extension().and_then(OsStr::to_str) {
173            Some("csv") => Self::from_csv(filename),
174            Some("yml") | Some("yaml") => {
175                let text = fs::read_to_string(filename)?;
176                Self::from_yaml(&text)
177            }
178            _ => Err(LoadError::NotSupportedFormat),
179        }
180    }
181    pub fn to_file(&self, filename: &Path) -> Result<(), LoadError> {
182        match filename.extension().and_then(OsStr::to_str) {
183            Some("yml") | Some("yaml") => {
184                let mut f = File::create(filename)?;
185                f.write_all(self.to_yaml()?.as_bytes())?;
186                Ok(())
187            }
188            _ => Err(LoadError::NotSupportedFormat),
189        }
190    }
191    pub fn find(&self, id: &str) -> Option<&Interaction> {
192        self.interactions
193            .iter()
194            .find(|interaction| interaction.id == id)
195    }
196}
197
198#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, EnumIter)]
199pub enum InteractionCategory {
200    Continuous,
201    Discrete,
202    Missing,
203}
204
205impl fmt::Display for InteractionCategory {
206    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
207        write!(f, "{self:?}")
208    }
209}
210
211impl From<&str> for InteractionCategory {
212    fn from(raw: &str) -> Self {
213        match raw.trim() {
214            "C" => InteractionCategory::Continuous,
215            "D" => InteractionCategory::Discrete,
216            _ => InteractionCategory::Missing,
217        }
218    }
219}
220
221#[cfg(test)]
222mod test {
223    use super::{Interactions, LoadError};
224
225    #[test]
226    fn test_load() -> Result<(), LoadError> {
227        let interactions = Interactions::from_yaml(
228            "interactions:\n- id: bob\n  name: bob box\n  diagram_label: test\n  description: bob\n  interaction_type: Feedback\n  \
229             start: a\n  end: b\n  category: Continuous\n  note: bobs",
230        ).unwrap();
231        assert_eq!(interactions.interactions.len(), 1);
232        assert_eq!(interactions.interactions[0].id, "bob");
233        assert_eq!(interactions.interactions[0].start.0, "a".into());
234        assert_eq!(interactions.interactions[0].end.0, "b".into());
235        Ok(())
236    }
237}