Skip to main content

observer_core/
cmake.rs

1// SPDX-FileCopyrightText: 2026 Alexander R. Croft
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4use 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}