1use crate::error::{ObserverError, ObserverResult};
5use serde::{Deserialize, Serialize, Serializer};
6use serde_json::{Map, Value};
7use sha2::{Digest, Sha256};
8use std::collections::BTreeMap;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct CmakeProductModel {
14 pub k: String,
15 pub v: String,
16 pub source_root: String,
17 pub build_root: String,
18 pub cmake: CmakeMetadata,
19 pub configurations: Vec<CmakeConfiguration>,
20 pub configure_state: CmakeConfigureState,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct CmakeMetadata {
25 pub version: String,
26 pub generator: CmakeGenerator,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct CmakeGenerator {
31 pub name: String,
32 pub multi_config: bool,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct CmakeConfiguration {
37 pub configuration_id: String,
38 pub targets: Vec<CmakeTarget>,
39 pub install_entries: Vec<CmakeInstallEntry>,
40 pub exports: Vec<CmakeExport>,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct CmakeTarget {
45 pub target_name: String,
46 pub target_kind: String,
47 pub is_abstract: bool,
48 pub source_directory: String,
49 pub build_directory: String,
50 pub artifact_paths: Vec<String>,
51 pub dependency_refs: Vec<CmakeTargetRef>,
52 pub install_destinations: Vec<String>,
53 pub file_sets: Vec<String>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct CmakeTargetRef {
58 pub configuration_id: String,
59 pub target_name: String,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct CmakeInstallEntry {
64 pub install_kind: String,
65 pub destination: String,
66 pub paths: Vec<String>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub component_name: Option<String>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub subject: Option<CmakeTargetRef>,
71 #[serde(default, skip_serializing_if = "Vec::is_empty")]
72 pub target_refs: Vec<CmakeTargetRef>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub export_name: Option<String>,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct CmakeExport {
79 pub export_name: String,
80 pub target_refs: Vec<CmakeTargetRef>,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84pub struct CmakeConfigureState {
85 pub status: String,
86}
87
88impl CmakeProductModel {
89 pub fn parse(source: &str) -> ObserverResult<Self> {
90 let model: Self = serde_json::from_str(source)
91 .map_err(|error| ObserverError::Cmake(format!("cmake model parse error: {error}")))?;
92 model.validate()?;
93 Ok(model)
94 }
95
96 pub fn validate(&self) -> ObserverResult<()> {
97 if self.k != "observer_cmake_product_model" {
98 return Err(ObserverError::Cmake(format!(
99 "expected `k` = `observer_cmake_product_model`, found `{}`",
100 self.k
101 )));
102 }
103 if self.v != "0" {
104 return Err(ObserverError::Cmake(format!(
105 "expected `v` = `0`, found `{}`",
106 self.v
107 )));
108 }
109 if self.source_root.trim().is_empty() {
110 return Err(ObserverError::Cmake("source_root must be non-empty".to_owned()));
111 }
112 if self.build_root.trim().is_empty() {
113 return Err(ObserverError::Cmake("build_root must be non-empty".to_owned()));
114 }
115 if self.cmake.version.trim().is_empty() {
116 return Err(ObserverError::Cmake("cmake.version must be non-empty".to_owned()));
117 }
118 if self.cmake.generator.name.trim().is_empty() {
119 return Err(ObserverError::Cmake(
120 "cmake.generator.name must be non-empty".to_owned(),
121 ));
122 }
123 match self.configure_state.status.as_str() {
124 "success" | "error" => {}
125 other => {
126 return Err(ObserverError::Cmake(format!(
127 "configure_state.status must be `success` or `error`, found `{other}`"
128 )));
129 }
130 }
131
132 for configuration in &self.configurations {
133 if configuration.configuration_id.trim().is_empty() {
134 return Err(ObserverError::Cmake(
135 "configuration_id must be non-empty".to_owned(),
136 ));
137 }
138 let mut target_names = BTreeMap::new();
139 for target in &configuration.targets {
140 if target.target_name.trim().is_empty() {
141 return Err(ObserverError::Cmake("target_name must be non-empty".to_owned()));
142 }
143 if target.target_kind.trim().is_empty() {
144 return Err(ObserverError::Cmake("target_kind must be non-empty".to_owned()));
145 }
146 if target.source_directory.trim().is_empty() {
147 return Err(ObserverError::Cmake(
148 "source_directory must be non-empty".to_owned(),
149 ));
150 }
151 if target.build_directory.trim().is_empty() {
152 return Err(ObserverError::Cmake(
153 "build_directory must be non-empty".to_owned(),
154 ));
155 }
156 if target_names
157 .insert(target.target_name.clone(), ())
158 .is_some()
159 {
160 return Err(ObserverError::Cmake(format!(
161 "duplicate target_name `{}` within configuration `{}`",
162 target.target_name, configuration.configuration_id
163 )));
164 }
165 }
166 }
167
168 Ok(())
169 }
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173pub struct ProductStageCmakeModel {
174 pub cwd: String,
175 pub model: String,
176 pub checks: Vec<CmakeModelCheck>,
177}
178
179impl ProductStageCmakeModel {
180 pub fn validate(&self) -> ObserverResult<()> {
181 validate_normalized_relative_path(&self.cwd, "runner.cwd", true)?;
182 validate_normalized_relative_path(&self.model, "runner.model", false)?;
183 if self.checks.is_empty() {
184 return Err(ObserverError::ProductParse(
185 "runner.checks must declare at least one check".to_owned(),
186 ));
187 }
188 let mut seen = BTreeMap::new();
189 for check in &self.checks {
190 check.validate()?;
191 if seen.insert(check.check_id().to_owned(), ()).is_some() {
192 return Err(ObserverError::ProductParse(format!(
193 "duplicate check_id `{}`",
194 check.check_id()
195 )));
196 }
197 }
198 Ok(())
199 }
200}
201
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(tag = "k", rename_all = "snake_case")]
204pub enum CmakeModelCheck {
205 TargetArtifactsExist {
206 check_id: String,
207 targets: Vec<CmakeTargetRef>,
208 },
209}
210
211impl CmakeModelCheck {
212 pub fn check_id(&self) -> &str {
213 match self {
214 Self::TargetArtifactsExist { check_id, .. } => check_id,
215 }
216 }
217
218 pub fn kind(&self) -> &'static str {
219 match self {
220 Self::TargetArtifactsExist { .. } => "target_artifacts_exist",
221 }
222 }
223
224 fn validate(&self) -> ObserverResult<()> {
225 match self {
226 Self::TargetArtifactsExist { check_id, targets } => {
227 if check_id.trim().is_empty() {
228 return Err(ObserverError::ProductParse(
229 "check_id must be non-empty".to_owned(),
230 ));
231 }
232 if targets.is_empty() {
233 return Err(ObserverError::ProductParse(format!(
234 "check `{check_id}` must declare at least one target"
235 )));
236 }
237 for target in targets {
238 if target.configuration_id.trim().is_empty() || target.target_name.trim().is_empty() {
239 return Err(ObserverError::ProductParse(format!(
240 "check `{check_id}` contains an empty target reference"
241 )));
242 }
243 }
244 }
245 }
246 Ok(())
247 }
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
251#[serde(rename_all = "snake_case")]
252pub enum CmakeCheckStatus {
253 Pass,
254 Fail,
255 ValidationFail,
256 RunnerFail,
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
260#[serde(rename_all = "snake_case")]
261pub enum CmakeCheckFailClass {
262 CertificationFailure,
263 ValidationFailure,
264 RunnerFailure,
265}
266
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
268pub struct CmakeModelReportHeader {
269 pub k: String,
270 pub v: String,
271 pub model_sha256: String,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub product_id: Option<String>,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub stage_id: Option<String>,
276}
277
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279pub struct CmakeCheckRecord {
280 pub check_id: String,
281 pub check_kind: String,
282 pub status: CmakeCheckStatus,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub ok: Option<Value>,
285 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub fail: Option<CmakeCheckFail>,
287}
288
289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290pub struct CmakeCheckFail {
291 pub class: CmakeCheckFailClass,
292 pub msg: String,
293 #[serde(default, skip_serializing_if = "Vec::is_empty")]
294 pub missing_artifacts: Vec<CmakeMissingArtifact>,
295}
296
297#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
298pub struct CmakeMissingArtifact {
299 pub target: CmakeTargetRef,
300 pub artifact_path: String,
301}
302
303#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
304pub struct CmakeSummaryRecord {
305 pub check_pass: usize,
306 pub check_fail: usize,
307 pub check_validation_fail: usize,
308 pub check_runner_fail: usize,
309 pub status: String,
310}
311
312#[derive(Debug, Clone, PartialEq, Eq)]
313pub enum CmakeModelReportRecord {
314 Header(CmakeModelReportHeader),
315 Check(CmakeCheckRecord),
316 Summary(CmakeSummaryRecord),
317}
318
319impl Serialize for CmakeModelReportRecord {
320 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
321 where
322 S: Serializer,
323 {
324 match self {
325 Self::Header(header) => header.serialize(serializer),
326 Self::Check(record) => TaggedRecord {
327 k: "cmake_check",
328 inner: record,
329 }
330 .serialize(serializer),
331 Self::Summary(record) => TaggedRecord {
332 k: "cmake_summary",
333 inner: record,
334 }
335 .serialize(serializer),
336 }
337 }
338}
339
340#[derive(Serialize)]
341struct TaggedRecord<'a, T> {
342 k: &'static str,
343 #[serde(flatten)]
344 inner: &'a T,
345}
346
347pub struct CmakeEvaluationOutcome {
348 pub records: Vec<CmakeModelReportRecord>,
349 pub exit_code: i32,
350 pub check_pass: usize,
351 pub check_fail: usize,
352 pub check_validation_fail: usize,
353 pub check_runner_fail: usize,
354}
355
356pub fn lower_cmake_product_model(build_root: &Path, base_dir: &Path) -> ObserverResult<CmakeProductModel> {
357 let build_root_abs = absolutize_path(build_root, base_dir);
358 let reply_dir = build_root_abs.join(".cmake").join("api").join("v1").join("reply");
359 let index_path = latest_index_path(&reply_dir)?;
360 let index_source = fs::read_to_string(&index_path)
361 .map_err(|error| ObserverError::Cmake(format!("failed to read {}: {error}", index_path.display())))?;
362 let index: FileApiIndex = serde_json::from_str(&index_source)
363 .map_err(|error| ObserverError::Cmake(format!("invalid File API reply index: {error}")))?;
364 let codemodel_ref = index
365 .objects
366 .iter()
367 .filter(|item| item.kind == "codemodel" && item.version.major == 2)
368 .max_by_key(|item| item.version.minor)
369 .ok_or_else(|| ObserverError::Cmake("File API reply contained no supported codemodel object".to_owned()))?;
370 let codemodel_path = reply_dir.join(&codemodel_ref.json_file);
371 let codemodel_source = fs::read_to_string(&codemodel_path)
372 .map_err(|error| ObserverError::Cmake(format!("failed to read {}: {error}", codemodel_path.display())))?;
373 let codemodel: Codemodel = serde_json::from_str(&codemodel_source)
374 .map_err(|error| ObserverError::Cmake(format!("invalid codemodel reply: {error}")))?;
375
376 let mut configurations = Vec::new();
377 for configuration in &codemodel.configurations {
378 let configuration_id = if configuration.name.trim().is_empty() {
379 "__default__".to_owned()
380 } else {
381 configuration.name.clone()
382 };
383
384 let mut raw_targets = Vec::new();
385 for target_ref in &configuration.targets {
386 let target_path = codemodel_path
387 .parent()
388 .unwrap_or(reply_dir.as_path())
389 .join(&target_ref.json_file);
390 let target_source = fs::read_to_string(&target_path).map_err(|error| {
391 ObserverError::Cmake(format!("failed to read {}: {error}", target_path.display()))
392 })?;
393 let target: CodemodelTarget = serde_json::from_str(&target_source)
394 .map_err(|error| ObserverError::Cmake(format!("invalid target reply: {error}")))?;
395 raw_targets.push(target);
396 }
397
398 let mut id_to_name = BTreeMap::new();
399 for target in &raw_targets {
400 id_to_name.insert(target.id.clone(), target.name.clone());
401 }
402
403 let mut targets = Vec::new();
404 for target in raw_targets {
405 let mut artifact_paths = target
406 .artifacts
407 .into_iter()
408 .map(|item| item.path)
409 .collect::<Vec<_>>();
410 artifact_paths.sort_by(|left, right| left.as_bytes().cmp(right.as_bytes()));
411
412 let mut dependency_refs = target
413 .dependencies
414 .unwrap_or_default()
415 .into_iter()
416 .filter_map(|dependency| {
417 id_to_name.get(&dependency.id).map(|target_name| CmakeTargetRef {
418 configuration_id: configuration_id.clone(),
419 target_name: target_name.clone(),
420 })
421 })
422 .collect::<Vec<_>>();
423 dependency_refs.sort_by(target_ref_cmp);
424
425 targets.push(CmakeTarget {
426 target_name: target.name,
427 target_kind: target.target_type,
428 is_abstract: target.abstract_target.unwrap_or(false),
429 source_directory: target.paths.source,
430 build_directory: target.paths.build,
431 artifact_paths,
432 dependency_refs,
433 install_destinations: Vec::new(),
434 file_sets: Vec::new(),
435 });
436 }
437 targets.sort_by(|left, right| left.target_name.as_bytes().cmp(right.target_name.as_bytes()));
438
439 configurations.push(CmakeConfiguration {
440 configuration_id,
441 targets,
442 install_entries: Vec::new(),
443 exports: Vec::new(),
444 });
445 }
446
447 let mut model = CmakeProductModel {
448 k: "observer_cmake_product_model".to_owned(),
449 v: "0".to_owned(),
450 source_root: relativize_or_slash(Path::new(&codemodel.paths.source), base_dir),
451 build_root: relativize_or_slash(&build_root_abs, base_dir),
452 cmake: CmakeMetadata {
453 version: index.cmake.version.string,
454 generator: CmakeGenerator {
455 name: index.cmake.generator.name,
456 multi_config: index.cmake.generator.multi_config,
457 },
458 },
459 configurations,
460 configure_state: CmakeConfigureState {
461 status: "success".to_owned(),
462 },
463 };
464 model.validate()?;
465 for configuration in &mut model.configurations {
466 configuration.targets.sort_by(|left, right| left.target_name.as_bytes().cmp(right.target_name.as_bytes()));
467 }
468 Ok(model)
469}
470
471pub fn normalized_cmake_product_model_sha256(model: &CmakeProductModel) -> ObserverResult<String> {
472 model.validate()?;
473 let value = serde_json::to_value(model)
474 .map_err(|error| ObserverError::Normalize(error.to_string()))?;
475 let canonical = canonicalize_json(value);
476 let bytes = serde_json::to_vec(&canonical)
477 .map_err(|error| ObserverError::Normalize(error.to_string()))?;
478 Ok(hash_hex(&bytes))
479}
480
481pub fn serialize_cmake_product_model_json(model: &CmakeProductModel) -> ObserverResult<String> {
482 model.validate()?;
483 let value = serde_json::to_value(model)
484 .map_err(|error| ObserverError::Normalize(error.to_string()))?;
485 let canonical = canonicalize_json(value);
486 serde_json::to_string(&canonical).map_err(|error| ObserverError::Normalize(error.to_string()))
487}
488
489pub fn evaluate_cmake_model(
490 model: &CmakeProductModel,
491 model_root: &Path,
492 checks: &[CmakeModelCheck],
493 product_id: Option<&str>,
494 stage_id: Option<&str>,
495) -> ObserverResult<CmakeEvaluationOutcome> {
496 model.validate()?;
497 let model_sha256 = normalized_cmake_product_model_sha256(model)?;
498 let mut records = Vec::new();
499 records.push(CmakeModelReportRecord::Header(CmakeModelReportHeader {
500 k: "observer_cmake_model_report".to_owned(),
501 v: "0".to_owned(),
502 model_sha256,
503 product_id: product_id.map(ToOwned::to_owned),
504 stage_id: stage_id.map(ToOwned::to_owned),
505 }));
506
507 let mut check_pass = 0usize;
508 let mut check_fail = 0usize;
509 let mut check_validation_fail = 0usize;
510 let mut check_runner_fail = 0usize;
511
512 if model.configure_state.status != "success" {
513 for check in checks {
514 check_runner_fail += 1;
515 records.push(CmakeModelReportRecord::Check(CmakeCheckRecord {
516 check_id: check.check_id().to_owned(),
517 check_kind: check.kind().to_owned(),
518 status: CmakeCheckStatus::RunnerFail,
519 ok: None,
520 fail: Some(CmakeCheckFail {
521 class: CmakeCheckFailClass::RunnerFailure,
522 msg: "configure_state.status was not `success`".to_owned(),
523 missing_artifacts: Vec::new(),
524 }),
525 }));
526 }
527 } else {
528 for check in checks {
529 let record = match check {
530 CmakeModelCheck::TargetArtifactsExist { check_id, targets } => {
531 evaluate_target_artifacts_exist(model, model_root, check_id, targets)
532 }
533 };
534 match record.status {
535 CmakeCheckStatus::Pass => check_pass += 1,
536 CmakeCheckStatus::Fail => check_fail += 1,
537 CmakeCheckStatus::ValidationFail => check_validation_fail += 1,
538 CmakeCheckStatus::RunnerFail => check_runner_fail += 1,
539 }
540 records.push(CmakeModelReportRecord::Check(record));
541 }
542 }
543
544 records.push(CmakeModelReportRecord::Summary(CmakeSummaryRecord {
545 check_pass,
546 check_fail,
547 check_validation_fail,
548 check_runner_fail,
549 status: if check_fail == 0 && check_validation_fail == 0 && check_runner_fail == 0 {
550 "pass".to_owned()
551 } else {
552 "fail".to_owned()
553 },
554 }));
555
556 let exit_code = if check_runner_fail > 0 {
557 2
558 } else if check_fail > 0 || check_validation_fail > 0 {
559 1
560 } else {
561 0
562 };
563
564 Ok(CmakeEvaluationOutcome {
565 records,
566 exit_code,
567 check_pass,
568 check_fail,
569 check_validation_fail,
570 check_runner_fail,
571 })
572}
573
574fn evaluate_target_artifacts_exist(
575 model: &CmakeProductModel,
576 model_root: &Path,
577 check_id: &str,
578 targets: &[CmakeTargetRef],
579) -> CmakeCheckRecord {
580 let build_root = resolve_model_path(model_root, &model.build_root);
581 let mut checked_targets = Vec::new();
582 let mut missing_artifacts = Vec::new();
583
584 for target_ref in targets {
585 let Some(target) = find_target(model, target_ref) else {
586 return CmakeCheckRecord {
587 check_id: check_id.to_owned(),
588 check_kind: "target_artifacts_exist".to_owned(),
589 status: CmakeCheckStatus::ValidationFail,
590 ok: None,
591 fail: Some(CmakeCheckFail {
592 class: CmakeCheckFailClass::ValidationFailure,
593 msg: format!(
594 "target reference `{}` / `{}` did not resolve",
595 target_ref.configuration_id, target_ref.target_name
596 ),
597 missing_artifacts: Vec::new(),
598 }),
599 };
600 };
601 if target.is_abstract {
602 return CmakeCheckRecord {
603 check_id: check_id.to_owned(),
604 check_kind: "target_artifacts_exist".to_owned(),
605 status: CmakeCheckStatus::ValidationFail,
606 ok: None,
607 fail: Some(CmakeCheckFail {
608 class: CmakeCheckFailClass::ValidationFailure,
609 msg: format!(
610 "target `{}` / `{}` is abstract",
611 target_ref.configuration_id, target_ref.target_name
612 ),
613 missing_artifacts: Vec::new(),
614 }),
615 };
616 }
617 if target.artifact_paths.is_empty() {
618 return CmakeCheckRecord {
619 check_id: check_id.to_owned(),
620 check_kind: "target_artifacts_exist".to_owned(),
621 status: CmakeCheckStatus::ValidationFail,
622 ok: None,
623 fail: Some(CmakeCheckFail {
624 class: CmakeCheckFailClass::ValidationFailure,
625 msg: format!(
626 "target `{}` / `{}` declared no artifact paths",
627 target_ref.configuration_id, target_ref.target_name
628 ),
629 missing_artifacts: Vec::new(),
630 }),
631 };
632 }
633 checked_targets.push(target_ref.clone());
634 for artifact_path in &target.artifact_paths {
635 let artifact_abs = if Path::new(artifact_path).is_absolute() {
636 PathBuf::from(artifact_path)
637 } else {
638 build_root.join(artifact_path)
639 };
640 if !artifact_abs.exists() {
641 missing_artifacts.push(CmakeMissingArtifact {
642 target: target_ref.clone(),
643 artifact_path: artifact_path.clone(),
644 });
645 }
646 }
647 }
648
649 if missing_artifacts.is_empty() {
650 CmakeCheckRecord {
651 check_id: check_id.to_owned(),
652 check_kind: "target_artifacts_exist".to_owned(),
653 status: CmakeCheckStatus::Pass,
654 ok: Some(serde_json::json!({ "checked_targets": checked_targets })),
655 fail: None,
656 }
657 } else {
658 CmakeCheckRecord {
659 check_id: check_id.to_owned(),
660 check_kind: "target_artifacts_exist".to_owned(),
661 status: CmakeCheckStatus::Fail,
662 ok: None,
663 fail: Some(CmakeCheckFail {
664 class: CmakeCheckFailClass::CertificationFailure,
665 msg: "one or more declared target artifacts did not exist on disk".to_owned(),
666 missing_artifacts,
667 }),
668 }
669 }
670}
671
672fn find_target<'a>(model: &'a CmakeProductModel, target_ref: &CmakeTargetRef) -> Option<&'a CmakeTarget> {
673 model
674 .configurations
675 .iter()
676 .find(|configuration| configuration.configuration_id == target_ref.configuration_id)
677 .and_then(|configuration| {
678 configuration
679 .targets
680 .iter()
681 .find(|target| target.target_name == target_ref.target_name)
682 })
683}
684
685fn resolve_model_path(model_root: &Path, path: &str) -> PathBuf {
686 let path = Path::new(path);
687 if path.is_absolute() {
688 path.to_path_buf()
689 } else {
690 model_root.join(path)
691 }
692}
693
694fn validate_normalized_relative_path(path: &str, field: &str, allow_dot: bool) -> ObserverResult<()> {
695 if path.is_empty() {
696 return Err(ObserverError::ProductParse(format!("{field} must be non-empty")));
697 }
698 if allow_dot && path == "." {
699 return Ok(());
700 }
701 if path.starts_with('/') || path.starts_with('~') || path.contains('\\') {
702 return Err(ObserverError::ProductParse(format!(
703 "{field} must be a normalized relative path"
704 )));
705 }
706 for segment in path.split('/') {
707 if segment.is_empty() || segment == "." || segment == ".." {
708 return Err(ObserverError::ProductParse(format!(
709 "{field} must be a normalized relative path"
710 )));
711 }
712 }
713 Ok(())
714}
715
716fn latest_index_path(reply_dir: &Path) -> ObserverResult<PathBuf> {
717 let entries = fs::read_dir(reply_dir).map_err(|error| {
718 ObserverError::Cmake(format!("failed to read File API reply dir {}: {error}", reply_dir.display()))
719 })?;
720 let mut best: Option<String> = None;
721 for entry in entries {
722 let entry = entry.map_err(|error| ObserverError::Cmake(format!("failed to read reply entry: {error}")))?;
723 let file_name = entry.file_name();
724 let file_name = file_name.to_string_lossy();
725 if !file_name.starts_with("index-") || !file_name.ends_with(".json") {
726 continue;
727 }
728 if best.as_ref().map_or(true, |current| file_name.as_ref() > current.as_str()) {
729 best = Some(file_name.into_owned());
730 }
731 }
732 let Some(file_name) = best else {
733 return Err(ObserverError::Cmake(format!(
734 "no File API reply index found under {}",
735 reply_dir.display()
736 )));
737 };
738 Ok(reply_dir.join(file_name))
739}
740
741fn absolutize_path(path: &Path, base_dir: &Path) -> PathBuf {
742 if path.is_absolute() {
743 path.to_path_buf()
744 } else {
745 base_dir.join(path)
746 }
747}
748
749fn relativize_or_slash(path: &Path, base_dir: &Path) -> String {
750 if let Ok(relative) = path.strip_prefix(base_dir) {
751 if relative.as_os_str().is_empty() {
752 ".".to_owned()
753 } else {
754 path_to_slash(relative)
755 }
756 } else {
757 path_to_slash(path)
758 }
759}
760
761fn path_to_slash(path: &Path) -> String {
762 path.components()
763 .map(|component| component.as_os_str().to_string_lossy().into_owned())
764 .collect::<Vec<_>>()
765 .join("/")
766}
767
768fn target_ref_cmp(left: &CmakeTargetRef, right: &CmakeTargetRef) -> std::cmp::Ordering {
769 left
770 .configuration_id
771 .as_bytes()
772 .cmp(right.configuration_id.as_bytes())
773 .then_with(|| left.target_name.as_bytes().cmp(right.target_name.as_bytes()))
774}
775
776fn hash_hex(bytes: &[u8]) -> String {
777 let digest = Sha256::digest(bytes);
778 format!("{digest:x}")
779}
780
781fn canonicalize_json(value: Value) -> Value {
782 match value {
783 Value::Object(object) => {
784 let mut ordered = Map::new();
785 let mut entries = object.into_iter().collect::<Vec<_>>();
786 entries.sort_by(|left, right| left.0.as_bytes().cmp(right.0.as_bytes()));
787 for (key, value) in entries {
788 ordered.insert(key, canonicalize_json(value));
789 }
790 Value::Object(ordered)
791 }
792 Value::Array(items) => Value::Array(items.into_iter().map(canonicalize_json).collect()),
793 other => other,
794 }
795}
796
797#[derive(Debug, Deserialize)]
798struct FileApiIndex {
799 cmake: FileApiCmake,
800 objects: Vec<FileApiReplyRef>,
801}
802
803#[derive(Debug, Deserialize)]
804struct FileApiCmake {
805 version: FileApiVersionInfo,
806 generator: FileApiGeneratorInfo,
807}
808
809#[derive(Debug, Deserialize)]
810struct FileApiVersionInfo {
811 string: String,
812}
813
814#[derive(Debug, Deserialize)]
815struct FileApiGeneratorInfo {
816 name: String,
817 #[serde(rename = "multiConfig")]
818 multi_config: bool,
819}
820
821#[derive(Debug, Deserialize)]
822struct FileApiReplyRef {
823 kind: String,
824 version: FileApiObjectVersion,
825 #[serde(rename = "jsonFile")]
826 json_file: String,
827}
828
829#[derive(Debug, Deserialize)]
830struct FileApiObjectVersion {
831 major: u32,
832 minor: u32,
833}
834
835#[derive(Debug, Deserialize)]
836struct Codemodel {
837 paths: CodemodelPaths,
838 configurations: Vec<CodemodelConfiguration>,
839}
840
841#[derive(Debug, Deserialize)]
842struct CodemodelPaths {
843 source: String,
844 #[allow(dead_code)]
845 build: String,
846}
847
848#[derive(Debug, Deserialize)]
849struct CodemodelConfiguration {
850 name: String,
851 #[serde(default)]
852 targets: Vec<CodemodelTargetRef>,
853}
854
855#[derive(Debug, Deserialize)]
856struct CodemodelTargetRef {
857 #[serde(rename = "jsonFile")]
858 json_file: String,
859}
860
861#[derive(Debug, Deserialize)]
862struct CodemodelTarget {
863 name: String,
864 id: String,
865 #[serde(rename = "type")]
866 target_type: String,
867 #[serde(default, rename = "abstract")]
868 abstract_target: Option<bool>,
869 paths: CodemodelTargetPaths,
870 #[serde(default)]
871 artifacts: Vec<CodemodelArtifact>,
872 #[serde(default)]
873 dependencies: Option<Vec<CodemodelDependency>>,
874}
875
876#[derive(Debug, Deserialize)]
877struct CodemodelTargetPaths {
878 source: String,
879 build: String,
880}
881
882#[derive(Debug, Deserialize)]
883struct CodemodelArtifact {
884 path: String,
885}
886
887#[derive(Debug, Deserialize)]
888struct CodemodelDependency {
889 id: String,
890}