Skip to main content

presolve_cli/
build_check_commands.rs

1//! L9-D public build/check request loading over the L9-C service adapter.
2//!
3//! The command layer loads only explicit `logical=relative-path` source
4//! specifications. It never enumerates source roots or infers membership.
5
6#![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
75/// Reads exactly the caller-authorized source paths after containment checks.
76/// No source-root traversal or membership inference is performed.
77pub 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
130/// Loads only explicit project/source inputs and delegates the complete
131/// candidate through the L9-C L4 adapter.
132pub 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}