1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::domain::core::OntologyClassIdentity;
6
7use super::super::{DomainError, GraphVersion, RepositoryCodeRange, error::required_text};
8use super::validation::{normalize_optional, stable_software_id};
9use super::vocabulary::SOFTWARE_CLASSES;
10
11const MAX_ENTITY_ATTRIBUTES: usize = 64;
12const MAX_EVIDENCE_REFS: usize = 64;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17#[repr(usize)]
18pub enum SoftwareEntityKind {
19 Domain,
20 SoftwareSystem,
21 Component,
22 Api,
23 Resource,
24 Configuration,
25 BuildDefinition,
26 DeploymentUnit,
27 RuntimeService,
28 TestCase,
29 ReleaseArtifact,
30 PackageComponent,
31 Sdk,
32 DocumentationUnit,
33 Pipeline,
34 BuildJob,
35 RepositorySnapshot,
36 FileRevision,
37 BuildRun,
38 DeploymentRevision,
39 RuntimeObservation,
40}
41
42impl SoftwareEntityKind {
43 pub const fn as_str(self) -> &'static str {
45 SOFTWARE_CLASSES[self as usize].id
46 }
47
48 pub const fn rdf_local_name(self) -> &'static str {
50 SOFTWARE_CLASSES[self as usize].rdf_local_name
51 }
52
53 pub fn parse(value: &str) -> Option<Self> {
55 match value {
56 "domain" => Some(Self::Domain),
57 "software_system" => Some(Self::SoftwareSystem),
58 "component" => Some(Self::Component),
59 "api" => Some(Self::Api),
60 "resource" => Some(Self::Resource),
61 "configuration" => Some(Self::Configuration),
62 "build_definition" => Some(Self::BuildDefinition),
63 "deployment_unit" => Some(Self::DeploymentUnit),
64 "runtime_service" => Some(Self::RuntimeService),
65 "test_case" => Some(Self::TestCase),
66 "release_artifact" => Some(Self::ReleaseArtifact),
67 "package_component" => Some(Self::PackageComponent),
68 "sdk" => Some(Self::Sdk),
69 "documentation_unit" => Some(Self::DocumentationUnit),
70 "pipeline" => Some(Self::Pipeline),
71 "build_job" => Some(Self::BuildJob),
72 "repository_snapshot" => Some(Self::RepositorySnapshot),
73 "file_revision" => Some(Self::FileRevision),
74 "build_run" => Some(Self::BuildRun),
75 "deployment_revision" => Some(Self::DeploymentRevision),
76 "runtime_observation" => Some(Self::RuntimeObservation),
77 _ => None,
78 }
79 }
80
81 pub const fn is_occurrence_kind(self) -> bool {
83 matches!(
84 SOFTWARE_CLASSES[self as usize].identity,
85 OntologyClassIdentity::Occurrence
86 )
87 }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum SoftwareSourceKind {
94 Manifest,
95 Lockfile,
96 Sbom,
97 BuildAttestation,
98 BuildFile,
99 Ci,
100 Iac,
101 ServiceDefinition,
102 ApiSchema,
103 Documentation,
104 Code,
105 Test,
106 Runtime,
107 Connector,
108}
109
110impl SoftwareSourceKind {
111 pub const fn as_str(self) -> &'static str {
112 match self {
113 Self::Manifest => "manifest",
114 Self::Lockfile => "lockfile",
115 Self::Sbom => "sbom",
116 Self::BuildAttestation => "build_attestation",
117 Self::BuildFile => "build_file",
118 Self::Ci => "ci",
119 Self::Iac => "iac",
120 Self::ServiceDefinition => "service_definition",
121 Self::ApiSchema => "api_schema",
122 Self::Documentation => "documentation",
123 Self::Code => "code",
124 Self::Test => "test",
125 Self::Runtime => "runtime",
126 Self::Connector => "connector",
127 }
128 }
129
130 pub fn parse(value: &str) -> Option<Self> {
131 match value {
132 "manifest" => Some(Self::Manifest),
133 "lockfile" => Some(Self::Lockfile),
134 "sbom" => Some(Self::Sbom),
135 "build_attestation" => Some(Self::BuildAttestation),
136 "build_file" => Some(Self::BuildFile),
137 "ci" => Some(Self::Ci),
138 "iac" => Some(Self::Iac),
139 "service_definition" => Some(Self::ServiceDefinition),
140 "api_schema" => Some(Self::ApiSchema),
141 "documentation" => Some(Self::Documentation),
142 "code" => Some(Self::Code),
143 "test" => Some(Self::Test),
144 "runtime" => Some(Self::Runtime),
145 "connector" => Some(Self::Connector),
146 _ => None,
147 }
148 }
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153pub struct SoftwareEvidenceRef {
154 pub evidence_id: String,
155 pub source_scope: String,
156 pub path: String,
157 pub line_range: RepositoryCodeRange,
158}
159
160impl SoftwareEvidenceRef {
161 pub fn new(
163 source_scope: impl Into<String>,
164 path: impl Into<String>,
165 line_range: RepositoryCodeRange,
166 ) -> Result<Self, DomainError> {
167 let source_scope = required_text("source_scope", source_scope.into())?;
168 let path = required_text("evidence_path", path.into())?;
169 if line_range.start == 0 || line_range.end < line_range.start {
170 return Err(DomainError::invalid(
171 "evidence_line_range",
172 "must use positive ordered line numbers",
173 ));
174 }
175 let start = line_range.start.to_string();
176 let end = line_range.end.to_string();
177 Ok(Self {
178 evidence_id: stable_software_id(
179 "software_evidence",
180 [
181 source_scope.as_str(),
182 path.as_str(),
183 start.as_str(),
184 end.as_str(),
185 ],
186 ),
187 source_scope,
188 path,
189 line_range,
190 })
191 }
192}
193
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196pub struct SoftwareEntity {
197 pub entity_key: String,
198 pub occurrence_id: String,
199 pub repository_id: String,
200 pub source_scope: String,
201 pub entity_kind: SoftwareEntityKind,
202 pub name: String,
203 #[serde(skip_serializing_if = "Option::is_none")]
204 pub namespace: Option<String>,
205 pub source_kind: SoftwareSourceKind,
206 pub evidence_refs: Vec<SoftwareEvidenceRef>,
207 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
208 pub attributes: BTreeMap<String, String>,
209 pub created_graph_version: GraphVersion,
210}
211
212impl SoftwareEntity {
213 pub fn new(input: SoftwareEntityInput) -> Result<Self, DomainError> {
215 let repository_id = required_text("repository_id", input.repository_id)?;
216 let source_scope = required_text("source_scope", input.source_scope)?;
217 let name = required_text("software_entity_name", input.name)?;
218 let namespace = normalize_optional("software_entity_namespace", input.namespace)?;
219 validate_evidence_refs(&input.evidence_refs)?;
220 validate_attributes(&input.attributes)?;
221
222 let kind = input.entity_kind.as_str();
223 let namespace_part = namespace.as_deref().unwrap_or("");
224 let entity_key = if input.entity_kind.is_occurrence_kind() {
225 stable_software_id(
226 "software_entity",
227 [
228 repository_id.as_str(),
229 kind,
230 namespace_part,
231 name.as_str(),
232 source_scope.as_str(),
233 ],
234 )
235 } else {
236 stable_software_id(
237 "software_entity",
238 [repository_id.as_str(), kind, namespace_part, name.as_str()],
239 )
240 };
241 let mut occurrence_parts = vec![entity_key.as_str(), source_scope.as_str()];
242 occurrence_parts.extend(
243 input
244 .evidence_refs
245 .iter()
246 .map(|evidence| evidence.evidence_id.as_str()),
247 );
248 let occurrence_id = stable_software_id("software_occurrence", occurrence_parts);
249
250 Ok(Self {
251 entity_key,
252 occurrence_id,
253 repository_id,
254 source_scope,
255 entity_kind: input.entity_kind,
256 name,
257 namespace,
258 source_kind: input.source_kind,
259 evidence_refs: input.evidence_refs,
260 attributes: input.attributes,
261 created_graph_version: input.created_graph_version,
262 })
263 }
264}
265
266#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct SoftwareEntityInput {
269 pub repository_id: String,
270 pub source_scope: String,
271 pub entity_kind: SoftwareEntityKind,
272 pub name: String,
273 pub namespace: Option<String>,
274 pub source_kind: SoftwareSourceKind,
275 pub evidence_refs: Vec<SoftwareEvidenceRef>,
276 pub attributes: BTreeMap<String, String>,
277 pub created_graph_version: GraphVersion,
278}
279
280fn validate_evidence_refs(evidence_refs: &[SoftwareEvidenceRef]) -> Result<(), DomainError> {
281 if evidence_refs.len() > MAX_EVIDENCE_REFS {
282 return Err(DomainError::invalid(
283 "evidence_refs",
284 format!("must contain {MAX_EVIDENCE_REFS} entries or fewer"),
285 ));
286 }
287 Ok(())
288}
289
290fn validate_attributes(attributes: &BTreeMap<String, String>) -> Result<(), DomainError> {
291 if attributes.len() > MAX_ENTITY_ATTRIBUTES {
292 return Err(DomainError::invalid(
293 "attributes",
294 format!("must contain {MAX_ENTITY_ATTRIBUTES} entries or fewer"),
295 ));
296 }
297 for (key, value) in attributes {
298 required_text("software_entity_attribute_key", key.clone())?;
299 required_text("software_entity_attribute_value", value.clone())?;
300 }
301 Ok(())
302}
303
304#[cfg(test)]
305#[path = "ontology_tests.rs"]
306mod tests;