1#![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
7
8use std::fmt;
9use std::fs;
10use std::path::{Component, Path, PathBuf};
11
12use presolve_compiler::persistent_cache::CacheReportSelector;
13use presolve_compiler::service::IncrementalReportSelector;
14
15use crate::{
16 compile_complete_candidate_v1, load_explicit_project_envelope_v1, CliCompilationCandidateV1,
17 CliCompilationResultV1, CliSourceInputV1,
18};
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct CliExplicitSourceSpecV1 {
22 pub logical_path: String,
23 pub relative_path: PathBuf,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct CliBuildCheckErrorV1 {
28 pub code: &'static str,
29 pub message: String,
30}
31impl fmt::Display for CliBuildCheckErrorV1 {
32 fn fmt(&self, output: &mut fmt::Formatter<'_>) -> fmt::Result {
33 write!(output, "{}: {}", self.code, self.message)
34 }
35}
36impl std::error::Error for CliBuildCheckErrorV1 {}
37
38fn parse_source_spec(value: &str) -> Result<CliExplicitSourceSpecV1, CliBuildCheckErrorV1> {
39 let (logical_path, relative_path) = value.split_once('=').ok_or(CliBuildCheckErrorV1 {
40 code: "L9D001_INVALID_SOURCE_SPEC",
41 message: "--source must use logical-path=relative-path".into(),
42 })?;
43 if logical_path.is_empty() || relative_path.is_empty() {
44 return Err(CliBuildCheckErrorV1 {
45 code: "L9D001_INVALID_SOURCE_SPEC",
46 message: "--source must name non-empty logical and relative paths".into(),
47 });
48 }
49 let relative_path = PathBuf::from(relative_path);
50 if relative_path.is_absolute()
51 || relative_path.components().any(|component| {
52 matches!(
53 component,
54 Component::ParentDir | Component::RootDir | Component::Prefix(_)
55 )
56 })
57 {
58 return Err(CliBuildCheckErrorV1 {
59 code: "L9D002_SOURCE_PATH_ESCAPE",
60 message: "--source host paths must be project-relative and contained".into(),
61 });
62 }
63 Ok(CliExplicitSourceSpecV1 {
64 logical_path: logical_path.into(),
65 relative_path,
66 })
67}
68
69pub fn parse_explicit_source_spec_v1(
70 value: &str,
71) -> Result<CliExplicitSourceSpecV1, CliBuildCheckErrorV1> {
72 parse_source_spec(value)
73}
74
75pub fn load_explicit_source_inputs_v1(
78 project_root: &Path,
79 source_specs: &[CliExplicitSourceSpecV1],
80) -> Result<Vec<CliSourceInputV1>, CliBuildCheckErrorV1> {
81 if source_specs.is_empty() {
82 return Err(CliBuildCheckErrorV1 {
83 code: "L9D003_MISSING_SOURCE_INPUT",
84 message: "build and check require at least one explicit --source input".into(),
85 });
86 }
87 let root = fs::canonicalize(project_root).map_err(|error| CliBuildCheckErrorV1 {
88 code: "L9D004_PROJECT_ROOT_UNAVAILABLE",
89 message: format!("failed to resolve project root: {error}"),
90 })?;
91 let mut sources = Vec::with_capacity(source_specs.len());
92 for spec in source_specs {
93 let host_path = fs::canonicalize(root.join(&spec.relative_path)).map_err(|error| {
94 CliBuildCheckErrorV1 {
95 code: "L9D005_SOURCE_READ_FAILED",
96 message: format!(
97 "failed to resolve {}: {error}",
98 spec.relative_path.display()
99 ),
100 }
101 })?;
102 if !host_path.starts_with(&root) || !host_path.is_file() {
103 return Err(CliBuildCheckErrorV1 {
104 code: "L9D002_SOURCE_PATH_ESCAPE",
105 message: "explicit source must be a regular file inside the project root".into(),
106 });
107 }
108 let content = fs::read_to_string(&host_path).map_err(|error| CliBuildCheckErrorV1 {
109 code: "L9D005_SOURCE_READ_FAILED",
110 message: format!("failed to read {}: {error}", spec.relative_path.display()),
111 })?;
112 sources.push(CliSourceInputV1 {
113 logical_path: spec.logical_path.clone(),
114 content,
115 });
116 }
117 sources.sort_by(|left, right| left.logical_path.cmp(&right.logical_path));
118 if sources
119 .windows(2)
120 .any(|pair| pair[0].logical_path == pair[1].logical_path)
121 {
122 return Err(CliBuildCheckErrorV1 {
123 code: "L9D006_DUPLICATE_LOGICAL_SOURCE",
124 message: "explicit logical source paths must be unique".into(),
125 });
126 }
127 Ok(sources)
128}
129
130pub fn run_explicit_build_or_check_v1(
133 configuration_path: &Path,
134 source_specs: &[CliExplicitSourceSpecV1],
135 verify_clean_equivalence: bool,
136) -> Result<CliCompilationResultV1, CliBuildCheckErrorV1> {
137 let project_root = configuration_path.parent().ok_or(CliBuildCheckErrorV1 {
138 code: "L9D007_CONFIGURATION_PATH_INVALID",
139 message: "configuration path must have a parent directory".into(),
140 })?;
141 let envelope =
142 load_explicit_project_envelope_v1(project_root, configuration_path).map_err(|error| {
143 CliBuildCheckErrorV1 {
144 code: error.code,
145 message: error.message,
146 }
147 })?;
148 let sources = load_explicit_source_inputs_v1(&envelope.project_root, source_specs)?;
149 compile_complete_candidate_v1(
150 &envelope.project_root.join(".presolve"),
151 CliCompilationCandidateV1 {
152 configuration: envelope.configuration,
153 sources,
154 verify_clean_equivalence,
155 report: IncrementalReportSelector::Summary,
156 cache_report: CacheReportSelector::Summary,
157 },
158 )
159 .map_err(|error| CliBuildCheckErrorV1 {
160 code: error.code,
161 message: error.message,
162 })
163}
164
165#[cfg(test)]
166mod tests {
167 use std::sync::atomic::{AtomicU64, Ordering};
168
169 use super::*;
170
171 static NEXT_ROOT: AtomicU64 = AtomicU64::new(1);
172
173 fn setup() -> (PathBuf, PathBuf) {
174 let root = std::env::temp_dir().join(format!(
175 "presolve-l9d-{}",
176 NEXT_ROOT.fetch_add(1, Ordering::Relaxed)
177 ));
178 fs::create_dir_all(root.join("src")).unwrap();
179 let config = root.join("presolve.json");
180 fs::write(
181 &config,
182 include_bytes!("../fixtures/configuration/minimum-cli-v1.json"),
183 )
184 .unwrap();
185 fs::write(root.join("src/main.ts"), "export const value = 1;\n").unwrap();
186 (root, config)
187 }
188
189 #[test]
190 fn l9d_build_check_loads_only_explicit_contained_sources() {
191 let (root, config) = setup();
192 let result = run_explicit_build_or_check_v1(
193 &config,
194 &[parse_explicit_source_spec_v1("src/main.ts=src/main.ts").unwrap()],
195 true,
196 )
197 .unwrap();
198 assert_eq!(result.commit_sequence, 1);
199 assert!(root.join(".presolve/service").is_dir());
200 fs::remove_dir_all(root).unwrap();
201 }
202
203 #[test]
204 fn l9d_rejects_escaping_and_missing_source_authority() {
205 assert!(parse_explicit_source_spec_v1("src/main.ts=../main.ts").is_err());
206 let (root, config) = setup();
207 assert!(run_explicit_build_or_check_v1(&config, &[], false).is_err());
208 fs::remove_dir_all(root).unwrap();
209 }
210}